From 3fb08423da25712b4e704693c267c9585d964bcd Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 12:58:58 +0800 Subject: [PATCH 01/10] refactor(cdsl_engine): split runtime_types into specs + topology with shim Phase 1 of the decoupling refactor (behavior-preserving move): - specs.py: vector math, plane/axis helpers, parametric feature specs - topology.py: diagnostics, planning contracts, TopologyRegistry - runtime_types.py: compatibility shim re-exporting all public names No behavior change; all historical import paths keep working. --- backend/engine/cdsl_engine/runtime_types.py | 1684 +------------------ backend/engine/cdsl_engine/specs.py | 619 +++++++ backend/engine/cdsl_engine/topology.py | 1010 +++++++++++ 3 files changed, 1703 insertions(+), 1610 deletions(-) create mode 100644 backend/engine/cdsl_engine/specs.py create mode 100644 backend/engine/cdsl_engine/topology.py diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index d2b06446..e9fe27fb 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -1,1615 +1,79 @@ -"""Runtime-neutral CDSL planning, diagnostics, and topology contracts. +"""Compatibility shim for the runtime-neutral CDSL contracts. -This module deliberately has no build123d dependency. The planner and -selector resolver can therefore be used by validation, batch reporting, and -any geometry adapter without importing OCC objects. +The contracts now live in two focused modules: + +- ``specs``: vector math, canonical plane/axis helpers, and parametric + feature spec dataclasses. +- ``topology``: diagnostics, planning contracts, and the topology registry + with explainable selector resolution. + +This module keeps the historical ``cdsl_engine.runtime_types`` import path +stable for the package, its tests, and external consumers. New code should +import from ``specs`` or ``topology`` directly. """ from __future__ import annotations -import warnings -from dataclasses import dataclass, field -from math import cos, pi, radians, sqrt -from typing import Any, Iterable - - -Vector3 = tuple[float, float, float] - - -def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str: - """Return the runtime-only body-member key for one proven pattern copy. - - CDSL keeps the three source fields separately, so callers never need to - manufacture this internal key. The body graph uses the same derivation in - runtime and capability preflight. - """ - return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}" - - -def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str: - """Return the runtime-only key for one source of a multi-body COPY. - - A multi-source ``transform_bodies`` COPY has several independently - addressable outputs. CDSL records the transform and its selected source as - separate fields; the opaque key stays internal to the body graph. - """ - return f"transform:{transform_feature_id}:{source_member_id}:copy" - -# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的, -# 予以保留;否则视为偏斜数据,正交化并显式警告。 -_Y_DIR_ORTHOGONALITY_TOL = 1e-6 - - -def _vector3(value: Any, *, field_name: str) -> Vector3: - if not isinstance(value, (list, tuple)) or len(value) != 3: - raise ValueError(f"{field_name} must contain three coordinates") - try: - return (float(value[0]), float(value[1]), float(value[2])) - except (TypeError, ValueError) as error: - raise ValueError(f"{field_name} must contain numeric coordinates") from error - - -def _length(value: Vector3) -> float: - return sqrt(sum(component * component for component in value)) - - -def _unit(value: Vector3, *, field_name: str) -> Vector3: - magnitude = _length(value) - if magnitude <= 1e-12: - raise ValueError(f"{field_name} must be non-zero") - return tuple(component / magnitude for component in value) # type: ignore[return-value] - - -def _dot(left: Vector3, right: Vector3) -> float: - return sum(a * b for a, b in zip(left, right)) - - -def _cross(left: Vector3, right: Vector3) -> Vector3: - return ( - left[1] * right[2] - left[2] * right[1], - left[2] * right[0] - left[0] * right[2], - left[0] * right[1] - left[1] * right[0], - ) - - -def vector_add(left: Vector3, right: Vector3) -> Vector3: - return tuple(a + b for a, b in zip(left, right)) # type: ignore[return-value] - - -def vector_subtract(left: Vector3, right: Vector3) -> Vector3: - return tuple(a - b for a, b in zip(left, right)) # type: ignore[return-value] - - -def vector_scale(value: Vector3, factor: float) -> Vector3: - return tuple(component * factor for component in value) # type: ignore[return-value] - - -def vector_dot(left: Vector3, right: Vector3) -> float: - return _dot(left, right) - - -def vector_cross(left: Vector3, right: Vector3) -> Vector3: - return _cross(left, right) - - -def vector_unit(value: Vector3, *, field_name: str = "vector") -> Vector3: - return _unit(value, field_name=field_name) - - -def canonical_plane_signature(normal: Vector3, point_mm: Vector3) -> tuple[Vector3, float]: - """Normalize a plane sign so source and OCC face orientations compare.""" - unit_normal = _unit(normal, field_name="plane.normal") - offset = _dot(unit_normal, point_mm) - for component in unit_normal: - if abs(component) <= 1e-12: - continue - if component < 0: - unit_normal = tuple(-value for value in unit_normal) # type: ignore[assignment] - offset = -offset - break - return unit_normal, offset - - -def normalize_selector_geometry(geometry: Any) -> dict[str, Any]: - """Convert legacy SolidWorks selector evidence into runtime-neutral units. - - Current CDSL records may already carry ``*_mm`` fields. Older exported - evidence instead stores SolidWorks surface parameters, boxes, and areas in - SI units. The selector remains the source of truth; this function only - makes its geometric signature comparable to an OCC topology snapshot. - """ - if not isinstance(geometry, dict): - return {} - result = dict(geometry) - surface = geometry.get("surface") - if isinstance(surface, dict): - surface_type = str(surface.get("type") or "").lower() - if surface_type: - result.setdefault("surface_type", surface_type) - parameters = surface.get("parameters") - if surface_type == "plane" and isinstance(parameters, list) and len(parameters) >= 6: - try: - raw_normal = _vector3(parameters[:3], field_name="selector surface normal") - # SolidWorks evidence uses metres for surface locations. - point_mm = tuple(float(value) * 1000.0 for value in parameters[3:6]) - plane_normal, plane_offset = canonical_plane_signature(raw_normal, point_mm) # type: ignore[arg-type] - result.setdefault("plane_normal", list(plane_normal)) - result.setdefault("plane_offset_mm", plane_offset) - except (TypeError, ValueError): - pass - curve = geometry.get("curve") - if isinstance(curve, dict) and curve.get("type"): - result.setdefault("curve_type", str(curve["type"]).lower()) - raw_box = geometry.get("box") - if isinstance(raw_box, list) and len(raw_box) == 6: - try: - result.setdefault("bbox_mm", [float(value) * 1000.0 for value in raw_box]) - except (TypeError, ValueError): - pass - raw_area = geometry.get("area") - if raw_area is not None: - try: - result.setdefault("area_mm2", float(raw_area) * 1_000_000.0) - except (TypeError, ValueError): - pass - for raw_key, normalized_key in (("start", "start_mm"), ("end", "end_mm")): - value = geometry.get(raw_key) - if isinstance(value, list) and len(value) == 3: - try: - result.setdefault(normalized_key, [float(component) * 1000.0 for component in value]) - except (TypeError, ValueError): - pass - return result - - -@dataclass(frozen=True) -class AxisSpec: - """Canonical axis with a normalized direction.""" - - origin_mm: Vector3 - direction: Vector3 - - @classmethod - def from_mapping(cls, value: dict[str, Any]) -> "AxisSpec": - return cls( - origin_mm=_vector3(value.get("origin_mm"), field_name="axis.origin_mm"), - direction=_unit(_vector3(value.get("direction"), field_name="axis.direction"), field_name="axis.direction"), - ) - - def as_dict(self) -> dict[str, list[float]]: - return {"origin_mm": list(self.origin_mm), "direction": list(self.direction)} - - -@dataclass(frozen=True) -class PlaneSpec: - """Canonical right-handed plane frame. - - SolidWorks exports may contain a redundant or non-orthogonal y direction. - The runtime persists the orthonormalized frame so later features all use - the same coordinate system. - """ - - origin_mm: Vector3 - x_dir: Vector3 - y_dir: Vector3 - normal: Vector3 - - @classmethod - def from_mapping(cls, value: dict[str, Any]) -> "PlaneSpec": - origin = _vector3(value.get("origin_mm"), field_name="plane.origin_mm") - normal = _unit(_vector3(value.get("normal"), field_name="plane.normal"), field_name="plane.normal") - x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir") - projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3)) - x_dir = _unit(projected_x, field_name="plane.x_dir") - generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir") - y_raw = value.get("y_dir") - if y_raw is None: - # y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。 - y_dir = generated - else: - y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir") - if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL: - # 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向, - # 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。 - y_dir = y_vec - else: - # 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。 - warnings.warn( - f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}", - UserWarning, - stacklevel=2, - ) - y_dir = generated - return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal) - - def as_dict(self) -> dict[str, list[float]]: - return { - "origin_mm": list(self.origin_mm), - "x_dir": list(self.x_dir), - "y_dir": list(self.y_dir), - "normal": list(self.normal), - } - - -@dataclass(frozen=True) -class HoleSpec: - """Runtime-neutral definition of a cylindrical Hole Wizard operation. - - The spec deliberately contains no OCC planes or shapes. The runtime - resolves the host frame and the adapter turns this definition into a - cutting tool, keeping source-contract parsing separate from B-rep work. - """ - - diameter_mm: float - depth_mm: float - end_condition: str - positions_mm: tuple[Vector3, ...] - countersink: tuple[float, float] | None = None - counterbore: tuple[float, float] | None = None - - @classmethod - def from_feature(cls, atomic_id: str, params: dict[str, Any], *, wizard: bool) -> "HoleSpec": - try: - diameter = float(params.get("diameter_mm") or 0.0) - depth = float(params.get("depth_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("hole dimensions must be numeric") from error - if diameter <= 0 or depth <= 0: - raise ValueError("hole requires positive diameter_mm and depth_mm") - condition = str((params.get("end_condition") or {"type": "blind"}).get("type") or "blind") - if condition not in {"blind", "through_all", "through_all_both"}: - raise ValueError(f"unsupported hole extent {condition!r}") - raw_positions = params.get("positions") or [] - positions = tuple(_vector3(item.get("mm"), field_name="hole position") for item in raw_positions if isinstance(item, dict)) - if len(positions) != len(raw_positions) or not positions: - raise ValueError("hole requires non-empty positions with three-dimensional mm coordinates") - - raw_sink: dict[str, Any] | None = params.get("countersink") if wizard else None - raw_bore: dict[str, Any] | None = params.get("counterbore") if wizard else None - if atomic_id == "hole_countersink": - raw_sink = {"diameter_mm": params.get("countersink_diameter_mm"), "angle_rad": params.get("countersink_angle_rad")} - if atomic_id == "hole_counterbore": - raw_bore = {"diameter_mm": params.get("counterbore_diameter_mm"), "depth_mm": params.get("counterbore_depth_mm")} - - def dimensions(value: dict[str, Any] | None, second: str, label: str) -> tuple[float, float] | None: - if value is None: - return None - try: - first_value = float(value.get("diameter_mm") or 0.0) - second_value = float(value.get(second) or 0.0) - except (AttributeError, TypeError, ValueError) as error: - raise ValueError(f"{label} dimensions must be numeric") from error - if first_value <= diameter or second_value <= 0: - raise ValueError(f"{label} requires a diameter larger than the main hole and a positive {second}") - return first_value, second_value - - return cls( - diameter_mm=diameter, - depth_mm=depth, - end_condition=condition, - positions_mm=positions, - countersink=dimensions(raw_sink, "angle_rad", "countersink"), - counterbore=dimensions(raw_bore, "depth_mm", "counterbore"), - ) - - -@dataclass(frozen=True) -class ThreadSpec: - """Runtime-neutral definition of a parametric screw thread. - - The spec deliberately contains no OCC planes or shapes. The adapter turns - this definition into a threaded solid segment keeping source-contract - parsing separate from B-rep work. - - ``axis.origin_mm`` anchors the leading end face of the threaded segment and - ``axis.direction`` is the outward thread axis; ``angle_deg`` is the full - flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews). - ``internal`` selects the cutting form used by later thread_cut support. - """ - - major_diameter_mm: float - minor_diameter_mm: float - pitch_mm: float - length_mm: float - axis: AxisSpec - angle_deg: float = 60.0 - internal: bool = False - lefthand: bool = False - relief_length_mm: float = 0.0 - crest_radius_mm: float = 0.0 - root_radius_mm: float = 0.0 - - @classmethod - def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec": - try: - major = float(params.get("major_diameter_mm") or 0.0) - minor = float(params.get("minor_diameter_mm") or 0.0) - pitch = float(params.get("pitch_mm") or 0.0) - length = float(params.get("length_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("thread dimensions must be numeric") from error - if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0: - raise ValueError("thread requires positive major/minor/pitch/length in millimetres") - if minor >= major: - raise ValueError("thread minor diameter must be smaller than the major diameter") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("thread requires an axis definition") - angle = 60.0 - raw_angle = params.get("angle_deg") - if raw_angle is not None: - try: - angle = float(raw_angle) - except (TypeError, ValueError) as error: - raise ValueError("thread angle_deg must be numeric") from error - if not 0 < angle < 180: - raise ValueError("thread angle_deg must be between 0 and 180") - relief = float(params.get("relief_length_mm") or 0.0) - crest_r = float(params.get("crest_radius_mm") or 0.0) - root_r = float(params.get("root_radius_mm") or 0.0) - if relief < 0: - raise ValueError("thread relief_length_mm must be non-negative") - if crest_r < 0 or root_r < 0: - raise ValueError("thread crest/root radius must be non-negative") - # 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半, - # 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。 - depth_radius = (major - minor) / 2.0 - if crest_r + root_r > 0.5 * depth_radius: - raise ValueError("thread crest_radius + root_radius exceeds half the thread depth") - # thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传 - # internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓 - # 刀具做布尔差会在 coincident faces 上退化)。 - internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False)) - return cls( - major_diameter_mm=major, - minor_diameter_mm=minor, - pitch_mm=pitch, - length_mm=length, - axis=AxisSpec.from_mapping(raw_axis), - angle_deg=angle, - internal=internal, - lefthand=bool(params.get("lefthand", False)), - relief_length_mm=relief, - crest_radius_mm=crest_r, - root_radius_mm=root_r, - ) - - -@dataclass(frozen=True) -class BendLeg: - """One straight wing of a bent sheet-metal chain (runtime-neutral). - - ``leg_mm`` is the straight mid-plane distance from the previous fold vertex - to the fold vertex leaving this wing (the unfolded chord length between the - two surrounding fold vertices). When the wing has a following wing, - ``bend_angle_deg`` is the required *interior* angle between the two wings - (0 < angle < 180, 90 = a right-angle bend), ``inner_radius_mm`` the inner - bend-surface fillet radius (>= 0; the outer radius is always - ``inner_radius_mm + thickness_mm``) and ``side`` the fold direction - (+1 / -1). A trailing wing carries no fold; stray fold fields on the last - wing are ignored for tolerance. - """ - - leg_mm: float - bend_angle_deg: float | None = None - inner_radius_mm: float = 0.0 - side: int = 1 - - -@dataclass(frozen=True) -class BendSpec: - """Runtime-neutral definition of a sheet-metal bend (``bend_add``). - - The part is described by its mid-plane centreline: ``chain`` lists the - straight wings joined by equal-thickness bend corners. ``thickness_mm`` - and ``width_mm`` are the sheet thickness and the full length along the fold - (width) axis. ``frame.origin_mm`` anchors the start of the first wing's - mid-plane path, ``frame.x_dir`` is the fold/width axis and - ``frame.normal`` is the mid-plane normal of the first wing; the first wing - extends along ``normal x x_dir``. - - The spec deliberately contains no OCC planes or shapes. The adapter turns - this definition into a bent solid keeping source-contract parsing separate - from B-rep work (same pattern as :class:`ThreadSpec`). - """ - - thickness_mm: float - width_mm: float - frame: PlaneSpec - chain: tuple[BendLeg, ...] - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "BendSpec": - try: - thickness = float(params.get("thickness_mm") or 0.0) - width = float(params.get("width_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("bend thickness/width must be numeric") from error - if thickness <= 0 or width <= 0: - raise ValueError("bend requires positive thickness_mm and width_mm in millimetres") - raw_chain = params.get("chain") - if not isinstance(raw_chain, (list, tuple)) or not raw_chain: - raise ValueError("bend requires a non-empty chain of wing segments") - chain: list[BendLeg] = [] - for index, raw_leg in enumerate(raw_chain): - if not isinstance(raw_leg, dict): - raise ValueError(f"bend chain[{index}] must be an object with leg_mm") - try: - leg = float(raw_leg.get("leg_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError(f"bend chain[{index}].leg_mm must be numeric") from error - if leg <= 0: - raise ValueError(f"bend chain[{index}].leg_mm must be positive") - bend_angle: float | None = None - if index < len(raw_chain) - 1: - raw_angle = raw_leg.get("bend_angle_deg") - if raw_angle is None: - raise ValueError( - f"bend chain[{index}] needs bend_angle_deg for the fold towards the next wing" - ) - try: - bend_angle = float(raw_angle) - except (TypeError, ValueError) as error: - raise ValueError(f"bend chain[{index}].bend_angle_deg must be numeric") from error - if not 0 < bend_angle < 180: - raise ValueError("bend interior angle must be between 0 and 180 degrees") - radius = float(raw_leg.get("inner_radius_mm") or 0.0) - raw_side = raw_leg.get("side") - side = 1 if raw_side is None else int(raw_side) - if radius < 0: - raise ValueError(f"bend chain[{index}].inner_radius_mm must be non-negative") - if side not in (1, -1): - raise ValueError(f"bend chain[{index}].side must be +1 or -1") - chain.append(BendLeg( - leg_mm=leg, - bend_angle_deg=bend_angle, - inner_radius_mm=radius, - side=side, - )) - raw_frame = params.get("frame") - if isinstance(raw_frame, dict): - frame = PlaneSpec.from_mapping(raw_frame) - else: - # frame 缺省:首翼沿世界 +X 延伸、厚度沿 +Y、折痕沿 +Z(贴 XY 平面)。 - frame = PlaneSpec( - origin_mm=(0.0, 0.0, 0.0), - x_dir=(0.0, 0.0, 1.0), - y_dir=(1.0, 0.0, 0.0), - normal=(0.0, 1.0, 0.0), - ) - return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain)) - - -@dataclass(frozen=True) -class GearSpec: - """Runtime-neutral definition of an involute spur/helical/herringbone gear. - - ``module_mm``/``teeth_count``/``pressure_angle_rad`` follow the ISO - involute convention: pitch radius ``m*z/2``, base radius - ``m*z/2*cos(alpha)``, addendum ``m`` and dedendum ``1.25*m``. ``width_mm`` - is the axial face width. ``helix_angle_rad`` is the pitch-cylinder helix - angle (0 = spur); ``herringbone=True`` builds a symmetric double-helical - gear whose helix reverses at mid-width (the mid-plane seam is shared - phase, no V-notch). ``axis.origin_mm`` is the centre of the ``z = 0`` end - face and ``axis.direction`` the outward gear axis. - """ - - module_mm: float - teeth_count: int - width_mm: float - axis: AxisSpec - helix_angle_rad: float = 0.0 - pressure_angle_rad: float = pi / 9.0 # 20 degrees - herringbone: bool = False - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "GearSpec": - try: - module = float(params.get("module_mm") or 0.0) - width = float(params.get("width_mm") or 0.0) - teeth = int(params.get("teeth_count") or 0) - helix_raw = params.get("helix_angle_rad") - helix = float(helix_raw) if helix_raw is not None else 0.0 - pressure_raw = params.get("pressure_angle_rad") - pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 - except (TypeError, ValueError) as error: - raise ValueError("gear dimensions must be numeric") from error - if module <= 0 or width <= 0: - raise ValueError("gear requires positive module_mm and width_mm") - if not 8 <= teeth <= 200: - raise ValueError("gear teeth_count must be between 8 and 200") - if not 0.0 <= helix < radians(45.0): - raise ValueError("gear helix_angle_rad must be within [0, 45) degrees") - if not 0.0 < pressure <= radians(35.0): - raise ValueError("gear pressure_angle_rad must be within (0, 35] degrees") - herringbone = bool(params.get("herringbone", False)) - if herringbone and helix <= 0.0: - raise ValueError("herringbone gear requires helix_angle_rad > 0") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("gear requires an axis definition") - return cls( - module_mm=module, - teeth_count=teeth, - width_mm=width, - axis=AxisSpec.from_mapping(raw_axis), - helix_angle_rad=helix, - pressure_angle_rad=pressure, - herringbone=herringbone, - ) - - @property - def pitch_radius_mm(self) -> float: - return self.module_mm * self.teeth_count / 2.0 - - @property - def base_radius_mm(self) -> float: - return self.pitch_radius_mm * cos(self.pressure_angle_rad) - - @property - def tip_radius_mm(self) -> float: - return self.pitch_radius_mm + self.module_mm - - @property - def root_radius_mm(self) -> float: - return self.pitch_radius_mm - 1.25 * self.module_mm - - -@dataclass(frozen=True) -class RackSpec: - """Runtime-neutral definition of a straight-sided rack (``rack_add``). - - The rack is the linear counterpart of a gear: tooth pitch ``p = pi*m``, - addendum ``m`` above the reference line and dedendum ``1.25*m`` below it, - flanks inclined by ``pressure_angle_rad``. ``teeth_count`` sets the exact - overall length ``teeth_count * pi * m`` (both ends land in tooth valley - centres). ``thickness_mm`` is the width along the tooth crest direction. - ``axis.origin_mm`` is the corner point where the length-start end face, - the thickness-start face and the tooth-valley plane intersect, and - ``axis.direction`` is the direction the teeth point (local +Z). - """ - - module_mm: float - teeth_count: int - thickness_mm: float - axis: AxisSpec - pressure_angle_rad: float = pi / 9.0 # 20 degrees - - @classmethod - def from_feature(cls, params: dict[str, Any]) -> "RackSpec": - try: - module = float(params.get("module_mm") or 0.0) - thickness = float(params.get("thickness_mm") or 0.0) - teeth = int(params.get("teeth_count") or 0) - pressure_raw = params.get("pressure_angle_rad") - pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 - except (TypeError, ValueError) as error: - raise ValueError("rack dimensions must be numeric") from error - if module <= 0 or thickness <= 0: - raise ValueError("rack requires positive module_mm and thickness_mm") - if not 1 <= teeth <= 2000: - raise ValueError("rack teeth_count must be between 1 and 2000") - if not 0.0 < pressure <= radians(35.0): - raise ValueError("rack pressure_angle_rad must be within (0, 35] degrees") - raw_axis = params.get("axis") - if not isinstance(raw_axis, dict): - raise ValueError("rack requires an axis definition") - return cls( - module_mm=module, - teeth_count=teeth, - thickness_mm=thickness, - axis=AxisSpec.from_mapping(raw_axis), - pressure_angle_rad=pressure, - ) - - @property - def pitch_mm(self) -> float: - return pi * self.module_mm - - @property - def length_mm(self) -> float: - return self.pitch_mm * self.teeth_count - - @property - def addendum_mm(self) -> float: - return self.module_mm - - @property - def dedendum_mm(self) -> float: - return 1.25 * self.module_mm - - -@dataclass(frozen=True) -class RuntimeDiagnostic: - code: str - message: str - feature_id: str | None = None - detail: dict[str, Any] = field(default_factory=dict) - - def as_dict(self) -> dict[str, Any]: - output: dict[str, Any] = {"code": self.code, "message": self.message} - if self.feature_id is not None: - output["feature_id"] = self.feature_id - if self.detail: - output["detail"] = self.detail - return output - - -@dataclass(frozen=True) -class CapabilityResult: - feature_id: str - atomic_id: str - resolved_status: str - required_capabilities: tuple[str, ...] = () - blockers: tuple[RuntimeDiagnostic, ...] = () - - @property - def executable(self) -> bool: - return self.resolved_status == "executable" - - def as_dict(self) -> dict[str, Any]: - return { - "feature_id": self.feature_id, - "atomic_id": self.atomic_id, - "resolved_status": self.resolved_status, - "required_capabilities": list(self.required_capabilities), - "blockers": [blocker.as_dict() for blocker in self.blockers], - } - - -@dataclass(frozen=True) -class FeaturePlanNode: - feature_id: str - atomic_id: str - name: str | None - depends_on: tuple[str, ...] - params: dict[str, Any] - selectors: tuple[dict[str, Any], ...] - sketch_id: str | None - declared_status: str | None - source_feature: dict[str, Any] - - -@dataclass -class FeatureResult: - feature_id: str - atomic_id: str - status: str - body_id: str | None = None - surface_id: str | None = None - context: PlaneSpec | AxisSpec | None = None - replay_definition: dict[str, Any] | None = None - diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) - - def as_dict(self) -> dict[str, Any]: - output: dict[str, Any] = { - "feature_id": self.feature_id, - "atomic_id": self.atomic_id, - "status": self.status, - "diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics], - } - if self.body_id is not None: - output["body_id"] = self.body_id - if self.surface_id is not None: - output["surface_id"] = self.surface_id - if self.context is not None: - output["context"] = self.context.as_dict() - if self.replay_definition is not None: - output["replay_definition"] = self.replay_definition - return output - - -@dataclass(frozen=True) -class TopologyRecord: - """Runtime-side signature of a topology item or context object. - - ``feature_id`` identifies the feature that produced this *snapshot*. - ``owner_feature_ids`` is durable semantic provenance for an unchanged - current B-rep item. Boolean and dress-up operations replace OCC objects, - so keeping these concepts separate prevents a later mutation from making - every surviving face appear to be owned by that mutation. - """ - - record_id: str - kind: str - feature_id: str - body_id: str | None = None - geometry: dict[str, Any] = field(default_factory=dict) - value: Any = None - owner_feature_ids: tuple[str, ...] = () - # Builder-produced roles describe a particular result subshape. They are - # deliberately separate from the geometric signature: equal geometry does - # not prove that two faces have the same feature-output meaning. - output_roles: tuple[str, ...] = () - # Generated roles may carry the direct feature-output role that the kernel - # operation transformed. This is semantic provenance, not a stable-id - # shortcut: the resolver still requires the exact active result snapshot. - output_role_sources: tuple[tuple[str, str, str], ...] = () - - @property - def owners(self) -> tuple[str, ...]: - """Return durable provenance, retaining compatibility for contexts.""" - return self.owner_feature_ids or (self.feature_id,) - - def public_dict(self) -> dict[str, Any]: - output: dict[str, Any] = { - "record_id": self.record_id, - "kind": self.kind, - "feature_id": self.feature_id, - "geometry": self.geometry, - } - if self.body_id is not None: - output["body_id"] = self.body_id - if self.owner_feature_ids: - output["owner_feature_ids"] = list(self.owner_feature_ids) - if self.output_roles: - output["output_roles"] = list(self.output_roles) - if self.output_role_sources: - output["output_role_sources"] = [ - {"output_role": role, "owner_feature_id": owner, "source_output_role": source_role} - for role, owner, source_role in self.output_role_sources - ] - return output - - -@dataclass(frozen=True) -class TopologyDeltaRelation: - """One opaque kernel-history relationship for a topology subshape. - - Geometry adapters retain ownership of the values in this structure. They - are intentionally opaque to the runtime: a build123d/OCC adapter may use - ``TopoDS_Shape`` values while another adapter can use its native handles. - The registry only asks whether a handle is exactly the same topology item; - it never uses this contract to score nearby geometry. - """ - - event: str - kind: str - source_value: Any - result_values: tuple[Any, ...] = () - output_role: str | None = None - - def __post_init__(self) -> None: - if self.event not in {"preserved", "modified", "generated", "deleted"}: - raise ValueError(f"unsupported topology delta event {self.event!r}") - if self.kind not in {"face", "edge", "vertex"}: - raise ValueError(f"unsupported topology delta kind {self.kind!r}") - if self.event == "deleted" and (self.result_values or self.output_role is not None): - raise ValueError("deleted topology delta relations cannot have result values or an output role") - if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): - raise ValueError("topology delta output_role must be a non-empty string when provided") - - -@dataclass(frozen=True) -class TopologyDelta: - """Kernel-backed topology history for one adapter operation. - - ``operation`` is evidence only. The runtime transfers durable provenance - solely from a unique exact relationship, never from an operation name or a - geometric resemblance. - """ - - operation: str - relations: tuple[TopologyDeltaRelation, ...] = () - - -@dataclass(frozen=True) -class SelectorResolution: - selector: dict[str, Any] - status: str - record: TopologyRecord | None = None - candidates: tuple[dict[str, Any], ...] = () - diagnostic: RuntimeDiagnostic | None = None - - def as_dict(self) -> dict[str, Any]: - output = { - "selector": self.selector, - "status": self.status, - "candidates": list(self.candidates), - } - if self.record is not None: - output["record"] = self.record.public_dict() - output["selected"] = self.record.public_dict() - score = next( - (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), - None, - ) - if score is not None: - output["score"] = score - if self.diagnostic is not None: - output["diagnostic"] = self.diagnostic.as_dict() - return output - - -class TopologyRegistry: - """Feature-scoped context/topology registry with explainable matching.""" - - def __init__(self) -> None: - self._records: list[TopologyRecord] = [] - self._by_feature: dict[str, list[TopologyRecord]] = {} - self._active_body_id: str | None = None - self._topology_deltas: list[dict[str, Any]] = [] - # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer - # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 - # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 - # stable_id 引用可以解析到 active body 内的新形态。 - self._successors: dict[str, list[str]] = {} - - def register(self, record: TopologyRecord) -> None: - self._records.append(record) - self._by_feature.setdefault(record.feature_id, []).append(record) - - def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]: - return tuple(self._by_feature.get(feature_id, ())) - - def records(self) -> tuple[TopologyRecord, ...]: - return tuple(self._records) - - def topology_deltas(self) -> tuple[dict[str, Any], ...]: - """Return serializable evidence derived from exact adapter history.""" - return tuple(self._topology_deltas) - - def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: - kind = "plane" if isinstance(context, PlaneSpec) else "axis" - record = TopologyRecord( - record_id=f"{feature_id}:{kind}", - kind=kind, - feature_id=feature_id, - geometry=context.as_dict(), - value=context, - ) - self.register(record) - return record - - def replace_body_topology( - self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], - *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, - additional_predecessors: Iterable[TopologyRecord] = (), - ) -> None: - self.replace_body_topologies( - feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta, - additional_predecessors=additional_predecessors, - ) - - def replace_body_topologies( - self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], - *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, - additional_predecessors: Iterable[TopologyRecord] = (), - ) -> None: - """Record a fresh B-rep snapshot after a feature mutates the body. - - OCC topology object identity is invalidated by most body mutations. - We therefore keep old objects out of active selector resolution but - carry their semantic owners forward when, and only when, one current - object has one geometrically equivalent predecessor. A changed or - split object intentionally becomes owned by this feature instead of - being guessed as belonging to an older one. - - ``active_body_id`` names the whole-body group when ``body_id`` is a - member of a multi-solid body (issue #7): the group id keeps the next - mutation's predecessor lookup scoped to every solid of the previous - body, while each member keeps its own ``body:{feature}:{index}`` id. - """ - active_previous = [ - record for record in self._records - if self._active_body_id is not None and record.body_id is not None - and ( - record.body_id == self._active_body_id - or record.body_id.startswith(f"{self._active_body_id}:") - ) - ] - # A pattern COPY can carry a chain of exact transform/boolean builder - # histories before its final aggregate snapshot is registered. These - # temporary records are valid predecessors only for that documented - # kernel-history bridge. They are deliberately excluded from geometric - # fallback matching: an equal-looking final face never proves that it - # belongs to one particular copy instance. - transient_previous = list(additional_predecessors) - previous = [*active_previous, *transient_previous] - # 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们 - # 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。 - # pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index - # 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。 - current = [(body_id, list(records)) for body_id, records in bodies] - current_records = [record for _body_id, records in current for record in records] - ( - exact_predecessors, - exact_successors, - kernel_covered_predecessors, - exact_output_roles, - exact_output_role_sources, - delta_evidence, - ) = self._exact_delta_links( - topology_delta, previous, current_records, - ) - previous_member_ids = { - suffix for record in active_previous - for suffix in [str(record.body_id).rsplit(":", 1)[-1]] - if suffix.isdigit() - } - use_member_indexes = len(current) > 1 and previous_member_ids - consumed_predecessors: set[str] = set() - registered: list[TopologyRecord] = [] - for body_id, records in current: - member_id = str(body_id).rsplit(":", 1)[-1] - local_predecessors = [ - record for record in active_previous - if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id - ] - for record in records: - exact_predecessor_id = exact_predecessors.get(record.record_id) - # Member order is a useful isolation boundary for geometric - # fallback matching, especially for coincident pattern - # copies. It is not a provenance boundary when a kernel - # builder explicitly relates one source subshape to one - # result subshape: boolean/delete lifecycle can remove an - # earlier member and shift a surviving source to another - # member index. A unique OCC continuation remains exact - # evidence across that index change. - predecessor = next( - (prior for prior in previous if prior.record_id == exact_predecessor_id), - None, - ) - if predecessor is None and record.record_id not in exact_predecessors: - predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors) - owners = predecessor.owners if predecessor is not None else (feature_id,) - output_roles = set(record.output_roles) - output_roles.update(exact_output_roles.get(record.record_id, ())) - output_role_sources = set(record.output_role_sources) - output_role_sources.update(exact_output_role_sources.get(record.record_id, ())) - # A feature-output role can survive a later operation only - # through the same unique kernel continuation used for owner - # provenance. Geometry equivalence alone never carries it. - if predecessor is not None and record.record_id in exact_predecessors: - output_roles.update(predecessor.output_roles) - if predecessor is not None and record.record_id not in exact_predecessors: - consumed_predecessors.add(predecessor.record_id) - registered.append(TopologyRecord( - record_id=record.record_id, - kind=record.kind, - feature_id=feature_id, - body_id=body_id, - geometry=dict(record.geometry), - value=record.value, - owner_feature_ids=owners, - output_roles=tuple(sorted(output_roles)), - output_role_sources=tuple(sorted(output_role_sources)), - )) - for record in registered: - self.register(record) - for predecessor_id, successor_ids in exact_successors.items(): - known = self._successors.setdefault(predecessor_id, []) - for successor_id in successor_ids: - if successor_id not in known: - known.append(successor_id) - if delta_evidence is not None: - self._topology_deltas.append({ - "feature_id": feature_id, - "operation": topology_delta.operation, - "relations": delta_evidence, - }) - # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 - # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 - # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 - # 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列 - # (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。 - for prior in previous: - if ( - prior.record_id in consumed_predecessors - or prior.record_id in exact_successors - or prior.record_id in kernel_covered_predecessors - ): - continue - candidates = sorted( - ( - (self._evolved_drift(prior, record), record.record_id) - for record in registered if self._evolved_equivalent(prior, record) - ), - key=lambda item: item[0], - ) - if not candidates: - continue - best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None) - if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]): - self._successors[prior.record_id] = [best[1]] - self._active_body_id = active_body_id or body_id - - @staticmethod - def _same_topology_value(left: Any, right: Any) -> bool: - """Compare adapter handles only through their exact topology identity.""" - left_value = getattr(left, "wrapped", left) - right_value = getattr(right, "wrapped", right) - if left_value is right_value: - return True - for candidate, other in ((left_value, right_value), (right_value, left_value)): - for method_name in ("IsSame", "is_same"): - method = getattr(candidate, method_name, None) - if callable(method): - try: - return bool(method(other)) - except (AttributeError, TypeError, ValueError): - continue - return False - - @classmethod - def _exact_delta_links( - cls, - topology_delta: TopologyDelta | None, - previous: list[TopologyRecord], - current: list[TopologyRecord], - ) -> tuple[ - dict[str, str], - dict[str, list[str]], - set[str], - dict[str, tuple[str, ...]], - dict[str, tuple[tuple[str, str, str], ...]], - list[dict[str, Any]] | None, - ]: - """Bind opaque kernel history to snapshots without geometric guessing. - - Ownership transfer is intentionally limited to a single source item and - a single output item. Split/merge history remains useful evidence, but - has no unique owner continuation until a later operation-specific - contract can express it. - """ - if topology_delta is None: - return {}, {}, set(), {}, {}, None - candidate_sources: dict[str, set[str]] = {} - kernel_covered_predecessors: set[str] = set() - output_roles: dict[str, set[str]] = {} - output_role_sources: dict[str, set[tuple[str, str, str]]] = {} - relation_links: list[tuple[str, str] | None] = [] - evidence: list[dict[str, Any]] = [] - for relation in topology_delta.relations: - sources = [ - record for record in previous - if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value) - ] - outputs = [ - record for record in current - if record.kind == relation.kind - and any(cls._same_topology_value(record.value, value) for value in relation.result_values) - ] - item = { - "event": relation.event, - "kind": relation.kind, - "source_record_ids": [record.record_id for record in sources], - "result_record_ids": [record.record_id for record in outputs], - "proof": "kernel_history", - } - if relation.output_role is not None: - item["output_role"] = relation.output_role - role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 - item["output_role_status"] = ( - "unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot" - ) - if role_is_unique: - output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role) - for source in sources: - for source_role in source.output_roles: - for source_owner in source.owners: - output_role_sources.setdefault(outputs[0].record_id, set()).add( - (relation.output_role, source_owner, source_role) - ) - if len(sources) == 1: - kernel_covered_predecessors.add(sources[0].record_id) - can_transfer = ( - relation.event in {"preserved", "modified"} - and len(relation.result_values) == 1 - and len(sources) == 1 - and len(outputs) == 1 - ) - if can_transfer: - source_id, result_id = sources[0].record_id, outputs[0].record_id - candidate_sources.setdefault(result_id, set()).add(source_id) - relation_links.append((source_id, result_id)) - else: - relation_links.append(None) - evidence.append(item) - predecessors = { - result_id: next(iter(source_ids)) - for result_id, source_ids in candidate_sources.items() - if len(source_ids) == 1 - } - successors: dict[str, list[str]] = {} - for result_id, source_id in predecessors.items(): - successors.setdefault(source_id, []).append(result_id) - for item, relation, link in zip(evidence, topology_delta.relations, relation_links): - if link is not None: - _source_id, result_id = link - item["status"] = ( - "unique_exact_continuation" - if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation" - ) - elif relation.event in {"preserved", "modified"}: - item["status"] = "non_unique_or_incomplete" - else: - item["status"] = "recorded_without_owner_transfer" - return ( - predecessors, - successors, - kernel_covered_predecessors, - {record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()}, - {record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()}, - evidence, - ) - - @staticmethod - def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: - try: - return abs(float(left) - float(right)) <= tolerance - except (TypeError, ValueError): - return False - - @classmethod - def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: - try: - first = _vector3(left, field_name="prior topology geometry") - second = _vector3(right, field_name="current topology geometry") - except ValueError: - return False - return all(abs(a - b) <= tolerance for a, b in zip(first, second)) - - @classmethod - def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool: - """Check a complete, orientation-aware snapshot signature. - - This is intentionally much stricter than selector scoring. Selector - scoring may compare partial source evidence; provenance transfer must - never manufacture ownership from a merely similar candidate. - """ - if prior.kind != current.kind: - return False - left, right = prior.geometry, current.geometry - for key in ("surface_type", "curve_type"): - if left.get(key) != right.get(key): - return False - for key in ("bbox_mm", "center_mm", "normal", "plane_normal"): - if key in left or key in right: - if key not in left or key not in right: - return False - left_value, right_value = left[key], right[key] - if key == "bbox_mm": - if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)): - return False - if len(left_value) != 6 or len(right_value) != 6: - return False - if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)): - return False - elif not cls._vectors_equal(left_value, right_value): - return False - for key in ("area_mm2", "length_mm", "plane_offset_mm"): - if key in left or key in right: - if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]): - return False - for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"): - if key in left or key in right: - if key not in left or key not in right or left[key] != right[key]: - return False - left_start, left_end = left.get("start_mm"), left.get("end_mm") - right_start, right_end = right.get("start_mm"), right.get("end_mm") - if any(value is not None for value in (left_start, left_end, right_start, right_end)): - if None in (left_start, left_end, right_start, right_end): - return False - same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end) - reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start) - if not same_direction and not reverse_direction: - return False - return True - - @classmethod - def _unique_equivalent_predecessor( - cls, - current: TopologyRecord, - predecessors: Iterable[TopologyRecord], - consumed_predecessors: set[str], - ) -> TopologyRecord | None: - matches = [ - record for record in predecessors - if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current) - ] - return matches[0] if len(matches) == 1 else None - - @staticmethod - def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None: - """Endpoint drift between direction-aligned straight edges. - - Returns the minimum total endpoint drift (mm) when the two edges are - collinear straight lines (either orientation), otherwise ``None``. - """ - if prior.kind != current.kind: - return None - left, right = prior.geometry, current.geometry - if left.get("curve_type") != "line" or right.get("curve_type") != "line": - return None - if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")): - return None - - def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: - return (b[0] - a[0], b[1] - a[1], b[2] - a[2]) - - def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: - return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) - - left_dir = _delta(left["start_mm"], left["end_mm"]) - right_dir = _delta(right["start_mm"], right["end_mm"]) - if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9: - return None - cross = ( - left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1], - left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2], - left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0], - ) - if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3: - return None - same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"]) - reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"]) - return min(same_order, reversed_order) - - @classmethod - def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool: - """Loose "position trajectory" equivalence used for evolved successors. - - Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance), - this deliberately tolerates small endpoint drift: fillet/chamfer split a - straight edge into segments (a middle straight run plus end arcs). The - straight run keeps the same direction and stays within ``drift_mm`` of the - original edge, so it can serve as the edge's evolved successor. Uniqueness - is enforced by the caller (only a single best candidate is recorded). - """ - drift = cls._evolved_drift(prior, current) - return drift is not None and drift <= drift_mm - - @staticmethod - def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None: - try: - left = _vector3(expected, field_name="selector geometry") - right = _vector3(actual, field_name="record geometry") - except ValueError: - return None - error = _length(tuple(a - b for a, b in zip(left, right))) - return max(0.0, 1.0 - error / tolerance) - - @classmethod - def _geometry_score(cls, selector_geometry: dict[str, Any], record_geometry: dict[str, Any]) -> float | None: - if not selector_geometry: - return 0.0 - scores: list[float] = [] - for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"): - if key in selector_geometry: - score = cls._vector_score(selector_geometry[key], record_geometry.get(key)) - if score is None: - return None - scores.append(score) - for key in ("surface_type", "curve_type"): - if key in selector_geometry: - if record_geometry.get(key) != selector_geometry[key]: - # #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、 - # 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点) - # 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全 - # 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的 - # 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。 - scores.append(0.5) - else: - scores.append(1.0) - if "bbox_mm" in selector_geometry: - expected = selector_geometry["bbox_mm"] - actual = record_geometry.get("bbox_mm") - if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual): - return None - delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual)) - scores.append(max(0.0, 1.0 - delta / 1e-4)) - if "plane_offset_mm" in selector_geometry: - try: - delta = abs(float(selector_geometry["plane_offset_mm"]) - float(record_geometry.get("plane_offset_mm"))) - except (TypeError, ValueError): - return None - scores.append(max(0.0, 1.0 - delta / 1e-4)) - if "radius_mm" in selector_geometry: - try: - delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm"))) - except (TypeError, ValueError): - return None - scores.append(max(0.0, 1.0 - delta / 1e-4)) - if "area_mm2" in selector_geometry: - try: - expected_area = float(selector_geometry["area_mm2"]) - actual_area = float(record_geometry.get("area_mm2")) - except (TypeError, ValueError): - return None - relative_delta = abs(expected_area - actual_area) / max(abs(expected_area), 1e-9) - scores.append(max(0.0, 1.0 - relative_delta / 1e-4)) - return sum(scores) / len(scores) if scores else 0.0 - - def resolve( - self, - selector: dict[str, Any], - *, - minimum_score: float = 0.8, - active_body_id: str | None = None, - ) -> SelectorResolution: - kind = selector.get("kind") - owner = selector.get("owner_feature_id") - candidates = [record for record in self._records if record.kind == kind] - if active_body_id and kind in {"face", "edge", "vertex", "body"}: - # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 - # 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的 - # 记录(不同 feature 前缀)不会泄漏进来。 - candidates = [ - record for record in candidates - if record.body_id == active_body_id - or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) - ] - if owner: - candidates = [record for record in candidates if owner in record.owners] - if kind == "plane" and selector.get("frame") is not None: - # #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时, - # mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame - # (_translated_node / _mirrored_node),这里直接构造 PlaneSpec, - # 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。 - try: - plane = PlaneSpec.from_mapping(selector.get("frame") or {}) - except (TypeError, ValueError): - plane = None - if plane is not None: - record = TopologyRecord( - record_id=f"inline:{id(plane)}", - kind="plane", - feature_id=str(owner or "inline"), - geometry=plane.as_dict(), - value=plane, - ) - return SelectorResolution( - selector=selector, - status="resolved", - record=record, - candidates=({"score": 1.0, **record.public_dict()},), - ) - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_frame_incomplete", - message="An inline plane frame requires origin_mm, x_dir and normal", - detail={"frame": selector.get("frame")}, - ), - ) - geometry = normalize_selector_geometry(selector.get("geometry")) - if selector.get("snapshot_id") and not owner: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_owner_required", - message="A snapshot selector requires owner_feature_id", - detail={"minimum_score": minimum_score}, - ), - ) - output_role = str(selector.get("output_role") or "").strip() - if output_role: - if not owner: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_owner_required", - message="A feature output role selector requires owner_feature_id", - detail={"output_role": output_role}, - ), - ) - if active_body_id is None: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_active_body_required", - message="A feature output role selector requires an active body snapshot", - detail={"output_role": output_role}, - ), - ) - if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_mixed_evidence", - message="A feature output role selector cannot mix stable or geometry evidence", - detail={"output_role": output_role}, - ), - ) - role_candidates = [record for record in candidates if output_role in record.output_roles] - role_source = selector.get("output_role_source") - if role_source is not None: - source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None - source_role = role_source.get("output_role") if isinstance(role_source, dict) else None - if not isinstance(source_owner, str) or not isinstance(source_role, str): - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_source_invalid", - message="An output role selector source requires owner_feature_id and output_role", - ), - ) - if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_source_unsupported", - message="Output role sources are currently supported only for shell.offset_face from an extrusion cap", - ), - ) - role_candidates = [ - record for record in role_candidates - if (output_role, source_owner, source_role) in record.output_role_sources - ] - public_candidates = tuple( - {"score": 1.0, **record.public_dict()} for record in role_candidates - ) - if len(role_candidates) == 1: - return SelectorResolution( - selector=selector, - status="resolved", - record=role_candidates[0], - candidates=public_candidates, - ) - if len(role_candidates) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_output_role_ambiguous", - message="More than one active topology record has the requested output role", - detail={"output_role": output_role, "candidate_count": len(role_candidates)}, - ), - ) - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_output_role_not_found", - message="No active topology record has the requested output role", - detail={"output_role": output_role, "candidate_count": 0}, - ), - ) - stable_id = str(selector.get("stable_id") or "").strip() - if stable_id: - # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 - # 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。 - # 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body - # 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到 - # 几何打分流程。 - stable_records = [ - record for record in self._records - if record.kind == kind and (not owner or owner in record.owners) - ] - exact = [record for record in stable_records if record.record_id == stable_id] - if len(exact) == 1: - record = exact[0] - is_active = active_body_id is None or ( - record.body_id == active_body_id - or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) - ) - if not is_active: - successors = [ - candidate for candidate in stable_records - if candidate.record_id in self._successors.get(record.record_id, ()) - and ( - candidate.body_id == active_body_id - or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:")) - ) - ] - if len(successors) == 1: - record = successors[0] - is_active = True - elif len(successors) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one evolved successor record satisfies the stable_id", - detail={"stable_id": stable_id, "candidate_count": len(successors)}, - ), - ) - if is_active: - # A stable ID is only a lookup accelerator for snapshot-aware - # selectors. It cannot revive a B-rep entity whose geometric - # signature changed after an upstream rebuild. - if selector.get("snapshot_id"): - score = self._geometry_score(geometry, record.geometry) if geometry else None - if score is None or score < minimum_score: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), - diagnostic=RuntimeDiagnostic( - code="selector_geometry_mismatch", - message="The stable selector record no longer matches its geometry signature", - detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, - ), - ) - return SelectorResolution( - selector=selector, - status="resolved", - record=record, - candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), - ) - if not geometry: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_stable_id_inactive", - message="The stable selector record is not active and has no geometry signature for rebinding", - detail={"stable_id": stable_id}, - ), - ) - if len(exact) > 1: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one runtime topology record has the requested stable_id", - detail={"stable_id": stable_id, "candidate_count": len(exact)}, - ), - ) - if selector.get("snapshot_id") and not geometry: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=(), - diagnostic=RuntimeDiagnostic( - code="selector_geometry_mismatch", - message="A snapshot selector requires a geometry signature", - detail={"minimum_score": minimum_score}, - ), - ) - scored: list[tuple[float, TopologyRecord]] = [] - for candidate in candidates: - # An owner-qualified context selector is deterministic when it has - # a single runtime candidate even if its source stable_id cannot - # survive the SolidWorks -> OCC boundary. - score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry) - if score is not None: - scored.append((score, candidate)) - scored.sort(key=lambda item: (-item[0], item[1].record_id)) - public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored) - if not scored or scored[0][0] < minimum_score: - return SelectorResolution( - selector=selector, - status="not_found", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_not_found", - message="No runtime topology record satisfies the selector", - detail={"candidate_count": len(scored), "minimum_score": minimum_score}, - ), - ) - best_score, best_record = scored[0] - if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9: - return SelectorResolution( - selector=selector, - status="ambiguous", - candidates=public_candidates, - diagnostic=RuntimeDiagnostic( - code="selector_ambiguous", - message="More than one runtime topology record has the best selector score", - detail={"best_score": best_score, "candidate_count": len(scored)}, - ), - ) - return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates) +from .specs import ( + AxisSpec, + BendLeg, + BendSpec, + GearSpec, + HoleSpec, + PlaneSpec, + RackSpec, + ThreadSpec, + Vector3, + canonical_plane_signature, + normalize_selector_geometry, + pattern_instance_member_id, + transform_copy_member_id, + vector_add, + vector_cross, + vector_dot, + vector_scale, + vector_subtract, + vector_unit, +) +from .topology import ( + CapabilityResult, + FeaturePlanNode, + FeatureResult, + RuntimeDiagnostic, + SelectorResolution, + TopologyDelta, + TopologyDeltaRelation, + TopologyRecord, + TopologyRegistry, +) + +__all__ = [ + "AxisSpec", + "BendLeg", + "BendSpec", + "CapabilityResult", + "FeaturePlanNode", + "FeatureResult", + "GearSpec", + "HoleSpec", + "PlaneSpec", + "RackSpec", + "RuntimeDiagnostic", + "SelectorResolution", + "ThreadSpec", + "TopologyDelta", + "TopologyDeltaRelation", + "TopologyRecord", + "TopologyRegistry", + "Vector3", + "canonical_plane_signature", + "normalize_selector_geometry", + "pattern_instance_member_id", + "transform_copy_member_id", + "vector_add", + "vector_cross", + "vector_dot", + "vector_scale", + "vector_subtract", + "vector_unit", +] diff --git a/backend/engine/cdsl_engine/specs.py b/backend/engine/cdsl_engine/specs.py new file mode 100644 index 00000000..30a9a29d --- /dev/null +++ b/backend/engine/cdsl_engine/specs.py @@ -0,0 +1,619 @@ +"""Runtime-neutral CDSL vector math, frames, and parametric feature specs. + +This module deliberately has no build123d dependency. The spec dataclasses +and canonical vector/plane helpers can therefore be used by validation, batch +reporting, and any geometry adapter without importing OCC objects. +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from math import cos, pi, radians, sqrt +from typing import Any + + +Vector3 = tuple[float, float, float] + + +def pattern_instance_member_id(pattern_feature_id: str, source_feature_id: str, instance_index: int) -> str: + """Return the runtime-only body-member key for one proven pattern copy. + + CDSL keeps the three source fields separately, so callers never need to + manufacture this internal key. The body graph uses the same derivation in + runtime and capability preflight. + """ + return f"pattern:{pattern_feature_id}:{source_feature_id}:copy:{instance_index}" + + +def transform_copy_member_id(transform_feature_id: str, source_member_id: str) -> str: + """Return the runtime-only key for one source of a multi-body COPY. + + A multi-source ``transform_bodies`` COPY has several independently + addressable outputs. CDSL records the transform and its selected source as + separate fields; the opaque key stays internal to the body graph. + """ + return f"transform:{transform_feature_id}:{source_member_id}:copy" + +# y_dir 与 x_dir / normal 点积的绝对值不超过该值时,认为 y_dir 是正交的, +# 予以保留;否则视为偏斜数据,正交化并显式警告。 +_Y_DIR_ORTHOGONALITY_TOL = 1e-6 + + +def _vector3(value: Any, *, field_name: str) -> Vector3: + if not isinstance(value, (list, tuple)) or len(value) != 3: + raise ValueError(f"{field_name} must contain three coordinates") + try: + return (float(value[0]), float(value[1]), float(value[2])) + except (TypeError, ValueError) as error: + raise ValueError(f"{field_name} must contain numeric coordinates") from error + + +def _length(value: Vector3) -> float: + return sqrt(sum(component * component for component in value)) + + +def _unit(value: Vector3, *, field_name: str) -> Vector3: + magnitude = _length(value) + if magnitude <= 1e-12: + raise ValueError(f"{field_name} must be non-zero") + return tuple(component / magnitude for component in value) # type: ignore[return-value] + + +def _dot(left: Vector3, right: Vector3) -> float: + return sum(a * b for a, b in zip(left, right)) + + +def _cross(left: Vector3, right: Vector3) -> Vector3: + return ( + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ) + + +def vector_add(left: Vector3, right: Vector3) -> Vector3: + return tuple(a + b for a, b in zip(left, right)) # type: ignore[return-value] + + +def vector_subtract(left: Vector3, right: Vector3) -> Vector3: + return tuple(a - b for a, b in zip(left, right)) # type: ignore[return-value] + + +def vector_scale(value: Vector3, factor: float) -> Vector3: + return tuple(component * factor for component in value) # type: ignore[return-value] + + +def vector_dot(left: Vector3, right: Vector3) -> float: + return _dot(left, right) + + +def vector_cross(left: Vector3, right: Vector3) -> Vector3: + return _cross(left, right) + + +def vector_unit(value: Vector3, *, field_name: str = "vector") -> Vector3: + return _unit(value, field_name=field_name) + + +def canonical_plane_signature(normal: Vector3, point_mm: Vector3) -> tuple[Vector3, float]: + """Normalize a plane sign so source and OCC face orientations compare.""" + unit_normal = _unit(normal, field_name="plane.normal") + offset = _dot(unit_normal, point_mm) + for component in unit_normal: + if abs(component) <= 1e-12: + continue + if component < 0: + unit_normal = tuple(-value for value in unit_normal) # type: ignore[assignment] + offset = -offset + break + return unit_normal, offset + + +def normalize_selector_geometry(geometry: Any) -> dict[str, Any]: + """Convert legacy SolidWorks selector evidence into runtime-neutral units. + + Current CDSL records may already carry ``*_mm`` fields. Older exported + evidence instead stores SolidWorks surface parameters, boxes, and areas in + SI units. The selector remains the source of truth; this function only + makes its geometric signature comparable to an OCC topology snapshot. + """ + if not isinstance(geometry, dict): + return {} + result = dict(geometry) + surface = geometry.get("surface") + if isinstance(surface, dict): + surface_type = str(surface.get("type") or "").lower() + if surface_type: + result.setdefault("surface_type", surface_type) + parameters = surface.get("parameters") + if surface_type == "plane" and isinstance(parameters, list) and len(parameters) >= 6: + try: + raw_normal = _vector3(parameters[:3], field_name="selector surface normal") + # SolidWorks evidence uses metres for surface locations. + point_mm = tuple(float(value) * 1000.0 for value in parameters[3:6]) + plane_normal, plane_offset = canonical_plane_signature(raw_normal, point_mm) # type: ignore[arg-type] + result.setdefault("plane_normal", list(plane_normal)) + result.setdefault("plane_offset_mm", plane_offset) + except (TypeError, ValueError): + pass + curve = geometry.get("curve") + if isinstance(curve, dict) and curve.get("type"): + result.setdefault("curve_type", str(curve["type"]).lower()) + raw_box = geometry.get("box") + if isinstance(raw_box, list) and len(raw_box) == 6: + try: + result.setdefault("bbox_mm", [float(value) * 1000.0 for value in raw_box]) + except (TypeError, ValueError): + pass + raw_area = geometry.get("area") + if raw_area is not None: + try: + result.setdefault("area_mm2", float(raw_area) * 1_000_000.0) + except (TypeError, ValueError): + pass + for raw_key, normalized_key in (("start", "start_mm"), ("end", "end_mm")): + value = geometry.get(raw_key) + if isinstance(value, list) and len(value) == 3: + try: + result.setdefault(normalized_key, [float(component) * 1000.0 for component in value]) + except (TypeError, ValueError): + pass + return result + + +@dataclass(frozen=True) +class AxisSpec: + """Canonical axis with a normalized direction.""" + + origin_mm: Vector3 + direction: Vector3 + + @classmethod + def from_mapping(cls, value: dict[str, Any]) -> "AxisSpec": + return cls( + origin_mm=_vector3(value.get("origin_mm"), field_name="axis.origin_mm"), + direction=_unit(_vector3(value.get("direction"), field_name="axis.direction"), field_name="axis.direction"), + ) + + def as_dict(self) -> dict[str, list[float]]: + return {"origin_mm": list(self.origin_mm), "direction": list(self.direction)} + + +@dataclass(frozen=True) +class PlaneSpec: + """Canonical right-handed plane frame. + + SolidWorks exports may contain a redundant or non-orthogonal y direction. + The runtime persists the orthonormalized frame so later features all use + the same coordinate system. + """ + + origin_mm: Vector3 + x_dir: Vector3 + y_dir: Vector3 + normal: Vector3 + + @classmethod + def from_mapping(cls, value: dict[str, Any]) -> "PlaneSpec": + origin = _vector3(value.get("origin_mm"), field_name="plane.origin_mm") + normal = _unit(_vector3(value.get("normal"), field_name="plane.normal"), field_name="plane.normal") + x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir") + projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3)) + x_dir = _unit(projected_x, field_name="plane.x_dir") + generated = _unit(_cross(normal, x_dir), field_name="plane.y_dir") + y_raw = value.get("y_dir") + if y_raw is None: + # y_dir 缺失:用 normal × x_dir 补全右手系(默认行为)。 + y_dir = generated + else: + y_vec = _unit(_vector3(y_raw, field_name="plane.y_dir"), field_name="plane.y_dir") + if abs(_dot(y_vec, x_dir)) <= _Y_DIR_ORTHOGONALITY_TOL and abs(_dot(y_vec, normal)) <= _Y_DIR_ORTHOGONALITY_TOL: + # 输入 y_dir 与 x_dir / normal 正交:尊重文档作者给的坐标方向, + # 不再静默丢弃(SolidWorks 导出的非标准 y_dir 得以保留)。 + y_dir = y_vec + else: + # 偏斜 y_dir:正交化并显式警告,避免"静默丢语义"。 + warnings.warn( + f"plane y_dir {list(y_vec)} is not orthogonal to x_dir/normal; re-orthogonalized to {list(generated)}", + UserWarning, + stacklevel=2, + ) + y_dir = generated + return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal) + + def as_dict(self) -> dict[str, list[float]]: + return { + "origin_mm": list(self.origin_mm), + "x_dir": list(self.x_dir), + "y_dir": list(self.y_dir), + "normal": list(self.normal), + } + + +@dataclass(frozen=True) +class HoleSpec: + """Runtime-neutral definition of a cylindrical Hole Wizard operation. + + The spec deliberately contains no OCC planes or shapes. The runtime + resolves the host frame and the adapter turns this definition into a + cutting tool, keeping source-contract parsing separate from B-rep work. + """ + + diameter_mm: float + depth_mm: float + end_condition: str + positions_mm: tuple[Vector3, ...] + countersink: tuple[float, float] | None = None + counterbore: tuple[float, float] | None = None + + @classmethod + def from_feature(cls, atomic_id: str, params: dict[str, Any], *, wizard: bool) -> "HoleSpec": + try: + diameter = float(params.get("diameter_mm") or 0.0) + depth = float(params.get("depth_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("hole dimensions must be numeric") from error + if diameter <= 0 or depth <= 0: + raise ValueError("hole requires positive diameter_mm and depth_mm") + condition = str((params.get("end_condition") or {"type": "blind"}).get("type") or "blind") + if condition not in {"blind", "through_all", "through_all_both"}: + raise ValueError(f"unsupported hole extent {condition!r}") + raw_positions = params.get("positions") or [] + positions = tuple(_vector3(item.get("mm"), field_name="hole position") for item in raw_positions if isinstance(item, dict)) + if len(positions) != len(raw_positions) or not positions: + raise ValueError("hole requires non-empty positions with three-dimensional mm coordinates") + + raw_sink: dict[str, Any] | None = params.get("countersink") if wizard else None + raw_bore: dict[str, Any] | None = params.get("counterbore") if wizard else None + if atomic_id == "hole_countersink": + raw_sink = {"diameter_mm": params.get("countersink_diameter_mm"), "angle_rad": params.get("countersink_angle_rad")} + if atomic_id == "hole_counterbore": + raw_bore = {"diameter_mm": params.get("counterbore_diameter_mm"), "depth_mm": params.get("counterbore_depth_mm")} + + def dimensions(value: dict[str, Any] | None, second: str, label: str) -> tuple[float, float] | None: + if value is None: + return None + try: + first_value = float(value.get("diameter_mm") or 0.0) + second_value = float(value.get(second) or 0.0) + except (AttributeError, TypeError, ValueError) as error: + raise ValueError(f"{label} dimensions must be numeric") from error + if first_value <= diameter or second_value <= 0: + raise ValueError(f"{label} requires a diameter larger than the main hole and a positive {second}") + return first_value, second_value + + return cls( + diameter_mm=diameter, + depth_mm=depth, + end_condition=condition, + positions_mm=positions, + countersink=dimensions(raw_sink, "angle_rad", "countersink"), + counterbore=dimensions(raw_bore, "depth_mm", "counterbore"), + ) + + +@dataclass(frozen=True) +class ThreadSpec: + """Runtime-neutral definition of a parametric screw thread. + + The spec deliberately contains no OCC planes or shapes. The adapter turns + this definition into a threaded solid segment keeping source-contract + parsing separate from B-rep work. + + ``axis.origin_mm`` anchors the leading end face of the threaded segment and + ``axis.direction`` is the outward thread axis; ``angle_deg`` is the full + flank angle (60 for ISO metric V threads, 30 for trapezoidal leadscrews). + ``internal`` selects the cutting form used by later thread_cut support. + """ + + major_diameter_mm: float + minor_diameter_mm: float + pitch_mm: float + length_mm: float + axis: AxisSpec + angle_deg: float = 60.0 + internal: bool = False + lefthand: bool = False + relief_length_mm: float = 0.0 + crest_radius_mm: float = 0.0 + root_radius_mm: float = 0.0 + + @classmethod + def from_feature(cls, atomic_id: str, params: dict[str, Any]) -> "ThreadSpec": + try: + major = float(params.get("major_diameter_mm") or 0.0) + minor = float(params.get("minor_diameter_mm") or 0.0) + pitch = float(params.get("pitch_mm") or 0.0) + length = float(params.get("length_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("thread dimensions must be numeric") from error + if major <= 0 or minor <= 0 or pitch <= 0 or length <= 0: + raise ValueError("thread requires positive major/minor/pitch/length in millimetres") + if minor >= major: + raise ValueError("thread minor diameter must be smaller than the major diameter") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("thread requires an axis definition") + angle = 60.0 + raw_angle = params.get("angle_deg") + if raw_angle is not None: + try: + angle = float(raw_angle) + except (TypeError, ValueError) as error: + raise ValueError("thread angle_deg must be numeric") from error + if not 0 < angle < 180: + raise ValueError("thread angle_deg must be between 0 and 180") + relief = float(params.get("relief_length_mm") or 0.0) + crest_r = float(params.get("crest_radius_mm") or 0.0) + root_r = float(params.get("root_radius_mm") or 0.0) + if relief < 0: + raise ValueError("thread relief_length_mm must be non-negative") + if crest_r < 0 or root_r < 0: + raise ValueError("thread crest/root radius must be non-negative") + # 清根退化保护:crest + root 圆角半径之和不能超过可用牙高的一半, + # 否则梯形牙截面会被 fillet 完全吃掉,无法形成封闭截面。 + depth_radius = (major - minor) / 2.0 + if crest_r + root_r > 0.5 * depth_radius: + raise ValueError("thread crest_radius + root_radius exceeds half the thread depth") + # thread_cut 语义上恒为内螺纹:CDSL 输入无需显式传 internal,即使传 + # internal=False 也强制为 True(外螺纹切槽没有任何物理意义,且外轮廓 + # 刀具做布尔差会在 coincident faces 上退化)。 + internal = True if atomic_id == "thread_cut" else bool(params.get("internal", False)) + return cls( + major_diameter_mm=major, + minor_diameter_mm=minor, + pitch_mm=pitch, + length_mm=length, + axis=AxisSpec.from_mapping(raw_axis), + angle_deg=angle, + internal=internal, + lefthand=bool(params.get("lefthand", False)), + relief_length_mm=relief, + crest_radius_mm=crest_r, + root_radius_mm=root_r, + ) + + +@dataclass(frozen=True) +class BendLeg: + """One straight wing of a bent sheet-metal chain (runtime-neutral). + + ``leg_mm`` is the straight mid-plane distance from the previous fold vertex + to the fold vertex leaving this wing (the unfolded chord length between the + two surrounding fold vertices). When the wing has a following wing, + ``bend_angle_deg`` is the required *interior* angle between the two wings + (0 < angle < 180, 90 = a right-angle bend), ``inner_radius_mm`` the inner + bend-surface fillet radius (>= 0; the outer radius is always + ``inner_radius_mm + thickness_mm``) and ``side`` the fold direction + (+1 / -1). A trailing wing carries no fold; stray fold fields on the last + wing are ignored for tolerance. + """ + + leg_mm: float + bend_angle_deg: float | None = None + inner_radius_mm: float = 0.0 + side: int = 1 + + +@dataclass(frozen=True) +class BendSpec: + """Runtime-neutral definition of a sheet-metal bend (``bend_add``). + + The part is described by its mid-plane centreline: ``chain`` lists the + straight wings joined by equal-thickness bend corners. ``thickness_mm`` + and ``width_mm`` are the sheet thickness and the full length along the fold + (width) axis. ``frame.origin_mm`` anchors the start of the first wing's + mid-plane path, ``frame.x_dir`` is the fold/width axis and + ``frame.normal`` is the mid-plane normal of the first wing; the first wing + extends along ``normal x x_dir``. + + The spec deliberately contains no OCC planes or shapes. The adapter turns + this definition into a bent solid keeping source-contract parsing separate + from B-rep work (same pattern as :class:`ThreadSpec`). + """ + + thickness_mm: float + width_mm: float + frame: PlaneSpec + chain: tuple[BendLeg, ...] + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "BendSpec": + try: + thickness = float(params.get("thickness_mm") or 0.0) + width = float(params.get("width_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("bend thickness/width must be numeric") from error + if thickness <= 0 or width <= 0: + raise ValueError("bend requires positive thickness_mm and width_mm in millimetres") + raw_chain = params.get("chain") + if not isinstance(raw_chain, (list, tuple)) or not raw_chain: + raise ValueError("bend requires a non-empty chain of wing segments") + chain: list[BendLeg] = [] + for index, raw_leg in enumerate(raw_chain): + if not isinstance(raw_leg, dict): + raise ValueError(f"bend chain[{index}] must be an object with leg_mm") + try: + leg = float(raw_leg.get("leg_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError(f"bend chain[{index}].leg_mm must be numeric") from error + if leg <= 0: + raise ValueError(f"bend chain[{index}].leg_mm must be positive") + bend_angle: float | None = None + if index < len(raw_chain) - 1: + raw_angle = raw_leg.get("bend_angle_deg") + if raw_angle is None: + raise ValueError( + f"bend chain[{index}] needs bend_angle_deg for the fold towards the next wing" + ) + try: + bend_angle = float(raw_angle) + except (TypeError, ValueError) as error: + raise ValueError(f"bend chain[{index}].bend_angle_deg must be numeric") from error + if not 0 < bend_angle < 180: + raise ValueError("bend interior angle must be between 0 and 180 degrees") + radius = float(raw_leg.get("inner_radius_mm") or 0.0) + raw_side = raw_leg.get("side") + side = 1 if raw_side is None else int(raw_side) + if radius < 0: + raise ValueError(f"bend chain[{index}].inner_radius_mm must be non-negative") + if side not in (1, -1): + raise ValueError(f"bend chain[{index}].side must be +1 or -1") + chain.append(BendLeg( + leg_mm=leg, + bend_angle_deg=bend_angle, + inner_radius_mm=radius, + side=side, + )) + raw_frame = params.get("frame") + if isinstance(raw_frame, dict): + frame = PlaneSpec.from_mapping(raw_frame) + else: + # frame 缺省:首翼沿世界 +X 延伸、厚度沿 +Y、折痕沿 +Z(贴 XY 平面)。 + frame = PlaneSpec( + origin_mm=(0.0, 0.0, 0.0), + x_dir=(0.0, 0.0, 1.0), + y_dir=(1.0, 0.0, 0.0), + normal=(0.0, 1.0, 0.0), + ) + return cls(thickness_mm=thickness, width_mm=width, frame=frame, chain=tuple(chain)) + + +@dataclass(frozen=True) +class GearSpec: + """Runtime-neutral definition of an involute spur/helical/herringbone gear. + + ``module_mm``/``teeth_count``/``pressure_angle_rad`` follow the ISO + involute convention: pitch radius ``m*z/2``, base radius + ``m*z/2*cos(alpha)``, addendum ``m`` and dedendum ``1.25*m``. ``width_mm`` + is the axial face width. ``helix_angle_rad`` is the pitch-cylinder helix + angle (0 = spur); ``herringbone=True`` builds a symmetric double-helical + gear whose helix reverses at mid-width (the mid-plane seam is shared + phase, no V-notch). ``axis.origin_mm`` is the centre of the ``z = 0`` end + face and ``axis.direction`` the outward gear axis. + """ + + module_mm: float + teeth_count: int + width_mm: float + axis: AxisSpec + helix_angle_rad: float = 0.0 + pressure_angle_rad: float = pi / 9.0 # 20 degrees + herringbone: bool = False + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "GearSpec": + try: + module = float(params.get("module_mm") or 0.0) + width = float(params.get("width_mm") or 0.0) + teeth = int(params.get("teeth_count") or 0) + helix_raw = params.get("helix_angle_rad") + helix = float(helix_raw) if helix_raw is not None else 0.0 + pressure_raw = params.get("pressure_angle_rad") + pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 + except (TypeError, ValueError) as error: + raise ValueError("gear dimensions must be numeric") from error + if module <= 0 or width <= 0: + raise ValueError("gear requires positive module_mm and width_mm") + if not 8 <= teeth <= 200: + raise ValueError("gear teeth_count must be between 8 and 200") + if not 0.0 <= helix < radians(45.0): + raise ValueError("gear helix_angle_rad must be within [0, 45) degrees") + if not 0.0 < pressure <= radians(35.0): + raise ValueError("gear pressure_angle_rad must be within (0, 35] degrees") + herringbone = bool(params.get("herringbone", False)) + if herringbone and helix <= 0.0: + raise ValueError("herringbone gear requires helix_angle_rad > 0") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("gear requires an axis definition") + return cls( + module_mm=module, + teeth_count=teeth, + width_mm=width, + axis=AxisSpec.from_mapping(raw_axis), + helix_angle_rad=helix, + pressure_angle_rad=pressure, + herringbone=herringbone, + ) + + @property + def pitch_radius_mm(self) -> float: + return self.module_mm * self.teeth_count / 2.0 + + @property + def base_radius_mm(self) -> float: + return self.pitch_radius_mm * cos(self.pressure_angle_rad) + + @property + def tip_radius_mm(self) -> float: + return self.pitch_radius_mm + self.module_mm + + @property + def root_radius_mm(self) -> float: + return self.pitch_radius_mm - 1.25 * self.module_mm + + +@dataclass(frozen=True) +class RackSpec: + """Runtime-neutral definition of a straight-sided rack (``rack_add``). + + The rack is the linear counterpart of a gear: tooth pitch ``p = pi*m``, + addendum ``m`` above the reference line and dedendum ``1.25*m`` below it, + flanks inclined by ``pressure_angle_rad``. ``teeth_count`` sets the exact + overall length ``teeth_count * pi * m`` (both ends land in tooth valley + centres). ``thickness_mm`` is the width along the tooth crest direction. + ``axis.origin_mm`` is the corner point where the length-start end face, + the thickness-start face and the tooth-valley plane intersect, and + ``axis.direction`` is the direction the teeth point (local +Z). + """ + + module_mm: float + teeth_count: int + thickness_mm: float + axis: AxisSpec + pressure_angle_rad: float = pi / 9.0 # 20 degrees + + @classmethod + def from_feature(cls, params: dict[str, Any]) -> "RackSpec": + try: + module = float(params.get("module_mm") or 0.0) + thickness = float(params.get("thickness_mm") or 0.0) + teeth = int(params.get("teeth_count") or 0) + pressure_raw = params.get("pressure_angle_rad") + pressure = float(pressure_raw) if pressure_raw is not None else pi / 9.0 + except (TypeError, ValueError) as error: + raise ValueError("rack dimensions must be numeric") from error + if module <= 0 or thickness <= 0: + raise ValueError("rack requires positive module_mm and thickness_mm") + if not 1 <= teeth <= 2000: + raise ValueError("rack teeth_count must be between 1 and 2000") + if not 0.0 < pressure <= radians(35.0): + raise ValueError("rack pressure_angle_rad must be within (0, 35] degrees") + raw_axis = params.get("axis") + if not isinstance(raw_axis, dict): + raise ValueError("rack requires an axis definition") + return cls( + module_mm=module, + teeth_count=teeth, + thickness_mm=thickness, + axis=AxisSpec.from_mapping(raw_axis), + pressure_angle_rad=pressure, + ) + + @property + def pitch_mm(self) -> float: + return pi * self.module_mm + + @property + def length_mm(self) -> float: + return self.pitch_mm * self.teeth_count + + @property + def addendum_mm(self) -> float: + return self.module_mm + + @property + def dedendum_mm(self) -> float: + return 1.25 * self.module_mm diff --git a/backend/engine/cdsl_engine/topology.py b/backend/engine/cdsl_engine/topology.py new file mode 100644 index 00000000..6b73e11f --- /dev/null +++ b/backend/engine/cdsl_engine/topology.py @@ -0,0 +1,1010 @@ +"""Runtime-neutral CDSL diagnostics, planning, and topology contracts. + +This module deliberately has no build123d dependency. The planner and +selector resolver can therefore be used by validation, batch reporting, and +any geometry adapter without importing OCC objects. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from math import sqrt +from typing import Any, Iterable + +from .specs import AxisSpec, PlaneSpec, Vector3, _length, _vector3, normalize_selector_geometry + + +@dataclass(frozen=True) +class RuntimeDiagnostic: + code: str + message: str + feature_id: str | None = None + detail: dict[str, Any] = field(default_factory=dict) + + def as_dict(self) -> dict[str, Any]: + output: dict[str, Any] = {"code": self.code, "message": self.message} + if self.feature_id is not None: + output["feature_id"] = self.feature_id + if self.detail: + output["detail"] = self.detail + return output + + +@dataclass(frozen=True) +class CapabilityResult: + feature_id: str + atomic_id: str + resolved_status: str + required_capabilities: tuple[str, ...] = () + blockers: tuple[RuntimeDiagnostic, ...] = () + + @property + def executable(self) -> bool: + return self.resolved_status == "executable" + + def as_dict(self) -> dict[str, Any]: + return { + "feature_id": self.feature_id, + "atomic_id": self.atomic_id, + "resolved_status": self.resolved_status, + "required_capabilities": list(self.required_capabilities), + "blockers": [blocker.as_dict() for blocker in self.blockers], + } + + +@dataclass(frozen=True) +class FeaturePlanNode: + feature_id: str + atomic_id: str + name: str | None + depends_on: tuple[str, ...] + params: dict[str, Any] + selectors: tuple[dict[str, Any], ...] + sketch_id: str | None + declared_status: str | None + source_feature: dict[str, Any] + + +@dataclass +class FeatureResult: + feature_id: str + atomic_id: str + status: str + body_id: str | None = None + surface_id: str | None = None + context: PlaneSpec | AxisSpec | None = None + replay_definition: dict[str, Any] | None = None + diagnostics: list[RuntimeDiagnostic] = field(default_factory=list) + + def as_dict(self) -> dict[str, Any]: + output: dict[str, Any] = { + "feature_id": self.feature_id, + "atomic_id": self.atomic_id, + "status": self.status, + "diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics], + } + if self.body_id is not None: + output["body_id"] = self.body_id + if self.surface_id is not None: + output["surface_id"] = self.surface_id + if self.context is not None: + output["context"] = self.context.as_dict() + if self.replay_definition is not None: + output["replay_definition"] = self.replay_definition + return output + + +@dataclass(frozen=True) +class TopologyRecord: + """Runtime-side signature of a topology item or context object. + + ``feature_id`` identifies the feature that produced this *snapshot*. + ``owner_feature_ids`` is durable semantic provenance for an unchanged + current B-rep item. Boolean and dress-up operations replace OCC objects, + so keeping these concepts separate prevents a later mutation from making + every surviving face appear to be owned by that mutation. + """ + + record_id: str + kind: str + feature_id: str + body_id: str | None = None + geometry: dict[str, Any] = field(default_factory=dict) + value: Any = None + owner_feature_ids: tuple[str, ...] = () + # Builder-produced roles describe a particular result subshape. They are + # deliberately separate from the geometric signature: equal geometry does + # not prove that two faces have the same feature-output meaning. + output_roles: tuple[str, ...] = () + # Generated roles may carry the direct feature-output role that the kernel + # operation transformed. This is semantic provenance, not a stable-id + # shortcut: the resolver still requires the exact active result snapshot. + output_role_sources: tuple[tuple[str, str, str], ...] = () + + @property + def owners(self) -> tuple[str, ...]: + """Return durable provenance, retaining compatibility for contexts.""" + return self.owner_feature_ids or (self.feature_id,) + + def public_dict(self) -> dict[str, Any]: + output: dict[str, Any] = { + "record_id": self.record_id, + "kind": self.kind, + "feature_id": self.feature_id, + "geometry": self.geometry, + } + if self.body_id is not None: + output["body_id"] = self.body_id + if self.owner_feature_ids: + output["owner_feature_ids"] = list(self.owner_feature_ids) + if self.output_roles: + output["output_roles"] = list(self.output_roles) + if self.output_role_sources: + output["output_role_sources"] = [ + {"output_role": role, "owner_feature_id": owner, "source_output_role": source_role} + for role, owner, source_role in self.output_role_sources + ] + return output + + +@dataclass(frozen=True) +class TopologyDeltaRelation: + """One opaque kernel-history relationship for a topology subshape. + + Geometry adapters retain ownership of the values in this structure. They + are intentionally opaque to the runtime: a build123d/OCC adapter may use + ``TopoDS_Shape`` values while another adapter can use its native handles. + The registry only asks whether a handle is exactly the same topology item; + it never uses this contract to score nearby geometry. + """ + + event: str + kind: str + source_value: Any + result_values: tuple[Any, ...] = () + output_role: str | None = None + + def __post_init__(self) -> None: + if self.event not in {"preserved", "modified", "generated", "deleted"}: + raise ValueError(f"unsupported topology delta event {self.event!r}") + if self.kind not in {"face", "edge", "vertex"}: + raise ValueError(f"unsupported topology delta kind {self.kind!r}") + if self.event == "deleted" and (self.result_values or self.output_role is not None): + raise ValueError("deleted topology delta relations cannot have result values or an output role") + if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): + raise ValueError("topology delta output_role must be a non-empty string when provided") + + +@dataclass(frozen=True) +class TopologyDelta: + """Kernel-backed topology history for one adapter operation. + + ``operation`` is evidence only. The runtime transfers durable provenance + solely from a unique exact relationship, never from an operation name or a + geometric resemblance. + """ + + operation: str + relations: tuple[TopologyDeltaRelation, ...] = () + + +@dataclass(frozen=True) +class SelectorResolution: + selector: dict[str, Any] + status: str + record: TopologyRecord | None = None + candidates: tuple[dict[str, Any], ...] = () + diagnostic: RuntimeDiagnostic | None = None + + def as_dict(self) -> dict[str, Any]: + output = { + "selector": self.selector, + "status": self.status, + "candidates": list(self.candidates), + } + if self.record is not None: + output["record"] = self.record.public_dict() + output["selected"] = self.record.public_dict() + score = next( + (candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id), + None, + ) + if score is not None: + output["score"] = score + if self.diagnostic is not None: + output["diagnostic"] = self.diagnostic.as_dict() + return output + + +class TopologyRegistry: + """Feature-scoped context/topology registry with explainable matching.""" + + def __init__(self) -> None: + self._records: list[TopologyRecord] = [] + self._by_feature: dict[str, list[TopologyRecord]] = {} + self._active_body_id: str | None = None + self._topology_deltas: list[dict[str, Any]] = [] + # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer + # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 + # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 + # stable_id 引用可以解析到 active body 内的新形态。 + self._successors: dict[str, list[str]] = {} + + def register(self, record: TopologyRecord) -> None: + self._records.append(record) + self._by_feature.setdefault(record.feature_id, []).append(record) + + def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]: + return tuple(self._by_feature.get(feature_id, ())) + + def records(self) -> tuple[TopologyRecord, ...]: + return tuple(self._records) + + def topology_deltas(self) -> tuple[dict[str, Any], ...]: + """Return serializable evidence derived from exact adapter history.""" + return tuple(self._topology_deltas) + + def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: + kind = "plane" if isinstance(context, PlaneSpec) else "axis" + record = TopologyRecord( + record_id=f"{feature_id}:{kind}", + kind=kind, + feature_id=feature_id, + geometry=context.as_dict(), + value=context, + ) + self.register(record) + return record + + def replace_body_topology( + self, feature_id: str, body_id: str, records: Iterable[TopologyRecord], + *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, + additional_predecessors: Iterable[TopologyRecord] = (), + ) -> None: + self.replace_body_topologies( + feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta, + additional_predecessors=additional_predecessors, + ) + + def replace_body_topologies( + self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]], + *, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None, + additional_predecessors: Iterable[TopologyRecord] = (), + ) -> None: + """Record a fresh B-rep snapshot after a feature mutates the body. + + OCC topology object identity is invalidated by most body mutations. + We therefore keep old objects out of active selector resolution but + carry their semantic owners forward when, and only when, one current + object has one geometrically equivalent predecessor. A changed or + split object intentionally becomes owned by this feature instead of + being guessed as belonging to an older one. + + ``active_body_id`` names the whole-body group when ``body_id`` is a + member of a multi-solid body (issue #7): the group id keeps the next + mutation's predecessor lookup scoped to every solid of the previous + body, while each member keeps its own ``body:{feature}:{index}`` id. + """ + active_previous = [ + record for record in self._records + if self._active_body_id is not None and record.body_id is not None + and ( + record.body_id == self._active_body_id + or record.body_id.startswith(f"{self._active_body_id}:") + ) + ] + # A pattern COPY can carry a chain of exact transform/boolean builder + # histories before its final aggregate snapshot is registered. These + # temporary records are valid predecessors only for that documented + # kernel-history bridge. They are deliberately excluded from geometric + # fallback matching: an equal-looking final face never proves that it + # belongs to one particular copy instance. + transient_previous = list(additional_predecessors) + previous = [*active_previous, *transient_previous] + # 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们 + # 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。 + # pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index + # 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。 + current = [(body_id, list(records)) for body_id, records in bodies] + current_records = [record for _body_id, records in current for record in records] + ( + exact_predecessors, + exact_successors, + kernel_covered_predecessors, + exact_output_roles, + exact_output_role_sources, + delta_evidence, + ) = self._exact_delta_links( + topology_delta, previous, current_records, + ) + previous_member_ids = { + suffix for record in active_previous + for suffix in [str(record.body_id).rsplit(":", 1)[-1]] + if suffix.isdigit() + } + use_member_indexes = len(current) > 1 and previous_member_ids + consumed_predecessors: set[str] = set() + registered: list[TopologyRecord] = [] + for body_id, records in current: + member_id = str(body_id).rsplit(":", 1)[-1] + local_predecessors = [ + record for record in active_previous + if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id + ] + for record in records: + exact_predecessor_id = exact_predecessors.get(record.record_id) + # Member order is a useful isolation boundary for geometric + # fallback matching, especially for coincident pattern + # copies. It is not a provenance boundary when a kernel + # builder explicitly relates one source subshape to one + # result subshape: boolean/delete lifecycle can remove an + # earlier member and shift a surviving source to another + # member index. A unique OCC continuation remains exact + # evidence across that index change. + predecessor = next( + (prior for prior in previous if prior.record_id == exact_predecessor_id), + None, + ) + if predecessor is None and record.record_id not in exact_predecessors: + predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors) + owners = predecessor.owners if predecessor is not None else (feature_id,) + output_roles = set(record.output_roles) + output_roles.update(exact_output_roles.get(record.record_id, ())) + output_role_sources = set(record.output_role_sources) + output_role_sources.update(exact_output_role_sources.get(record.record_id, ())) + # A feature-output role can survive a later operation only + # through the same unique kernel continuation used for owner + # provenance. Geometry equivalence alone never carries it. + if predecessor is not None and record.record_id in exact_predecessors: + output_roles.update(predecessor.output_roles) + if predecessor is not None and record.record_id not in exact_predecessors: + consumed_predecessors.add(predecessor.record_id) + registered.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=feature_id, + body_id=body_id, + geometry=dict(record.geometry), + value=record.value, + owner_feature_ids=owners, + output_roles=tuple(sorted(output_roles)), + output_role_sources=tuple(sorted(output_role_sources)), + )) + for record in registered: + self.register(record) + for predecessor_id, successor_ids in exact_successors.items(): + known = self._successors.setdefault(predecessor_id, []) + for successor_id in successor_ids: + if successor_id not in known: + known.append(successor_id) + if delta_evidence is not None: + self._topology_deltas.append({ + "feature_id": feature_id, + "operation": topology_delta.operation, + "relations": delta_evidence, + }) + # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 + # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 + # 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段 + # 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列 + # (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。 + for prior in previous: + if ( + prior.record_id in consumed_predecessors + or prior.record_id in exact_successors + or prior.record_id in kernel_covered_predecessors + ): + continue + candidates = sorted( + ( + (self._evolved_drift(prior, record), record.record_id) + for record in registered if self._evolved_equivalent(prior, record) + ), + key=lambda item: item[0], + ) + if not candidates: + continue + best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None) + if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]): + self._successors[prior.record_id] = [best[1]] + self._active_body_id = active_body_id or body_id + + @staticmethod + def _same_topology_value(left: Any, right: Any) -> bool: + """Compare adapter handles only through their exact topology identity.""" + left_value = getattr(left, "wrapped", left) + right_value = getattr(right, "wrapped", right) + if left_value is right_value: + return True + for candidate, other in ((left_value, right_value), (right_value, left_value)): + for method_name in ("IsSame", "is_same"): + method = getattr(candidate, method_name, None) + if callable(method): + try: + return bool(method(other)) + except (AttributeError, TypeError, ValueError): + continue + return False + + @classmethod + def _exact_delta_links( + cls, + topology_delta: TopologyDelta | None, + previous: list[TopologyRecord], + current: list[TopologyRecord], + ) -> tuple[ + dict[str, str], + dict[str, list[str]], + set[str], + dict[str, tuple[str, ...]], + dict[str, tuple[tuple[str, str, str], ...]], + list[dict[str, Any]] | None, + ]: + """Bind opaque kernel history to snapshots without geometric guessing. + + Ownership transfer is intentionally limited to a single source item and + a single output item. Split/merge history remains useful evidence, but + has no unique owner continuation until a later operation-specific + contract can express it. + """ + if topology_delta is None: + return {}, {}, set(), {}, {}, None + candidate_sources: dict[str, set[str]] = {} + kernel_covered_predecessors: set[str] = set() + output_roles: dict[str, set[str]] = {} + output_role_sources: dict[str, set[tuple[str, str, str]]] = {} + relation_links: list[tuple[str, str] | None] = [] + evidence: list[dict[str, Any]] = [] + for relation in topology_delta.relations: + sources = [ + record for record in previous + if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value) + ] + outputs = [ + record for record in current + if record.kind == relation.kind + and any(cls._same_topology_value(record.value, value) for value in relation.result_values) + ] + item = { + "event": relation.event, + "kind": relation.kind, + "source_record_ids": [record.record_id for record in sources], + "result_record_ids": [record.record_id for record in outputs], + "proof": "kernel_history", + } + if relation.output_role is not None: + item["output_role"] = relation.output_role + role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 + item["output_role_status"] = ( + "unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot" + ) + if role_is_unique: + output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role) + for source in sources: + for source_role in source.output_roles: + for source_owner in source.owners: + output_role_sources.setdefault(outputs[0].record_id, set()).add( + (relation.output_role, source_owner, source_role) + ) + if len(sources) == 1: + kernel_covered_predecessors.add(sources[0].record_id) + can_transfer = ( + relation.event in {"preserved", "modified"} + and len(relation.result_values) == 1 + and len(sources) == 1 + and len(outputs) == 1 + ) + if can_transfer: + source_id, result_id = sources[0].record_id, outputs[0].record_id + candidate_sources.setdefault(result_id, set()).add(source_id) + relation_links.append((source_id, result_id)) + else: + relation_links.append(None) + evidence.append(item) + predecessors = { + result_id: next(iter(source_ids)) + for result_id, source_ids in candidate_sources.items() + if len(source_ids) == 1 + } + successors: dict[str, list[str]] = {} + for result_id, source_id in predecessors.items(): + successors.setdefault(source_id, []).append(result_id) + for item, relation, link in zip(evidence, topology_delta.relations, relation_links): + if link is not None: + _source_id, result_id = link + item["status"] = ( + "unique_exact_continuation" + if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation" + ) + elif relation.event in {"preserved", "modified"}: + item["status"] = "non_unique_or_incomplete" + else: + item["status"] = "recorded_without_owner_transfer" + return ( + predecessors, + successors, + kernel_covered_predecessors, + {record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()}, + {record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()}, + evidence, + ) + + @staticmethod + def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: + try: + return abs(float(left) - float(right)) <= tolerance + except (TypeError, ValueError): + return False + + @classmethod + def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: + try: + first = _vector3(left, field_name="prior topology geometry") + second = _vector3(right, field_name="current topology geometry") + except ValueError: + return False + return all(abs(a - b) <= tolerance for a, b in zip(first, second)) + + @classmethod + def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool: + """Check a complete, orientation-aware snapshot signature. + + This is intentionally much stricter than selector scoring. Selector + scoring may compare partial source evidence; provenance transfer must + never manufacture ownership from a merely similar candidate. + """ + if prior.kind != current.kind: + return False + left, right = prior.geometry, current.geometry + for key in ("surface_type", "curve_type"): + if left.get(key) != right.get(key): + return False + for key in ("bbox_mm", "center_mm", "normal", "plane_normal"): + if key in left or key in right: + if key not in left or key not in right: + return False + left_value, right_value = left[key], right[key] + if key == "bbox_mm": + if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)): + return False + if len(left_value) != 6 or len(right_value) != 6: + return False + if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)): + return False + elif not cls._vectors_equal(left_value, right_value): + return False + for key in ("area_mm2", "length_mm", "plane_offset_mm"): + if key in left or key in right: + if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]): + return False + for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"): + if key in left or key in right: + if key not in left or key not in right or left[key] != right[key]: + return False + left_start, left_end = left.get("start_mm"), left.get("end_mm") + right_start, right_end = right.get("start_mm"), right.get("end_mm") + if any(value is not None for value in (left_start, left_end, right_start, right_end)): + if None in (left_start, left_end, right_start, right_end): + return False + same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end) + reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start) + if not same_direction and not reverse_direction: + return False + return True + + @classmethod + def _unique_equivalent_predecessor( + cls, + current: TopologyRecord, + predecessors: Iterable[TopologyRecord], + consumed_predecessors: set[str], + ) -> TopologyRecord | None: + matches = [ + record for record in predecessors + if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current) + ] + return matches[0] if len(matches) == 1 else None + + @staticmethod + def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None: + """Endpoint drift between direction-aligned straight edges. + + Returns the minimum total endpoint drift (mm) when the two edges are + collinear straight lines (either orientation), otherwise ``None``. + """ + if prior.kind != current.kind: + return None + left, right = prior.geometry, current.geometry + if left.get("curve_type") != "line" or right.get("curve_type") != "line": + return None + if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")): + return None + + def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]: + return (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + + def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float: + return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) + + left_dir = _delta(left["start_mm"], left["end_mm"]) + right_dir = _delta(right["start_mm"], right["end_mm"]) + if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9: + return None + cross = ( + left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1], + left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2], + left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0], + ) + if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3: + return None + same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"]) + reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"]) + return min(same_order, reversed_order) + + @classmethod + def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool: + """Loose "position trajectory" equivalence used for evolved successors. + + Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance), + this deliberately tolerates small endpoint drift: fillet/chamfer split a + straight edge into segments (a middle straight run plus end arcs). The + straight run keeps the same direction and stays within ``drift_mm`` of the + original edge, so it can serve as the edge's evolved successor. Uniqueness + is enforced by the caller (only a single best candidate is recorded). + """ + drift = cls._evolved_drift(prior, current) + return drift is not None and drift <= drift_mm + + @staticmethod + def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None: + try: + left = _vector3(expected, field_name="selector geometry") + right = _vector3(actual, field_name="record geometry") + except ValueError: + return None + error = _length(tuple(a - b for a, b in zip(left, right))) + return max(0.0, 1.0 - error / tolerance) + + @classmethod + def _geometry_score(cls, selector_geometry: dict[str, Any], record_geometry: dict[str, Any]) -> float | None: + if not selector_geometry: + return 0.0 + scores: list[float] = [] + for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"): + if key in selector_geometry: + score = cls._vector_score(selector_geometry[key], record_geometry.get(key)) + if score is None: + return None + scores.append(score) + for key in ("surface_type", "curve_type"): + if key in selector_geometry: + if record_geometry.get(key) != selector_geometry[key]: + # #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、 + # 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点) + # 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全 + # 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的 + # 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。 + scores.append(0.5) + else: + scores.append(1.0) + if "bbox_mm" in selector_geometry: + expected = selector_geometry["bbox_mm"] + actual = record_geometry.get("bbox_mm") + if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual): + return None + delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual)) + scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "plane_offset_mm" in selector_geometry: + try: + delta = abs(float(selector_geometry["plane_offset_mm"]) - float(record_geometry.get("plane_offset_mm"))) + except (TypeError, ValueError): + return None + scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "radius_mm" in selector_geometry: + try: + delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm"))) + except (TypeError, ValueError): + return None + scores.append(max(0.0, 1.0 - delta / 1e-4)) + if "area_mm2" in selector_geometry: + try: + expected_area = float(selector_geometry["area_mm2"]) + actual_area = float(record_geometry.get("area_mm2")) + except (TypeError, ValueError): + return None + relative_delta = abs(expected_area - actual_area) / max(abs(expected_area), 1e-9) + scores.append(max(0.0, 1.0 - relative_delta / 1e-4)) + return sum(scores) / len(scores) if scores else 0.0 + + def resolve( + self, + selector: dict[str, Any], + *, + minimum_score: float = 0.8, + active_body_id: str | None = None, + ) -> SelectorResolution: + kind = selector.get("kind") + owner = selector.get("owner_feature_id") + candidates = [record for record in self._records if record.kind == kind] + if active_body_id and kind in {"face", "edge", "vertex", "body"}: + # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 + # 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的 + # 记录(不同 feature 前缀)不会泄漏进来。 + candidates = [ + record for record in candidates + if record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ] + if owner: + candidates = [record for record in candidates if owner in record.owners] + if kind == "plane" and selector.get("frame") is not None: + # #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时, + # mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame + # (_translated_node / _mirrored_node),这里直接构造 PlaneSpec, + # 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。 + try: + plane = PlaneSpec.from_mapping(selector.get("frame") or {}) + except (TypeError, ValueError): + plane = None + if plane is not None: + record = TopologyRecord( + record_id=f"inline:{id(plane)}", + kind="plane", + feature_id=str(owner or "inline"), + geometry=plane.as_dict(), + value=plane, + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": 1.0, **record.public_dict()},), + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_frame_incomplete", + message="An inline plane frame requires origin_mm, x_dir and normal", + detail={"frame": selector.get("frame")}, + ), + ) + geometry = normalize_selector_geometry(selector.get("geometry")) + if selector.get("snapshot_id") and not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_owner_required", + message="A snapshot selector requires owner_feature_id", + detail={"minimum_score": minimum_score}, + ), + ) + output_role = str(selector.get("output_role") or "").strip() + if output_role: + if not owner: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_owner_required", + message="A feature output role selector requires owner_feature_id", + detail={"output_role": output_role}, + ), + ) + if active_body_id is None: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_active_body_required", + message="A feature output role selector requires an active body snapshot", + detail={"output_role": output_role}, + ), + ) + if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_mixed_evidence", + message="A feature output role selector cannot mix stable or geometry evidence", + detail={"output_role": output_role}, + ), + ) + role_candidates = [record for record in candidates if output_role in record.output_roles] + role_source = selector.get("output_role_source") + if role_source is not None: + source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None + source_role = role_source.get("output_role") if isinstance(role_source, dict) else None + if not isinstance(source_owner, str) or not isinstance(source_role, str): + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_source_invalid", + message="An output role selector source requires owner_feature_id and output_role", + ), + ) + if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_source_unsupported", + message="Output role sources are currently supported only for shell.offset_face from an extrusion cap", + ), + ) + role_candidates = [ + record for record in role_candidates + if (output_role, source_owner, source_role) in record.output_role_sources + ] + public_candidates = tuple( + {"score": 1.0, **record.public_dict()} for record in role_candidates + ) + if len(role_candidates) == 1: + return SelectorResolution( + selector=selector, + status="resolved", + record=role_candidates[0], + candidates=public_candidates, + ) + if len(role_candidates) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_output_role_ambiguous", + message="More than one active topology record has the requested output role", + detail={"output_role": output_role, "candidate_count": len(role_candidates)}, + ), + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_output_role_not_found", + message="No active topology record has the requested output role", + detail={"output_role": output_role, "candidate_count": 0}, + ), + ) + stable_id = str(selector.get("stable_id") or "").strip() + if stable_id: + # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确 + # 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。 + # 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body + # 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到 + # 几何打分流程。 + stable_records = [ + record for record in self._records + if record.kind == kind and (not owner or owner in record.owners) + ] + exact = [record for record in stable_records if record.record_id == stable_id] + if len(exact) == 1: + record = exact[0] + is_active = active_body_id is None or ( + record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ) + if not is_active: + successors = [ + candidate for candidate in stable_records + if candidate.record_id in self._successors.get(record.record_id, ()) + and ( + candidate.body_id == active_body_id + or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:")) + ) + ] + if len(successors) == 1: + record = successors[0] + is_active = True + elif len(successors) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one evolved successor record satisfies the stable_id", + detail={"stable_id": stable_id, "candidate_count": len(successors)}, + ), + ) + if is_active: + # A stable ID is only a lookup accelerator for snapshot-aware + # selectors. It cannot revive a B-rep entity whose geometric + # signature changed after an upstream rebuild. + if selector.get("snapshot_id"): + score = self._geometry_score(geometry, record.geometry) if geometry else None + if score is None or score < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="The stable selector record no longer matches its geometry signature", + detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, + ), + ) + return SelectorResolution( + selector=selector, + status="resolved", + record=record, + candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), + ) + if not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_stable_id_inactive", + message="The stable selector record is not active and has no geometry signature for rebinding", + detail={"stable_id": stable_id}, + ), + ) + if len(exact) > 1: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact), + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the requested stable_id", + detail={"stable_id": stable_id, "candidate_count": len(exact)}, + ), + ) + if selector.get("snapshot_id") and not geometry: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_geometry_mismatch", + message="A snapshot selector requires a geometry signature", + detail={"minimum_score": minimum_score}, + ), + ) + scored: list[tuple[float, TopologyRecord]] = [] + for candidate in candidates: + # An owner-qualified context selector is deterministic when it has + # a single runtime candidate even if its source stable_id cannot + # survive the SolidWorks -> OCC boundary. + score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry) + if score is not None: + scored.append((score, candidate)) + scored.sort(key=lambda item: (-item[0], item[1].record_id)) + public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored) + if not scored or scored[0][0] < minimum_score: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_not_found", + message="No runtime topology record satisfies the selector", + detail={"candidate_count": len(scored), "minimum_score": minimum_score}, + ), + ) + best_score, best_record = scored[0] + if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9: + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=public_candidates, + diagnostic=RuntimeDiagnostic( + code="selector_ambiguous", + message="More than one runtime topology record has the best selector score", + detail={"best_score": best_score, "candidate_count": len(scored)}, + ), + ) + return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates) -- 2.52.0 From 871070c440a47d69734841117502291fa56c85e1 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 13:13:17 +0800 Subject: [PATCH 02/10] refactor(cdsl_engine): extract session/extents/pattern_transform from runtime Phase 2 of the decoupling refactor (behavior-preserving move): - runtime_base.py: RuntimeExecutionError, FeatureExecutionError, ExtentVector - session.py: GeometryAdapter protocol + ExecutionSession - extents.py: end-condition planning (_extent_vectors family) - pattern_transform.py: translate/mirror/rotate replay parameter algebra - runtime.py: keeps executors + registry + entry points; re-exports all moved names (incl. test-referenced privates) for import stability No behavior change; verified against baseline (zero new failures). --- backend/engine/cdsl_engine/extents.py | 231 ++++ .../engine/cdsl_engine/pattern_transform.py | 509 ++++++++ backend/engine/cdsl_engine/runtime.py | 1040 +---------------- backend/engine/cdsl_engine/runtime_base.py | 46 + backend/engine/cdsl_engine/session.py | 274 +++++ 5 files changed, 1108 insertions(+), 992 deletions(-) create mode 100644 backend/engine/cdsl_engine/extents.py create mode 100644 backend/engine/cdsl_engine/pattern_transform.py create mode 100644 backend/engine/cdsl_engine/runtime_base.py create mode 100644 backend/engine/cdsl_engine/session.py diff --git a/backend/engine/cdsl_engine/extents.py b/backend/engine/cdsl_engine/extents.py new file mode 100644 index 00000000..1df0a516 --- /dev/null +++ b/backend/engine/cdsl_engine/extents.py @@ -0,0 +1,231 @@ +"""Extrusion/termination-condition planning for the session runtime. + +These helpers turn a CDSL feature's end condition (blind, mid-plane, +through-all, up-to-surface, ...) into one or more :class:`ExtentVector` +displacements. They depend on the session only through its adapter and +selector resolution, never on executors. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from .runtime_base import ExtentVector, FeatureExecutionError +from .specs import PlaneSpec, Vector3, vector_dot, vector_scale, vector_subtract, vector_unit +from .topology import FeaturePlanNode + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from .session import ExecutionSession + + +def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3: + return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal + + +def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]: + condition = condition or node.params.get("end_condition") or {} + reference = condition.get("reference") + if not isinstance(reference, dict): + raise FeatureExecutionError( + "missing_extent_reference", + "This end condition requires a captured target selector", + extent=condition.get("type"), + ) + return reference + + +def _targeted_extent_vector( + node: FeaturePlanNode, + faces: list[Any], + direction: Vector3, + session: "ExecutionSession", + condition: str, + *, + end_condition: dict[str, Any] | None = None, + offset_mm: float | None = None, +) -> ExtentVector: + if session.body is None: + raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition) + if condition == "through_next": + target = session.body + else: + reference = _extent_reference(node, end_condition) + resolution = session.resolve(reference) + if resolution.status != "resolved" or resolution.record is None: + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved") + expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face") + if resolution.record.kind != expected_kind: + raise FeatureExecutionError( + "unsupported_extent_target", + "The resolved target kind is incompatible with this end condition", + extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind, + ) + target = resolution.record.value + if condition == "up_to_vertex": + target_point = session.adapter.vertex_coordinates(target) + projections = [ + vector_dot(vector_subtract(target_point, point), direction) + for face in faces + for point in session.adapter.profile_sample_points(face) + ] + if not projections or min(projections) <= 1e-6: + raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition) + if max(projections) - min(projections) > 1e-5: + raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition) + distance = sum(projections) / len(projections) + else: + if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces): + # 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始 + # 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile + # 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。 + try: + next_face = session.adapter.next_body_face_after( + session.body, faces, direction, excluded_face=target, + ) + except ValueError as error: + raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error + return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face) + try: + distance = session.adapter.uniform_intersection_distance(target, faces, direction) + except ValueError as error: + message = str(error) + code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached" + if condition in {"up_to_surface", "through_next"}: + # #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未 + # 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体 + # 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。 + # extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达 + # 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都 + # 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛 + # "not reached",保持显式拒绝。 + # through_next 从当前主体中选取实际命中的下一张面; + # up_to_vertex/up_to_body/offset_from_surface 无 face 可构造 + # 裁剪体层,仍保持显式拒绝。 + return ExtentVector(vector_scale(direction, 1.0), trim_to=target) + raise FeatureExecutionError(code, message, extent=condition) from error + offset = abs(float((end_condition or {}).get("offset_mm") or 0.0)) + if condition == "offset_from_surface": + offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0)) + if offset: + distance -= offset + if distance <= 1e-6: + raise FeatureExecutionError( + "invalid_extent_offset", + "Offset distance reaches or passes the target extent", + extent=condition, offset_mm=offset, + ) + return ExtentVector(vector_scale(direction, distance)) + + +def _side_extent_vectors( + node: FeaturePlanNode, + faces: list[Any], + direction: Vector3, + session: "ExecutionSession", + *, + end_condition: dict[str, Any], + distance_mm: float, +) -> list[ExtentVector]: + """Resolve one directional extent without borrowing the opposite side. + + ``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured + termination. The regular one-sided executor also uses it for all simple + termination modes, keeping the geometry adapter interface uniform. + """ + condition = str(end_condition.get("type") or "blind") + distance = abs(float(distance_mm or 0.0)) + if condition == "blind": + if distance <= 0: + raise ValueError("blind extent requires distance_mm > 0") + return [ExtentVector(vector_scale(direction, distance))] + if condition == "mid_plane": + if distance <= 0: + raise ValueError("mid_plane extent requires distance_mm > 0") + return [ + ExtentVector(vector_scale(direction, distance / 2)), + ExtentVector(vector_scale(direction, -distance / 2)), + ] + if condition == "through_all": + if session.body is None: + if distance <= 0: + raise ValueError("through_all on an initial feature has no body and no fallback distance") + # 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组 + # 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。 + return [ExtentVector(vector_scale(direction, distance))] + return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))] + if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}: + return [ + _targeted_extent_vector( + node, faces, direction, session, condition, + end_condition=end_condition, offset_mm=distance, + ) + ] + raise ValueError(f"unsupported directional extent {condition!r}") + + +def _extent_vectors( + node: FeaturePlanNode, + faces: list[Any], + sketch: dict[str, Any], + session: "ExecutionSession", +) -> list[ExtentVector]: + return _extent_vectors_from_normal( + node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session, + ) + + +def _extent_vectors_from_normal( + node: FeaturePlanNode, + faces: list[Any], + profile_normal: Vector3, + session: "ExecutionSession", +) -> list[ExtentVector]: + """Resolve extents from an explicit profile normal. + + A derived profile can be an actual B-rep face rather than a sketch. Its + outward normal is just as authoritative as a sketch workplane normal, so + both profile sources share the same bounded extent semantics. + """ + params = node.params + normal = vector_unit(profile_normal, field_name="profile normal") + if bool(params.get("reverse")): + normal = vector_scale(normal, -1) + end_condition = params.get("end_condition") or {"type": "blind"} + condition = end_condition.get("type", "blind") + distance = abs(float(params.get("distance_mm") or 0.0)) + if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")): + reverse_condition = params.get("reverse_end_condition") or {"type": "blind"} + reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) + if reverse_distance <= 0: + raise ValueError("two-sided extrusion requires reverse_distance_mm > 0") + return [ + *_side_extent_vectors( + node, faces, normal, session, end_condition=end_condition, distance_mm=distance, + ), + *_side_extent_vectors( + node, faces, vector_scale(normal, -1), session, + end_condition=reverse_condition, distance_mm=reverse_distance, + ), + ] + if condition in {"through_all", "through_all_both", "through_all_and_blind"}: + if session.body is None: + # A first feature with through-all has no body to terminate + # against. The source must provide a usable blind component. + if distance <= 0: + raise ValueError("through_all on an initial feature has no body and no fallback distance") + return [ExtentVector(vector_scale(normal, distance))] + span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0 + if condition == "through_all": + return [ExtentVector(vector_scale(normal, span))] + if condition == "through_all_both": + return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))] + # Through-all-and-blind is represented by a through direction plus + # its captured opposite blind direction when available. + reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) + return [ + ExtentVector(vector_scale(normal, span)), + ExtentVector(vector_scale(normal, -(reverse_distance or span))), + ] + return _side_extent_vectors( + node, faces, normal, session, end_condition=end_condition, distance_mm=distance, + ) diff --git a/backend/engine/cdsl_engine/pattern_transform.py b/backend/engine/cdsl_engine/pattern_transform.py new file mode 100644 index 00000000..bb9c832b --- /dev/null +++ b/backend/engine/cdsl_engine/pattern_transform.py @@ -0,0 +1,509 @@ +"""Parametric transforms for pattern replay (translate / mirror / rotate). + +A pattern instance re-executes its source feature with every absolute +coordinate parameter transformed (sketch, host frame, axis, positions, +center, nested mirror-plane references). These helpers own that parameter +algebra; the pattern executors only decide which transform to apply. +""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Callable + +from .specs import AxisSpec, PlaneSpec, Vector3, vector_cross, vector_dot, vector_scale, vector_subtract +from .topology import FeaturePlanNode + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from .session import ExecutionSession + + +def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]: + output = deepcopy(sketch) + components = offset + workplane = output.get("workplane") or {} + origin = workplane.get("origin_mm") or [0, 0, 0] + workplane["origin_mm"] = [float(origin[index]) + components[index] for index in range(3)] + output["workplane"] = workplane + for key in ("contour_edges_mm", "contour_regions_mm"): + def translate(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("start_mm", "end_mm", "center_mm"): + if point_key in value: + value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)] + if "points_mm" in value: + value["points_mm"] = [ + [float(point[index]) + components[index] for index in range(3)] + for point in value["points_mm"] + ] + for child in value.values(): + translate(child) + elif isinstance(value, list): + for child in value: + translate(child) + translate(output.get(key)) + return output + + +def _transformed_loft_profiles( + node: FeaturePlanNode, + params: dict[str, Any], + instance_id: str, + session: "ExecutionSession", + transform: Callable[[dict[str, Any]], dict[str, Any]], +) -> None: + """为 pattern replay 创建放样截面的变换副本。""" + if node.atomic_id != "loft_add": + return + profile_ids = params.get("profile_sketch_ids") or [] + transformed_ids: list[str] = [] + for index, sketch_id in enumerate(profile_ids): + source = session.sketches.get(str(sketch_id)) + if source is None: + raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition") + transformed_id = f"{instance_id}.profile.{index}" + # 不复用原 profile:pattern 中的每个截面都必须与 source feature + # 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。 + session.sketches[transformed_id] = transform(source) + transformed_ids.append(transformed_id) + params["profile_sketch_ids"] = transformed_ids + + +def _owner_plane_frame(session: "ExecutionSession", selector: dict[str, Any]) -> dict[str, Any] | None: + """解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。 + + #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时,镜像面 + 是 selector,其 owner 是 reference_plane 特征;该特征执行时把显式 + PlaneSpec 登记为拓扑上下文,这里取出该 frame 供随实例变换使用。 + """ + owner = selector.get("owner_feature_id") + if not owner: + return None + for record in session.topology.records_for_feature(str(owner)): + if record.kind == "plane" and isinstance(record.value, PlaneSpec): + return record.value.as_dict() + return None + + +def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, session: "ExecutionSession") -> FeaturePlanNode: + params = deepcopy(node.params) + components = offset + if isinstance(params.get("plane"), dict) and params["plane"].get("origin_mm"): + params["plane"]["origin_mm"] = [float(params["plane"]["origin_mm"][index]) + components[index] for index in range(3)] + host = params.get("host_face") + host_frame = host.get("frame") if isinstance(host, dict) else None + positions_are_local = isinstance(host_frame, dict) and all( + host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") + ) + if positions_are_local and host_frame.get("origin_mm"): + host_frame["origin_mm"] = [float(host_frame["origin_mm"][index]) + components[index] for index in range(3)] + if not positions_are_local: + for position in params.get("positions") or []: + if position.get("mm"): + position["mm"] = [float(position["mm"][index]) + components[index] for index in range(3)] + axis = params.get("axis") or {} + if axis.get("origin_mm"): + axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)] + center = params.get("center_mm") + if center: + # box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心, + # 否则阵列副本会静默重合在原位置。 + params["center_mm"] = [float(center[index]) + components[index] for index in range(3)] + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _translated_sketch(sketch, offset), + ) + mirror_plane = params.get("mirror_plane") + if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": + # #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移 + # 到新位置后内联为显式 frame;否则重放时 resolve 到原始面,镜像 + # 副本会错误地重合在源特征附近。同时源特征也必须平移后重放:镜像 + # 副本 = reflect(源@t, 面@t),只平移面不平移源会落在 2P+t-x 处 + # 而非正确位置 2P-x+t。 + frame = _owner_plane_frame(session, mirror_plane) + if frame is None: + raise ValueError("mirror plane reference cannot be transformed for pattern replay") + cloned_selector = deepcopy(mirror_plane) + cloned_selector["frame"] = { + "origin_mm": [frame["origin_mm"][index] + components[index] for index in range(3)], + "x_dir": list(frame["x_dir"]), + "normal": list(frame["normal"]), + } + params["mirror_plane"] = cloned_selector + transformed_ids: list[str] = [] + for source_id in node.params.get("source_feature_ids") or []: + source_node = session.replay_definitions.get(str(source_id)) + if source_node is None: + raise ValueError(f"mirror pattern source feature {source_id} has no replay definition") + temp_id = f"{instance_id}.src.{source_id}" + shifted = _translated_node(source_node, temp_id, offset, session) + if shifted.sketch_id: + source_sketch = session.sketches.get(str(source_node.sketch_id)) + if source_sketch is not None: + temp_sketch_id = f"{temp_id}.sk" + session.sketches[temp_sketch_id] = _translated_sketch(source_sketch, offset) + shifted = FeaturePlanNode( + shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, + shifted.params, shifted.selectors, temp_sketch_id, + shifted.declared_status, shifted.source_feature, + ) + # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 + session.nodes[temp_id] = shifted + session.replay_definitions[temp_id] = shifted + transformed_ids.append(temp_id) + params["source_feature_ids"] = transformed_ids + return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) + + +def _reflect_point(point: list[float] | tuple[float, float, float], plane: PlaneSpec, *, vector: bool = False) -> list[float]: + value = tuple(float(component) for component in point) + offset = value if vector else vector_subtract(value, plane.origin_mm) + mirrored = vector_subtract(value, vector_scale(plane.normal, 2 * vector_dot(offset, plane.normal))) + return list(mirrored) + + +def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]: + output = deepcopy(sketch) + workplane = output.get("workplane") or {} + if workplane.get("origin_mm"): + workplane["origin_mm"] = _reflect_point(workplane["origin_mm"], plane) + for key in ("x_dir", "y_dir", "normal"): + if workplane.get(key): + workplane[key] = _reflect_point(workplane[key], plane, vector=True) + output["workplane"] = workplane + + # A reflection reverses handedness. ``PlaneSpec`` reconstructs its local + # y direction as normal x x, so keeping the reflected normal means that + # local y is the inverse of the reflected source y. Profiles represented + # as local circles (rather than already-transformed contour edges) must + # therefore invert v to remain at their actual reflected world position. + def mirror_local_coordinates(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("center", "start", "end"): + point = value.get(point_key) + if isinstance(point, list) and len(point) == 2: + value[point_key] = [float(point[0]), -float(point[1])] + if isinstance(value.get("points"), list): + value["points"] = [ + [float(point[0]), -float(point[1])] + for point in value["points"] + if isinstance(point, list) and len(point) == 2 + ] + for child in value.values(): + mirror_local_coordinates(child) + elif isinstance(value, list): + for child in value: + mirror_local_coordinates(child) + + mirror_local_coordinates(output.get("entities")) + # This is not consumed after sketch resolution, but retaining the same + # local semantics makes an overridden sketch safe to inspect or replay. + mirror_local_coordinates(output.get("profile")) + + def mirror(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("start_mm", "end_mm", "center_mm"): + if point_key in value: + value[point_key] = _reflect_point(value[point_key], plane) + if "points_mm" in value: + value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]] + if value.get("normal"): + value["normal"] = _reflect_point(value["normal"], plane, vector=True) + for child in value.values(): + mirror(child) + elif isinstance(value, list): + for child in value: + mirror(child) + mirror(output.get("contour_edges_mm")) + mirror(output.get("contour_regions_mm")) + return output + + +def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, session: "ExecutionSession") -> FeaturePlanNode: + params = deepcopy(node.params) + if isinstance(params.get("plane"), dict): + for key in ("origin_mm", "x_dir", "y_dir", "normal"): + if params["plane"].get(key): + params["plane"][key] = _reflect_point(params["plane"][key], plane, vector=key != "origin_mm") + host = params.get("host_face") + host_frame = host.get("frame") if isinstance(host, dict) else None + positions_are_local = isinstance(host_frame, dict) and all( + host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") + ) + if positions_are_local: + for key in ("origin_mm", "x_dir", "normal"): + if host_frame.get(key): + host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm") + # #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的 + # canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame + # 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。 + x_reflected = host_frame.get("x_dir") + n_reflected = host_frame.get("normal") + if x_reflected is not None and n_reflected is not None: + host_frame["y_dir"] = [ + n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1], + n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2], + n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0], + ] + # See _mirrored_sketch: the canonical reflected plane reverses local + # y, so local hole coordinates must do the same. + for position in params.get("positions") or []: + point = position.get("mm") + if isinstance(point, list) and len(point) == 3: + position["mm"] = [float(point[0]), -float(point[1]), float(point[2])] + else: + for position in params.get("positions") or []: + if position.get("mm"): + position["mm"] = _reflect_point(position["mm"], plane) + axis = params.get("axis") or {} + if axis.get("origin_mm"): + axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane) + if axis.get("direction"): + axis["direction"] = _reflect_point(axis["direction"], plane, vector=True) + center = params.get("center_mm") + if isinstance(center, list) and len(center) == 3: + # box_add/sphere_add 以世界坐标几何中心定位:反射该中心即可。box_add 固定 + # 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern + # 中已被显式拒绝)。 + params["center_mm"] = _reflect_point(center, plane) + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _mirrored_sketch(sketch, plane), + ) + mirror_plane = params.get("mirror_plane") + if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": + # #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用 + # 随本实例的镜像面一起反射(内联为显式 frame),否则重放 resolve + # 到原始面,嵌套镜像会退化成与源镜像重合的错误几何。源特征同样 + # 反射后重放:镜像副本 = reflect(源@P_B, reflect(面,P_B))。 + frame = _owner_plane_frame(session, mirror_plane) + if frame is None: + raise ValueError("mirror plane reference cannot be transformed for pattern replay") + cloned_selector = deepcopy(mirror_plane) + cloned_selector["frame"] = { + "origin_mm": _reflect_point(frame["origin_mm"], plane), + "x_dir": _reflect_point(frame["x_dir"], plane, vector=True), + "normal": _reflect_point(frame["normal"], plane, vector=True), + } + params["mirror_plane"] = cloned_selector + transformed_ids: list[str] = [] + for source_id in node.params.get("source_feature_ids") or []: + source_node = session.replay_definitions.get(str(source_id)) + if source_node is None: + raise ValueError(f"mirror pattern source feature {source_id} has no replay definition") + temp_id = f"{instance_id}.src.{source_id}" + shifted = _mirrored_node(source_node, temp_id, plane, session) + if shifted.sketch_id: + source_sketch = session.sketches.get(str(source_node.sketch_id)) + if source_sketch is not None: + temp_sketch_id = f"{temp_id}.sk" + session.sketches[temp_sketch_id] = _mirrored_sketch(source_sketch, plane) + shifted = FeaturePlanNode( + shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, + shifted.params, shifted.selectors, temp_sketch_id, + shifted.declared_status, shifted.source_feature, + ) + # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 + session.nodes[temp_id] = shifted + session.replay_definitions[temp_id] = shifted + transformed_ids.append(temp_id) + params["source_feature_ids"] = transformed_ids + return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) + + +def _normal_is_coordinate_axis(normal: Any) -> bool: + # 判断单位法向是否平行于任一世界坐标轴:跨这样的平面镜像会保持轴对齐朝向。 + return ( + isinstance(normal, (list, tuple)) + and len(normal) == 3 + and any(abs(float(normal[index])) > 1 - 1e-9 for index in range(3)) + ) + + +def _coordinate_axis_direction(direction: Any) -> bool: + # 判断方向是否平行于任一世界坐标轴(circular 的 box 限制用)。 + # 不依赖输入已是单位向量:非零向量至多一个分量非零即为坐标轴方向 + # (_box_circular_is_exact 对任意长度/含小残差的 direction 都稳健)。 + if not isinstance(direction, (list, tuple)) or len(direction) != 3: + return False + return sum(1 for component in direction if abs(float(component)) > 1e-9) == 1 + + +def _rotated_vector(value: Vector3, axis: AxisSpec, angle_rad: float) -> Vector3: + # Rodrigues 旋转公式:绕单位轴 axis.direction 旋转向量(无平移项)。 + cosine = math.cos(angle_rad) + sine = math.sin(angle_rad) + axis_direction = axis.direction + cross = vector_cross(axis_direction, value) + dot = vector_dot(axis_direction, value) + return tuple( # type: ignore[return-value] + value[index] * cosine + cross[index] * sine + axis_direction[index] * dot * (1.0 - cosine) + for index in range(3) + ) + + +def _rotated_point(point: Any, axis: AxisSpec, angle_rad: float) -> list[float]: + # 绕轴旋转三维点:先平移到轴原点、旋转向量、再平移回。 + value = tuple(float(component) for component in point) + relative = vector_subtract(value, axis.origin_mm) + rotated = _rotated_vector(relative, axis, angle_rad) + return [axis.origin_mm[index] + rotated[index] for index in range(3)] + + +def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> dict[str, Any]: + # 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转; + # 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与 + # _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center + # 世界坐标点和圆弧法向绕轴旋转。 + output = deepcopy(sketch) + workplane = output.get("workplane") or {} + if workplane.get("origin_mm"): + workplane["origin_mm"] = _rotated_point(workplane["origin_mm"], axis, angle_rad) + for key in ("x_dir", "y_dir", "normal"): + if workplane.get(key): + workplane[key] = list(_rotated_vector(tuple(float(v) for v in workplane[key]), axis, angle_rad)) + output["workplane"] = workplane + + def rotate(value: Any) -> None: + if isinstance(value, dict): + for point_key in ("start_mm", "end_mm", "center_mm"): + if point_key in value: + value[point_key] = _rotated_point(value[point_key], axis, angle_rad) + if "points_mm" in value: + value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]] + if "normal" in value: + value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), axis, angle_rad)) + for child in value.values(): + rotate(child) + elif isinstance(value, list): + for child in value: + rotate(child) + + for key in ("contour_edges_mm", "contour_regions_mm"): + rotate(output.get(key)) + return output + + +def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle_rad: float, session: "ExecutionSession") -> FeaturePlanNode: + # 环形阵列实例节点:把源特征的全部绝对坐标参数绕 axis 旋转(参数键布局与 + # _translated_node/_mirrored_node 一致)。workplane/宿主 frame 的轴方向旋转, + # 世界坐标点旋转;局部 positions(随宿主 frame)不动。特征自带 axis(圆柱轴/ + # 旋转轴/嵌套 circular 轴)与几何中心 center_mm 随实例旋转。嵌套 pattern + # source(pattern_mirror/pattern_circular)带绝对引用:镜像面 frame / 内层 + # 源需连同本实例一起旋转,否则重放会退化成与源重合的错误几何。 + params = deepcopy(node.params) + plane = params.get("plane") + if isinstance(plane, dict): + for key in ("origin_mm", "x_dir", "y_dir", "normal"): + if plane.get(key): + if key == "origin_mm": + plane[key] = _rotated_point(plane[key], axis, angle_rad) + else: + plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad)) + path = params.get("path") + path_plane = path.get("workplane") if isinstance(path, dict) else None + if isinstance(path_plane, dict): + if path_plane.get("origin_mm"): + path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad) + for key in ("x_dir", "y_dir", "normal"): + if path_plane.get(key): + path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad)) + host = params.get("host_face") + host_frame = host.get("frame") if isinstance(host, dict) else None + positions_are_local = isinstance(host_frame, dict) and all( + host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") + ) + if positions_are_local: + if host_frame.get("origin_mm"): + host_frame["origin_mm"] = _rotated_point(host_frame["origin_mm"], axis, angle_rad) + for key in ("x_dir", "normal"): + if host_frame.get(key): + host_frame[key] = list(_rotated_vector(tuple(float(v) for v in host_frame[key]), axis, angle_rad)) + else: + for position in params.get("positions") or []: + if position.get("mm"): + position["mm"] = _rotated_point(position["mm"], axis, angle_rad) + feature_axis = params.get("axis") + if isinstance(feature_axis, dict): + if feature_axis.get("origin_mm"): + feature_axis["origin_mm"] = _rotated_point(feature_axis["origin_mm"], axis, angle_rad) + if feature_axis.get("direction"): + feature_axis["direction"] = list(_rotated_vector(tuple(float(v) for v in feature_axis["direction"]), axis, angle_rad)) + center = params.get("center_mm") + if isinstance(center, list) and len(center) == 3: + params["center_mm"] = _rotated_point(center, axis, angle_rad) + _transformed_loft_profiles( + node, params, instance_id, session, + lambda sketch: _rotated_sketch(sketch, axis, angle_rad), + ) + if node.atomic_id in {"pattern_mirror", "pattern_circular"}: + # pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套 + # pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。 + if node.atomic_id == "pattern_mirror": + mirror_plane = params.get("mirror_plane") + if not isinstance(mirror_plane, dict): + raise ValueError("mirror pattern replayed by circular pattern has no mirror plane reference") + frame = _owner_plane_frame(session, mirror_plane) + if frame is None: + raise ValueError("mirror plane reference cannot be transformed for circular pattern replay") + cloned_selector = deepcopy(mirror_plane) + cloned_selector["frame"] = { + "origin_mm": _rotated_point(frame["origin_mm"], axis, angle_rad), + "x_dir": list(_rotated_vector(tuple(frame["x_dir"]), axis, angle_rad)), + "normal": list(_rotated_vector(tuple(frame["normal"]), axis, angle_rad)), + } + params["mirror_plane"] = cloned_selector + transformed_ids: list[str] = [] + for source_id in node.params.get("source_feature_ids") or []: + source_node = session.replay_definitions.get(str(source_id)) + if source_node is None: + raise ValueError(f"pattern source feature {source_id} has no replay definition") + temp_id = f"{instance_id}.src.{source_id}" + shifted = _rotated_node(source_node, temp_id, axis, angle_rad, session) + if shifted.sketch_id: + source_sketch = session.sketches.get(str(source_node.sketch_id)) + if source_sketch is not None: + temp_sketch_id = f"{temp_id}.sk" + session.sketches[temp_sketch_id] = _rotated_sketch(source_sketch, axis, angle_rad) + shifted = FeaturePlanNode( + shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, + shifted.params, shifted.selectors, temp_sketch_id, + shifted.declared_status, shifted.source_feature, + ) + # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 + session.nodes[temp_id] = shifted + session.replay_definitions[temp_id] = shifted + transformed_ids.append(temp_id) + params["source_feature_ids"] = transformed_ids + return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) + + +def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode: + # REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是 + # add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以 + # 支持完整的 CDSL replay contract。 + if operation_mode != "remove": return node + atomic_id = { + "extrude_add_blind": "extrude_cut_blind", + "extrude_add_two_sided": "extrude_cut_two_sided", + "revolve_add": "revolve_cut", + }.get(node.atomic_id, node.atomic_id) + if atomic_id == node.atomic_id and "cut" not in atomic_id: + raise ValueError("REMOVE pattern source is not a replayable cutting feature") + params = {key: value for key, value in node.params.items() if key != "result_mode"} + return FeaturePlanNode( + node.feature_id, atomic_id, node.name, node.depends_on, params, + node.selectors, node.sketch_id, node.declared_status, node.source_feature, + ) + + +def _box_circular_is_exact(axis: AxisSpec, angle_rad: float) -> bool: + # box_add 是固定世界轴对齐的原生图元:绕轴旋转任意角度会使其棱偏离坐标轴, + # 当前参数语义无法表达 → 仅坐标轴旋转且每份转角为 180° 的整数倍时精确 + # (180° 翻转把轴对齐 box 映射回轴对齐 box)。与 _execute_mirror_pattern 的 + # box 坐标平面限制同思路:宁可显式拒绝,也不静默产出错误几何。 + if not _coordinate_axis_direction(axis.direction): + return False + half_turns = abs(math.degrees(angle_rad)) / 180.0 + return abs(half_turns - round(half_turns)) < 1e-9 diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index aa7cb729..5706d560 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -1,23 +1,63 @@ -"""Session-based CDSL execution with atomic executor registry.""" +"""Session-based CDSL execution with atomic executor registry. + +The runtime was split into focused modules (behavior-preserving move): + +- ``runtime_base``: shared error types and the ``ExtentVector`` value. +- ``session``: ``ExecutionSession`` and the ``GeometryAdapter`` protocol. +- ``extents``: end-condition planning. +- ``pattern_transform``: translate/mirror/rotate parameter algebra for replay. + +This module keeps the executor registry, the per-atomic executors, and the +``analyze_cdsl`` / ``rebuild_cdsl`` entry points. The moved names are +re-exported so every historical ``cdsl_engine.runtime`` import keeps working. +""" from __future__ import annotations from copy import deepcopy -from dataclasses import dataclass, field import math from pathlib import Path from typing import Any, Callable, Protocol -from .build123d_adapter import Build123dGeometryAdapter +from .build123d_adapter import Build123dGeometryAdapter # noqa: F401 (historical re-export) from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract -from .runtime_types import ( - AxisSpec, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, - GearSpec, RackSpec, ThreadSpec, TopologyDelta, TopologyDeltaRelation, Vector3, - RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry, +from .extents import ( + _extent_reference, + _extent_vectors, + _extent_vectors_from_normal, + _normal_from_sketch, + _side_extent_vectors, + _targeted_extent_vector, +) +from .pattern_transform import ( + _box_circular_is_exact, + _coordinate_axis_direction, + _mirrored_node, + _mirrored_sketch, + _normal_is_coordinate_axis, + _owner_plane_frame, + _pattern_operation_node, + _reflect_point, + _rotated_node, + _rotated_point, + _rotated_sketch, + _rotated_vector, + _transformed_loft_profiles, + _translated_node, + _translated_sketch, +) +from .runtime_base import ExtentVector, FeatureExecutionError, RuntimeExecutionError +from .session import ExecutionSession, GeometryAdapter +from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches +from .specs import ( + AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, ) -from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches +from .topology import ( + CapabilityResult, FeaturePlanNode, FeatureResult, RuntimeDiagnostic, + SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry, +) ALL_ATOMIC_IDS = frozenset({ @@ -34,39 +74,6 @@ ALL_ATOMIC_IDS = frozenset({ }) -class RuntimeExecutionError(RuntimeError): - """A feature execution failure with serializable runtime evidence.""" - - def __init__(self, diagnostic: RuntimeDiagnostic, selector_resolutions: list[dict[str, Any]]) -> None: - super().__init__(diagnostic.message) - self.diagnostic = diagnostic - self.selector_resolutions = selector_resolutions - - -class FeatureExecutionError(RuntimeError): - """An expected feature-level execution rejection with a stable code.""" - - def __init__(self, code: str, message: str, **detail: Any) -> None: - super().__init__(message) - self.code = code - self.detail = detail - - -@dataclass(frozen=True) -class ExtentVector: - """Single-directional extrusion displacement for one profile face. - - ``trim_to`` stays ``None`` for an exact vector extrusion. When set, the - extent means "extrude until the target face, trimming any profile region - that does not reach it" (up_to_surface trim semantics, issue #5). The - piercing distance is computed inside the adapter, so ``vector`` only - supplies the direction. - """ - - vector: Vector3 - trim_to: Any | None = None - - class AtomicExecutor(Protocol): atomic_id: str @@ -74,467 +81,6 @@ class AtomicExecutor(Protocol): def execute(self, node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: ... -class GeometryAdapter(Protocol): - """Kernel boundary consumed by the session runtime. - - Geometry values remain opaque here. A future adapter may use a different - B-rep kernel as long as it preserves these construction/query contracts. - """ - - def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ... - def body_solids(self, body: Any) -> list[Any]: ... - def body_geometry(self, body: Any) -> dict[str, Any]: ... - def surface_geometry(self, surface: Any) -> dict[str, Any]: ... - def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... - def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ... - def loft(self, sketches: list[dict[str, Any]]) -> Any: ... - def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ... - def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ... - def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ... - def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ... - def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ... - def face_normal(self, face: Any) -> Vector3: ... - def extrude(self, face: Any, direction: Vector3) -> Any: ... - def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ... - def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ... - def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ... - def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ... - def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... - def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ... - def combine_surfaces(self, *surfaces: Any) -> Any: ... - def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ... - def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ... - def intersect(self, left: Any, right: Any) -> Any: ... - def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ... - def transform(self, body: Any, transform: dict[str, Any]) -> Any: ... - def transform_with_topology_delta(self, body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: ... - def fuse(self, body: Any | None, solid: Any) -> Any: ... - def fuse_with_topology_delta(self, body: Any | None, solid: Any) -> tuple[Any, TopologyDelta | None]: ... - def combine(self, body: Any | None, solid: Any) -> Any: ... - def cut(self, body: Any, tool: Any) -> Any: ... - def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... - def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... - def thread_solid(self, spec: ThreadSpec) -> Any: ... - def bend_solid(self, spec: BendSpec) -> Any: ... - def gear_solid(self, spec: GearSpec) -> Any: ... - def rack_solid(self, spec: RackSpec) -> Any: ... - def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ... - def body_center(self, body: Any) -> Vector3: ... - def body_span(self, body: Any, direction: Vector3) -> float: ... - def vertex_coordinates(self, vertex: Any) -> Vector3: ... - def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ... - def profile_sample_points(self, face: Any) -> list[Any]: ... - def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ... - def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ... - def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ... - def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ... - def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ... - def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ... - def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ... - def chamfer_with_topology_delta(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> tuple[Any, TopologyDelta | None]: ... - def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ... - def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ... - def shell_with_topology_delta(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> tuple[Any, TopologyDelta]: ... - def export(self, body: Any, path: str) -> None: ... - - -@dataclass -class ExecutionSession: - sketches: dict[str, dict[str, Any]] - nodes: dict[str, FeaturePlanNode] - adapter: GeometryAdapter = field(default_factory=Build123dGeometryAdapter) - topology: TopologyRegistry = field(default_factory=TopologyRegistry) - body: Any | None = None - body_id: str | None = None - results: dict[str, FeatureResult] = field(default_factory=dict) - replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) - body_members: dict[str, Any] = field(default_factory=dict) - surface_members: dict[str, Any] = field(default_factory=dict) - selector_resolutions: list[dict[str, Any]] = field(default_factory=list) - active_feature_id: str = "" - - def register_body( - self, - feature_id: str, - body: Any, - *, - replay_node: FeaturePlanNode | None = None, - body_members: dict[str, Any] | None = None, - topology_delta: TopologyDelta | None = None, - topology_predecessors: list[TopologyRecord] | None = None, - ) -> None: - # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 - # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": - # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 - # 实体;单体保持 body:{feature}(与历史行为完全一致)。 - self.body = body - self.body_id = f"body:{feature_id}" - self.body_members = dict(body_members) if body_members is not None else {feature_id: body} - solids = self.adapter.body_solids(body) - if len(solids) <= 1: - self.topology.replace_body_topology( - feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id), - topology_delta=topology_delta, - additional_predecessors=topology_predecessors or (), - ) - else: - # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 - # 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy - # 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。 - members = [ - (member_id, self.adapter.topology_records(solid, feature_id, member_id)) - for index, solid in enumerate(solids) - for member_id in [f"{self.body_id}:{index}"] - ] - self.topology.replace_body_topologies( - feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta, - additional_predecessors=topology_predecessors or (), - ) - self.topology.register(TopologyRecord( - record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, - geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,), - )) - if replay_node is not None: - self.replay_definitions[feature_id] = replay_node - - def register_surface(self, feature_id: str, surface: Any) -> str: - # 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body, - # 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。 - surface_id = f"surface:{feature_id}" - self.surface_members[feature_id] = surface - for record in self.adapter.topology_records(surface, feature_id, surface_id): - self.topology.register(record) - self.topology.register(TopologyRecord( - record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id, - geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,), - )) - return surface_id - - def clear_body(self) -> None: - """Clear the active solid after an explicit deleteBodies result.""" - self.body = None - self.body_id = None - self.body_members = {} - - def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution: - evidence = resolution.as_dict() - evidence["feature_id"] = self.active_feature_id - self.selector_resolutions.append(evidence) - return resolution - - def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]: - matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None - if matched is not None: - if not isinstance(matched, list) or not matched: - raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches") - resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched] - else: - binding_feature_id = selector.get("binding_feature_id") - active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id - resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] - failures = [item for item in resolved if item.status != "resolved" or item.record is None] - if failures: - detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" - raise FeatureExecutionError("intersection_selector_component_unresolved", detail) - return [item.record for item in resolved if item.record is not None] - - def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: - components = selector.get("intersection_of") - if self.body is None: - return SelectorResolution( - selector=selector, status="not_found", candidates=(), - diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"), - ) - if not isinstance(components, list) or len(components) < 2: - return SelectorResolution( - selector=selector, status="not_found", candidates=(), - diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"), - ) - try: - face_sets = [self._intersection_component_records(component) for component in components] - if any(record.kind != "face" for records in face_sets for record in records): - raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces") - vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets]) - except FeatureExecutionError as error: - return SelectorResolution( - selector=selector, status="not_found", candidates=(), - diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail), - ) - except ValueError as error: - return SelectorResolution( - selector=selector, status="not_found", candidates=(), - diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)), - ) - point = self.adapter.vertex_coordinates(vertex) - record = TopologyRecord( - record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"), - kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id, - geometry={"center_mm": list(point)}, value=vertex, - owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])), - ) - return SelectorResolution( - selector=selector, status="resolved", record=record, - candidates=({"score": 1.0, **record.public_dict()},), - ) - - def resolve(self, selector: dict[str, Any]) -> SelectorResolution: - if selector.get("intersection_of") is not None: - return self._record_selector_resolution(self._resolve_intersection_vertex(selector)) - owner = str(selector.get("owner_feature_id") or "") - active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id - return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id)) - - def result( - self, - node: FeaturePlanNode, - *, - context: PlaneSpec | AxisSpec | None = None, - diagnostics: list[RuntimeDiagnostic] | None = None, - include_body: bool = True, - surface_id: str | None = None, - ) -> FeatureResult: - result = FeatureResult( - feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", - body_id=self.body_id if include_body else None, surface_id=surface_id, - context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id}, - diagnostics=diagnostics or [], - ) - self.results[node.feature_id] = result - return result - - def replay_sources(self, source_feature_ids: list[Any]) -> list[FeaturePlanNode]: - """Return selected source features in their original history order. - - A pattern's exported selection order is not an execution order. In - particular, a boolean cut may appear before its parent boss in the - raw selection array. The CDSL feature list is dependency-ordered by - semantic validation, so it is the stable order for replay. - """ - requested = {str(feature_id) for feature_id in source_feature_ids} - sources = [ - feature - for feature_id, feature in self.nodes.items() - if feature_id in requested and feature_id in self.replay_definitions - ] - if len(sources) != len(requested): - missing = sorted(requested - {source.feature_id for source in sources}) - raise ValueError(f"pattern source features have no replay definitions: {', '.join(missing)}") - return sources - - -def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3: - return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal - - -def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]: - condition = condition or node.params.get("end_condition") or {} - reference = condition.get("reference") - if not isinstance(reference, dict): - raise FeatureExecutionError( - "missing_extent_reference", - "This end condition requires a captured target selector", - extent=condition.get("type"), - ) - return reference - - -def _targeted_extent_vector( - node: FeaturePlanNode, - faces: list[Any], - direction: Vector3, - session: ExecutionSession, - condition: str, - *, - end_condition: dict[str, Any] | None = None, - offset_mm: float | None = None, -) -> ExtentVector: - if session.body is None: - raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition) - if condition == "through_next": - target = session.body - else: - reference = _extent_reference(node, end_condition) - resolution = session.resolve(reference) - if resolution.status != "resolved" or resolution.record is None: - raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved") - expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face") - if resolution.record.kind != expected_kind: - raise FeatureExecutionError( - "unsupported_extent_target", - "The resolved target kind is incompatible with this end condition", - extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind, - ) - target = resolution.record.value - if condition == "up_to_vertex": - target_point = session.adapter.vertex_coordinates(target) - projections = [ - vector_dot(vector_subtract(target_point, point), direction) - for face in faces - for point in session.adapter.profile_sample_points(face) - ] - if not projections or min(projections) <= 1e-6: - raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition) - if max(projections) - min(projections) > 1e-5: - raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition) - distance = sum(projections) / len(projections) - else: - if condition == "up_to_surface" and session.adapter.profile_touches_target(target, faces): - # 草图轮廓本身就在所选终止面上时,selected face 只是拉伸的起始 - # 边界。应沿实际拉伸方向穿过当前 body,取下一张完整截获 profile - # 的边界面作为终止面;直接裁剪到 selected face 会生成零厚度工具体。 - try: - next_face = session.adapter.next_body_face_after( - session.body, faces, direction, excluded_face=target, - ) - except ValueError as error: - raise FeatureExecutionError("extent_target_not_reached", str(error), extent=condition) from error - return ExtentVector(vector_scale(direction, 1.0), trim_to=next_face) - try: - distance = session.adapter.uniform_intersection_distance(target, faces, direction) - except ValueError as error: - message = str(error) - code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached" - if condition in {"up_to_surface", "through_next"}: - # #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未 - # 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体 - # 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。 - # extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达 - # 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都 - # 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛 - # "not reached",保持显式拒绝。 - # through_next 从当前主体中选取实际命中的下一张面; - # up_to_vertex/up_to_body/offset_from_surface 无 face 可构造 - # 裁剪体层,仍保持显式拒绝。 - return ExtentVector(vector_scale(direction, 1.0), trim_to=target) - raise FeatureExecutionError(code, message, extent=condition) from error - offset = abs(float((end_condition or {}).get("offset_mm") or 0.0)) - if condition == "offset_from_surface": - offset = abs(float(offset_mm if offset_mm is not None else offset or node.params.get("distance_mm") or 0.0)) - if offset: - distance -= offset - if distance <= 1e-6: - raise FeatureExecutionError( - "invalid_extent_offset", - "Offset distance reaches or passes the target extent", - extent=condition, offset_mm=offset, - ) - return ExtentVector(vector_scale(direction, distance)) - - -def _side_extent_vectors( - node: FeaturePlanNode, - faces: list[Any], - direction: Vector3, - session: ExecutionSession, - *, - end_condition: dict[str, Any], - distance_mm: float, -) -> list[ExtentVector]: - """Resolve one directional extent without borrowing the opposite side. - - ``extrude_add_two_sided`` and ``extrude_cut_two_sided`` call this once for each independently captured - termination. The regular one-sided executor also uses it for all simple - termination modes, keeping the geometry adapter interface uniform. - """ - condition = str(end_condition.get("type") or "blind") - distance = abs(float(distance_mm or 0.0)) - if condition == "blind": - if distance <= 0: - raise ValueError("blind extent requires distance_mm > 0") - return [ExtentVector(vector_scale(direction, distance))] - if condition == "mid_plane": - if distance <= 0: - raise ValueError("mid_plane extent requires distance_mm > 0") - return [ - ExtentVector(vector_scale(direction, distance / 2)), - ExtentVector(vector_scale(direction, -distance / 2)), - ] - if condition == "through_all": - if session.body is None: - if distance <= 0: - raise ValueError("through_all on an initial feature has no body and no fallback distance") - # 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组 - # 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。 - return [ExtentVector(vector_scale(direction, distance))] - return [ExtentVector(vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0))] - if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}: - return [ - _targeted_extent_vector( - node, faces, direction, session, condition, - end_condition=end_condition, offset_mm=distance, - ) - ] - raise ValueError(f"unsupported directional extent {condition!r}") - - -def _extent_vectors( - node: FeaturePlanNode, - faces: list[Any], - sketch: dict[str, Any], - session: ExecutionSession, -) -> list[ExtentVector]: - return _extent_vectors_from_normal( - node, faces, vector_unit(_normal_from_sketch(sketch), field_name="sketch normal"), session, - ) - - -def _extent_vectors_from_normal( - node: FeaturePlanNode, - faces: list[Any], - profile_normal: Vector3, - session: ExecutionSession, -) -> list[ExtentVector]: - """Resolve extents from an explicit profile normal. - - A derived profile can be an actual B-rep face rather than a sketch. Its - outward normal is just as authoritative as a sketch workplane normal, so - both profile sources share the same bounded extent semantics. - """ - params = node.params - normal = vector_unit(profile_normal, field_name="profile normal") - if bool(params.get("reverse")): - normal = vector_scale(normal, -1) - end_condition = params.get("end_condition") or {"type": "blind"} - condition = end_condition.get("type", "blind") - distance = abs(float(params.get("distance_mm") or 0.0)) - if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")): - reverse_condition = params.get("reverse_end_condition") or {"type": "blind"} - reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) - if reverse_distance <= 0: - raise ValueError("two-sided extrusion requires reverse_distance_mm > 0") - return [ - *_side_extent_vectors( - node, faces, normal, session, end_condition=end_condition, distance_mm=distance, - ), - *_side_extent_vectors( - node, faces, vector_scale(normal, -1), session, - end_condition=reverse_condition, distance_mm=reverse_distance, - ), - ] - if condition in {"through_all", "through_all_both", "through_all_and_blind"}: - if session.body is None: - # A first feature with through-all has no body to terminate - # against. The source must provide a usable blind component. - if distance <= 0: - raise ValueError("through_all on an initial feature has no body and no fallback distance") - return [ExtentVector(vector_scale(normal, distance))] - span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0 - if condition == "through_all": - return [ExtentVector(vector_scale(normal, span))] - if condition == "through_all_both": - return [ExtentVector(vector_scale(normal, span)), ExtentVector(vector_scale(normal, -span))] - # Through-all-and-blind is represented by a through direction plus - # its captured opposite blind direction when available. - reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0)) - return [ - ExtentVector(vector_scale(normal, span)), - ExtentVector(vector_scale(normal, -(reverse_distance or span))), - ] - return _side_extent_vectors( - node, faces, normal, session, end_condition=end_condition, distance_mm=distance, - ) - - def _revolve_axis(node: FeaturePlanNode, session: ExecutionSession) -> AxisSpec: raw_axis = node.params.get("axis") or {} if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None: @@ -1553,143 +1099,6 @@ def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> Featur return session.result(node, diagnostics=diagnostics) -def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]: - output = deepcopy(sketch) - components = offset - workplane = output.get("workplane") or {} - origin = workplane.get("origin_mm") or [0, 0, 0] - workplane["origin_mm"] = [float(origin[index]) + components[index] for index in range(3)] - output["workplane"] = workplane - for key in ("contour_edges_mm", "contour_regions_mm"): - def translate(value: Any) -> None: - if isinstance(value, dict): - for point_key in ("start_mm", "end_mm", "center_mm"): - if point_key in value: - value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)] - if "points_mm" in value: - value["points_mm"] = [ - [float(point[index]) + components[index] for index in range(3)] - for point in value["points_mm"] - ] - for child in value.values(): - translate(child) - elif isinstance(value, list): - for child in value: - translate(child) - translate(output.get(key)) - return output - - -def _transformed_loft_profiles( - node: FeaturePlanNode, - params: dict[str, Any], - instance_id: str, - session: ExecutionSession, - transform: Callable[[dict[str, Any]], dict[str, Any]], -) -> None: - """为 pattern replay 创建放样截面的变换副本。""" - if node.atomic_id != "loft_add": - return - profile_ids = params.get("profile_sketch_ids") or [] - transformed_ids: list[str] = [] - for index, sketch_id in enumerate(profile_ids): - source = session.sketches.get(str(sketch_id)) - if source is None: - raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition") - transformed_id = f"{instance_id}.profile.{index}" - # 不复用原 profile:pattern 中的每个截面都必须与 source feature - # 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。 - session.sketches[transformed_id] = transform(source) - transformed_ids.append(transformed_id) - params["profile_sketch_ids"] = transformed_ids - - -def _owner_plane_frame(session: ExecutionSession, selector: dict[str, Any]) -> dict[str, Any] | None: - """解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。 - - #6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时,镜像面 - 是 selector,其 owner 是 reference_plane 特征;该特征执行时把显式 - PlaneSpec 登记为拓扑上下文,这里取出该 frame 供随实例变换使用。 - """ - owner = selector.get("owner_feature_id") - if not owner: - return None - for record in session.topology.records_for_feature(str(owner)): - if record.kind == "plane" and isinstance(record.value, PlaneSpec): - return record.value.as_dict() - return None - - -def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, session: ExecutionSession) -> FeaturePlanNode: - params = deepcopy(node.params) - components = offset - if isinstance(params.get("plane"), dict) and params["plane"].get("origin_mm"): - params["plane"]["origin_mm"] = [float(params["plane"]["origin_mm"][index]) + components[index] for index in range(3)] - host = params.get("host_face") - host_frame = host.get("frame") if isinstance(host, dict) else None - positions_are_local = isinstance(host_frame, dict) and all( - host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") - ) - if positions_are_local and host_frame.get("origin_mm"): - host_frame["origin_mm"] = [float(host_frame["origin_mm"][index]) + components[index] for index in range(3)] - if not positions_are_local: - for position in params.get("positions") or []: - if position.get("mm"): - position["mm"] = [float(position["mm"][index]) + components[index] for index in range(3)] - axis = params.get("axis") or {} - if axis.get("origin_mm"): - axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)] - center = params.get("center_mm") - if center: - # box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心, - # 否则阵列副本会静默重合在原位置。 - params["center_mm"] = [float(center[index]) + components[index] for index in range(3)] - _transformed_loft_profiles( - node, params, instance_id, session, - lambda sketch: _translated_sketch(sketch, offset), - ) - mirror_plane = params.get("mirror_plane") - if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": - # #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移 - # 到新位置后内联为显式 frame;否则重放时 resolve 到原始面,镜像 - # 副本会错误地重合在源特征附近。同时源特征也必须平移后重放:镜像 - # 副本 = reflect(源@t, 面@t),只平移面不平移源会落在 2P+t-x 处 - # 而非正确位置 2P-x+t。 - frame = _owner_plane_frame(session, mirror_plane) - if frame is None: - raise ValueError("mirror plane reference cannot be transformed for pattern replay") - cloned_selector = deepcopy(mirror_plane) - cloned_selector["frame"] = { - "origin_mm": [frame["origin_mm"][index] + components[index] for index in range(3)], - "x_dir": list(frame["x_dir"]), - "normal": list(frame["normal"]), - } - params["mirror_plane"] = cloned_selector - transformed_ids: list[str] = [] - for source_id in node.params.get("source_feature_ids") or []: - source_node = session.replay_definitions.get(str(source_id)) - if source_node is None: - raise ValueError(f"mirror pattern source feature {source_id} has no replay definition") - temp_id = f"{instance_id}.src.{source_id}" - shifted = _translated_node(source_node, temp_id, offset, session) - if shifted.sketch_id: - source_sketch = session.sketches.get(str(source_node.sketch_id)) - if source_sketch is not None: - temp_sketch_id = f"{temp_id}.sk" - session.sketches[temp_sketch_id] = _translated_sketch(source_sketch, offset) - shifted = FeaturePlanNode( - shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, - shifted.params, shifted.selectors, temp_sketch_id, - shifted.declared_status, shifted.source_feature, - ) - # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 - session.nodes[temp_id] = shifted - session.replay_definitions[temp_id] = shifted - transformed_ids.append(temp_id) - params["source_feature_ids"] = transformed_ids - return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) - - def _execute_linear_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: # 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。 @@ -1727,171 +1136,6 @@ def _execute_linear_pattern(node: FeaturePlanNode, session: ExecutionSession, ex return session.result(node) -def _reflect_point(point: list[float] | tuple[float, float, float], plane: PlaneSpec, *, vector: bool = False) -> list[float]: - value = tuple(float(component) for component in point) - offset = value if vector else vector_subtract(value, plane.origin_mm) - mirrored = vector_subtract(value, vector_scale(plane.normal, 2 * vector_dot(offset, plane.normal))) - return list(mirrored) - - -def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]: - output = deepcopy(sketch) - workplane = output.get("workplane") or {} - if workplane.get("origin_mm"): - workplane["origin_mm"] = _reflect_point(workplane["origin_mm"], plane) - for key in ("x_dir", "y_dir", "normal"): - if workplane.get(key): - workplane[key] = _reflect_point(workplane[key], plane, vector=True) - output["workplane"] = workplane - - # A reflection reverses handedness. ``PlaneSpec`` reconstructs its local - # y direction as normal x x, so keeping the reflected normal means that - # local y is the inverse of the reflected source y. Profiles represented - # as local circles (rather than already-transformed contour edges) must - # therefore invert v to remain at their actual reflected world position. - def mirror_local_coordinates(value: Any) -> None: - if isinstance(value, dict): - for point_key in ("center", "start", "end"): - point = value.get(point_key) - if isinstance(point, list) and len(point) == 2: - value[point_key] = [float(point[0]), -float(point[1])] - if isinstance(value.get("points"), list): - value["points"] = [ - [float(point[0]), -float(point[1])] - for point in value["points"] - if isinstance(point, list) and len(point) == 2 - ] - for child in value.values(): - mirror_local_coordinates(child) - elif isinstance(value, list): - for child in value: - mirror_local_coordinates(child) - - mirror_local_coordinates(output.get("entities")) - # This is not consumed after sketch resolution, but retaining the same - # local semantics makes an overridden sketch safe to inspect or replay. - mirror_local_coordinates(output.get("profile")) - - def mirror(value: Any) -> None: - if isinstance(value, dict): - for point_key in ("start_mm", "end_mm", "center_mm"): - if point_key in value: - value[point_key] = _reflect_point(value[point_key], plane) - if "points_mm" in value: - value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]] - if value.get("normal"): - value["normal"] = _reflect_point(value["normal"], plane, vector=True) - for child in value.values(): - mirror(child) - elif isinstance(value, list): - for child in value: - mirror(child) - mirror(output.get("contour_edges_mm")) - mirror(output.get("contour_regions_mm")) - return output - - -def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec, session: ExecutionSession) -> FeaturePlanNode: - params = deepcopy(node.params) - if isinstance(params.get("plane"), dict): - for key in ("origin_mm", "x_dir", "y_dir", "normal"): - if params["plane"].get(key): - params["plane"][key] = _reflect_point(params["plane"][key], plane, vector=key != "origin_mm") - host = params.get("host_face") - host_frame = host.get("frame") if isinstance(host, dict) else None - positions_are_local = isinstance(host_frame, dict) and all( - host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") - ) - if positions_are_local: - for key in ("origin_mm", "x_dir", "normal"): - if host_frame.get(key): - host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm") - # #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的 - # canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame - # 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。 - x_reflected = host_frame.get("x_dir") - n_reflected = host_frame.get("normal") - if x_reflected is not None and n_reflected is not None: - host_frame["y_dir"] = [ - n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1], - n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2], - n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0], - ] - # See _mirrored_sketch: the canonical reflected plane reverses local - # y, so local hole coordinates must do the same. - for position in params.get("positions") or []: - point = position.get("mm") - if isinstance(point, list) and len(point) == 3: - position["mm"] = [float(point[0]), -float(point[1]), float(point[2])] - else: - for position in params.get("positions") or []: - if position.get("mm"): - position["mm"] = _reflect_point(position["mm"], plane) - axis = params.get("axis") or {} - if axis.get("origin_mm"): - axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane) - if axis.get("direction"): - axis["direction"] = _reflect_point(axis["direction"], plane, vector=True) - center = params.get("center_mm") - if isinstance(center, list) and len(center) == 3: - # box_add/sphere_add 以世界坐标几何中心定位:反射该中心即可。box_add 固定 - # 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern - # 中已被显式拒绝)。 - params["center_mm"] = _reflect_point(center, plane) - _transformed_loft_profiles( - node, params, instance_id, session, - lambda sketch: _mirrored_sketch(sketch, plane), - ) - mirror_plane = params.get("mirror_plane") - if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror": - # #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用 - # 随本实例的镜像面一起反射(内联为显式 frame),否则重放 resolve - # 到原始面,嵌套镜像会退化成与源镜像重合的错误几何。源特征同样 - # 反射后重放:镜像副本 = reflect(源@P_B, reflect(面,P_B))。 - frame = _owner_plane_frame(session, mirror_plane) - if frame is None: - raise ValueError("mirror plane reference cannot be transformed for pattern replay") - cloned_selector = deepcopy(mirror_plane) - cloned_selector["frame"] = { - "origin_mm": _reflect_point(frame["origin_mm"], plane), - "x_dir": _reflect_point(frame["x_dir"], plane, vector=True), - "normal": _reflect_point(frame["normal"], plane, vector=True), - } - params["mirror_plane"] = cloned_selector - transformed_ids: list[str] = [] - for source_id in node.params.get("source_feature_ids") or []: - source_node = session.replay_definitions.get(str(source_id)) - if source_node is None: - raise ValueError(f"mirror pattern source feature {source_id} has no replay definition") - temp_id = f"{instance_id}.src.{source_id}" - shifted = _mirrored_node(source_node, temp_id, plane, session) - if shifted.sketch_id: - source_sketch = session.sketches.get(str(source_node.sketch_id)) - if source_sketch is not None: - temp_sketch_id = f"{temp_id}.sk" - session.sketches[temp_sketch_id] = _mirrored_sketch(source_sketch, plane) - shifted = FeaturePlanNode( - shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, - shifted.params, shifted.selectors, temp_sketch_id, - shifted.declared_status, shifted.source_feature, - ) - # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 - session.nodes[temp_id] = shifted - session.replay_definitions[temp_id] = shifted - transformed_ids.append(temp_id) - params["source_feature_ids"] = transformed_ids - return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) - - -def _normal_is_coordinate_axis(normal: Any) -> bool: - # 判断单位法向是否平行于任一世界坐标轴:跨这样的平面镜像会保持轴对齐朝向。 - return ( - isinstance(normal, (list, tuple)) - and len(normal) == 3 - and any(abs(float(normal[index])) > 1 - 1e-9 for index in range(3)) - ) - - def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: mirror = node.params.get("mirror_plane") or {} resolution = session.resolve(mirror) @@ -1950,183 +1194,6 @@ def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> return session.result(node) -def _coordinate_axis_direction(direction: Any) -> bool: - # 判断方向是否平行于任一世界坐标轴(circular 的 box 限制用)。 - # 不依赖输入已是单位向量:非零向量至多一个分量非零即为坐标轴方向 - # (_box_circular_is_exact 对任意长度/含小残差的 direction 都稳健)。 - if not isinstance(direction, (list, tuple)) or len(direction) != 3: - return False - return sum(1 for component in direction if abs(float(component)) > 1e-9) == 1 - - -def _rotated_vector(value: Vector3, axis: AxisSpec, angle_rad: float) -> Vector3: - # Rodrigues 旋转公式:绕单位轴 axis.direction 旋转向量(无平移项)。 - cosine = math.cos(angle_rad) - sine = math.sin(angle_rad) - axis_direction = axis.direction - cross = vector_cross(axis_direction, value) - dot = vector_dot(axis_direction, value) - return tuple( # type: ignore[return-value] - value[index] * cosine + cross[index] * sine + axis_direction[index] * dot * (1.0 - cosine) - for index in range(3) - ) - - -def _rotated_point(point: Any, axis: AxisSpec, angle_rad: float) -> list[float]: - # 绕轴旋转三维点:先平移到轴原点、旋转向量、再平移回。 - value = tuple(float(component) for component in point) - relative = vector_subtract(value, axis.origin_mm) - rotated = _rotated_vector(relative, axis, angle_rad) - return [axis.origin_mm[index] + rotated[index] for index in range(3)] - - -def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> dict[str, Any]: - # 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转; - # 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与 - # _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center - # 世界坐标点和圆弧法向绕轴旋转。 - output = deepcopy(sketch) - workplane = output.get("workplane") or {} - if workplane.get("origin_mm"): - workplane["origin_mm"] = _rotated_point(workplane["origin_mm"], axis, angle_rad) - for key in ("x_dir", "y_dir", "normal"): - if workplane.get(key): - workplane[key] = list(_rotated_vector(tuple(float(v) for v in workplane[key]), axis, angle_rad)) - output["workplane"] = workplane - - def rotate(value: Any) -> None: - if isinstance(value, dict): - for point_key in ("start_mm", "end_mm", "center_mm"): - if point_key in value: - value[point_key] = _rotated_point(value[point_key], axis, angle_rad) - if "points_mm" in value: - value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]] - if "normal" in value: - value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), axis, angle_rad)) - for child in value.values(): - rotate(child) - elif isinstance(value, list): - for child in value: - rotate(child) - - for key in ("contour_edges_mm", "contour_regions_mm"): - rotate(output.get(key)) - return output - - -def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle_rad: float, session: ExecutionSession) -> FeaturePlanNode: - # 环形阵列实例节点:把源特征的全部绝对坐标参数绕 axis 旋转(参数键布局与 - # _translated_node/_mirrored_node 一致)。workplane/宿主 frame 的轴方向旋转, - # 世界坐标点旋转;局部 positions(随宿主 frame)不动。特征自带 axis(圆柱轴/ - # 旋转轴/嵌套 circular 轴)与几何中心 center_mm 随实例旋转。嵌套 pattern - # source(pattern_mirror/pattern_circular)带绝对引用:镜像面 frame / 内层 - # 源需连同本实例一起旋转,否则重放会退化成与源重合的错误几何。 - params = deepcopy(node.params) - plane = params.get("plane") - if isinstance(plane, dict): - for key in ("origin_mm", "x_dir", "y_dir", "normal"): - if plane.get(key): - if key == "origin_mm": - plane[key] = _rotated_point(plane[key], axis, angle_rad) - else: - plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad)) - path = params.get("path") - path_plane = path.get("workplane") if isinstance(path, dict) else None - if isinstance(path_plane, dict): - if path_plane.get("origin_mm"): - path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad) - for key in ("x_dir", "y_dir", "normal"): - if path_plane.get(key): - path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad)) - host = params.get("host_face") - host_frame = host.get("frame") if isinstance(host, dict) else None - positions_are_local = isinstance(host_frame, dict) and all( - host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal") - ) - if positions_are_local: - if host_frame.get("origin_mm"): - host_frame["origin_mm"] = _rotated_point(host_frame["origin_mm"], axis, angle_rad) - for key in ("x_dir", "normal"): - if host_frame.get(key): - host_frame[key] = list(_rotated_vector(tuple(float(v) for v in host_frame[key]), axis, angle_rad)) - else: - for position in params.get("positions") or []: - if position.get("mm"): - position["mm"] = _rotated_point(position["mm"], axis, angle_rad) - feature_axis = params.get("axis") - if isinstance(feature_axis, dict): - if feature_axis.get("origin_mm"): - feature_axis["origin_mm"] = _rotated_point(feature_axis["origin_mm"], axis, angle_rad) - if feature_axis.get("direction"): - feature_axis["direction"] = list(_rotated_vector(tuple(float(v) for v in feature_axis["direction"]), axis, angle_rad)) - center = params.get("center_mm") - if isinstance(center, list) and len(center) == 3: - params["center_mm"] = _rotated_point(center, axis, angle_rad) - _transformed_loft_profiles( - node, params, instance_id, session, - lambda sketch: _rotated_sketch(sketch, axis, angle_rad), - ) - if node.atomic_id in {"pattern_mirror", "pattern_circular"}: - # pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套 - # pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。 - if node.atomic_id == "pattern_mirror": - mirror_plane = params.get("mirror_plane") - if not isinstance(mirror_plane, dict): - raise ValueError("mirror pattern replayed by circular pattern has no mirror plane reference") - frame = _owner_plane_frame(session, mirror_plane) - if frame is None: - raise ValueError("mirror plane reference cannot be transformed for circular pattern replay") - cloned_selector = deepcopy(mirror_plane) - cloned_selector["frame"] = { - "origin_mm": _rotated_point(frame["origin_mm"], axis, angle_rad), - "x_dir": list(_rotated_vector(tuple(frame["x_dir"]), axis, angle_rad)), - "normal": list(_rotated_vector(tuple(frame["normal"]), axis, angle_rad)), - } - params["mirror_plane"] = cloned_selector - transformed_ids: list[str] = [] - for source_id in node.params.get("source_feature_ids") or []: - source_node = session.replay_definitions.get(str(source_id)) - if source_node is None: - raise ValueError(f"pattern source feature {source_id} has no replay definition") - temp_id = f"{instance_id}.src.{source_id}" - shifted = _rotated_node(source_node, temp_id, axis, angle_rad, session) - if shifted.sketch_id: - source_sketch = session.sketches.get(str(source_node.sketch_id)) - if source_sketch is not None: - temp_sketch_id = f"{temp_id}.sk" - session.sketches[temp_sketch_id] = _rotated_sketch(source_sketch, axis, angle_rad) - shifted = FeaturePlanNode( - shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on, - shifted.params, shifted.selectors, temp_sketch_id, - shifted.declared_status, shifted.source_feature, - ) - # 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。 - session.nodes[temp_id] = shifted - session.replay_definitions[temp_id] = shifted - transformed_ids.append(temp_id) - params["source_feature_ids"] = transformed_ids - return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature) - - -def _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode: - # REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是 - # add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以 - # 支持完整的 CDSL replay contract。 - if operation_mode != "remove": return node - atomic_id = { - "extrude_add_blind": "extrude_cut_blind", - "extrude_add_two_sided": "extrude_cut_two_sided", - "revolve_add": "revolve_cut", - }.get(node.atomic_id, node.atomic_id) - if atomic_id == node.atomic_id and "cut" not in atomic_id: - raise ValueError("REMOVE pattern source is not a replayable cutting feature") - params = {key: value for key, value in node.params.items() if key != "result_mode"} - return FeaturePlanNode( - node.feature_id, atomic_id, node.name, node.depends_on, params, - node.selectors, node.sketch_id, node.declared_status, node.source_feature, - ) - - def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: ExecutionSession, axis: AxisSpec) -> bool: """Whether rotating a direct circular extrusion creates no new geometry.""" if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}: @@ -2354,17 +1421,6 @@ def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, return session.result(node) -def _box_circular_is_exact(axis: AxisSpec, angle_rad: float) -> bool: - # box_add 是固定世界轴对齐的原生图元:绕轴旋转任意角度会使其棱偏离坐标轴, - # 当前参数语义无法表达 → 仅坐标轴旋转且每份转角为 180° 的整数倍时精确 - # (180° 翻转把轴对齐 box 映射回轴对齐 box)。与 _execute_mirror_pattern 的 - # box 坐标平面限制同思路:宁可显式拒绝,也不静默产出错误几何。 - if not _coordinate_axis_direction(axis.direction): - return False - half_turns = abs(math.degrees(angle_rad)) / 180.0 - return abs(half_turns - round(half_turns)) < 1e-9 - - def _circular_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_circular_pattern(node, session, _execute_node) diff --git a/backend/engine/cdsl_engine/runtime_base.py b/backend/engine/cdsl_engine/runtime_base.py new file mode 100644 index 00000000..5d2449b0 --- /dev/null +++ b/backend/engine/cdsl_engine/runtime_base.py @@ -0,0 +1,46 @@ +"""Shared runtime error types and extent planning values. + +These primitives sit below both ``session`` (execution state) and the +executor modules, so geometry-independent helpers can raise the same stable +execution errors without importing the session or any executor. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .topology import RuntimeDiagnostic, Vector3 + + +class RuntimeExecutionError(RuntimeError): + """A feature execution failure with serializable runtime evidence.""" + + def __init__(self, diagnostic: RuntimeDiagnostic, selector_resolutions: list[dict[str, Any]]) -> None: + super().__init__(diagnostic.message) + self.diagnostic = diagnostic + self.selector_resolutions = selector_resolutions + + +class FeatureExecutionError(RuntimeError): + """An expected feature-level execution rejection with a stable code.""" + + def __init__(self, code: str, message: str, **detail: Any) -> None: + super().__init__(message) + self.code = code + self.detail = detail + + +@dataclass(frozen=True) +class ExtentVector: + """Single-directional extrusion displacement for one profile face. + + ``trim_to`` stays ``None`` for an exact vector extrusion. When set, the + extent means "extrude until the target face, trimming any profile region + that does not reach it" (up_to_surface trim semantics, issue #5). The + piercing distance is computed inside the adapter, so ``vector`` only + supplies the direction. + """ + + vector: Vector3 + trim_to: Any | None = None diff --git a/backend/engine/cdsl_engine/session.py b/backend/engine/cdsl_engine/session.py new file mode 100644 index 00000000..9dc99bd4 --- /dev/null +++ b/backend/engine/cdsl_engine/session.py @@ -0,0 +1,274 @@ +"""Execution session state and the geometry adapter boundary. + +``ExecutionSession`` owns the active body, body-member graph, replay +definitions, and selector-resolution evidence. ``GeometryAdapter`` is the +kernel-facing protocol the session consumes; geometry values stay opaque so a +different B-rep backend can replace build123d without touching the runtime. +""" + +from __future__ import annotations + +from copy import deepcopy +from dataclasses import dataclass, field +from typing import Any, Protocol + +from .build123d_adapter import Build123dGeometryAdapter +from .runtime_base import FeatureExecutionError +from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3 +from .topology import ( + FeaturePlanNode, + FeatureResult, + RuntimeDiagnostic, + SelectorResolution, + TopologyDelta, + TopologyRecord, + TopologyRegistry, +) + + +class GeometryAdapter(Protocol): + """Kernel boundary consumed by the session runtime. + + Geometry values remain opaque here. A future adapter may use a different + B-rep kernel as long as it preserves these construction/query contracts. + """ + + def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ... + def body_solids(self, body: Any) -> list[Any]: ... + def body_geometry(self, body: Any) -> dict[str, Any]: ... + def surface_geometry(self, surface: Any) -> dict[str, Any]: ... + def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... + def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ... + def loft(self, sketches: list[dict[str, Any]]) -> Any: ... + def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ... + def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ... + def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ... + def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ... + def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ... + def face_normal(self, face: Any) -> Vector3: ... + def extrude(self, face: Any, direction: Vector3) -> Any: ... + def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ... + def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ... + def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ... + def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ... + def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... + def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ... + def combine_surfaces(self, *surfaces: Any) -> Any: ... + def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ... + def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ... + def intersect(self, left: Any, right: Any) -> Any: ... + def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ... + def transform(self, body: Any, transform: dict[str, Any]) -> Any: ... + def transform_with_topology_delta(self, body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: ... + def fuse(self, body: Any | None, solid: Any) -> Any: ... + def fuse_with_topology_delta(self, body: Any | None, solid: Any) -> tuple[Any, TopologyDelta | None]: ... + def combine(self, body: Any | None, solid: Any) -> Any: ... + def cut(self, body: Any, tool: Any) -> Any: ... + def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... + def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... + def thread_solid(self, spec: ThreadSpec) -> Any: ... + def bend_solid(self, spec: BendSpec) -> Any: ... + def gear_solid(self, spec: GearSpec) -> Any: ... + def rack_solid(self, spec: RackSpec) -> Any: ... + def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ... + def body_center(self, body: Any) -> Vector3: ... + def body_span(self, body: Any, direction: Vector3) -> float: ... + def vertex_coordinates(self, vertex: Any) -> Vector3: ... + def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ... + def profile_sample_points(self, face: Any) -> list[Any]: ... + def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ... + def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ... + def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ... + def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ... + def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ... + def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ... + def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ... + def chamfer_with_topology_delta(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> tuple[Any, TopologyDelta | None]: ... + def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ... + def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ... + def shell_with_topology_delta(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> tuple[Any, TopologyDelta]: ... + def export(self, body: Any, path: str) -> None: ... + + +@dataclass +class ExecutionSession: + sketches: dict[str, dict[str, Any]] + nodes: dict[str, FeaturePlanNode] + adapter: GeometryAdapter = field(default_factory=Build123dGeometryAdapter) + topology: TopologyRegistry = field(default_factory=TopologyRegistry) + body: Any | None = None + body_id: str | None = None + results: dict[str, FeatureResult] = field(default_factory=dict) + replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict) + body_members: dict[str, Any] = field(default_factory=dict) + surface_members: dict[str, Any] = field(default_factory=dict) + selector_resolutions: list[dict[str, Any]] = field(default_factory=list) + active_feature_id: str = "" + + def register_body( + self, + feature_id: str, + body: Any, + *, + replay_node: FeaturePlanNode | None = None, + body_members: dict[str, Any] | None = None, + topology_delta: TopologyDelta | None = None, + topology_predecessors: list[TopologyRecord] | None = None, + ) -> None: + # #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的 + # 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id": + # 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标 + # 实体;单体保持 body:{feature}(与历史行为完全一致)。 + self.body = body + self.body_id = f"body:{feature_id}" + self.body_members = dict(body_members) if body_members is not None else {feature_id: body} + solids = self.adapter.body_solids(body) + if len(solids) <= 1: + self.topology.replace_body_topology( + feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id), + topology_delta=topology_delta, + additional_predecessors=topology_predecessors or (), + ) + else: + # 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让 + # 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy + # 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。 + members = [ + (member_id, self.adapter.topology_records(solid, feature_id, member_id)) + for index, solid in enumerate(solids) + for member_id in [f"{self.body_id}:{index}"] + ] + self.topology.replace_body_topologies( + feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta, + additional_predecessors=topology_predecessors or (), + ) + self.topology.register(TopologyRecord( + record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, + geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,), + )) + if replay_node is not None: + self.replay_definitions[feature_id] = replay_node + + def register_surface(self, feature_id: str, surface: Any) -> str: + # 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body, + # 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。 + surface_id = f"surface:{feature_id}" + self.surface_members[feature_id] = surface + for record in self.adapter.topology_records(surface, feature_id, surface_id): + self.topology.register(record) + self.topology.register(TopologyRecord( + record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id, + geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,), + )) + return surface_id + + def clear_body(self) -> None: + """Clear the active solid after an explicit deleteBodies result.""" + self.body = None + self.body_id = None + self.body_members = {} + + def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution: + evidence = resolution.as_dict() + evidence["feature_id"] = self.active_feature_id + self.selector_resolutions.append(evidence) + return resolution + + def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]: + matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None + if matched is not None: + if not isinstance(matched, list) or not matched: + raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches") + resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched] + else: + binding_feature_id = selector.get("binding_feature_id") + active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id + resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] + failures = [item for item in resolved if item.status != "resolved" or item.record is None] + if failures: + detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" + raise FeatureExecutionError("intersection_selector_component_unresolved", detail) + return [item.record for item in resolved if item.record is not None] + + def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: + components = selector.get("intersection_of") + if self.body is None: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"), + ) + if not isinstance(components, list) or len(components) < 2: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"), + ) + try: + face_sets = [self._intersection_component_records(component) for component in components] + if any(record.kind != "face" for records in face_sets for record in records): + raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces") + vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets]) + except FeatureExecutionError as error: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail), + ) + except ValueError as error: + return SelectorResolution( + selector=selector, status="not_found", candidates=(), + diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)), + ) + point = self.adapter.vertex_coordinates(vertex) + record = TopologyRecord( + record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"), + kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id, + geometry={"center_mm": list(point)}, value=vertex, + owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])), + ) + return SelectorResolution( + selector=selector, status="resolved", record=record, + candidates=({"score": 1.0, **record.public_dict()},), + ) + + def resolve(self, selector: dict[str, Any]) -> SelectorResolution: + if selector.get("intersection_of") is not None: + return self._record_selector_resolution(self._resolve_intersection_vertex(selector)) + owner = str(selector.get("owner_feature_id") or "") + active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id + return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id)) + + def result( + self, + node: FeaturePlanNode, + *, + context: PlaneSpec | AxisSpec | None = None, + diagnostics: list[RuntimeDiagnostic] | None = None, + include_body: bool = True, + surface_id: str | None = None, + ) -> FeatureResult: + result = FeatureResult( + feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", + body_id=self.body_id if include_body else None, surface_id=surface_id, + context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id}, + diagnostics=diagnostics or [], + ) + self.results[node.feature_id] = result + return result + + def replay_sources(self, source_feature_ids: list[Any]) -> list[FeaturePlanNode]: + """Return selected source features in their original history order. + + A pattern's exported selection order is not an execution order. In + particular, a boolean cut may appear before its parent boss in the + raw selection array. The CDSL feature list is dependency-ordered by + semantic validation, so it is the stable order for replay. + """ + requested = {str(feature_id) for feature_id in source_feature_ids} + sources = [ + feature + for feature_id, feature in self.nodes.items() + if feature_id in requested and feature_id in self.replay_definitions + ] + if len(sources) != len(requested): + missing = sorted(requested - {source.feature_id for source in sources}) + raise ValueError(f"pattern source features have no replay definitions: {', '.join(missing)}") + return sources -- 2.52.0 From 5ffb106f368b8209b664d652b23d59e7cd4290d8 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 13:33:06 +0800 Subject: [PATCH 03/10] refactor(cdsl_engine): executor registry + per-family executor package Phase 3 of the decoupling refactor (behavior-preserving): - registry.py: atomic_executor decorator, ALL_ATOMIC_IDS with fail-fast registration validation, execute_node dispatcher - executors/: one module per family (extrude, revolve, surfaces, loft_sweep, bodies, context, primitives, parametric, holes, dressup, patterns) + shared helpers in executors/common - executors/__init__: explicit aggregation + completeness check (registry must cover every declared atomic id at import time) - runtime.py: slimmed to entry points (analyze_cdsl/rebuild_cdsl) plus full historical re-exports incl. test-referenced privates Adding an atomic operation now touches only one executor module and its schema contract; the shared registry never changes. Verified against baseline: zero new failures. --- .../engine/cdsl_engine/executors/__init__.py | 28 + .../engine/cdsl_engine/executors/bodies.py | 124 ++ .../engine/cdsl_engine/executors/common.py | 488 ++++++ .../engine/cdsl_engine/executors/context.py | 63 + .../engine/cdsl_engine/executors/dressup.py | 120 ++ .../engine/cdsl_engine/executors/extrude.py | 42 + backend/engine/cdsl_engine/executors/holes.py | 78 + .../cdsl_engine/executors/loft_sweep.py | 89 + .../cdsl_engine/executors/parametric.py | 118 ++ .../engine/cdsl_engine/executors/patterns.py | 382 ++++ .../cdsl_engine/executors/primitives.py | 97 + .../engine/cdsl_engine/executors/revolve.py | 17 + .../engine/cdsl_engine/executors/surfaces.py | 75 + backend/engine/cdsl_engine/registry.py | 83 + backend/engine/cdsl_engine/runtime.py | 1557 +---------------- 15 files changed, 1843 insertions(+), 1518 deletions(-) create mode 100644 backend/engine/cdsl_engine/executors/__init__.py create mode 100644 backend/engine/cdsl_engine/executors/bodies.py create mode 100644 backend/engine/cdsl_engine/executors/common.py create mode 100644 backend/engine/cdsl_engine/executors/context.py create mode 100644 backend/engine/cdsl_engine/executors/dressup.py create mode 100644 backend/engine/cdsl_engine/executors/extrude.py create mode 100644 backend/engine/cdsl_engine/executors/holes.py create mode 100644 backend/engine/cdsl_engine/executors/loft_sweep.py create mode 100644 backend/engine/cdsl_engine/executors/parametric.py create mode 100644 backend/engine/cdsl_engine/executors/patterns.py create mode 100644 backend/engine/cdsl_engine/executors/primitives.py create mode 100644 backend/engine/cdsl_engine/executors/revolve.py create mode 100644 backend/engine/cdsl_engine/executors/surfaces.py create mode 100644 backend/engine/cdsl_engine/registry.py diff --git a/backend/engine/cdsl_engine/executors/__init__.py b/backend/engine/cdsl_engine/executors/__init__.py new file mode 100644 index 00000000..ef3cbcf8 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/__init__.py @@ -0,0 +1,28 @@ +"""Executor modules for the session runtime. + +Importing this package registers every atomic executor into +``cdsl_engine.registry.EXECUTORS`` exactly once, then verifies that the +registry covers the complete declared atomic-id set. Executor modules must +not import each other; shared helpers live in ``common``. +""" + +from __future__ import annotations + +from ..registry import ALL_ATOMIC_IDS, EXECUTORS +from . import ( # noqa: F401 (importing the modules performs registration) + bodies, + context, + dressup, + extrude, + holes, + loft_sweep, + parametric, + patterns, + primitives, + revolve, + surfaces, +) + +_missing = sorted(ALL_ATOMIC_IDS - set(EXECUTORS)) +if _missing: + raise RuntimeError(f"atomic ids without a registered executor: {_missing}") diff --git a/backend/engine/cdsl_engine/executors/bodies.py b/backend/engine/cdsl_engine/executors/bodies.py new file mode 100644 index 00000000..a2b1e17a --- /dev/null +++ b/backend/engine/cdsl_engine/executors/bodies.py @@ -0,0 +1,124 @@ +"""Body-graph executors (boolean_bodies / transform_bodies / delete_bodies). + +These operate on explicitly named body members instead of the aggregate +session body, so adjacent independent solids never accidentally become tools +or targets of one another. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import transform_copy_member_id +from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta +from .common import _combine_members, _member_sources + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为 + # 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。 + params = node.params + target_ids = _member_sources( + node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs", + ) + tool_ids = _member_sources( + node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs", + ) + targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids} + tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids} + target = _combine_members(session, targets) + tool = _combine_members(session, tools) + operation = str(params.get("operation") or "") + topology_delta: TopologyDelta | None = None + if operation == "union": + result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool) + elif operation == "subtract": + result, topology_delta = session.adapter.cut_with_topology_delta(target, tool) + elif operation == "intersect": + result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool) + else: + raise ValueError(f"unsupported booleanBodies operation {operation!r}") + members = { + feature_id: body + for feature_id, body in session.body_members.items() + if feature_id not in set(target_ids + tool_ids) + } + members[node.feature_id] = result + if bool(params.get("keep_tools")): + members.update(tools) + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +@atomic_executor("boolean_bodies") +def _boolean_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_boolean_bodies(node, session) + + +def _execute_transform_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # FeatureScript transform targets explicit bodies. Do not move the + # aggregate session body, because it may include unrelated members. + source_ids = _member_sources( + node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True, + ) + make_copy = bool(node.params.get("make_copy")) + direct_sources = node.params.get("source_feature_ids") or [] + if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1: + # The aggregate is only an export compound. Each source transform has + # its own B-rep builder and is the only output a later COPY query may + # select. Do not attach an aggregate topology delta to source members. + members = dict(session.body_members) + members.update({ + transform_copy_member_id(node.feature_id, source_id): session.adapter.transform( + session.body_members[source_id], dict(node.params.get("transform") or {}), + ) + for source_id in source_ids + }) + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, + ) + return session.result(node) + source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids}) + transformed, topology_delta = session.adapter.transform_with_topology_delta( + source, dict(node.params.get("transform") or {}), + ) + members = dict(session.body_members) + if not make_copy: + for feature_id in source_ids: + members.pop(feature_id) + members[node.feature_id] = transformed + session.register_body( + node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +@atomic_executor("transform_bodies") +def _transform_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_transform_bodies(node, session) + + +def _execute_delete_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # Deletion is a body-graph operation, never a Boolean subtraction. A + # selected member can be disjoint or overlap another independent body. + source_ids = _member_sources(node, session, "target_feature_ids") + members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)} + if members: + session.register_body(node.feature_id, _combine_members(session, members), body_members=members) + else: + session.clear_body() + return session.result(node) + + +@atomic_executor("delete_bodies") +def _delete_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_delete_bodies(node, session) diff --git a/backend/engine/cdsl_engine/executors/common.py b/backend/engine/cdsl_engine/executors/common.py new file mode 100644 index 00000000..e3a6c4f2 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/common.py @@ -0,0 +1,488 @@ +"""Helpers shared by executor family modules. + +Every function here is imported by two or more executor modules. Anything +used by exactly one family lives in that family's module instead. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any, Callable + +from ..extents import _extent_vectors_from_normal, _normal_from_sketch +from ..runtime_base import ExtentVector, FeatureExecutionError +from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _revolve_axis(node: FeaturePlanNode, session: "ExecutionSession") -> AxisSpec: + raw_axis = node.params.get("axis") or {} + if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None: + return AxisSpec.from_mapping(raw_axis) + selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None + if not isinstance(selector, dict): + selector = next((item for item in node.selectors if item.get("kind") == "axis"), None) + if not isinstance(selector, dict): + raise FeatureExecutionError( + "missing_revolve_axis", + "Revolve requires an explicit axis or an owner-qualified reference-axis selector", + ) + resolution = session.resolve(selector) + if resolution.status != "resolved" or resolution.record is None: + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved") + if not isinstance(resolution.record.value, AxisSpec): + raise FeatureExecutionError( + "unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind, + ) + return resolution.record.value + + +def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None: + """Defend direct CDSL execution from an out-of-plane revolve axis.""" + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + direction_normal_dot = abs(vector_dot(axis.direction, plane.normal)) + if direction_normal_dot > 1e-7: + raise ValueError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to " + f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" + ) + origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal)) + if origin_plane_offset > 1e-6: + raise ValueError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in " + f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}" + ) + + +def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]: + """Apply a cut to each independently owned body without erasing ownership. + + A CADFS NEW body stays independently addressable even when a later REMOVE + feature affects several active bodies. Cutting the aggregate first loses + that identity, so this path uses the equivalent per-member set difference + and drops only members that the tool removes completely. + """ + members: dict[str, Any] = {} + for feature_id, body in session.body_members.items(): + result = session.adapter.cut(body, tool) + if abs(float(result.volume)) > 1e-12: + members[feature_id] = result + return members + + +def _extruded_tool( + node: FeaturePlanNode, + faces: list[Any], + profile_normal: Vector3, + session: "ExecutionSession", +) -> tuple[Any, TopologyDelta | None]: + """Build one extrude tool, retaining caps only from one exact builder result.""" + extents = _extent_vectors_from_normal(node, faces, profile_normal, session) + draft = node.params.get("draft") + taper_deg = 0.0 + if isinstance(draft, dict): + taper_deg = float(draft["angle_deg"]) + if not bool(draft["pull_direction"]): + taper_deg = -taper_deg + topology_delta: TopologyDelta | None = None + solids: list[Any] = [] + for face in faces: + for extent in extents: + if draft is not None: + if len(faces) == 1 and len(extents) == 1: + solid, topology_delta = session.adapter.extrude_taper_with_topology_delta( + face, extent.vector, taper_deg, + ) + solids.append(solid) + else: + solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) + elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: + solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) + solids.append(solid) + elif extent.trim_to is None: + solids.append(session.adapter.extrude(face, extent.vector)) + else: + solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) + tool = None + for solid in solids: + tool = session.adapter.fuse(tool, solid) + if tool is None: + raise ValueError("extrude produced no solid") + return tool, topology_delta + + +def _apply_primary_tool( + node: FeaturePlanNode, + session: "ExecutionSession", + tool: Any, + *, + cutting: bool, + topology_delta: TopologyDelta | None = None, +) -> FeatureResult: + """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" + if cutting: + if session.body is None: + raise ValueError("cut feature has no body") + members = _cut_explicit_body_members(session, tool) + if not members: + session.clear_body() + return session.result(node) + body = session.adapter.cut(session.body, tool) + topology_delta = None + elif node.params.get("result_mode") == "new_body": + body = session.adapter.combine(session.body, tool) + members = {**session.body_members, node.feature_id: tool} + else: + body = session.adapter.fuse(session.body, tool) + members = {node.feature_id: body} + # A fuse rebuilds subshape identity. Builder evidence belongs only to + # an unchanged standalone/new-body prism snapshot. + if session.body is not None: + topology_delta = None + session.register_body( + node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult: + # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 + + # 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景), + # 否则按 sketch_id 从会话草图表中取原始草图。 + selected_sketch = sketch or session.sketches.get(str(node.sketch_id)) + if selected_sketch is None: + raise ValueError("primary feature has no resolved sketch") + # 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。 + faces = session.adapter.faces_for_sketch(selected_sketch) + if not faces: + raise ValueError("sketch does not create a closed profile region") + if node.atomic_id == "extrude_add_blind_with_hole": + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved") + if len(faces) != 1: + raise ValueError("profile hole extrusion requires exactly one outer sketch region") + faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] + topology_delta: TopologyDelta | None = None + # 3. 按特征类型生成子实体: + if node.atomic_id.startswith("extrude_"): + # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, + # 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在 + # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 + # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 + tool, topology_delta = _extruded_tool( + node, faces, _normal_from_sketch(selected_sketch), session, + ) + else: + # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 + axis = _revolve_axis(node, session) + _validate_revolve_axis_in_sketch_plane(axis, selected_sketch) + angle = float(node.params.get("angle_deg") or 0.0) + if angle <= 0: + raise ValueError("revolve requires angle_deg > 0") + # reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负 + # 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一 + # 语义。profile_schema.json 已声明 revolve.* optional_params 含 + # reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime + # 侧实现,使三方合同一致。 + if bool(node.params.get("reverse")): + angle = -angle + tool = None + for solid in (session.adapter.revolve(face, angle, axis) for face in faces): + tool = session.adapter.fuse(tool, solid) + if tool is None: + raise ValueError("revolve produced no solid") + return _apply_primary_tool( + node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, + ) + + +def _combine_members(session: "ExecutionSession", members: dict[str, Any]) -> Any: + body = None + for member in members.values(): + body = session.adapter.combine(body, member) + if body is None: + raise ValueError("booleanBodies produced no result bodies") + return body + + +def _pattern_instance_sources( + node: FeaturePlanNode, + session: "ExecutionSession", + parameter: str = "pattern_instance_refs", +) -> list[str]: + """Resolve CDSL pattern-instance refs to their internal body-member keys.""" + resolved: list[str] = [] + for reference in node.params.get(parameter) or (): + if not isinstance(reference, dict): + raise ValueError("pattern instance reference must be an object") + pattern_id = str(reference.get("pattern_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + instance = reference.get("instance_index") + if not pattern_id or not source_id or not isinstance(instance, int): + raise ValueError("pattern instance reference is incomplete") + pattern = session.nodes.get(pattern_id) + if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}: + raise ValueError(f"pattern instance owner is unavailable: {pattern_id}") + params = pattern.params + if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}: + raise ValueError("pattern instance source is not selected by its pattern") + count = int(params.get("pattern_count") or 0) + excluded = {int(value) for value in params.get("excluded_instance_indices") or ()} + if ( + pattern.atomic_id == "pattern_mirror" and instance != 1 + ) or ( + pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) + ): + raise ValueError("pattern instance is outside the pattern's surviving instances") + member_id = pattern_instance_member_id(pattern_id, source_id, instance) + if member_id not in session.body_members: + raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}") + if member_id not in resolved: + resolved.append(member_id) + return resolved + + +def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]: + """Resolve source-qualified outputs of preceding multi-body COPY transforms.""" + resolved: list[str] = [] + for reference in node.params.get("transform_copy_refs") or (): + if not isinstance(reference, dict): + raise ValueError("transform COPY reference must be an object") + transform_id = str(reference.get("transform_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + if not transform_id or not source_id: + raise ValueError("transform COPY reference is incomplete") + transform = session.nodes.get(transform_id) + params = transform.params if transform is not None else {} + sources = params.get("source_feature_ids") or [] + if ( + transform is None + or transform.atomic_id != "transform_bodies" + or not bool(params.get("make_copy")) + or not isinstance(sources, list) + or len(sources) < 2 + or source_id not in {str(value) for value in sources} + ): + raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}") + member_id = transform_copy_member_id(transform_id, source_id) + if member_id not in session.body_members: + raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}") + if member_id not in resolved: + resolved.append(member_id) + return resolved + + +def _member_sources( + node: FeaturePlanNode, + session: "ExecutionSession", + parameter: str, + *, + pattern_instance_parameter: str | None = None, + allow_transform_copies: bool = False, +) -> list[str]: + source_ids = [str(value) for value in node.params.get(parameter) or []] + if pattern_instance_parameter is not None: + source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter)) + if allow_transform_copies: + source_ids.extend(_transform_copy_sources(node, session)) + if not source_ids: + raise ValueError(f"{node.atomic_id} requires explicit {parameter}") + missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members] + if missing: + raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing)) + return source_ids + + +def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any: + # 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。 + path = node.params.get("path") or {} + if not isinstance(path, dict): + raise ValueError("sweep path must be an object") + plane = PlaneSpec.from_mapping(path.get("workplane") or {}) + segment = path.get("segment") or {} + if not isinstance(segment, dict): + raise ValueError("sweep path segment must be an object") + kind = str(segment.get("type") or "") + if kind == "line": + local_points = [segment.get("start"), segment.get("end")] + elif kind == "bspline": + local_points = segment.get("points") or [] + else: + raise ValueError(f"unsupported sweep path segment {kind!r}") + if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points): + raise ValueError("sweep path requires two-dimensional points") + + def point(value: list[float]) -> Vector3: + return vector_add( + plane.origin_mm, + vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), + ) + + def tangent(value: Any) -> Vector3 | None: + if value is None: + return None + if not isinstance(value, list) or len(value) != 2: + raise ValueError("sweep path tangent must contain two coordinates") + return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) + + return session.adapter.sweep_path( + [point(value) for value in local_points], + start_tangent=tangent(segment.get("start_tangent")), + end_tangent=tangent(segment.get("end_tangent")), + parameters=[float(value) for value in segment.get("parameters") or []] or None, + ) + + +def _register_added_solid( + session: "ExecutionSession", + node: FeaturePlanNode, + solid: Any, +) -> None: + """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). + + When ``node.params['result_mode'] == "new_body"`` the primitive is kept as + an independent body member so that downstream ``boolean_bodies`` can + reference it without pulling in the accumulated fuse history. The current + body is replaced by a Compound that preserves both, matching the + ``extrude_add_blind`` ``new_body`` semantics. Any other value (including + missing) falls back to the legacy fuse-into-body behavior. + """ + if node.params.get("result_mode") == "new_body": + combined = session.adapter.combine(session.body, solid) + members = {**session.body_members, node.feature_id: solid} + session.register_body(node.feature_id, combined, replay_node=node, body_members=members) + return + fused = session.adapter.fuse(session.body, solid) + session.register_body(node.feature_id, fused, replay_node=node) + + +def _host_plane(resolution: SelectorResolution) -> PlaneSpec: + if resolution.record is None: + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") + geometry = resolution.record.geometry + return PlaneSpec.from_mapping({ + "origin_mm": geometry["center_mm"], + "x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0], + "normal": geometry["normal"], + }) + + +def _hole_starts( + spec: HoleSpec, + *, + host_plane: PlaneSpec, + positions_are_local: bool, +) -> list[Vector3]: + starts: list[Vector3] = [] + for point in spec.positions_mm: + if positions_are_local: + start = vector_add( + vector_add( + vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])), + vector_scale(host_plane.y_dir, point[1]), + ), + vector_scale(host_plane.normal, point[2]), + ) + else: + start = point + starts.append(start) + return starts + + +def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tangent_propagation: bool = False) -> list[Any]: + resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed: + raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") + + def is_body_boundary(edge: Any) -> bool: + # 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体 + # 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边, + # 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的 + # 单边选择,所以这里只约束由 FACE 展开的候选边。 + face_count = sum( + 1 + for face in session.body.faces() + if any(candidate.is_same(edge) for candidate in face.edges()) + ) + return face_count >= 2 + + edges: list[Any] = [] + for item in resolved: + if item.record.kind == "edge": + edges.append(item.record.value) + elif item.record.kind == "face": + edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge)) + if not edges: + raise ValueError("selectors did not resolve any edges") + return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges + + +def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[Any, list[Any]]: + # shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个 + # Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测 + # 应抽壳的成员。 + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed: + raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") + records = [item.record for item in resolved if item.record is not None] + if not records or any(record.kind != "face" for record in records): + raise ValueError("shell selectors must resolve to faces") + target_ids = {record.body_id for record in records} + if len(target_ids) != 1: + raise ValueError("shell faces must belong to one target body") + target_id = next(iter(target_ids)) + members = session.adapter.body_solids(session.body) + if len(members) == 1: + target = members[0] + else: + if target_id is None or session.body_id is None: + raise ValueError("shell target body is unresolved") + prefix = f"{session.body_id}:" + if not target_id.startswith(prefix): + raise ValueError("shell target body is outside the active body set") + try: + member_index = int(target_id[len(prefix):]) + except ValueError as error: + raise ValueError("shell target body has an invalid member id") from error + if member_index < 0 or member_index >= len(members): + raise ValueError("shell target body member is unavailable") + target = members[member_index] + target_feature_id = node.params.get("target_feature_id") + if target_feature_id is not None: + if not isinstance(target_feature_id, str) or not target_feature_id: + raise ValueError("shell target_feature_id is invalid") + declared = session.body_members.get(target_feature_id) + if declared is None: + raise ValueError("shell target body is no longer an independently selectable member") + declared_solids = session.adapter.body_solids(declared) + if len(declared_solids) != 1: + raise ValueError("shell target body must resolve to exactly one active solid") + if not declared_solids[0].is_same(target): + raise ValueError("shell target body does not match the resolved face member") + return target, [record.value for record in records] + + +def _replace_shell_target(session: "ExecutionSession", target: Any, replacement: Any) -> Any: + # 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。 + members = session.adapter.body_solids(session.body) + if len(members) == 1: + return replacement + replaced = False + result = None + for member in members: + if member.is_same(target): + result = session.adapter.combine(result, replacement) + replaced = True + else: + result = session.adapter.combine(result, member) + if not replaced or result is None: + raise ValueError("shell target solid is no longer part of the active body") + return result diff --git a/backend/engine/cdsl_engine/executors/context.py b/backend/engine/cdsl_engine/executors/context.py new file mode 100644 index 00000000..a5f5771c --- /dev/null +++ b/backend/engine/cdsl_engine/executors/context.py @@ -0,0 +1,63 @@ +"""Reference-geometry executors (reference_plane / reference_axis). + +Context features produce no solid; they register durable topology contexts +that later features resolve through owner-qualified selectors. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec, vector_add, vector_cross, vector_dot, vector_scale, vector_unit +from ..topology import FeaturePlanNode, FeatureResult + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("reference_plane") +def _reference_plane_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + # 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。 + + # 1. 从特征参数 plane 中解析出平面定义 PlaneSpec(原点到法向)。 + plane = PlaneSpec.from_mapping(node.params.get("plane") or {}) + # 2. 将该平面注册到拓扑上下文,供后续特征(如草图基准、参考轴)引用。 + session.topology.register_context(node.feature_id, plane) + # 3. 返回结果对象,并将该平面作为上下文一并携带。 + return session.result(node, context=plane) + + +@atomic_executor("reference_axis") +def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + # 基准轴特征(reference_axis)执行入口:由参数直接定义轴,或由两个基准平面求交线得到轴。 + + # 1. 尝试直接取参数:若同时给出原点 origin_mm 与方向 direction,则直接构造轴。 + params = node.params.get("axis") or {} + if params.get("origin_mm") and params.get("direction"): + axis = AxisSpec.from_mapping(params) + else: + # 2. 否则从特征选择器中筛选出已解析的基准平面。 + planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"] + resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)] + # 3. 校验:轴需要两个非平行的平面,不足两个则报错。 + if len(resolved) < 2: + raise ValueError("reference axis requires two uniquely resolved planes") + # 4. 用两平面法线叉积求交线方向;若方向长度接近 0 说明两平面平行,无法成轴。 + first, second = resolved[0], resolved[1] + n1, n2 = first.normal, second.normal + direction = vector_cross(n1, n2) + squared_length = vector_dot(direction, direction) + if squared_length <= 1e-18: + raise ValueError("reference planes are parallel and cannot define an axis") + # 5. 求交线上的一点:两平面到各自原点的垂距参与线性组合,得到交线上的最近点。 + d1 = vector_dot(n1, first.origin_mm) + d2 = vector_dot(n2, second.origin_mm) + point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length) + # 6. 由该点与归一化的交线方向组合成基准轴 AxisSpec。 + axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis")) + # 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。 + session.topology.register_context(node.feature_id, axis) + return session.result(node, context=axis) diff --git a/backend/engine/cdsl_engine/executors/dressup.py b/backend/engine/cdsl_engine/executors/dressup.py new file mode 100644 index 00000000..11335019 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/dressup.py @@ -0,0 +1,120 @@ +"""Dress-up executors (fillet / chamfer / shell). + +These mutate an existing body through edge/face selectors resolved from the +current B-rep snapshot. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, TopologyDelta +from .common import _replace_shell_target, _selector_edges, _shell_target + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 + + # 1. 校验:圆角作用于已有主体,必须先有主体。 + if session.body is None: + raise ValueError("fillet has no body") + # 2. 解析圆角半径并校验必须大于 0。 + radius = float(node.params.get("radius_mm") or 0) + if radius <= 0: + raise ValueError("fillet radius_mm must be > 0") + # 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。 + body, topology_delta = session.adapter.fillet_with_topology_delta( + session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), + ) + # 4. 登记新主体并返回结果。 + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node) + + +@atomic_executor("fillet") +def _fillet_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_fillet(node, session) + + +def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。 + + # 1. 校验:倒角作用于已有主体,必须先有主体。 + if session.body is None: + raise ValueError("chamfer has no body") + # 2. 解析主距离并校验必须大于 0。 + distance = float(node.params.get("distance_mm") or 0) + if distance <= 0: + raise ValueError("chamfer distance_mm must be > 0") + # 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出 + # angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时 + # tan=1,退化为等距倒角(与历史行为一致,零回归)。 + # 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45° + # 倒角仅保证量级正确,距离所在侧可能反转。 + distance_2 = node.params.get("distance_2_mm") + angle_rad = node.params.get("angle_rad") + if distance_2 is None and angle_rad is not None: + distance_2 = distance * math.tan(float(angle_rad)) + # 4. 解析目标边(支持相切传播),执行倒角。 + edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) + diagnostics: list[RuntimeDiagnostic] = [] + topology_delta: TopologyDelta | None = None + try: + body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges) + except ValueError as error: + # 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准 + # OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据 + # 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。 + if distance_2 is not None or not session.surface_members: + raise + try: + body = session.adapter.surface_limited_chamfer( + session.body, distance, edges, list(session.surface_members.values()), + ) + except ValueError: + raise error + diagnostics.append(RuntimeDiagnostic( + "chamfer_surface_limited", + "Chamfer was limited by an explicit coaxial surface boundary", + feature_id=node.feature_id, + detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, + )) + # 5. 登记新主体并返回结果。 + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node, diagnostics=diagnostics) + + +@atomic_executor("chamfer") +def _chamfer_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_chamfer(node, session) + + +def _execute_shell(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。 + if session.body is None: + raise ValueError("shell has no body") + thickness = float(node.params.get("thickness_mm") or 0) + if thickness <= 0: + raise ValueError("shell thickness_mm must be > 0") + target, faces = _shell_target(node, session) + result, topology_delta = session.adapter.shell_with_topology_delta( + target, faces, thickness, inward=bool(node.params.get("inward", True)), + ) + session.register_body( + node.feature_id, _replace_shell_target(session, target, result), replay_node=node, + topology_delta=topology_delta, + ) + return session.result(node) + + +@atomic_executor("shell") +def _shell_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_shell(node, session) diff --git a/backend/engine/cdsl_engine/executors/extrude.py b/backend/engine/cdsl_engine/executors/extrude.py new file mode 100644 index 00000000..e4b89829 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/extrude.py @@ -0,0 +1,42 @@ +"""Extrusion executors (blind / two-sided / cut / through / from-face).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _apply_primary_tool, _extruded_tool, _shape_from_primary + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor( + "extrude_add_blind", + "extrude_add_blind_with_hole", + "extrude_add_two_sided", + "extrude_cut_blind", + "extrude_cut_two_sided", + "extrude_cut_through", +) +def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _shape_from_primary(node, session, sketch=sketch) + + +def _execute_extrude_from_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved") + face = resolved[0].record.value + tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session) + return _apply_primary_tool( + node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta, + ) + + +@atomic_executor("extrude_from_face") +def _extrude_from_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_extrude_from_face(node, session) diff --git a/backend/engine/cdsl_engine/executors/holes.py b/backend/engine/cdsl_engine/executors/holes.py new file mode 100644 index 00000000..9a66b991 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/holes.py @@ -0,0 +1,78 @@ +"""Hole executors (hole_blind / hole_countersink / hole_counterbore / hole_wizard).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import HoleSpec, PlaneSpec, vector_scale +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic +from .common import _hole_starts, _host_plane + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("hole_blind", "hole_countersink", "hole_counterbore") +def _hole_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_hole(node, session, wizard=False) + + +@atomic_executor("hole_wizard") +def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_hole(node, session, wizard=True) + + +def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult: + # 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。 + + # 1. 校验:孔是切除操作,必须先有主体。 + if session.body is None: + raise ValueError("hole feature has no body") + # 2. 确定宿主面 host_face: + host_selector = node.params.get("host_face") + if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict): + # 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。 + host = PlaneSpec.from_mapping(host_selector["frame"]) + positions_are_local = True + else: + # 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。 + selectors = list(node.selectors) + if isinstance(host_selector, dict): + selectors.append(host_selector) + selector = next((item for item in selectors if item.get("kind") == "face"), None) + if selector is None: + raise ValueError("hole requires host_face selector or frame") + host = _host_plane(session.resolve(selector)) + positions_are_local = False + # 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。 + spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard) + # 4. A host-face normal is an outward B-rep orientation, so its inverse + # always enters the material. Inferring direction from the global body + # centre fails for concave or multi-leg parts: for example, the top face + # of an L bracket can sit below the whole body's centre and the old rule + # drilled outward, producing a no-op feature reported as successful. + # The selected topology face is the local, authoritative orientation. + inward = vector_scale(host.normal, -1) + # 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。 + tool = session.adapter.hole_tool( + spec, + _hole_starts(spec, host_plane=host, positions_are_local=positions_are_local), + inward, + session.adapter.body_span(session.body, inward) + 2.0, + ) + # 6. 从主体上减去工具实体,登记新主体并返回结果。 + # thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔 + # 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告 + # 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。 + diagnostics: list[RuntimeDiagnostic] = [] + if wizard and node.params.get("thread"): + diagnostics.append(RuntimeDiagnostic( + code="thread_decoration_ignored", + message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore", + feature_id=node.feature_id, + )) + session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) + return session.result(node, diagnostics=diagnostics) diff --git a/backend/engine/cdsl_engine/executors/loft_sweep.py b/backend/engine/cdsl_engine/executors/loft_sweep.py new file mode 100644 index 00000000..4cbb1ba5 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/loft_sweep.py @@ -0,0 +1,89 @@ +"""Loft and sweep executors (loft_add / loft_add_with_cap_face / sweep_add).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _sweep_path + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_loft_add(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析 + # 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。 + profile_ids = node.params.get("profile_sketch_ids") or [] + profiles: list[dict[str, Any]] = [] + for sketch_id in profile_ids: + sketch = session.sketches.get(str(sketch_id)) + if sketch is None: + raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") + profiles.append(sketch) + solid, topology_delta = session.adapter.loft_with_topology_delta(profiles) + body = session.adapter.fuse(session.body, solid) + # Fusing a loft into an existing body replaces its subshapes through a + # different builder. Only an initial direct loft can expose this builder's + # cap evidence for the final B-rep snapshot. + if session.body is not None: + topology_delta = None + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node) + + +@atomic_executor("loft_add") +def _loft_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_loft_add(node, session) + + +def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved") + profile_ids = node.params.get("profile_sketch_ids") or [] + profiles: list[dict[str, Any]] = [] + for sketch_id in profile_ids: + sketch = session.sketches.get(str(sketch_id)) + if sketch is None: + raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") + profiles.append(sketch) + solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles) + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return session.result(node) + + +@atomic_executor("loft_add_with_cap_face") +def _loft_cap_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_loft_add_with_cap_face(node, session) + + +def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None = None) -> FeatureResult: + profile = sketch or session.sketches.get(str(node.sketch_id)) + if profile is None: + raise ValueError("sweep has no resolved profile sketch") + faces = session.adapter.faces_for_sketch(profile) + if len(faces) != 1: + raise ValueError("sweep requires exactly one closed profile region") + solid, topology_delta = session.adapter.sweep_with_topology_delta( + faces[0], _sweep_path(node, session), + is_frenet=bool(node.params.get("is_frenet", False)), + ) + is_new_body = node.params.get("result_mode") == "new_body" + body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid) + # A union rebuilds topology, so the pipe-shell builder cannot prove the + # final aggregate's relations. The independent-body path retains its exact + # subshape identity and may expose evidence for the new member. + if session.body is not None and not is_new_body: + topology_delta = None + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node) + + +@atomic_executor("sweep_add") +def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _execute_sweep_add(node, session, sketch) diff --git a/backend/engine/cdsl_engine/executors/parametric.py b/backend/engine/cdsl_engine/executors/parametric.py new file mode 100644 index 00000000..7cb74084 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/parametric.py @@ -0,0 +1,118 @@ +"""Parametric feature executors (thread / bend / gear / rack). + +Each executor parses its runtime-neutral spec from ``runtime_types`` specs, +asks the geometry adapter to build and place the local-frame solid, then +fuses it into the active body (or subtracts, for thread_cut). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import BendSpec, GearSpec, RackSpec, ThreadSpec +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic +from .common import _register_added_solid + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_thread(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。 + # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 + spec = ThreadSpec.from_feature(node.atomic_id, node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。 + solid = session.adapter.thread_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +def _execute_thread_cut(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置 + # internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿 + # spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔, + # 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。 + # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 + spec = ThreadSpec.from_feature(node.atomic_id, node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。 + tool = session.adapter.thread_solid(spec) + # 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。 + session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) + return session.result(node) + + +@atomic_executor("thread_add", "thread_cut") +def _thread_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。 + # thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。 + del sketch + if node.atomic_id == "thread_cut": + return _execute_thread_cut(node, session) + return _execute_thread(node, session) + + +def _execute_bend(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。 + # 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。 + spec = BendSpec.from_feature(node.params) + # 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。 + solid = session.adapter.bend_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +@atomic_executor("bend_add") +def _bend_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_bend(node, session) + + +def _execute_gear(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。 + # 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。 + spec = GearSpec.from_feature(node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。 + solid = session.adapter.gear_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + # 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。 + diagnostics: list[RuntimeDiagnostic] = [] + if spec.teeth_count < 17: + diagnostics.append(RuntimeDiagnostic( + code="undercut_risk", + message=( + f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; " + "standard involute geometry is generated without profile shift" + ), + feature_id=node.feature_id, + )) + return session.result(node, diagnostics=diagnostics) + + +@atomic_executor("gear_add") +def _gear_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_gear(node, session) + + +def _execute_rack(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。 + # 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。 + spec = RackSpec.from_feature(node.params) + # 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。 + solid = session.adapter.rack_solid(spec) + # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +@atomic_executor("rack_add") +def _rack_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_rack(node, session) diff --git a/backend/engine/cdsl_engine/executors/patterns.py b/backend/engine/cdsl_engine/executors/patterns.py new file mode 100644 index 00000000..f7c4d613 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/patterns.py @@ -0,0 +1,382 @@ +"""Pattern executors (pattern_linear / pattern_mirror / pattern_circular). + +Instances replay their source features with transformed parameters rather +than copying the current body. NEW-body sources can additionally be +instanced as rigid body-graph copies, keeping each instance independently +addressable for later COPY/DELETE queries. +""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Callable + +from ..capabilities import pattern_transform_blocker +from ..pattern_transform import ( + _box_circular_is_exact, + _mirrored_node, + _mirrored_sketch, + _normal_is_coordinate_axis, + _pattern_operation_node, + _rotated_node, + _rotated_sketch, + _translated_node, + _translated_sketch, +) +from ..registry import atomic_executor, execute_node +from ..specs import AxisSpec, PlaneSpec, Vector3, pattern_instance_member_id, vector_add, vector_dot, vector_scale, vector_subtract, vector_unit +from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + +ExecutorFunction = Callable[[FeaturePlanNode, "ExecutionSession", dict[str, Any] | None], FeatureResult] + + +def _execute_linear_pattern( + node: FeaturePlanNode, + session: "ExecutionSession", + execute: ExecutorFunction, +) -> FeatureResult: + # 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。 + + # 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。 + params = node.params + sources = session.replay_sources(params.get("source_feature_ids") or []) + if not sources: + raise ValueError("pattern source features have no replay definitions") + # 2. 解析两个方向的实例数量。 + count_1 = int(params.get("pattern_count_1") or 1) + count_2 = int(params.get("pattern_count_2") or 1) + # 3. 解析两个方向的步长向量(方向单位向量 × 间距),作为阵列位移基准。 + direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0)) + direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0)) + # 4. 双重循环生成每个阵列实例(跳过原点 0,0 处,那里是源特征本身)。 + for first in range(count_1): + for second in range(count_2): + if first == 0 and second == 0: + continue + # 计算当前实例相对源特征的偏移向量。 + offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second)) + for source in sources: + # 逐个源特征克隆并按偏移平移后重放执行(草图也同步平移)。 + dependency = pattern_transform_blocker(source) + if dependency: + raise ValueError(f"pattern source uses an unsupported {dependency}") + cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session) + sketch = session.sketches.get(str(source.sketch_id)) + execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None) + # 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放, + # 而非复制当前主体做近似。 + # A later pattern may select this pattern feature. The definition is + # replayed recursively, never approximated by copying the current body. + session.replay_definitions[node.feature_id] = node + return session.result(node) + + +@atomic_executor("pattern_linear") +def _linear_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_linear_pattern(node, session, execute_node) + + +def _execute_mirror_pattern(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + mirror = node.params.get("mirror_plane") or {} + resolution = session.resolve(mirror) + if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec): + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved") + source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()] + if ( + source_ids + and all(source_id in session.body_members for source_id in source_ids) + and all( + (source := session.nodes.get(source_id)) is not None + and source.params.get("result_mode") == "new_body" + for source_id in source_ids + ) + ): + # Only a direct NEW body has a standalone source identity after a + # mirror. A hole, dress-up, or ordinary additive source is merely an + # aggregate successor and must use the feature-replay path below. + # Keeping this condition identical to capability preflight prevents a + # downstream COPY body query from selecting an arbitrary aggregate. + members = dict(session.body_members) + body = session.body + for source_id in source_ids: + mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value) + members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored + body = session.adapter.fuse(body, mirrored) + if body is None: + raise ValueError("mirror pattern produced no body") + session.register_body(node.feature_id, body, replay_node=node, body_members=members) + return session.result(node) + if node.params.get("mirror_current_body"): + # CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制 + # 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会 + # 丢失后续 cut/fillet 并生成独立错误实体。 + if session.body is None: + raise ValueError("mirror current body has no active body") + mirrored = session.adapter.mirror(session.body, resolution.record.value) + session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node) + return session.result(node) + sources = session.replay_sources(node.params.get("source_feature_ids") or []) + if not sources: + raise ValueError("mirror pattern source features have no replay definitions") + for source in sources: + dependency = pattern_transform_blocker(source) + if dependency: + raise ValueError(f"mirror pattern source uses an unsupported {dependency}") + if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal): + # box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向, + # 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面 + # (法向平行于任一坐标轴)的镜像仍然精确。 + raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes") + cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session) + sketch = session.sketches.get(str(source.sketch_id)) + execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None) + session.replay_definitions[node.feature_id] = node + return session.result(node) + + +@atomic_executor("pattern_mirror") +def _mirror_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_mirror_pattern(node, session) + + +def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: "ExecutionSession", axis: AxisSpec) -> bool: + """Whether rotating a direct circular extrusion creates no new geometry.""" + if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}: + return False + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + return False + profile = sketch.get("profile") or {} + circle = profile if profile.get("type") == "circle" else None + if circle is None: + contours = profile.get("contours") or [] + segments = (contours[0] or {}).get("segments") if len(contours) == 1 else [] + circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None + center = (circle or {}).get("center") + if not isinstance(center, list) or len(center) != 2: + return False + try: + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + except (TypeError, ValueError): + return False + if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7: + return False + world_center = vector_add( + plane.origin_mm, + vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))), + ) + offset = vector_subtract(world_center, axis.origin_mm) + radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction))) + return math.sqrt(vector_dot(radial, radial)) <= 1e-6 + + +def _advance_copy_topology_records( + records: list[TopologyRecord], topology_delta: TopologyDelta | None, +) -> list[TopologyRecord]: + """Carry COPY provenance through one exact adapter-history operation. + + Pattern copies are separate CDSL results even when their solids fuse into + a single final body. The temporary records here are never selector + candidates themselves. They only retain instance ownership while opaque + OCC history proves a unique subshape continuation to the final snapshot. + """ + if topology_delta is None: + return [] + advanced: list[TopologyRecord] = [] + for record in records: + values: list[Any] = [] + for relation in topology_delta.relations: + if ( + relation.kind != record.kind + or relation.event not in {"preserved", "modified"} + or not TopologyRegistry._same_topology_value(record.value, relation.source_value) + ): + continue + for value in relation.result_values: + if not any(TopologyRegistry._same_topology_value(value, known) for known in values): + values.append(value) + # A split/merge has no unique COPY owner in the present selector + # contract. Keep the executable model, but do not make a claim that a + # later COPY selector can bind one arbitrary descendant. + if len(values) != 1: + continue + advanced.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=record.feature_id, + body_id=record.body_id, + geometry=dict(record.geometry), + value=values[0], + owner_feature_ids=record.owners, + output_roles=record.output_roles, + output_role_sources=record.output_role_sources, + )) + return advanced + + +def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None: + """Bridge traced final COPY handles into the one registered body snapshot.""" + if not records: + return None + return TopologyDelta( + operation="pattern_circular_copy_snapshot", + relations=tuple( + # ``record.value`` has already passed through every transform/fuse + # builder in this pattern and is an actual final-B-rep handle. The + # identity relation merely connects that evidence to the fresh + # adapter snapshot; it is not a geometric rebinding shortcut. + TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,)) + for record in records + ), + ) + + +def _has_usable_pattern_body(session: "ExecutionSession", body: Any | None) -> bool: + """Reject a formally valid but empty OCC boolean result before publishing it.""" + if body is None or not session.adapter.body_solids(body): + return False + try: + return abs(float(body.volume)) > 1e-12 + except (AttributeError, TypeError, ValueError): + return False + + +def _execute_circular_pattern( + node: FeaturePlanNode, + session: "ExecutionSession", + execute: ExecutorFunction, +) -> FeatureResult: + # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 + # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 + params = node.params + raw_axis = params.get("axis") + if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None): + raise ValueError("circular pattern requires an explicit axis with origin_mm and direction") + axis = AxisSpec.from_mapping(raw_axis) + count = int(params.get("pattern_count") or 1) + if count < 1: + raise ValueError("circular pattern pattern_count must be >= 1") + sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0) + operation_mode = str(params.get("operation_mode") or "add") + if operation_mode not in {"add", "remove"}: + raise ValueError("circular pattern operation_mode must be add or remove") + excluded = {int(value) for value in params.get("excluded_instance_indices") or []} + if any(instance < 1 or instance >= count for instance in excluded): + raise ValueError("circular pattern excluded instance is outside the generated range") + sources = session.replay_sources(params.get("source_feature_ids") or []) + if not sources: + raise ValueError("circular pattern source features have no replay definitions") + source_ids = [source.feature_id for source in sources] + pre_pattern_members = dict(session.body_members) + if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids): + # A pattern over explicit NEW/kept body members has a stronger contract + # than replay: each copy is an independently addressable rigid image of + # the named source member. Keep the instance keys in the body graph so + # a later CADFS COPY(BODY) transform/delete can name exactly one copy. + members = dict(session.body_members) + body = session.body + traced_copy_records: list[TopologyRecord] = [] + for instance in range(1, count): + if instance in excluded: + continue + angle_deg = sweep_angle_deg * instance / count + transform = { + "type": "rotation", + "axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)}, + "angle_deg": angle_deg, + } + for source_id in source_ids: + member_id = pattern_instance_member_id(node.feature_id, source_id, instance) + owner_id = f"{node.feature_id}.c{instance}.{source_id}" + source_body = session.body_members[source_id] + copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform) + source_records = session.adapter.topology_records( + source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source", + ) + copy_records = _advance_copy_topology_records(source_records, transform_delta) + members[member_id] = copy + body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy) + traced_copy_records = _advance_copy_topology_records( + [*traced_copy_records, *copy_records], fuse_delta, + ) + if _has_usable_pattern_body(session, body): + session.register_body( + node.feature_id, body, replay_node=node, body_members=members, + topology_delta=_copy_snapshot_topology_delta(traced_copy_records), + topology_predecessors=traced_copy_records, + ) + return session.result(node) + # An OCC boolean may report IsDone/valid for an empty result when a + # copied fused body contains coincident internal topology. The normal + # pattern contract can replay the source feature contribution instead; + # it is the only sound fallback because it keeps source operation, + # sketch frame, and body lifecycle semantics intact. + for instance in range(1, count): + if instance in excluded: + continue + # 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。 + angle_deg = sweep_angle_deg * instance / count + angle_rad = math.radians(angle_deg) + for source in sources: + # 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与 + # 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse, + # 后续非轴对称 source 可能因此丢失已生成的实体分支。 + if _circular_source_is_axisymmetric(source, session, axis): + continue + dependency = pattern_transform_blocker(source) + if dependency: + raise ValueError(f"circular pattern source uses an unsupported {dependency}") + if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad): + raise ValueError( + "box_add circular pattern is exact only for coordinate-axis rotation " + "by multiples of 180 degrees" + ) + cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session) + cloned = _pattern_operation_node(cloned, operation_mode) + # CADFS pattern instances are copies of the source result, not + # independent `NEW` operations. Replay them through normal add + # semantics: intersecting or face-sharing instances fuse, while + # spatially separate copies remain separate solids in the result. + if cloned.params.get("result_mode") == "new_body": + cloned = FeaturePlanNode( + cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on, + {key: value for key, value in cloned.params.items() if key != "result_mode"}, + cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature, + ) + sketch = session.sketches.get(str(source.sketch_id)) + execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None) + # 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新 + # active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续 + # selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。 + if session.body is None: + raise ValueError("circular pattern produced no body") + # Replaying a fused sole-body source may be more robust than copying its + # full aggregate B-rep (for example, when a rotationally invariant base + # would otherwise be unioned with itself). If that replay still has one + # physical body, the direct source remains a proven alias of the current + # member. Preserve it for a following parts-scoped operation such as + # shell; do not extend this alias across multi-body patterns or multiple + # source members. + members = {node.feature_id: session.body} + if ( + len(source_ids) == 1 + and len(pre_pattern_members) == 1 + and source_ids[0] in pre_pattern_members + and _has_usable_pattern_body(session, session.body) + and len(session.adapter.body_solids(session.body)) == 1 + ): + members[source_ids[0]] = session.body + session.register_body(node.feature_id, session.body, replay_node=node, body_members=members) + return session.result(node) + + +@atomic_executor("pattern_circular") +def _circular_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_circular_pattern(node, session, execute_node) diff --git a/backend/engine/cdsl_engine/executors/primitives.py b/backend/engine/cdsl_engine/executors/primitives.py new file mode 100644 index 00000000..54b9d3b1 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/primitives.py @@ -0,0 +1,97 @@ +"""Analytic primitive executors (sphere_add / box_add / cylinder_add).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec +from ..topology import FeaturePlanNode, FeatureResult +from .common import _register_added_solid + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_sphere(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。 + + # 1. 解析参数:半径 radius_mm 与球心 center_mm。 + radius = float(node.params.get("radius_mm") or 0.0) + center = node.params.get("center_mm") or [] + # 2. 校验:半径必须大于 0,球心必须是三维坐标。 + if radius <= 0 or len(center) != 3: + raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm") + # 3. 由适配器创建球体实体。 + solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2]))) + # 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +def _execute_box(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。 + # 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。 + try: + length = float(node.params.get("length_mm") or 0.0) + width = float(node.params.get("width_mm") or 0.0) + height = float(node.params.get("height_mm") or 0.0) + center = node.params.get("center_mm") or [] + except (TypeError, ValueError) as error: + raise ValueError("box dimensions must be numeric") from error + if length <= 0 or width <= 0 or height <= 0 or len(center) != 3: + raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm") + # 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半 + # 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。 + corner = ( + float(center[0]) - length / 2, + float(center[1]) - width / 2, + float(center[2]) - height / 2, + ) + plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}) + solid = session.adapter.box(length, width, height, plane) + # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +def _execute_cylinder(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向; + # axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。 + # 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。 + try: + radius = float(node.params.get("radius_mm") or 0.0) + height = float(node.params.get("height_mm") or 0.0) + except (TypeError, ValueError) as error: + raise ValueError("cylinder dimensions must be numeric") from error + if radius <= 0 or height <= 0: + raise ValueError("cylinder_add requires positive radius_mm and height_mm") + raw_axis = node.params.get("axis") + if raw_axis is not None and not ( + isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None + ): + raise ValueError("cylinder_add axis must define origin_mm and direction") + axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None + # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 + solid = session.adapter.cylinder(radius, height, axis) + # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 + _register_added_solid(session, node, solid) + return session.result(node) + + +@atomic_executor("sphere_add") +def _sphere_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_sphere(node, session) + + +@atomic_executor("box_add") +def _box_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_box(node, session) + + +@atomic_executor("cylinder_add") +def _cylinder_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_cylinder(node, session) diff --git a/backend/engine/cdsl_engine/executors/revolve.py b/backend/engine/cdsl_engine/executors/revolve.py new file mode 100644 index 00000000..0a11aae1 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/revolve.py @@ -0,0 +1,17 @@ +"""Revolution solid executors (revolve_add / revolve_cut).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _shape_from_primary + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("revolve_add", "revolve_cut") +def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _shape_from_primary(node, session, sketch=sketch) diff --git a/backend/engine/cdsl_engine/executors/surfaces.py b/backend/engine/cdsl_engine/executors/surfaces.py new file mode 100644 index 00000000..5ef44378 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/surfaces.py @@ -0,0 +1,75 @@ +"""Surface-feature executors (extrude_surface / revolve_surface). + +Surface features register an independent shell and never touch the active +solid body's fuse/cut lifecycle. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..extents import _normal_from_sketch +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec, vector_cross, vector_dot, vector_scale, vector_unit +from ..topology import FeaturePlanNode, FeatureResult +from .common import _revolve_axis, _validate_revolve_axis_in_sketch_plane + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _execute_revolve_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与 + # 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。 + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + raise ValueError("surface revolve has no resolved sketch") + faces = session.adapter.faces_for_sketch(sketch) + if len(faces) != 1 or faces[0].inner_wires(): + raise ValueError("surface revolve requires exactly one closed profile without holes") + axis = _revolve_axis(node, session) + _validate_revolve_axis_in_sketch_plane(axis, sketch) + angle = float(node.params.get("angle_deg") or 0.0) + if angle <= 0: + raise ValueError("surface revolve requires angle_deg > 0") + if bool(node.params.get("reverse")): + angle = -angle + surface_id = session.register_surface( + node.feature_id, + session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis), + ) + return session.result(node, include_body=False, surface_id=surface_id) + + +@atomic_executor("revolve_surface") +def _revolve_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_revolve_surface(node, session) + + +def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为 + # shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。 + sketch = session.sketches.get(str(node.sketch_id)) + if sketch is None: + raise ValueError("surface extrude has no resolved sketch") + direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal") + if bool(node.params.get("reverse")): + direction = vector_scale(direction, -1) + distance = float(node.params.get("distance_mm") or 0.0) + if distance <= 0: + raise ValueError("surface extrude requires distance_mm > 0") + wires = session.adapter.surface_wires_for_sketch(sketch) + surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance)) + reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0) + if reverse_distance > 0: + opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance)) + surface = session.adapter.combine_surfaces(surface, opposite) + surface_id = session.register_surface(node.feature_id, surface) + return session.result(node, include_body=False, surface_id=surface_id) + + +@atomic_executor("extrude_surface") +def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_extrude_surface(node, session) diff --git a/backend/engine/cdsl_engine/registry.py b/backend/engine/cdsl_engine/registry.py new file mode 100644 index 00000000..129ccfdb --- /dev/null +++ b/backend/engine/cdsl_engine/registry.py @@ -0,0 +1,83 @@ +"""Atomic executor registry and dispatch for the session runtime. + +Executors register themselves with :func:`atomic_executor` from their own +modules; ``cdsl_engine.executors`` imports every executor module exactly once +to build the registry. Adding a new atomic operation therefore means adding +a decorated function in a family module -- the registry itself never changes. + +``registry`` deliberately imports no executor module, so pattern executors +can re-enter :func:`execute_node` for replay without an import cycle. +""" + +from __future__ import annotations + +from typing import Any, Callable, Protocol + +from .session import ExecutionSession +from .topology import CapabilityResult, FeaturePlanNode, FeatureResult + + +class AtomicExecutor(Protocol): + atomic_id: str + + def preflight(self, node: FeaturePlanNode, session: ExecutionSession) -> CapabilityResult: ... + def execute(self, node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: ... + + +#: The complete capability contract of this runtime. Every registered +#: executor must name an id from this set, so a mistyped registration fails +#: at import time instead of surfacing as an unknown-atomic blocker later. +ALL_ATOMIC_IDS = frozenset({ + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", + "extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", + "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", + "hole_counterbore", "sphere_add", "box_add", "cylinder_add", + "reference_plane", "reference_axis", + "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", + "pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies", + "thread_add", "thread_cut", + "bend_add", + "gear_add", "rack_add", +}) + +#: ``atomic_id -> executor``. Populated by ``cdsl_engine.executors``. +EXECUTORS: dict[str, "ExecutorFunction"] = {} + +ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] + + +def atomic_executor(*atomic_ids: str) -> Callable[["ExecutorFunction"], "ExecutorFunction"]: + """Register one executor function for one or more atomic ids. + + Registration validates against ``ALL_ATOMIC_IDS`` and rejects duplicates, + keeping the registry consistent with the declared capability contract. + """ + unknown = [atomic_id for atomic_id in atomic_ids if atomic_id not in ALL_ATOMIC_IDS] + if unknown: + raise ValueError(f"cannot register executors for unknown atomic ids: {unknown}") + + def decorator(func: "ExecutorFunction") -> "ExecutorFunction": + for atomic_id in atomic_ids: + if atomic_id in EXECUTORS: + raise ValueError(f"duplicate executor registration for {atomic_id!r}") + EXECUTORS[atomic_id] = func + return func + + return decorator + + +def execute_node( + node: FeaturePlanNode, + session: ExecutionSession, + sketch_override: dict[str, Any] | None = None, +) -> FeatureResult: + """Dispatch one plan node through its registered executor.""" + executor = EXECUTORS.get(node.atomic_id) + if executor is None: + raise ValueError(f"No executor registered for {node.atomic_id!r}") + previous_feature_id = session.active_feature_id + session.active_feature_id = node.feature_id + try: + return executor(node, session, sketch_override) + finally: + session.active_feature_id = previous_feature_id diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 5706d560..e01c20bd 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -1,26 +1,32 @@ -"""Session-based CDSL execution with atomic executor registry. +"""Session-based CDSL execution entry points. The runtime was split into focused modules (behavior-preserving move): -- ``runtime_base``: shared error types and the ``ExtentVector`` value. +- ``registry``: atomic executor registry, ``atomic_executor`` decorator, and + the ``execute_node`` dispatcher. +- ``executors/``: one module per executor family; importing the package + performs the registration and verifies registry completeness. - ``session``: ``ExecutionSession`` and the ``GeometryAdapter`` protocol. +- ``runtime_base``: shared error types and the ``ExtentVector`` value. - ``extents``: end-condition planning. - ``pattern_transform``: translate/mirror/rotate parameter algebra for replay. -This module keeps the executor registry, the per-atomic executors, and the -``analyze_cdsl`` / ``rebuild_cdsl`` entry points. The moved names are -re-exported so every historical ``cdsl_engine.runtime`` import keeps working. +This module keeps the ``analyze_cdsl`` / ``rebuild_cdsl`` entry points and +re-exports the historical ``cdsl_engine.runtime`` names, including the +private helpers referenced by the test suite, so every existing import keeps +working. """ from __future__ import annotations from copy import deepcopy -import math from pathlib import Path -from typing import Any, Callable, Protocol +from typing import Any -from .build123d_adapter import Build123dGeometryAdapter # noqa: F401 (historical re-export) +from . import executors # noqa: F401 (importing performs executor registration) from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract +# Historical private name still imported by the test suite. +from .executors.primitives import _execute_box # noqa: F401 from .extents import ( _extent_reference, _extent_vectors, @@ -46,6 +52,14 @@ from .pattern_transform import ( _translated_node, _translated_sketch, ) +from .registry import ( + ALL_ATOMIC_IDS, + EXECUTORS, + AtomicExecutor, + ExecutorFunction, + atomic_executor, + execute_node, +) from .runtime_base import ExtentVector, FeatureExecutionError, RuntimeExecutionError from .session import ExecutionSession, GeometryAdapter from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches @@ -59,1515 +73,22 @@ from .topology import ( SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry, ) - -ALL_ATOMIC_IDS = frozenset({ - "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", - "extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", - "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", - "hole_counterbore", "sphere_add", "box_add", "cylinder_add", - "reference_plane", "reference_axis", - "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", - "pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies", - "thread_add", "thread_cut", - "bend_add", - "gear_add", "rack_add", -}) - - -class AtomicExecutor(Protocol): - atomic_id: str - - def preflight(self, node: FeaturePlanNode, session: "ExecutionSession") -> CapabilityResult: ... - def execute(self, node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: ... - - -def _revolve_axis(node: FeaturePlanNode, session: ExecutionSession) -> AxisSpec: - raw_axis = node.params.get("axis") or {} - if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None: - return AxisSpec.from_mapping(raw_axis) - selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None - if not isinstance(selector, dict): - selector = next((item for item in node.selectors if item.get("kind") == "axis"), None) - if not isinstance(selector, dict): - raise FeatureExecutionError( - "missing_revolve_axis", - "Revolve requires an explicit axis or an owner-qualified reference-axis selector", - ) - resolution = session.resolve(selector) - if resolution.status != "resolved" or resolution.record is None: - raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved") - if not isinstance(resolution.record.value, AxisSpec): - raise FeatureExecutionError( - "unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind, - ) - return resolution.record.value - - -def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None: - """Defend direct CDSL execution from an out-of-plane revolve axis.""" - plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) - direction_normal_dot = abs(vector_dot(axis.direction, plane.normal)) - if direction_normal_dot > 1e-7: - raise ValueError( - "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to " - f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" - ) - origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal)) - if origin_plane_offset > 1e-6: - raise ValueError( - "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in " - f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}" - ) - - -def _cut_explicit_body_members(session: ExecutionSession, tool: Any) -> dict[str, Any]: - """Apply a cut to each independently owned body without erasing ownership. - - A CADFS NEW body stays independently addressable even when a later REMOVE - feature affects several active bodies. Cutting the aggregate first loses - that identity, so this path uses the equivalent per-member set difference - and drops only members that the tool removes completely. - """ - members: dict[str, Any] = {} - for feature_id, body in session.body_members.items(): - result = session.adapter.cut(body, tool) - if abs(float(result.volume)) > 1e-12: - members[feature_id] = result - return members - - -def _extruded_tool( - node: FeaturePlanNode, - faces: list[Any], - profile_normal: Vector3, - session: ExecutionSession, -) -> tuple[Any, TopologyDelta | None]: - """Build one extrude tool, retaining caps only from one exact builder result.""" - extents = _extent_vectors_from_normal(node, faces, profile_normal, session) - draft = node.params.get("draft") - taper_deg = 0.0 - if isinstance(draft, dict): - taper_deg = float(draft["angle_deg"]) - if not bool(draft["pull_direction"]): - taper_deg = -taper_deg - topology_delta: TopologyDelta | None = None - solids: list[Any] = [] - for face in faces: - for extent in extents: - if draft is not None: - if len(faces) == 1 and len(extents) == 1: - solid, topology_delta = session.adapter.extrude_taper_with_topology_delta( - face, extent.vector, taper_deg, - ) - solids.append(solid) - else: - solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) - elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: - solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) - solids.append(solid) - elif extent.trim_to is None: - solids.append(session.adapter.extrude(face, extent.vector)) - else: - solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) - tool = None - for solid in solids: - tool = session.adapter.fuse(tool, solid) - if tool is None: - raise ValueError("extrude produced no solid") - return tool, topology_delta - - -def _apply_primary_tool( - node: FeaturePlanNode, - session: ExecutionSession, - tool: Any, - *, - cutting: bool, - topology_delta: TopologyDelta | None = None, -) -> FeatureResult: - """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" - if cutting: - if session.body is None: - raise ValueError("cut feature has no body") - members = _cut_explicit_body_members(session, tool) - if not members: - session.clear_body() - return session.result(node) - body = session.adapter.cut(session.body, tool) - topology_delta = None - elif node.params.get("result_mode") == "new_body": - body = session.adapter.combine(session.body, tool) - members = {**session.body_members, node.feature_id: tool} - else: - body = session.adapter.fuse(session.body, tool) - members = {node.feature_id: body} - # A fuse rebuilds subshape identity. Builder evidence belongs only to - # an unchanged standalone/new-body prism snapshot. - if session.body is not None: - topology_delta = None - session.register_body( - node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, - ) - return session.result(node) - - -def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, sketch: dict[str, Any] | None = None) -> FeatureResult: - # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 - - # 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景), - # 否则按 sketch_id 从会话草图表中取原始草图。 - selected_sketch = sketch or session.sketches.get(str(node.sketch_id)) - if selected_sketch is None: - raise ValueError("primary feature has no resolved sketch") - # 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。 - faces = session.adapter.faces_for_sketch(selected_sketch) - if not faces: - raise ValueError("sketch does not create a closed profile region") - if node.atomic_id == "extrude_add_blind_with_hole": - resolved = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": - raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved") - if len(faces) != 1: - raise ValueError("profile hole extrusion requires exactly one outer sketch region") - faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] - topology_delta: TopologyDelta | None = None - # 3. 按特征类型生成子实体: - if node.atomic_id.startswith("extrude_"): - # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, - # 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在 - # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 - # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 - tool, topology_delta = _extruded_tool( - node, faces, _normal_from_sketch(selected_sketch), session, - ) - else: - # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 - axis = _revolve_axis(node, session) - _validate_revolve_axis_in_sketch_plane(axis, selected_sketch) - angle = float(node.params.get("angle_deg") or 0.0) - if angle <= 0: - raise ValueError("revolve requires angle_deg > 0") - # reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负 - # 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一 - # 语义。profile_schema.json 已声明 revolve.* optional_params 含 - # reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime - # 侧实现,使三方合同一致。 - if bool(node.params.get("reverse")): - angle = -angle - tool = None - for solid in (session.adapter.revolve(face, angle, axis) for face in faces): - tool = session.adapter.fuse(tool, solid) - if tool is None: - raise ValueError("revolve produced no solid") - return _apply_primary_tool( - node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, - ) - - -def _execute_extrude_from_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - resolved = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": - raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved") - face = resolved[0].record.value - tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session) - return _apply_primary_tool( - node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta, - ) - - -def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与 - # 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。 - sketch = session.sketches.get(str(node.sketch_id)) - if sketch is None: - raise ValueError("surface revolve has no resolved sketch") - faces = session.adapter.faces_for_sketch(sketch) - if len(faces) != 1 or faces[0].inner_wires(): - raise ValueError("surface revolve requires exactly one closed profile without holes") - axis = _revolve_axis(node, session) - _validate_revolve_axis_in_sketch_plane(axis, sketch) - angle = float(node.params.get("angle_deg") or 0.0) - if angle <= 0: - raise ValueError("surface revolve requires angle_deg > 0") - if bool(node.params.get("reverse")): - angle = -angle - surface_id = session.register_surface( - node.feature_id, - session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis), - ) - return session.result(node, include_body=False, surface_id=surface_id) - - -def _execute_extrude_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为 - # shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。 - sketch = session.sketches.get(str(node.sketch_id)) - if sketch is None: - raise ValueError("surface extrude has no resolved sketch") - direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal") - if bool(node.params.get("reverse")): - direction = vector_scale(direction, -1) - distance = float(node.params.get("distance_mm") or 0.0) - if distance <= 0: - raise ValueError("surface extrude requires distance_mm > 0") - wires = session.adapter.surface_wires_for_sketch(sketch) - surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance)) - reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0) - if reverse_distance > 0: - opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance)) - surface = session.adapter.combine_surfaces(surface, opposite) - surface_id = session.register_surface(node.feature_id, surface) - return session.result(node, include_body=False, surface_id=surface_id) - - -def _combine_members(session: ExecutionSession, members: dict[str, Any]) -> Any: - body = None - for member in members.values(): - body = session.adapter.combine(body, member) - if body is None: - raise ValueError("booleanBodies produced no result bodies") - return body - - -def _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为 - # 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。 - params = node.params - target_ids = _member_sources( - node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs", - ) - tool_ids = _member_sources( - node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs", - ) - targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids} - tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids} - target = _combine_members(session, targets) - tool = _combine_members(session, tools) - operation = str(params.get("operation") or "") - topology_delta: TopologyDelta | None = None - if operation == "union": - result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool) - elif operation == "subtract": - result, topology_delta = session.adapter.cut_with_topology_delta(target, tool) - elif operation == "intersect": - result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool) - else: - raise ValueError(f"unsupported booleanBodies operation {operation!r}") - members = { - feature_id: body - for feature_id, body in session.body_members.items() - if feature_id not in set(target_ids + tool_ids) - } - members[node.feature_id] = result - if bool(params.get("keep_tools")): - members.update(tools) - session.register_body( - node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, - ) - return session.result(node) - - -def _pattern_instance_sources( - node: FeaturePlanNode, - session: ExecutionSession, - parameter: str = "pattern_instance_refs", -) -> list[str]: - """Resolve CDSL pattern-instance refs to their internal body-member keys.""" - resolved: list[str] = [] - for reference in node.params.get(parameter) or (): - if not isinstance(reference, dict): - raise ValueError("pattern instance reference must be an object") - pattern_id = str(reference.get("pattern_feature_id") or "") - source_id = str(reference.get("source_feature_id") or "") - instance = reference.get("instance_index") - if not pattern_id or not source_id or not isinstance(instance, int): - raise ValueError("pattern instance reference is incomplete") - pattern = session.nodes.get(pattern_id) - if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}: - raise ValueError(f"pattern instance owner is unavailable: {pattern_id}") - params = pattern.params - if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}: - raise ValueError("pattern instance source is not selected by its pattern") - count = int(params.get("pattern_count") or 0) - excluded = {int(value) for value in params.get("excluded_instance_indices") or ()} - if ( - pattern.atomic_id == "pattern_mirror" and instance != 1 - ) or ( - pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) - ): - raise ValueError("pattern instance is outside the pattern's surviving instances") - member_id = pattern_instance_member_id(pattern_id, source_id, instance) - if member_id not in session.body_members: - raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}") - if member_id not in resolved: - resolved.append(member_id) - return resolved - - -def _transform_copy_sources(node: FeaturePlanNode, session: ExecutionSession) -> list[str]: - """Resolve source-qualified outputs of preceding multi-body COPY transforms.""" - resolved: list[str] = [] - for reference in node.params.get("transform_copy_refs") or (): - if not isinstance(reference, dict): - raise ValueError("transform COPY reference must be an object") - transform_id = str(reference.get("transform_feature_id") or "") - source_id = str(reference.get("source_feature_id") or "") - if not transform_id or not source_id: - raise ValueError("transform COPY reference is incomplete") - transform = session.nodes.get(transform_id) - params = transform.params if transform is not None else {} - sources = params.get("source_feature_ids") or [] - if ( - transform is None - or transform.atomic_id != "transform_bodies" - or not bool(params.get("make_copy")) - or not isinstance(sources, list) - or len(sources) < 2 - or source_id not in {str(value) for value in sources} - ): - raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}") - member_id = transform_copy_member_id(transform_id, source_id) - if member_id not in session.body_members: - raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}") - if member_id not in resolved: - resolved.append(member_id) - return resolved - - -def _member_sources( - node: FeaturePlanNode, - session: ExecutionSession, - parameter: str, - *, - pattern_instance_parameter: str | None = None, - allow_transform_copies: bool = False, -) -> list[str]: - source_ids = [str(value) for value in node.params.get(parameter) or []] - if pattern_instance_parameter is not None: - source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter)) - if allow_transform_copies: - source_ids.extend(_transform_copy_sources(node, session)) - if not source_ids: - raise ValueError(f"{node.atomic_id} requires explicit {parameter}") - missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members] - if missing: - raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing)) - return source_ids - - -def _execute_transform_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # FeatureScript transform targets explicit bodies. Do not move the - # aggregate session body, because it may include unrelated members. - source_ids = _member_sources( - node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True, - ) - make_copy = bool(node.params.get("make_copy")) - direct_sources = node.params.get("source_feature_ids") or [] - if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1: - # The aggregate is only an export compound. Each source transform has - # its own B-rep builder and is the only output a later COPY query may - # select. Do not attach an aggregate topology delta to source members. - members = dict(session.body_members) - members.update({ - transform_copy_member_id(node.feature_id, source_id): session.adapter.transform( - session.body_members[source_id], dict(node.params.get("transform") or {}), - ) - for source_id in source_ids - }) - session.register_body( - node.feature_id, _combine_members(session, members), body_members=members, - ) - return session.result(node) - source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids}) - transformed, topology_delta = session.adapter.transform_with_topology_delta( - source, dict(node.params.get("transform") or {}), - ) - members = dict(session.body_members) - if not make_copy: - for feature_id in source_ids: - members.pop(feature_id) - members[node.feature_id] = transformed - session.register_body( - node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, - ) - return session.result(node) - - -def _execute_delete_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # Deletion is a body-graph operation, never a Boolean subtraction. A - # selected member can be disjoint or overlap another independent body. - source_ids = _member_sources(node, session, "target_feature_ids") - members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)} - if members: - session.register_body(node.feature_id, _combine_members(session, members), body_members=members) - else: - session.clear_body() - return session.result(node) - - -def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析 - # 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。 - profile_ids = node.params.get("profile_sketch_ids") or [] - profiles: list[dict[str, Any]] = [] - for sketch_id in profile_ids: - sketch = session.sketches.get(str(sketch_id)) - if sketch is None: - raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") - profiles.append(sketch) - solid, topology_delta = session.adapter.loft_with_topology_delta(profiles) - body = session.adapter.fuse(session.body, solid) - # Fusing a loft into an existing body replaces its subshapes through a - # different builder. Only an initial direct loft can expose this builder's - # cap evidence for the final B-rep snapshot. - if session.body is not None: - topology_delta = None - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) - return session.result(node) - - -def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - resolved = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": - raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved") - profile_ids = node.params.get("profile_sketch_ids") or [] - profiles: list[dict[str, Any]] = [] - for sketch_id in profile_ids: - sketch = session.sketches.get(str(sketch_id)) - if sketch is None: - raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") - profiles.append(sketch) - solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles) - session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) - return session.result(node) - - -def _sweep_path(node: FeaturePlanNode, session: ExecutionSession) -> Any: - # 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。 - path = node.params.get("path") or {} - if not isinstance(path, dict): - raise ValueError("sweep path must be an object") - plane = PlaneSpec.from_mapping(path.get("workplane") or {}) - segment = path.get("segment") or {} - if not isinstance(segment, dict): - raise ValueError("sweep path segment must be an object") - kind = str(segment.get("type") or "") - if kind == "line": - local_points = [segment.get("start"), segment.get("end")] - elif kind == "bspline": - local_points = segment.get("points") or [] - else: - raise ValueError(f"unsupported sweep path segment {kind!r}") - if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points): - raise ValueError("sweep path requires two-dimensional points") - - def point(value: list[float]) -> Vector3: - return vector_add( - plane.origin_mm, - vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), - ) - - def tangent(value: Any) -> Vector3 | None: - if value is None: - return None - if not isinstance(value, list) or len(value) != 2: - raise ValueError("sweep path tangent must contain two coordinates") - return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) - - return session.adapter.sweep_path( - [point(value) for value in local_points], - start_tangent=tangent(segment.get("start_tangent")), - end_tangent=tangent(segment.get("end_tangent")), - parameters=[float(value) for value in segment.get("parameters") or []] or None, - ) - - -def _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None = None) -> FeatureResult: - profile = sketch or session.sketches.get(str(node.sketch_id)) - if profile is None: - raise ValueError("sweep has no resolved profile sketch") - faces = session.adapter.faces_for_sketch(profile) - if len(faces) != 1: - raise ValueError("sweep requires exactly one closed profile region") - solid, topology_delta = session.adapter.sweep_with_topology_delta( - faces[0], _sweep_path(node, session), - is_frenet=bool(node.params.get("is_frenet", False)), - ) - is_new_body = node.params.get("result_mode") == "new_body" - body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid) - # A union rebuilds topology, so the pipe-shell builder cannot prove the - # final aggregate's relations. The independent-body path retains its exact - # subshape identity and may expose evidence for the new member. - if session.body is not None and not is_new_body: - topology_delta = None - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) - return session.result(node) - - -def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。 - - # 1. 从特征参数 plane 中解析出平面定义 PlaneSpec(原点到法向)。 - plane = PlaneSpec.from_mapping(node.params.get("plane") or {}) - # 2. 将该平面注册到拓扑上下文,供后续特征(如草图基准、参考轴)引用。 - session.topology.register_context(node.feature_id, plane) - # 3. 返回结果对象,并将该平面作为上下文一并携带。 - return session.result(node, context=plane) - - -def _execute_reference_axis(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 基准轴特征(reference_axis)执行入口:由参数直接定义轴,或由两个基准平面求交线得到轴。 - - # 1. 尝试直接取参数:若同时给出原点 origin_mm 与方向 direction,则直接构造轴。 - params = node.params.get("axis") or {} - if params.get("origin_mm") and params.get("direction"): - axis = AxisSpec.from_mapping(params) - else: - # 2. 否则从特征选择器中筛选出已解析的基准平面。 - planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"] - resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)] - # 3. 校验:轴需要两个非平行的平面,不足两个则报错。 - if len(resolved) < 2: - raise ValueError("reference axis requires two uniquely resolved planes") - # 4. 用两平面法线叉积求交线方向;若方向长度接近 0 说明两平面平行,无法成轴。 - first, second = resolved[0], resolved[1] - n1, n2 = first.normal, second.normal - direction = vector_cross(n1, n2) - squared_length = vector_dot(direction, direction) - if squared_length <= 1e-18: - raise ValueError("reference planes are parallel and cannot define an axis") - # 5. 求交线上的一点:两平面到各自原点的垂距参与线性组合,得到交线上的最近点。 - d1 = vector_dot(n1, first.origin_mm) - d2 = vector_dot(n2, second.origin_mm) - point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length) - # 6. 由该点与归一化的交线方向组合成基准轴 AxisSpec。 - axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis")) - # 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。 - session.topology.register_context(node.feature_id, axis) - return session.result(node, context=axis) - - -def _register_added_solid( - session: ExecutionSession, - node: FeaturePlanNode, - solid: Any, -) -> None: - """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). - - When ``node.params['result_mode'] == "new_body"`` the primitive is kept as - an independent body member so that downstream ``boolean_bodies`` can - reference it without pulling in the accumulated fuse history. The current - body is replaced by a Compound that preserves both, matching the - ``extrude_add_blind`` ``new_body`` semantics. Any other value (including - missing) falls back to the legacy fuse-into-body behavior. - """ - if node.params.get("result_mode") == "new_body": - combined = session.adapter.combine(session.body, solid) - members = {**session.body_members, node.feature_id: solid} - session.register_body(node.feature_id, combined, replay_node=node, body_members=members) - return - fused = session.adapter.fuse(session.body, solid) - session.register_body(node.feature_id, fused, replay_node=node) - - -def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。 - - # 1. 解析参数:半径 radius_mm 与球心 center_mm。 - radius = float(node.params.get("radius_mm") or 0.0) - center = node.params.get("center_mm") or [] - # 2. 校验:半径必须大于 0,球心必须是三维坐标。 - if radius <= 0 or len(center) != 3: - raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm") - # 3. 由适配器创建球体实体。 - solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2]))) - # 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _execute_box(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。 - # 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。 - try: - length = float(node.params.get("length_mm") or 0.0) - width = float(node.params.get("width_mm") or 0.0) - height = float(node.params.get("height_mm") or 0.0) - center = node.params.get("center_mm") or [] - except (TypeError, ValueError) as error: - raise ValueError("box dimensions must be numeric") from error - if length <= 0 or width <= 0 or height <= 0 or len(center) != 3: - raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm") - # 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半 - # 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。 - corner = ( - float(center[0]) - length / 2, - float(center[1]) - width / 2, - float(center[2]) - height / 2, - ) - plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}) - solid = session.adapter.box(length, width, height, plane) - # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向; - # axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。 - # 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。 - try: - radius = float(node.params.get("radius_mm") or 0.0) - height = float(node.params.get("height_mm") or 0.0) - except (TypeError, ValueError) as error: - raise ValueError("cylinder dimensions must be numeric") from error - if radius <= 0 or height <= 0: - raise ValueError("cylinder_add requires positive radius_mm and height_mm") - raw_axis = node.params.get("axis") - if raw_axis is not None and not ( - isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None - ): - raise ValueError("cylinder_add axis must define origin_mm and direction") - axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None - # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 - solid = session.adapter.cylinder(radius, height, axis) - # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。 - # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 - spec = ThreadSpec.from_feature(node.atomic_id, node.params) - # 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。 - solid = session.adapter.thread_solid(spec) - # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _thread_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - # 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。 - # thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。 - del sketch - if node.atomic_id == "thread_cut": - return _execute_thread_cut(node, session) - return _execute_thread(node, session) - - -def _execute_thread_cut(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置 - # internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿 - # spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔, - # 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。 - # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 - spec = ThreadSpec.from_feature(node.atomic_id, node.params) - # 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。 - tool = session.adapter.thread_solid(spec) - # 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。 - session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) - return session.result(node) - - -def _execute_bend(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。 - # 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。 - spec = BendSpec.from_feature(node.params) - # 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。 - solid = session.adapter.bend_solid(spec) - # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _bend_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - # 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。 - del sketch - return _execute_bend(node, session) - - -def _execute_gear(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。 - # 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。 - spec = GearSpec.from_feature(node.params) - # 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。 - solid = session.adapter.gear_solid(spec) - # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - # 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。 - diagnostics: list[RuntimeDiagnostic] = [] - if spec.teeth_count < 17: - diagnostics.append(RuntimeDiagnostic( - code="undercut_risk", - message=( - f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; " - "standard involute geometry is generated without profile shift" - ), - feature_id=node.feature_id, - )) - return session.result(node, diagnostics=diagnostics) - - -def _gear_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - # 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。 - del sketch - return _execute_gear(node, session) - - -def _execute_rack(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。 - # 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。 - spec = RackSpec.from_feature(node.params) - # 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。 - solid = session.adapter.rack_solid(spec) - # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) - return session.result(node) - - -def _rack_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - # 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。 - del sketch - return _execute_rack(node, session) - - -def _host_plane(resolution: SelectorResolution) -> PlaneSpec: - if resolution.record is None: - raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") - geometry = resolution.record.geometry - return PlaneSpec.from_mapping({ - "origin_mm": geometry["center_mm"], - "x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0], - "normal": geometry["normal"], - }) - - -def _hole_starts( - spec: HoleSpec, - *, - host_plane: PlaneSpec, - positions_are_local: bool, -) -> list[Vector3]: - starts: list[Vector3] = [] - for point in spec.positions_mm: - if positions_are_local: - start = vector_add( - vector_add( - vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])), - vector_scale(host_plane.y_dir, point[1]), - ), - vector_scale(host_plane.normal, point[2]), - ) - else: - start = point - starts.append(start) - return starts - - -def _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: bool = False) -> FeatureResult: - # 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。 - - # 1. 校验:孔是切除操作,必须先有主体。 - if session.body is None: - raise ValueError("hole feature has no body") - # 2. 确定宿主面 host_face: - host_selector = node.params.get("host_face") - if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict): - # 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。 - host = PlaneSpec.from_mapping(host_selector["frame"]) - positions_are_local = True - else: - # 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。 - selectors = list(node.selectors) - if isinstance(host_selector, dict): - selectors.append(host_selector) - selector = next((item for item in selectors if item.get("kind") == "face"), None) - if selector is None: - raise ValueError("hole requires host_face selector or frame") - host = _host_plane(session.resolve(selector)) - positions_are_local = False - # 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。 - spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard) - # 4. A host-face normal is an outward B-rep orientation, so its inverse - # always enters the material. Inferring direction from the global body - # centre fails for concave or multi-leg parts: for example, the top face - # of an L bracket can sit below the whole body's centre and the old rule - # drilled outward, producing a no-op feature reported as successful. - # The selected topology face is the local, authoritative orientation. - inward = vector_scale(host.normal, -1) - # 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。 - tool = session.adapter.hole_tool( - spec, - _hole_starts(spec, host_plane=host, positions_are_local=positions_are_local), - inward, - session.adapter.body_span(session.body, inward) + 2.0, - ) - # 6. 从主体上减去工具实体,登记新主体并返回结果。 - # thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔 - # 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告 - # 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。 - diagnostics: list[RuntimeDiagnostic] = [] - if wizard and node.params.get("thread"): - diagnostics.append(RuntimeDiagnostic( - code="thread_decoration_ignored", - message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore", - feature_id=node.feature_id, - )) - session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) - return session.result(node, diagnostics=diagnostics) - - -def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent_propagation: bool = False) -> list[Any]: - resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed: - raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") - - def is_body_boundary(edge: Any) -> bool: - # 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体 - # 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边, - # 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的 - # 单边选择,所以这里只约束由 FACE 展开的候选边。 - face_count = sum( - 1 - for face in session.body.faces() - if any(candidate.is_same(edge) for candidate in face.edges()) - ) - return face_count >= 2 - - edges: list[Any] = [] - for item in resolved: - if item.record.kind == "edge": - edges.append(item.record.value) - elif item.record.kind == "face": - edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge)) - if not edges: - raise ValueError("selectors did not resolve any edges") - return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges - - -def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any, list[Any]]: - # shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个 - # Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测 - # 应抽壳的成员。 - resolved = [session.resolve(selector) for selector in node.selectors] - failed = next((item for item in resolved if item.status != "resolved"), None) - if failed: - raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") - records = [item.record for item in resolved if item.record is not None] - if not records or any(record.kind != "face" for record in records): - raise ValueError("shell selectors must resolve to faces") - target_ids = {record.body_id for record in records} - if len(target_ids) != 1: - raise ValueError("shell faces must belong to one target body") - target_id = next(iter(target_ids)) - members = session.adapter.body_solids(session.body) - if len(members) == 1: - target = members[0] - else: - if target_id is None or session.body_id is None: - raise ValueError("shell target body is unresolved") - prefix = f"{session.body_id}:" - if not target_id.startswith(prefix): - raise ValueError("shell target body is outside the active body set") - try: - member_index = int(target_id[len(prefix):]) - except ValueError as error: - raise ValueError("shell target body has an invalid member id") from error - if member_index < 0 or member_index >= len(members): - raise ValueError("shell target body member is unavailable") - target = members[member_index] - target_feature_id = node.params.get("target_feature_id") - if target_feature_id is not None: - if not isinstance(target_feature_id, str) or not target_feature_id: - raise ValueError("shell target_feature_id is invalid") - declared = session.body_members.get(target_feature_id) - if declared is None: - raise ValueError("shell target body is no longer an independently selectable member") - declared_solids = session.adapter.body_solids(declared) - if len(declared_solids) != 1: - raise ValueError("shell target body must resolve to exactly one active solid") - if not declared_solids[0].is_same(target): - raise ValueError("shell target body does not match the resolved face member") - return target, [record.value for record in records] - - -def _replace_shell_target(session: ExecutionSession, target: Any, replacement: Any) -> Any: - # 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。 - members = session.adapter.body_solids(session.body) - if len(members) == 1: - return replacement - replaced = False - result = None - for member in members: - if member.is_same(target): - result = session.adapter.combine(result, replacement) - replaced = True - else: - result = session.adapter.combine(result, member) - if not replaced or result is None: - raise ValueError("shell target solid is no longer part of the active body") - return result - - -def _execute_shell(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。 - if session.body is None: - raise ValueError("shell has no body") - thickness = float(node.params.get("thickness_mm") or 0) - if thickness <= 0: - raise ValueError("shell thickness_mm must be > 0") - target, faces = _shell_target(node, session) - result, topology_delta = session.adapter.shell_with_topology_delta( - target, faces, thickness, inward=bool(node.params.get("inward", True)), - ) - session.register_body( - node.feature_id, _replace_shell_target(session, target, result), replay_node=node, - topology_delta=topology_delta, - ) - return session.result(node) - - -def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 - - # 1. 校验:圆角作用于已有主体,必须先有主体。 - if session.body is None: - raise ValueError("fillet has no body") - # 2. 解析圆角半径并校验必须大于 0。 - radius = float(node.params.get("radius_mm") or 0) - if radius <= 0: - raise ValueError("fillet radius_mm must be > 0") - # 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。 - body, topology_delta = session.adapter.fillet_with_topology_delta( - session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), - ) - # 4. 登记新主体并返回结果。 - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) - return session.result(node) - - -def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - # 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。 - - # 1. 校验:倒角作用于已有主体,必须先有主体。 - if session.body is None: - raise ValueError("chamfer has no body") - # 2. 解析主距离并校验必须大于 0。 - distance = float(node.params.get("distance_mm") or 0) - if distance <= 0: - raise ValueError("chamfer distance_mm must be > 0") - # 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出 - # angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时 - # tan=1,退化为等距倒角(与历史行为一致,零回归)。 - # 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45° - # 倒角仅保证量级正确,距离所在侧可能反转。 - distance_2 = node.params.get("distance_2_mm") - angle_rad = node.params.get("angle_rad") - if distance_2 is None and angle_rad is not None: - distance_2 = distance * math.tan(float(angle_rad)) - # 4. 解析目标边(支持相切传播),执行倒角。 - edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) - diagnostics: list[RuntimeDiagnostic] = [] - topology_delta: TopologyDelta | None = None - try: - body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges) - except ValueError as error: - # 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准 - # OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据 - # 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。 - if distance_2 is not None or not session.surface_members: - raise - try: - body = session.adapter.surface_limited_chamfer( - session.body, distance, edges, list(session.surface_members.values()), - ) - except ValueError: - raise error - diagnostics.append(RuntimeDiagnostic( - "chamfer_surface_limited", - "Chamfer was limited by an explicit coaxial surface boundary", - feature_id=node.feature_id, - detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, - )) - # 5. 登记新主体并返回结果。 - session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) - return session.result(node, diagnostics=diagnostics) - - -def _execute_linear_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: - # 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。 - - # 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。 - params = node.params - sources = session.replay_sources(params.get("source_feature_ids") or []) - if not sources: - raise ValueError("pattern source features have no replay definitions") - # 2. 解析两个方向的实例数量。 - count_1 = int(params.get("pattern_count_1") or 1) - count_2 = int(params.get("pattern_count_2") or 1) - # 3. 解析两个方向的步长向量(方向单位向量 × 间距),作为阵列位移基准。 - direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0)) - direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0)) - # 4. 双重循环生成每个阵列实例(跳过原点 0,0 处,那里是源特征本身)。 - for first in range(count_1): - for second in range(count_2): - if first == 0 and second == 0: - continue - # 计算当前实例相对源特征的偏移向量。 - offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second)) - for source in sources: - # 逐个源特征克隆并按偏移平移后重放执行(草图也同步平移)。 - dependency = pattern_transform_blocker(source) - if dependency: - raise ValueError(f"pattern source uses an unsupported {dependency}") - cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session) - sketch = session.sketches.get(str(source.sketch_id)) - execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None) - # 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放, - # 而非复制当前主体做近似。 - # A later pattern may select this pattern feature. The definition is - # replayed recursively, never approximated by copying the current body. - session.replay_definitions[node.feature_id] = node - return session.result(node) - - -def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: - mirror = node.params.get("mirror_plane") or {} - resolution = session.resolve(mirror) - if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec): - raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved") - source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()] - if ( - source_ids - and all(source_id in session.body_members for source_id in source_ids) - and all( - (source := session.nodes.get(source_id)) is not None - and source.params.get("result_mode") == "new_body" - for source_id in source_ids - ) - ): - # Only a direct NEW body has a standalone source identity after a - # mirror. A hole, dress-up, or ordinary additive source is merely an - # aggregate successor and must use the feature-replay path below. - # Keeping this condition identical to capability preflight prevents a - # downstream COPY body query from selecting an arbitrary aggregate. - members = dict(session.body_members) - body = session.body - for source_id in source_ids: - mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value) - members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored - body = session.adapter.fuse(body, mirrored) - if body is None: - raise ValueError("mirror pattern produced no body") - session.register_body(node.feature_id, body, replay_node=node, body_members=members) - return session.result(node) - if node.params.get("mirror_current_body"): - # CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制 - # 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会 - # 丢失后续 cut/fillet 并生成独立错误实体。 - if session.body is None: - raise ValueError("mirror current body has no active body") - mirrored = session.adapter.mirror(session.body, resolution.record.value) - session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node) - return session.result(node) - sources = session.replay_sources(node.params.get("source_feature_ids") or []) - if not sources: - raise ValueError("mirror pattern source features have no replay definitions") - for source in sources: - dependency = pattern_transform_blocker(source) - if dependency: - raise ValueError(f"mirror pattern source uses an unsupported {dependency}") - if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal): - # box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向, - # 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面 - # (法向平行于任一坐标轴)的镜像仍然精确。 - raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes") - cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session) - sketch = session.sketches.get(str(source.sketch_id)) - _execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None) - session.replay_definitions[node.feature_id] = node - return session.result(node) - - -def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: ExecutionSession, axis: AxisSpec) -> bool: - """Whether rotating a direct circular extrusion creates no new geometry.""" - if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}: - return False - sketch = session.sketches.get(str(node.sketch_id)) - if sketch is None: - return False - profile = sketch.get("profile") or {} - circle = profile if profile.get("type") == "circle" else None - if circle is None: - contours = profile.get("contours") or [] - segments = (contours[0] or {}).get("segments") if len(contours) == 1 else [] - circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None - center = (circle or {}).get("center") - if not isinstance(center, list) or len(center) != 2: - return False - try: - plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) - except (TypeError, ValueError): - return False - if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7: - return False - world_center = vector_add( - plane.origin_mm, - vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))), - ) - offset = vector_subtract(world_center, axis.origin_mm) - radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction))) - return math.sqrt(vector_dot(radial, radial)) <= 1e-6 - - -def _advance_copy_topology_records( - records: list[TopologyRecord], topology_delta: TopologyDelta | None, -) -> list[TopologyRecord]: - """Carry COPY provenance through one exact adapter-history operation. - - Pattern copies are separate CDSL results even when their solids fuse into - a single final body. The temporary records here are never selector - candidates themselves. They only retain instance ownership while opaque - OCC history proves a unique subshape continuation to the final snapshot. - """ - if topology_delta is None: - return [] - advanced: list[TopologyRecord] = [] - for record in records: - values: list[Any] = [] - for relation in topology_delta.relations: - if ( - relation.kind != record.kind - or relation.event not in {"preserved", "modified"} - or not TopologyRegistry._same_topology_value(record.value, relation.source_value) - ): - continue - for value in relation.result_values: - if not any(TopologyRegistry._same_topology_value(value, known) for known in values): - values.append(value) - # A split/merge has no unique COPY owner in the present selector - # contract. Keep the executable model, but do not make a claim that a - # later COPY selector can bind one arbitrary descendant. - if len(values) != 1: - continue - advanced.append(TopologyRecord( - record_id=record.record_id, - kind=record.kind, - feature_id=record.feature_id, - body_id=record.body_id, - geometry=dict(record.geometry), - value=values[0], - owner_feature_ids=record.owners, - output_roles=record.output_roles, - output_role_sources=record.output_role_sources, - )) - return advanced - - -def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None: - """Bridge traced final COPY handles into the one registered body snapshot.""" - if not records: - return None - return TopologyDelta( - operation="pattern_circular_copy_snapshot", - relations=tuple( - # ``record.value`` has already passed through every transform/fuse - # builder in this pattern and is an actual final-B-rep handle. The - # identity relation merely connects that evidence to the fresh - # adapter snapshot; it is not a geometric rebinding shortcut. - TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,)) - for record in records - ), - ) - - -def _has_usable_pattern_body(session: ExecutionSession, body: Any | None) -> bool: - """Reject a formally valid but empty OCC boolean result before publishing it.""" - if body is None or not session.adapter.body_solids(body): - return False - try: - return abs(float(body.volume)) > 1e-12 - except (AttributeError, TypeError, ValueError): - return False - - -def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: - # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 - # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 - params = node.params - raw_axis = params.get("axis") - if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None): - raise ValueError("circular pattern requires an explicit axis with origin_mm and direction") - axis = AxisSpec.from_mapping(raw_axis) - count = int(params.get("pattern_count") or 1) - if count < 1: - raise ValueError("circular pattern pattern_count must be >= 1") - sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0) - operation_mode = str(params.get("operation_mode") or "add") - if operation_mode not in {"add", "remove"}: - raise ValueError("circular pattern operation_mode must be add or remove") - excluded = {int(value) for value in params.get("excluded_instance_indices") or []} - if any(instance < 1 or instance >= count for instance in excluded): - raise ValueError("circular pattern excluded instance is outside the generated range") - sources = session.replay_sources(params.get("source_feature_ids") or []) - if not sources: - raise ValueError("circular pattern source features have no replay definitions") - source_ids = [source.feature_id for source in sources] - pre_pattern_members = dict(session.body_members) - if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids): - # A pattern over explicit NEW/kept body members has a stronger contract - # than replay: each copy is an independently addressable rigid image of - # the named source member. Keep the instance keys in the body graph so - # a later CADFS COPY(BODY) transform/delete can name exactly one copy. - members = dict(session.body_members) - body = session.body - traced_copy_records: list[TopologyRecord] = [] - for instance in range(1, count): - if instance in excluded: - continue - angle_deg = sweep_angle_deg * instance / count - transform = { - "type": "rotation", - "axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)}, - "angle_deg": angle_deg, - } - for source_id in source_ids: - member_id = pattern_instance_member_id(node.feature_id, source_id, instance) - owner_id = f"{node.feature_id}.c{instance}.{source_id}" - source_body = session.body_members[source_id] - copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform) - source_records = session.adapter.topology_records( - source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source", - ) - copy_records = _advance_copy_topology_records(source_records, transform_delta) - members[member_id] = copy - body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy) - traced_copy_records = _advance_copy_topology_records( - [*traced_copy_records, *copy_records], fuse_delta, - ) - if _has_usable_pattern_body(session, body): - session.register_body( - node.feature_id, body, replay_node=node, body_members=members, - topology_delta=_copy_snapshot_topology_delta(traced_copy_records), - topology_predecessors=traced_copy_records, - ) - return session.result(node) - # An OCC boolean may report IsDone/valid for an empty result when a - # copied fused body contains coincident internal topology. The normal - # pattern contract can replay the source feature contribution instead; - # it is the only sound fallback because it keeps source operation, - # sketch frame, and body lifecycle semantics intact. - for instance in range(1, count): - if instance in excluded: - continue - # 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。 - angle_deg = sweep_angle_deg * instance / count - angle_rad = math.radians(angle_deg) - for source in sources: - # 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与 - # 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse, - # 后续非轴对称 source 可能因此丢失已生成的实体分支。 - if _circular_source_is_axisymmetric(source, session, axis): - continue - dependency = pattern_transform_blocker(source) - if dependency: - raise ValueError(f"circular pattern source uses an unsupported {dependency}") - if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad): - raise ValueError( - "box_add circular pattern is exact only for coordinate-axis rotation " - "by multiples of 180 degrees" - ) - cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session) - cloned = _pattern_operation_node(cloned, operation_mode) - # CADFS pattern instances are copies of the source result, not - # independent `NEW` operations. Replay them through normal add - # semantics: intersecting or face-sharing instances fuse, while - # spatially separate copies remain separate solids in the result. - if cloned.params.get("result_mode") == "new_body": - cloned = FeaturePlanNode( - cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on, - {key: value for key, value in cloned.params.items() if key != "result_mode"}, - cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature, - ) - sketch = session.sketches.get(str(source.sketch_id)) - execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None) - # 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新 - # active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续 - # selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。 - if session.body is None: - raise ValueError("circular pattern produced no body") - # Replaying a fused sole-body source may be more robust than copying its - # full aggregate B-rep (for example, when a rotationally invariant base - # would otherwise be unioned with itself). If that replay still has one - # physical body, the direct source remains a proven alias of the current - # member. Preserve it for a following parts-scoped operation such as - # shell; do not extend this alias across multi-body patterns or multiple - # source members. - members = {node.feature_id: session.body} - if ( - len(source_ids) == 1 - and len(pre_pattern_members) == 1 - and source_ids[0] in pre_pattern_members - and _has_usable_pattern_body(session, session.body) - and len(session.adapter.body_solids(session.body)) == 1 - ): - members[source_ids[0]] = session.body - session.register_body(node.feature_id, session.body, replay_node=node, body_members=members) - return session.result(node) - - -def _circular_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_circular_pattern(node, session, _execute_node) - - -def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_override: dict[str, Any] | None = None) -> FeatureResult: - executor = EXECUTORS.get(node.atomic_id) - if executor is None: - raise ValueError(f"No executor registered for {node.atomic_id!r}") - previous_feature_id = session.active_feature_id - session.active_feature_id = node.feature_id - try: - return executor(node, session, sketch_override) - finally: - session.active_feature_id = previous_feature_id - - -ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] - - -def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - return _shape_from_primary(node, session, sketch=sketch) - - -def _revolve_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_revolve_surface(node, session) - - -def _extrude_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_extrude_surface(node, session) - - -def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_loft_add(node, session) - - -def _loft_cap_face_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_loft_add_with_cap_face(node, session) - - -def _sweep_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - return _execute_sweep_add(node, session, sketch) - - -def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_reference_plane(node, session) - - -def _reference_axis_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_reference_axis(node, session) - - -def _sphere_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_sphere(node, session) - - -def _box_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_box(node, session) - - -def _cylinder_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_cylinder(node, session) - - -def _hole_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_hole(node, session) - - -def _hole_wizard_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_hole(node, session, wizard=True) - - -def _fillet_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_fillet(node, session) - - -def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_chamfer(node, session) - - -def _shell_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_shell(node, session) - - -def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_linear_pattern(node, session, _execute_node) - - -def _mirror_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: - del sketch - return _execute_mirror_pattern(node, session) - - -EXECUTORS: dict[str, ExecutorFunction] = { - "reference_plane": _reference_plane_executor, - "reference_axis": _reference_axis_executor, - "sphere_add": _sphere_executor, - "box_add": _box_executor, - "cylinder_add": _cylinder_executor, - "thread_add": _thread_executor, - "thread_cut": _thread_executor, - "bend_add": _bend_executor, - "gear_add": _gear_executor, - "rack_add": _rack_executor, - "extrude_add_blind": _primary_executor, - "extrude_add_blind_with_hole": _primary_executor, - "extrude_add_two_sided": _primary_executor, - "extrude_cut_blind": _primary_executor, - "extrude_cut_two_sided": _primary_executor, - "extrude_cut_through": _primary_executor, - "extrude_from_face": lambda node, session, _sketch: _execute_extrude_from_face(node, session), - "loft_add": _loft_executor, - "loft_add_with_cap_face": _loft_cap_face_executor, - "sweep_add": _sweep_executor, - "revolve_add": _primary_executor, - "revolve_cut": _primary_executor, - "revolve_surface": _revolve_surface_executor, - "extrude_surface": _extrude_surface_executor, - "hole_blind": _hole_executor, - "hole_countersink": _hole_executor, - "hole_counterbore": _hole_executor, - "hole_wizard": _hole_wizard_executor, - "fillet": _fillet_executor, - "chamfer": _chamfer_executor, - "shell": _shell_executor, - "boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session), - "transform_bodies": lambda node, session, sketch: _execute_transform_bodies(node, session), - "delete_bodies": lambda node, session, sketch: _execute_delete_bodies(node, session), - "pattern_linear": _linear_pattern_executor, - "pattern_mirror": _mirror_pattern_executor, - "pattern_circular": _circular_pattern_executor, -} +__all__ = [ + "ALL_ATOMIC_IDS", + "EXECUTORS", + "ExecutionSession", + "ExecutorFunction", + "ExtentVector", + "FeatureExecutionError", + "FeaturePlanNode", + "FeatureResult", + "GeometryAdapter", + "RuntimeDiagnostic", + "RuntimeExecutionError", + "analyze_cdsl", + "execute_node", + "rebuild_cdsl", +] def analyze_cdsl(cdsl: dict[str, Any]): @@ -1609,7 +130,7 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - break continue try: - _execute_node(node, session) + execute_node(node, session) except Exception as error: failed_resolution = next( (item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None, -- 2.52.0 From beac59fc8b2cf43c09686d6cf5067cd932f39a51 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 13:41:38 +0800 Subject: [PATCH 04/10] refactor(cdsl_engine): sink atomic runtime capability flags into schema contracts Phase 4 of the decoupling refactor (behavior-preserving): - profile_schema.json: every operation contract now carries runtime_capability {body_mutating, requires_active_body, replayable, requires_selector, open_profile_ok} (single source of truth) - operation_contracts.py: validates and forwards the flags - capabilities.py: the five data-classification frozensets are now derived from the schema at import time; dispatch-logic sets (_HOLE_ATOMICS, _PATTERN_ATOMICS, extent constants) stay in code - test_profile_schema.py: completeness + structural invariants test Equivalence proven by flag counts (27/13/25/6/2) matching the previous hand-written sets and by the unchanged test-baseline failure set. --- backend/engine/cdsl_engine/capabilities.py | 64 +++++++++-------- .../engine/cdsl_engine/operation_contracts.py | 9 +++ .../engine/cdsl_engine/profile_schema.json | 72 ++++++++++--------- backend/tests/test_profile_schema.py | 22 ++++++ 4 files changed, 101 insertions(+), 66 deletions(-) diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index f5ff8c76..d40f10a2 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -19,31 +19,8 @@ from .runtime_types import ( from .operation_contracts import materialized_feature_contracts -_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face", "fillet", "chamfer", "shell"}) _SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_") -# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的 -# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。 -_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"}) -_PRIMARY_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface", - "extrude_cut_through", - "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", - "hole_counterbore", "sphere_add", "box_add", "cylinder_add", -}) _HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}) -_ACTIVE_BODY_REQUIRED = frozenset({ - "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", - "loft_add_with_cap_face", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", - # thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空 - # 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。 - "thread_cut", -}) -_BODY_MUTATING_ATOMICS = frozenset({ - "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", - "extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", - "revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add", - "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", -}) # ``ExecutionSession.body_members`` only contains independently selectable # body outputs. A normal additive/cut/dress-up feature replaces the active # aggregate, while ``result_mode: new_body`` and ``keep_tools`` are the two @@ -51,14 +28,6 @@ _BODY_MUTATING_ATOMICS = frozenset({ # members can additionally expose a proven COPY instance; replayed/fused # patterns remain ineligible because no exact instance ownership exists. _PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) -# A pattern may replay a previous pattern as well as a direct body mutation. -# Context-only features have no geometry definition to instance. thread_add, -# thread_cut, bend_add, gear_add and rack_add are excluded: pattern -# translation does not yet move their parametric axis/frame, so a replayed -# instance would silently re-run at the original location. -_REPLAYABLE_ATOMICS = ( - _BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add"}) -) | frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"}) _SUPPORTED_EXTENTS = frozenset({ "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind", "up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body", @@ -71,6 +40,39 @@ _EXTENT_TARGET_KINDS = { } +def _runtime_capabilities() -> dict[str, dict[str, bool]]: + """Return the per-atomic runtime capability flags from the schema registry. + + ``profile_schema.json.operation_contracts[*].runtime_capability`` is the + single source of truth for these classifications. Adding an atomic + operation updates one JSON contract instead of editing code-level sets. + """ + path = Path(__file__).with_name("profile_schema.json") + contracts = materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8"))) + return {atomic_id: contract["runtime_capability"] for atomic_id, contract in contracts.items()} + + +_RUNTIME_CAPABILITIES = _runtime_capabilities() +_SELECTOR_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_selector"]) +# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的 +# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。 +_OPEN_PROFILE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["open_profile_ok"]) +_PRIMARY_ATOMICS = frozenset({ + "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface", + "extrude_cut_through", + "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", + "hole_counterbore", "sphere_add", "box_add", "cylinder_add", +}) +_ACTIVE_BODY_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_active_body"]) +_BODY_MUTATING_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["body_mutating"]) +# A pattern may replay a previous pattern as well as a direct body mutation. +# Context-only features have no geometry definition to instance. thread_add, +# thread_cut, bend_add, gear_add and rack_add are excluded: pattern +# translation does not yet move their parametric axis/frame, so a replayed +# instance would silently re-run at the original location. +_REPLAYABLE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["replayable"]) + + def _mappings(value: Any): """Yield nested feature mappings for capability-only contract checks.""" if isinstance(value, dict): diff --git a/backend/engine/cdsl_engine/operation_contracts.py b/backend/engine/cdsl_engine/operation_contracts.py index a4a3de93..c5318216 100644 --- a/backend/engine/cdsl_engine/operation_contracts.py +++ b/backend/engine/cdsl_engine/operation_contracts.py @@ -31,6 +31,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st params_schema = raw.get("author_params_schema") injected_paths = raw.get("server_injected_paths") selector_policy = raw.get("selector_policy") + runtime_capability = raw.get("runtime_capability") if ( not isinstance(shape, dict) or not isinstance(params_schema, dict) @@ -38,6 +39,13 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st or not isinstance(selector_policy, dict) ): raise ValueError(f"operation contract is incomplete for {atomic_id}") + if not isinstance(runtime_capability, dict) or not all( + isinstance(runtime_capability.get(flag), bool) + for flag in ( + "body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok", + ) + ): + raise ValueError(f"operation runtime_capability is invalid for {atomic_id}") properties = params_schema.get("properties") required = params_schema.get("required") if not isinstance(properties, dict) or not isinstance(required, list): @@ -59,5 +67,6 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st "requires_sketch": shape.get("sketch") == "required", "selector_slot": selector_policy.get("slot"), "selector_token_kind": selector_policy.get("token_kind"), + "runtime_capability": dict(runtime_capability), } return derived diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json index 8a8f6677..15043830 100644 --- a/backend/engine/cdsl_engine/profile_schema.json +++ b/backend/engine/cdsl_engine/profile_schema.json @@ -6,34 +6,35 @@ "coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.", "runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"], "operation_contracts": { - "extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, - "extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, - "extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"]}, - "extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[]}, - "loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]}, - "loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"]}, - "sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"]}, - "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"]}, - "extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"]}, - "revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "revolve_surface": {"atomic_id":"revolve_surface","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch","single_closed_profile_without_holes"],"candidate_verifiers":["surface_shell"]}, - "hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"]}, - "hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, - "hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"]}, - "bend_add": {"atomic_id":"bend_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"chain":{"type":"array","minItems":1,"items":{"type":"object","properties":{"leg_mm":{"type":"number","exclusiveMinimum":0},"bend_angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"inner_radius_mm":{"type":"number","minimum":0},"side":{"type":"integer","enum":[1,-1]}},"required":["leg_mm"],"additionalProperties":false}}},"required":["thickness_mm","width_mm","chain"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "gear_add": {"atomic_id":"gear_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":8,"maximum":200},"width_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"helix_angle_rad":{"type":"number","minimum":0,"exclusiveMaximum":0.7853981633974483},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"herringbone":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","width_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "rack_add": {"atomic_id":"rack_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":1,"maximum":2000},"thickness_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","thickness_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["radius_mm","height_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"]}, - "thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[]}, - "reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[]}, + "extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}}, + "extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}}, + "extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "revolve_cut": {"atomic_id":"revolve_cut","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "revolve_surface": {"atomic_id":"revolve_surface","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch","single_closed_profile_without_holes"],"candidate_verifiers":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "hole_blind": {"atomic_id":"hole_blind","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore","through_cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "hole_countersink": {"atomic_id":"hole_countersink","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"countersink_diameter_mm":{"type":"number","exclusiveMinimum":0},"countersink_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","countersink_diameter_mm","countersink_angle_rad"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "hole_counterbore": {"atomic_id":"hole_counterbore","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"diameter_mm":{"type":"number","exclusiveMinimum":0},"depth_mm":{"type":"number","exclusiveMinimum":0},"positions":{"type":"array","minItems":1,"maxItems":64,"items":{"type":"object","properties":{"mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["mm"],"additionalProperties":false}},"counterbore_diameter_mm":{"type":"number","exclusiveMinimum":0},"counterbore_depth_mm":{"type":"number","exclusiveMinimum":0},"drill_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793}},"required":["diameter_mm","depth_mm","positions","counterbore_diameter_mm","counterbore_depth_mm"],"additionalProperties":false},"selector_policy":{"slot":"params.host_face","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.host_face"],"reference_policy":{"mode":"none"},"semantic_preflight":["host_face_exists","hole_positions_on_host_plane","cut_exit_distance"],"candidate_verifiers":["cylindrical_bore"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "bend_add": {"atomic_id":"bend_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"chain":{"type":"array","minItems":1,"items":{"type":"object","properties":{"leg_mm":{"type":"number","exclusiveMinimum":0},"bend_angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"inner_radius_mm":{"type":"number","minimum":0},"side":{"type":"integer","enum":[1,-1]}},"required":["leg_mm"],"additionalProperties":false}}},"required":["thickness_mm","width_mm","chain"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "gear_add": {"atomic_id":"gear_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":8,"maximum":200},"width_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"helix_angle_rad":{"type":"number","minimum":0,"exclusiveMaximum":0.7853981633974483},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"herringbone":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","width_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "rack_add": {"atomic_id":"rack_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"module_mm":{"type":"number","exclusiveMinimum":0},"teeth_count":{"type":"integer","minimum":1,"maximum":2000},"thickness_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pressure_angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":0.6108652381980153},"result_mode":{"enum":["fuse","new_body"]}},"required":["module_mm","teeth_count","thickness_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "sphere_add": {"atomic_id":"sphere_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["radius_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "box_add": {"atomic_id":"box_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"length_mm":{"type":"number","exclusiveMinimum":0},"width_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"center_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["length_mm","width_mm","height_mm","center_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "cylinder_add": {"atomic_id":"cylinder_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"height_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["radius_mm","height_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "thread_add": {"atomic_id":"thread_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"relief_length_mm":{"type":"number","minimum":0},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":[],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "hole_wizard": { + "runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false}, "atomic_id": "hole_wizard", "contract_version": "3.0", "fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "required"}, @@ -63,11 +64,12 @@ "semantic_preflight": ["host_face_exists", "hole_positions_on_host_plane"], "candidate_verifiers": ["cylindrical_bore"] }, - "fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, - "chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"]}, - "shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"]}, - "boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[]}, + "fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}}, + "boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, "transform_bodies": { + "runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false}, "atomic_id": "transform_bodies", "contract_version": "3.3", "fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"}, @@ -107,10 +109,10 @@ "semantic_preflight": ["source_bodies_exist", "pattern_instance_bodies_exist", "transform_copy_bodies_exist", "body_transform"], "candidate_verifiers": [] }, - "delete_bodies": {"atomic_id":"delete_bodies","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["target_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[]}, - "pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]}, - "pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"mirror_current_body":{"type":"boolean"}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[]}, - "pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360},"operation_mode":{"enum":["add","remove"]},"excluded_instance_indices":{"type":"array","items":{"type":"integer","minimum":1,"maximum":127},"uniqueItems":true}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[]} + "delete_bodies": {"atomic_id":"delete_bodies","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true}},"required":["target_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}}, + "pattern_linear": {"atomic_id":"pattern_linear","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"direction_1":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_1_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_1":{"type":"integer","minimum":1,"maximum":128},"direction_2":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"spacing_2_mm":{"type":"number","exclusiveMinimum":0},"pattern_count_2":{"type":"integer","minimum":1,"maximum":128}},"required":["source_feature_ids","direction_1","spacing_1_mm","pattern_count_1"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "pattern_mirror": {"atomic_id":"pattern_mirror","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"mirror_current_body":{"type":"boolean"}},"required":["source_feature_ids"],"additionalProperties":false},"selector_policy":{"slot":"params.mirror_plane","token_kind":"plane","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["params.mirror_plane"],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist","mirror_plane_exists"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}}, + "pattern_circular": {"atomic_id":"pattern_circular","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"source_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"pattern_count":{"type":"integer","minimum":1,"maximum":128},"sweep_angle_deg":{"type":"number","minimum":-360,"maximum":360},"operation_mode":{"enum":["add","remove"]},"excluded_instance_indices":{"type":"array","items":{"type":"integer","minimum":1,"maximum":127},"uniqueItems":true}},"required":["source_feature_ids","axis","pattern_count"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.source_feature_ids","token_kind":"feature","min_items":1,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_features_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}} }, "profiles": { "circle": { diff --git a/backend/tests/test_profile_schema.py b/backend/tests/test_profile_schema.py index df41b7c4..dd180c04 100644 --- a/backend/tests/test_profile_schema.py +++ b/backend/tests/test_profile_schema.py @@ -60,6 +60,28 @@ class ProfileSchemaTests(unittest.TestCase): ) self.assertEqual(set(self.schema["profiles"]), set(self.schema["runtime_supported_profiles"])) + def test_runtime_capability_flags_are_complete_and_structurally_sound(self) -> None: + contracts = self.schema["operation_contracts"] + flags = ("body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok") + for atomic_id, contract in contracts.items(): + capability = contract.get("runtime_capability") + self.assertIsInstance(capability, dict, atomic_id) + for flag in flags: + self.assertIsInstance(capability.get(flag), bool, f"{atomic_id}.{flag}") + for atomic_id, contract in contracts.items(): + capability = contract["runtime_capability"] + if capability["replayable"]: + # Only body-mutating features (or pattern replay itself) can be + # the source of another pattern replay. + self.assertTrue( + capability["body_mutating"] or atomic_id.startswith("pattern_"), + atomic_id, + ) + if capability["requires_active_body"]: + self.assertTrue(capability["body_mutating"], atomic_id) + if capability["open_profile_ok"]: + self.assertTrue(atomic_id.startswith("extrude_cut"), atomic_id) + def test_rejects_unsupported_atomic_operation_before_rebuild(self) -> None: cdsl = { "schema": "cad.cdsl.llm.v1", -- 2.52.0 From ad88d92ab904410706d1edf3f4a55d2f807013ca Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 13:56:08 +0800 Subject: [PATCH 05/10] refactor(cdsl_engine): split translator.py into the translator package Phase 5 of the decoupling refactor (behavior-preserving move): - translator/ir.py: SolidWorks plugin JSON to backend-IR conversion (70 syms) - translator/codegen.py: backend IR to build123d source generation (64 syms) - translator/runtime_lib.py: frozen generated-script runtime library, spliced into generate_build123d_code as *RUNTIME_LIB_LINES - translator/common.py: helpers shared by both sides - translator/__init__.py: full historical symbol surface re-exported Generated-code equivalence verified byte-for-byte against the pre-split output for a representative IR sample; py_compile clean. --- backend/engine/cdsl_engine/translator.py | 5184 ----------------- .../engine/cdsl_engine/translator/__init__.py | 164 + .../engine/cdsl_engine/translator/codegen.py | 2016 +++++++ .../engine/cdsl_engine/translator/common.py | 194 + backend/engine/cdsl_engine/translator/ir.py | 1959 +++++++ .../cdsl_engine/translator/runtime_lib.py | 949 +++ 6 files changed, 5282 insertions(+), 5184 deletions(-) delete mode 100644 backend/engine/cdsl_engine/translator.py create mode 100644 backend/engine/cdsl_engine/translator/__init__.py create mode 100644 backend/engine/cdsl_engine/translator/codegen.py create mode 100644 backend/engine/cdsl_engine/translator/common.py create mode 100644 backend/engine/cdsl_engine/translator/ir.py create mode 100644 backend/engine/cdsl_engine/translator/runtime_lib.py diff --git a/backend/engine/cdsl_engine/translator.py b/backend/engine/cdsl_engine/translator.py deleted file mode 100644 index 74a7439d..00000000 --- a/backend/engine/cdsl_engine/translator.py +++ /dev/null @@ -1,5184 +0,0 @@ -"""Generic SolidWorks JSON/IR to build123d code translator.""" - -from __future__ import annotations - -import json -import math -import os -import re -from copy import deepcopy -from typing import Any, Dict, Optional - - -SW_END_CONDITIONS = { - 0: "Blind", - 1: "ThroughAll", - 2: "ThroughAllBoth", - 3: "UpToVertex", - 4: "UpToSurface", - 5: "OffsetFromSurface", - 6: "ThroughAllAndBlind", - 7: "UpToBody", - 8: "MidPlane", - 9: "ThroughNext", -} - -THROUGH_CUT_AMOUNT_MM = 200 - - -def normalize_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: - """Normalize supported input formats to the backend internal IR.""" - if "operations" in data and "sketches" in data: - return enrich_rebuild_parameters(data) - - if "features" in data: - return enrich_rebuild_parameters(convert_sw_plugin_json_to_ir(data)) - - raise ValueError("Unsupported JSON format: expected internal IR or SW plugin features JSON") - - -def enrich_rebuild_parameters(data: Dict[str, Any]) -> Dict[str, Any]: - """Add a generic editable-parameter index without changing feature history. - - The returned rebuild JSON remains the source of truth for execution. The - `editable_parameters` section is an index of JSON paths that a UI or caller - can modify safely while preserving the original feature order and links. - """ - enriched = dict(data) - enriched["editable_parameters"] = extract_editable_parameters(enriched) - enriched["parameterization_status"] = analyze_parameterization_status(enriched) - return enriched - - -def analyze_parameterization_status(data: Dict[str, Any]) -> Dict[str, Any]: - issues = [] - - for sketch in data.get("sketches", []): - host_reference = sketch.get("host_reference", {}) - reference = host_reference.get("reference") or {} - if reference.get("kind") == "face" and not reference.get("owner_feature"): - issues.append({ - "kind": "missing_stable_face_owner", - "sketch": {"id": sketch.get("id"), "name": sketch.get("name")}, - "message": ( - "Sketch is attached to a face geometry, but the JSON does not identify " - "the owning feature/face id. Parameter edits may require updating this " - "sketch workplane manually unless the plugin exports stable face ownership." - ), - }) - - for op in data.get("operations", []): - if op.get("type") in ("unsupported", "unknown"): - sw_type = op.get("parameters", {}).get("sw_type") or op.get("type") - issues.append({ - "kind": "unsupported_geometry_feature", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": sw_type}, - "message": ( - f"SolidWorks feature '{sw_type}' is present in the history, but the " - "core build123d translator has no generic implementation for it. " - "The feature is retained in IR and must not be treated as a complete rebuild." - ), - }) - - if op.get("type") == "hole": - host_face = op.get("parameters", {}).get("host_face") or {} - if host_face and not host_face.get("frame"): - issues.append({ - "kind": "missing_hole_host_frame", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "Hole feature has a host face, but the JSON does not include the " - "face-local x/y axes. The translator can infer common axis-aligned " - "cases, but the plugin should export the sketch/face frame for exact " - "generic hole placement." - ), - }) - - if op.get("type") == "extrude_cut": - end_code = op.get("parameters", {}).get("end_condition_code") - if end_code in (3, 4, 5, 7, 9): - params = op.get("parameters", {}) - has_termination_reference = any( - params.get(key) - for key in ( - "end_condition_reference", - "reverse_end_condition_reference", - "termination_reference", - ) - ) - kind = ( - "sw_end_condition_requires_exact_translator" - if has_termination_reference - else "missing_extrude_termination_reference" - ) - issues.append({ - "kind": kind, - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "end_condition_code": end_code, - "end_condition": SW_END_CONDITIONS.get(end_code), - "message": ( - "This SW cut uses a non-blind end condition. ThroughAll can be " - "replayed generically, but ThroughNext/UpTo-style rebuilds need the " - "selected terminating face/body/reference from the plugin for exact 1:1." - ), - }) - - if op.get("type") in ("revolve_cut", "revolve_add"): - axis_reference = op.get("parameters", {}).get("axis_reference") - if not axis_reference or not ( - isinstance(axis_reference, dict) - and axis_reference.get("origin_mm") - and axis_reference.get("direction") - ): - axis_candidates = op.get("parameters", {}).get("axis_candidates") or [] - if axis_candidates: - issues.append({ - "kind": "revolve_axis_inferred_from_candidate", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "Revolve feature lacks the original SolidWorks selected axis, but " - "the translator can use a construction-line candidate. For exact " - "auditability the plugin should still export the selected axis " - "reference and selection mark." - ), - }) - continue - issues.append({ - "kind": "missing_revolve_axis_reference", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "Revolve feature does not include the SolidWorks selected axis. " - "The translator can only infer an axis from the sketch workplane, " - "which is not reliable enough for exact 1:1 rebuild." - ), - }) - - if op.get("type") in ("linear_pattern", "pattern_linear"): - params = op.get("parameters", {}) - if not params.get("source_features") or not _linear_pattern_offsets(op): - issues.append({ - "kind": "linear_pattern_missing_source_or_direction", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "This SW linear pattern lacks source-feature selection or direction data. " - "The translator can replay patterns when source features and offsets are " - "available; otherwise the plugin should export the selected feature list " - "and pattern direction references." - ), - }) - - if op.get("type") in ("fillet", "chamfer"): - selectors = op.get("selectors") or [] - if selectors and not any(_selector_has_persistent_reference(selector) for selector in selectors): - issues.append({ - "kind": "missing_original_feature_selection", - "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, - "message": ( - "This feature only has final-geometry edge signatures. For exact replay " - "the plugin should export the original SolidWorks feature selections " - "including persistent references and selection marks." - ), - }) - - return { - "safe_to_edit": not issues, - "issues": issues, - } - - -def _selector_has_persistent_reference(selector: Dict[str, Any]) -> bool: - stack = [selector] - while stack: - value = stack.pop() - if isinstance(value, dict): - if value.get("persistent_reference"): - return True - stack.extend(value.values()) - elif isinstance(value, list): - stack.extend(value) - return False - - -def extract_editable_parameters(data: Dict[str, Any]) -> list[Dict[str, Any]]: - parameters: list[Dict[str, Any]] = [] - sketches = {sketch.get("id"): sketch for sketch in data.get("sketches", [])} - - for op_index, op in enumerate(data.get("operations", [])): - op_type = op.get("type", "") - op_name = op.get("name", op.get("id", f"operation_{op_index}")) - op_path = f"/operations/{op_index}" - params = op.get("parameters", {}) - - if op_type in ("extrude_add", "extrude_cut") and "distance_mm" in params: - semantic = "body_length" if op_type == "extrude_add" else "cut_depth" - parameters.append(_editable_param( - id=f"{op.get('id', op_index)}.distance_mm", - label=f"{op_name} distance", - semantic=semantic, - unit="mm", - value=params.get("distance_mm"), - path=f"{op_path}/parameters/distance_mm", - feature=op, - )) - - if "reverse_distance_mm" in params: - parameters.append(_editable_param( - id=f"{op.get('id', op_index)}.reverse_distance_mm", - label=f"{op_name} reverse distance", - semantic="reverse_depth", - unit="mm", - value=params.get("reverse_distance_mm"), - path=f"{op_path}/parameters/reverse_distance_mm", - feature=op, - )) - - if op_type in ("fillet", "chamfer"): - key = "radius_mm" if op_type == "fillet" else "distance_mm" - if key in params: - parameters.append(_editable_param( - id=f"{op.get('id', op_index)}.{key}", - label=f"{op_name} {key}", - semantic="fillet_radius" if op_type == "fillet" else "chamfer_distance", - unit="mm", - value=params.get(key), - path=f"{op_path}/parameters/{key}", - feature=op, - )) - - sketch_id = op.get("sketch") - sketch = sketches.get(sketch_id) - if sketch: - parameters.extend(_extract_sketch_parameters(sketch, sketch_id, op, op_index, data)) - - return parameters - - -def _extract_sketch_parameters( - sketch: Dict[str, Any], - sketch_id: str, - op: Dict[str, Any], - op_index: int, - data: Dict[str, Any], -) -> list[Dict[str, Any]]: - parameters: list[Dict[str, Any]] = [] - sketch_index = next((i for i, item in enumerate(data.get("sketches", [])) if item.get("id") == sketch_id), None) - if sketch_index is None: - return parameters - - op_type = op.get("type", "") - entities = sketch.get("entities", []) - drawable = [entity for entity in entities if not entity.get("construction", False)] - - for entity_index, entity in enumerate(entities): - entity_type = entity.get("type") - entity_path = f"/sketches/{sketch_index}/entities/{entity_index}" - - if entity_type in ("circle", "arc") and entity.get("is_circle", entity_type == "circle"): - center = entity.get("center", [0, 0, 0]) - radius = entity.get("radius_mm") - semantic = "hole" if op_type == "extrude_cut" else "circle_profile" - if radius is not None: - parameters.append(_editable_param( - id=f"{sketch_id}.entity{entity_index}.radius_mm", - label=f"{sketch.get('name', sketch_id)} circle radius", - semantic=f"{semantic}_radius", - unit="mm", - value=radius, - path=f"{entity_path}/radius_mm", - feature=op, - )) - for axis, value in zip(("x", "y"), center[:2]): - parameters.append(_editable_param( - id=f"{sketch_id}.entity{entity_index}.center_{axis}", - label=f"{sketch.get('name', sketch_id)} {semantic} center {axis}", - semantic=f"{semantic}_center_{axis}", - unit="mm", - value=value, - path=f"{entity_path}/center/{0 if axis == 'x' else 1}", - feature=op, - )) - - bounds = _sketch_bounds(drawable) - if bounds: - min_x, min_y, max_x, max_y = bounds - center_x = (min_x + max_x) / 2 - center_y = (min_y + max_y) / 2 - width = max_x - min_x - height = max_y - min_y - semantic_prefix = "slot" if op_type == "extrude_cut" else "profile" - for suffix, value, semantic in ( - ("center_x", center_x, f"{semantic_prefix}_center_x"), - ("center_y", center_y, f"{semantic_prefix}_center_y"), - ("width", width, f"{semantic_prefix}_width"), - ("height", height, f"{semantic_prefix}_height"), - ): - parameters.append(_editable_param( - id=f"{sketch_id}.{suffix}", - label=f"{sketch.get('name', sketch_id)} {suffix}", - semantic=semantic, - unit="mm", - value=value, - path=f"/sketches/{sketch_index}", - feature=op, - editable=False, - note="Derived from sketch entity bounds; edit underlying entities to change this safely.", - )) - - workplane = sketch.get("workplane", {}) - origin = workplane.get("origin_mm") - if origin: - for axis, value in zip(("x", "y", "z"), origin[:3]): - parameters.append(_editable_param( - id=f"{sketch_id}.workplane_origin_{axis}", - label=f"{sketch.get('name', sketch_id)} workplane origin {axis}", - semantic=f"sketch_plane_origin_{axis}", - unit="mm", - value=value, - path=f"/sketches/{sketch_index}/workplane/origin_mm/{'xyz'.index(axis)}", - feature=op, - )) - - return parameters - - -def _sketch_bounds(entities: list[Dict[str, Any]]) -> Optional[tuple[float, float, float, float]]: - points: list[tuple[float, float]] = [] - for entity in entities: - for key in ("start", "end", "center"): - point = entity.get(key) - if point and len(point) >= 2: - points.append((float(point[0]), float(point[1]))) - radius = entity.get("radius_mm") - center = entity.get("center") - if radius is not None and center and len(center) >= 2: - cx, cy = float(center[0]), float(center[1]) - r = float(radius) - points.extend([(cx - r, cy - r), (cx + r, cy + r)]) - if not points: - return None - xs = [point[0] for point in points] - ys = [point[1] for point in points] - return min(xs), min(ys), max(xs), max(ys) - - -def _editable_param( - *, - id: str, - label: str, - semantic: str, - unit: str, - value: Any, - path: str, - feature: Dict[str, Any], - editable: bool = True, - note: Optional[str] = None, -) -> Dict[str, Any]: - result = { - "id": id, - "label": label, - "semantic": semantic, - "unit": unit, - "value": value, - "path": path, - "editable": editable, - "feature": { - "id": feature.get("id"), - "name": feature.get("name"), - "type": feature.get("type"), - "source_index": feature.get("source_feature", {}).get("index"), - }, - } - if note: - result["note"] = note - return result - - -def convert_sw_plugin_json_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: - """Convert the current SW plugin feature dump into the backend IR.""" - features = data.get("features", []) - sketches = [] - operations = [] - last_sketch_id = None - last_build_op = None - references = [] - source_bbox = _source_bbox_from_plugin_json(data) - - if data.get("document_kind") == "assembly" and isinstance(data.get("assembly_data"), dict): - operations.append(_convert_sw_assembly(data)) - part_name = data.get("part_name", "part") - return { - "version": "ir-0.1", - "metadata": { - "source": { - "format": "sw-plugin-json", - "file_name": f"{part_name}.sldasm", - "sw_version": data.get("sw_version"), - } - }, - "sketches": sketches, - "operations": operations, - "references": references, - "validation_hints": data.get("validation_hints", {}), - "geometry_inventory": data.get("geometry_inventory", {}), - "rebuild_contract": data.get("rebuild_contract", {}), - } - - for index, feature in enumerate(features): - if feature.get("is_suppressed"): - continue - - feature_type = feature.get("type", "") - type_name = feature.get("type_name", "") - feature_id = feature.get("id") or f"feat_{index:03d}" - feature_name = feature.get("name", feature_id) - - if feature_type in ("refplane", "refaxis"): - references.append(_convert_sw_reference(feature, index)) - elif feature_type == "sketch": - sketch_id = f"sketch_{len(sketches):03d}" - sketches.append(_convert_sw_sketch(feature, sketch_id, index)) - last_sketch_id = sketch_id - elif feature_type in ("extrude", "ice", "cut") and isinstance(feature.get("extrude_data"), dict): - sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id - op = _convert_sw_extrude(feature, type_name, sketch_ref, index) - operations.append(op) - last_build_op = op - elif feature_type == "revolve": - sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id - op = _convert_sw_revolve(feature, type_name, sketch_ref, index) - operations.append(op) - last_build_op = op - elif feature_type == "hole": - op = _convert_sw_hole(feature, index) - operations.append(op) - last_build_op = op - elif feature_type == "pattern_linear": - data_block = feature.get("linear_pattern_data", {}) - source_op = _find_source_operation_for_pattern(operations, data_block.get("source_features") or []) - source_frame = _source_pattern_frame(source_op or last_build_op, sketches) - operations.append(_convert_sw_linear_pattern(feature, index, source_op or last_build_op, source_frame, sketches, source_bbox)) - elif feature_type == "pattern_mirror": - data_block = feature.get("mirror_data") or {} - src_features = data_block.get("source_features") or [] - mirror_origin = data_block.get("mirror_plane_origin") - mirror_normal = data_block.get("mirror_plane_normal") - operations.append({ - "id": feature_id, - "name": feature_name, - "type": "pattern_mirror", - "parameters": {"source_features": src_features}, - "raw_parameters": { - "mirror_plane_origin": mirror_origin, - "mirror_plane_normal": mirror_normal, - }, - "source_feature": _source_feature(feature, index), - }) - elif feature_type == "fillet": - data_block = feature.get("fillet_data", {}) - radius_mm = data_block.get("radius") or _feature_length_dimension_mm(feature) - operations.append({ - "id": feature_id, - "name": feature_name, - "type": "fillet", - "parameters": {"radius_mm": radius_mm}, - "selectors": _feature_selection_selectors(feature, data_block), - "selection_source": _feature_selection_source(feature, data_block), - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - }) - elif feature_type == "chamfer": - data_block = feature.get("chamfer_data", {}) - distance_mm = data_block.get("distance") or _feature_length_dimension_mm(feature) - operations.append({ - "id": feature_id, - "name": feature_name, - "type": "chamfer", - "parameters": {"distance_mm": distance_mm}, - "selectors": _feature_selection_selectors(feature, data_block), - "selection_source": _feature_selection_source(feature, data_block), - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - }) - elif _is_imported_body_feature(feature): - op = _convert_sw_imported_body(feature, index) - operations.append(op) - last_build_op = op - elif feature_type == "moveface": - data_block = feature.get("move_face_data") if isinstance(feature.get("move_face_data"), dict) else {} - op = { - "id": feature_id, - "name": feature_name, - "type": "move_face", - "parameters": {"sw_type": type_name or feature_type, "move_face_data": data_block}, - "source_feature": _source_feature(feature, index), - } - operations.append(op) - last_build_op = op - elif feature_type not in _SW_METADATA_FEATURE_TYPES: - operations.append({ - "id": feature_id, - "name": feature_name, - "type": "unsupported", - "parameters": {"sw_type": type_name or feature_type}, - "source_feature": _source_feature(feature, index), - }) - - part_name = data.get("part_name", "part") - return { - "version": "ir-0.1", - "metadata": { - "source": { - "format": "sw-plugin-json", - "file_name": f"{part_name}.sldprt", - "sw_version": data.get("sw_version"), - } - }, - "sketches": sketches, - "operations": operations, - "references": references, - "validation_hints": data.get("validation_hints", {}), - "geometry_inventory": data.get("geometry_inventory", {}), - "rebuild_contract": data.get("rebuild_contract", {}), - } - - -def _is_imported_body_feature(feature: Dict[str, Any]) -> bool: - feature_type = str(feature.get("type") or "").lower() - type_name = str(feature.get("type_name") or "").lower() - return bool(feature.get("imported_body_data")) or feature_type in { - "mbimport", - "savedextbody", - "importedbody", - "imported", - "stock", - } or type_name in {"mbimport", "savedextbody", "importedbody"} - - -def _convert_sw_imported_body(feature: Dict[str, Any], index: int) -> Dict[str, Any]: - data_block = feature.get("imported_body_data") if isinstance(feature.get("imported_body_data"), dict) else {} - solid_bodies = data_block.get("solid_bodies") or [] - solid_body_stats = data_block.get("solid_body_stats") or [] - source_name = feature.get("name") - parameters = { - "sw_type": feature.get("type_name") or feature.get("type"), - "source_name": source_name, - "history_status": data_block.get("history_status"), - "body_count": len(solid_bodies) if isinstance(solid_bodies, list) else len(solid_body_stats), - "solid_body_stats": solid_body_stats, - "solid_bodies": solid_bodies, - } - return { - "id": feature.get("id") or f"feat_{index:03d}", - "name": feature.get("name") or f"imported_body_{index:03d}", - "type": "imported_body", - "parameters": parameters, - "source_feature": _source_feature(feature, index), - "source_imported_body": data_block, - } - - -def _convert_sw_assembly(data: Dict[str, Any]) -> Dict[str, Any]: - assembly_data = data.get("assembly_data") or {} - components = [] - for index, component in enumerate(assembly_data.get("components") or []): - if component.get("is_suppressed") or component.get("is_hidden"): - continue - path = component.get("path") or "" - component_name = component.get("name") or f"component_{index:03d}" - base_name = os.path.splitext(os.path.basename(str(path).replace("\\", "/")))[0] or component_name - components.append({ - "index": index, - "name": component_name, - "component_id": base_name, - "source_path": path, - "component_json": f"{base_name}.solidworks_rebuild_extract.json", - "transform": component.get("transform") or {}, - }) - return { - "id": "assembly_000", - "name": data.get("part_name") or "assembly", - "type": "assembly_compose", - "parameters": { - "components": components, - }, - "source_feature": {"index": 0, "name": data.get("part_name"), "type": "assembly"}, - } - - -def _append_feature_source_sketches(feature: Dict[str, Any], sketches: list[Dict[str, Any]], index: int) -> Optional[str]: - """Promote feature-owned SW sketches into the rebuild sketch table.""" - source_sketches = [] - for block_name in ("extrude_data", "revolve_data"): - block = feature.get(block_name) - if isinstance(block, dict): - source_sketches.extend(sketch for sketch in (block.get("source_sketches") or []) if isinstance(sketch, dict)) - - if not source_sketches: - return None - - last_id = None - for sketch_data in source_sketches: - sketch_id = f"sketch_{len(sketches):03d}" - sketch_feature = dict(feature) - sketch_feature["sketch_data"] = sketch_data - if sketch_data.get("name"): - sketch_feature["name"] = sketch_data.get("name") - sketches.append(_convert_sw_sketch(sketch_feature, sketch_id, index)) - last_id = sketch_id - return last_id - - -def get_part_name(data: Dict[str, Any]) -> str: - part_name = data.get("part_name") or data.get("metadata", {}).get("source", {}).get("file_name", "part") - part_name = str(part_name) - for suffix in (".sldprt", ".sldasm", ".step", ".stp", ".json"): - if part_name.lower().endswith(suffix): - part_name = part_name[:-len(suffix)] - break - return re.sub(r"[^0-9A-Za-z_\u4e00-\u9fff]+", "_", part_name).strip("_") or "part" - - -def generate_build123d_code(data: Dict[str, Any], gold_volume_mm3: float | None = None) -> str: - """Generate build123d Python code from generic SW/build123d IR.""" - rebuild_contract = data.get("rebuild_contract") if isinstance(data.get("rebuild_contract"), dict) else {} - if rebuild_contract and rebuild_contract.get("ready") is False: - blockers = rebuild_contract.get("blockers") or [] - raise ValueError(f"Pure-JSON rebuild contract is not ready: {blockers}") - source_volume_mm3 = None - source_area_mm2 = None - mass_props = data.get("validation_hints", {}).get("mass_properties_raw") - if mass_props and len(mass_props) >= 5: - source_volume_mm3 = float(mass_props[3]) * 1_000_000_000 - source_area_mm2 = float(mass_props[4]) * 1_000_000 - lines = [ - "from build123d import *", - "import math", - f"SOURCE_VOLUME_MM3 = {source_volume_mm3!r}", - f"SOURCE_AREA_MM2 = {source_area_mm2!r}", - "", - "def _dist(a, b):", - " return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))", - "", - "def _owned_face_match_score(shape, expected_faces):", - " if not expected_faces:", - " return 0.0", - " try:", - " available = list(shape.faces())", - " except Exception:", - " return 1e99", - " total = 0.0", - " for expected in expected_faces:", - " bbox_m = expected.get('box_m')", - " if not bbox_m or len(bbox_m) < 6 or not available:", - " total += 1e6", - " continue", - " target_box = [float(v) * 1000 for v in bbox_m[:6]]", - " surface = expected.get('surface') or {}", - " target_type = next((name for name in ('plane', 'cylinder', 'cone', 'sphere', 'torus') if surface.get('is_' + name)), '')", - " target_area = float(expected.get('area_m2') or 0) * 1_000_000", - " ranked = []", - " for index, face in enumerate(available):", - " try:", - " fb = face.bounding_box()", - " face_box = [fb.min.X, fb.min.Y, fb.min.Z, fb.max.X, fb.max.Y, fb.max.Z]", - " geom = face.geom_type() if callable(face.geom_type) else face.geom_type", - " geom_name = getattr(geom, 'name', str(geom)).lower()", - " type_penalty = 0.0 if not target_type or target_type in geom_name else 1000.0", - " bbox_penalty = sum(abs(face_box[i] - target_box[i]) for i in range(6))", - " area_penalty = abs(float(face.area) - target_area) / max(math.sqrt(abs(target_area)), 1.0) if target_area else 0.0", - " ranked.append((type_penalty + bbox_penalty + area_penalty, index))", - " except Exception:", - " continue", - " if not ranked:", - " total += 1e6", - " continue", - " best, index = min(ranked, key=lambda item: item[0])", - " total += best", - " available.pop(index)", - " return total / max(len(expected_faces), 1)", - "", - "def _candidate_score(shape, expected_faces=None):", - " # Owned faces describe this exact SW history step. Final-part mass properties", - " # must not be used to choose an intermediate feature candidate.", - " if expected_faces:", - " return _owned_face_match_score(shape, expected_faces)", - " score = 0", - " if SOURCE_VOLUME_MM3 is not None:", - " try:", - " score += abs(float(shape.volume) - SOURCE_VOLUME_MM3)", - " except Exception:", - " score += 1e99", - " if SOURCE_AREA_MM2 is not None:", - " try:", - " score += abs(float(shape.area) - SOURCE_AREA_MM2) * 0.01", - " except Exception:", - " score += 1e99", - " score += _owned_face_match_score(shape, expected_faces)", - " return score", - "", - "def _edge_endpoints(edge):", - " vertices = [v.to_tuple() for v in edge.vertices()]", - " if len(vertices) != 2:", - " center = edge.center().to_tuple()", - " return center, center", - " return vertices[0], vertices[1]", - "", - "def _edge_match_score(edge, start, end):", - " a, b = _edge_endpoints(edge)", - " endpoint_score = min(_dist(a, start) + _dist(b, end), _dist(a, end) + _dist(b, start))", - " containment_score = edge.distance_to(start) + edge.distance_to(end)", - " return min(endpoint_score, containment_score)", - "", - "def select_edges_by_endpoints(part, selector_points, tolerance=0.5):", - " edges = list(part.edges())", - " selected = []", - " used = set()", - " for selector in selector_points:", - " start, end = selector", - " ranked = sorted(((_edge_match_score(edge, start, end), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " score, index, edge = ranked[0]", - " if score > tolerance:", - " raise ValueError(f\"No edge matched selector {selector}; best score={score:.4f} mm\")", - " if index not in used:", - " selected.append(edge)", - " used.add(index)", - " return selected", - "", - "def _bbox_match_score(edge, bbox_mm):", - " if not bbox_mm or len(bbox_mm) < 6:", - " return float('inf')", - " try:", - " a, b = _edge_endpoints(edge)", - " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", - " mins = tuple(float(bbox_mm[i]) for i in range(3))", - " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", - " diag = math.sqrt(sum((maxs[i] - mins[i]) ** 2 for i in range(3)))", - " pad = max(0.25, diag * 0.15)", - " def point_score(point):", - " total = 0.0", - " for axis in range(3):", - " if point[axis] < mins[axis] - pad:", - " total += mins[axis] - pad - point[axis]", - " elif point[axis] > maxs[axis] + pad:", - " total += point[axis] - maxs[axis] - pad", - " return total", - " return min(point_score(mid), (point_score(a) + point_score(b)) / 2)", - " except Exception:", - " return float('inf')", - "", - "def _circle_match_score(edge, circle_params):", - " if not circle_params or len(circle_params) < 7:", - " return float('inf')", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'CIRCLE' not in geom_name:", - " return float('inf')", - " target_center = tuple(float(v) * 1000 for v in circle_params[:3])", - " target_radius = float(circle_params[6]) * 1000", - " edge_center = edge.arc_center.to_tuple()", - " return _dist(edge_center, target_center) + abs(edge.radius - target_radius)", - " except Exception:", - " return float('inf')", - "", - "def _line_match_score(edge, line_params):", - " if not line_params or len(line_params) < 6:", - " return float('inf')", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'LINE' not in geom_name:", - " return float('inf')", - " target_point = tuple(float(v) * 1000 for v in line_params[:3])", - " target_dir = tuple(float(v) for v in line_params[3:6])", - " a, b = _edge_endpoints(edge)", - " edge_dir_raw = tuple(b[i] - a[i] for i in range(3))", - " length = math.sqrt(sum(v * v for v in edge_dir_raw))", - " if length <= 0:", - " return float('inf')", - " edge_dir = tuple(v / length for v in edge_dir_raw)", - " parallel = 1 - abs(sum(edge_dir[i] * target_dir[i] for i in range(3)))", - " distance = edge.distance_to(target_point)", - " return distance + parallel * 10", - " except Exception:", - " return float('inf')", - "", - "def select_edges_by_selectors(part, selectors, tolerance=0.5):", - " if part is None:", - " return []", - " edges = list(part.edges())", - " selected = []", - " used = set()", - " for selector in selectors or []:", - " geometry = selector.get('geometry', {})", - " start_vertex = geometry.get('start_vertex')", - " end_vertex = geometry.get('end_vertex')", - " start = start_vertex.get('point_m') if start_vertex else None", - " end = end_vertex.get('point_m') if end_vertex else None", - " bbox_mm = geometry.get('bbox_mm')", - " if start and end:", - " start_mm = tuple(float(v) * 1000 for v in start)", - " end_mm = tuple(float(v) * 1000 for v in end)", - " line_params = geometry.get('curve', {}).get('line_params')", - " if line_params:", - " ranked = sorted(((min(_edge_match_score(edge, start_mm, end_mm), _line_match_score(edge, line_params)) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " else:", - " ranked = sorted(((_edge_match_score(edge, start_mm, end_mm) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " else:", - " line_params = geometry.get('curve', {}).get('line_params')", - " circle_params = geometry.get('curve', {}).get('circle_params')", - " if line_params:", - " ranked = sorted(((_line_match_score(edge, line_params) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " elif bbox_mm:", - " ranked = sorted(((_bbox_match_score(edge, bbox_mm), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " else:", - " ranked = sorted(((_circle_match_score(edge, circle_params), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", - " score, index, edge = ranked[0]", - " selector_tolerance = float(selector.get('tolerance_mm') or tolerance)", - " if score > selector_tolerance:", - " # Skip edges that don't match well enough", - " continue", - " if index not in used:", - " selected.append(edge)", - " used.add(index)", - " return selected", - "", - "def _point_inside_bbox(point, bbox_mm, pad=0.25):", - " return all(float(bbox_mm[i]) - pad <= point[i] <= float(bbox_mm[i + 3]) + pad for i in range(3))", - "", - "def fillet_edges_from_owned_surface_bbox(part, selectors):", - " if part is None:", - " return []", - " boxes = []", - " seen_boxes = set()", - " for selector in selectors or []:", - " if selector.get('source') not in ('owned_cylindrical_face_axis', 'owned_face_bbox'):", - " continue", - " bbox = (selector.get('geometry') or {}).get('bbox_mm')", - " if bbox and len(bbox) >= 6:", - " normalized = [float(v) for v in bbox[:6]]", - " key = tuple(round(v, 6) for v in normalized)", - " if key not in seen_boxes:", - " seen_boxes.add(key)", - " boxes.append(normalized)", - " if len(boxes) < 2:", - " return []", - " selected = []", - " used_keys = set()", - " for box in boxes:", - " diag = math.sqrt(sum((box[i + 3] - box[i]) ** 2 for i in range(3)))", - " pad = max(0.25, diag * 0.08)", - " sizes = [abs(box[i + 3] - box[i]) for i in range(3)]", - " thin_axes = [i for i, size in enumerate(sizes) if size <= max(1.5, diag * 0.08)]", - " circle_candidates = []", - " if thin_axes:", - " thin_axis = thin_axes[0]", - " for edge in part.edges():", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'CIRCLE' not in geom_name:", - " continue", - " eb = edge.bounding_box()", - " edge_box = [eb.min.X, eb.min.Y, eb.min.Z, eb.max.X, eb.max.Y, eb.max.Z]", - " ok = True", - " score = 0.0", - " for axis in range(3):", - " if axis == thin_axis:", - " plane_delta = min(abs(edge_box[axis] - box[axis]), abs(edge_box[axis] - box[axis + 3]), abs(edge_box[axis + 3] - box[axis]), abs(edge_box[axis + 3] - box[axis + 3]))", - " if plane_delta > pad:", - " ok = False", - " break", - " score += plane_delta", - " else:", - " if edge_box[axis] < box[axis] - pad or edge_box[axis + 3] > box[axis + 3] + pad:", - " ok = False", - " break", - " score += abs(edge_box[axis] - box[axis]) + abs(edge_box[axis + 3] - box[axis + 3])", - " if not ok:", - " continue", - " key = tuple(round(v, 5) for v in edge_box)", - " circle_candidates.append((score, key, edge))", - " except Exception:", - " continue", - " if circle_candidates:", - " circle_candidates.sort(key=lambda item: item[0])", - " for _, key, edge in circle_candidates:", - " if key in used_keys:", - " continue", - " used_keys.add(key)", - " selected.append(edge)", - " break", - " continue", - " box_candidates = []", - " for edge in part.edges():", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'LINE' not in geom_name:", - " continue", - " a, b = _edge_endpoints(edge)", - " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", - " if not (_point_inside_bbox(a, box, pad) and _point_inside_bbox(b, box, pad) and _point_inside_bbox(mid, box, pad)):", - " continue", - " key = tuple(round(v, 5) for point in (a, b) for v in point)", - " box_candidates.append((float(edge.length), key, edge))", - " except Exception:", - " continue", - " if not box_candidates:", - " continue", - " box_candidates.sort(key=lambda item: item[0], reverse=True)", - " for _, key, edge in box_candidates:", - " reverse_key = key[3:] + key[:3]", - " if key in used_keys or reverse_key in used_keys:", - " continue", - " used_keys.add(key)", - " selected.append(edge)", - " break", - " if selected:", - " return selected", - " union_bbox = [", - " min(box[i] for box in boxes) if i < 3 else max(box[i] for box in boxes)", - " for i in range(6)", - " ]", - " diag = math.sqrt(sum((union_bbox[i + 3] - union_bbox[i]) ** 2 for i in range(3)))", - " pad = max(0.25, diag * 0.05)", - " candidates = []", - " for edge in part.edges():", - " try:", - " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", - " geom_name = getattr(geom_type, 'name', str(geom_type))", - " if 'LINE' not in geom_name:", - " continue", - " a, b = _edge_endpoints(edge)", - " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", - " if not (_point_inside_bbox(a, union_bbox, pad) and _point_inside_bbox(b, union_bbox, pad) and _point_inside_bbox(mid, union_bbox, pad)):", - " continue", - " candidates.append((float(edge.length), edge))", - " except Exception:", - " continue", - " if not candidates:", - " return []", - " candidates.sort(key=lambda item: item[0], reverse=True)", - " return [candidates[0][1]]", - "", - "def fillet_with_tolerance(edges, radius):", - " radii = [float(radius)]", - " shrink = max(0.001, abs(float(radius)) * 0.001)", - " if float(radius) > shrink:", - " radii.append(float(radius) - shrink)", - " radii.append(float(radius) * 0.99)", - " last_error = None", - " for candidate_radius in radii:", - " if candidate_radius <= 0:", - " continue", - " try:", - " return fillet(edges, radius=candidate_radius)", - " except Exception as exc:", - " last_error = exc", - " continue", - " if last_error:", - " raise last_error", - " return fillet(edges, radius=radius)", - "", - "def fillet_selected(part, radius, selectors, owned_faces=None):", - " if part is None:", - " return part", - " if not selectors:", - " # No edge selectors - skip fillet to avoid failing on all edges", - " return part", - " candidates = []", - " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", - " if owned_edges:", - " try:", - " candidates.append(fillet_with_tolerance(owned_edges, radius))", - " except Exception:", - " pass", - " try:", - " target_edges = select_edges_by_selectors(part, selectors)", - " if target_edges:", - " candidates.append(fillet_with_tolerance(target_edges, radius))", - " except Exception:", - " pass", - " result = part", - " applied_any = False", - " for selector in selectors:", - " edges = select_edges_by_selectors(result, [selector])", - " if not edges:", - " continue # Skip selectors that don't match any edge", - " try:", - " result = fillet_with_tolerance([edges[0]], radius)", - " applied_any = True", - " except Exception:", - " # OCC fillets are fragile: one invalid edge/radius should not abort the whole rebuild.", - " continue", - " if applied_any:", - " candidates.append(result)", - " variants = []", - " for selector in selectors:", - " edges = select_edges_by_selectors(part, [selector])", - " if not edges:", - " continue", - " try:", - " variants.append(fillet_with_tolerance([edges[0]], radius))", - " except Exception:", - " continue", - " if variants:", - " try:", - " union_result = part", - " for variant in variants:", - " union_result = union_result + variant", - " candidates.append(union_result)", - " except Exception:", - " pass", - " try:", - " intersection_result = part", - " for variant in variants:", - " intersection_result = intersection_result & variant", - " candidates.append(intersection_result)", - " except Exception:", - " pass", - " if candidates:", - " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", - " return part", - "", - "def chamfer_selected(part, distance, selectors, owned_faces=None):", - " if part is None:", - " return part", - " if not selectors:", - " # No edge selectors available - chamfer would fail on all edges", - " return part", - " candidates = []", - " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", - " if owned_edges:", - " try:", - " candidates.append(chamfer(owned_edges, length=distance))", - " except Exception:", - " pass", - " target_edges = select_edges_by_selectors(part, selectors)", - " if target_edges:", - " try:", - " candidates.append(chamfer(target_edges, length=distance))", - " except Exception:", - " pass", - " result = part", - " applied_any = False", - " for selector in selectors:", - " edges = select_edges_by_selectors(result, [selector])", - " if not edges:", - " continue", - " try:", - " result = chamfer([edges[0]], length=distance)", - " applied_any = True", - " except Exception:", - " continue", - " if applied_any:", - " candidates.append(result)", - " if candidates:", - " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", - " return part", - "", - "def is_internal_cone_face(face, part):", - " try:", - " bbox_m = face.get('box_m')", - " surface = face.get('surface') or {}", - " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", - " return False", - " params = surface.get('cone_params')", - " if not params or len(params) < 6:", - " return False", - " direction = tuple(float(v) for v in params[3:6])", - " axis = max(range(3), key=lambda i: abs(direction[i]))", - " radial_axes = tuple(i for i in range(3) if i != axis)", - " part_bbox = part.bounding_box()", - " part_min = part_bbox.min.to_tuple()", - " part_max = part_bbox.max.to_tuple()", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " tol = 0.25", - " touches_outer = any(", - " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", - " for i in radial_axes", - " )", - " return not touches_outer", - " except Exception:", - " return False", - "", - "def is_external_cone_face(face, part):", - " try:", - " bbox_m = face.get('box_m')", - " surface = face.get('surface') or {}", - " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", - " return False", - " params = surface.get('cone_params')", - " if not params or len(params) < 6:", - " return False", - " direction = tuple(float(v) for v in params[3:6])", - " axis = max(range(3), key=lambda i: abs(direction[i]))", - " radial_axes = tuple(i for i in range(3) if i != axis)", - " part_bbox = part.bounding_box()", - " part_min = part_bbox.min.to_tuple()", - " part_max = part_bbox.max.to_tuple()", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " tol = 0.25", - " return any(", - " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", - " for i in radial_axes", - " )", - " except Exception:", - " return False", - "", - "def make_owned_external_cone_chamfer_cutter(face):", - " surface = face.get('surface') or {}", - " params = surface.get('cone_params')", - " bbox_m = face.get('box_m')", - " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", - " return None", - " origin = tuple(float(v) * 1000 for v in params[:3])", - " direction = tuple(float(v) for v in params[3:6])", - " norm = math.sqrt(sum(v * v for v in direction))", - " base_radius = abs(float(params[6]) * 1000)", - " half_angle = abs(float(params[7]))", - " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", - " return None", - " direction = tuple(v / norm for v in direction)", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " projections = []", - " for x in (mins[0], maxs[0]):", - " for y in (mins[1], maxs[1]):", - " for z in (mins[2], maxs[2]):", - " delta = (x - origin[0], y - origin[1], z - origin[2])", - " axial = sum(delta[i] * direction[i] for i in range(3))", - " projections.append(axial)", - " start = min(projections)", - " end = max(projections)", - " height = max(0.001, end - start)", - " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", - " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", - " outer_radius = max(r1, r2) + 0.001", - " center_offset = (start + end) / 2", - " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", - " if r1 <= 1e-9:", - " r1 = 1e-6", - " if r2 <= 1e-9:", - " r2 = 1e-6", - " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", - " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", - " Cone(r1, r2, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", - " return cutter_part.part", - "", - "def chamfer_owned_external_cones(part, faces):", - " if part is None:", - " return part, False", - " result = part", - " applied = False", - " for face in faces or []:", - " if not is_external_cone_face(face, result):", - " continue", - " cutter = make_owned_external_cone_chamfer_cutter(face)", - " new_result = safe_subtract(result, cutter)", - " if new_result is not result:", - " result = new_result", - " applied = True", - " return result, applied", - "", - "def chamfer_owned_internal_cones(part, faces):", - " if part is None:", - " return part, False", - " result = part", - " applied = False", - " for face in faces or []:", - " if not is_internal_cone_face(face, result):", - " continue", - " cutter = make_owned_cone_cutter(face)", - " new_result = safe_subtract(result, cutter)", - " if new_result is not result:", - " result = new_result", - " applied = True", - " return result, applied", - "", - "def chamfer_selected_with_owned_faces(part, distance, selectors, owned_faces):", - " cone_faces = [face for face in (owned_faces or []) if (face.get('surface') or {}).get('is_cone')]", - " if len(cone_faces) == 1 and is_internal_cone_face(cone_faces[0], part):", - " result, applied = chamfer_owned_internal_cones(part, cone_faces)", - " if applied:", - " return result", - " if len(cone_faces) == 1 and is_external_cone_face(cone_faces[0], part):", - " result, applied = chamfer_owned_external_cones(part, cone_faces)", - " if applied:", - " return result", - " return chamfer_selected(part, distance, selectors, owned_faces)", - "", - "def safe_subtract(part, cutter):", - " if part is None or cutter is None:", - " return part", - " try:", - " vol_before = float(part.volume)", - " except Exception:", - " vol_before = -1", - " try:", - " cut = part - cutter", - " if cut is None:", - " print(f' SUBTRACT: cutter resulted in None, keeping original (vol={vol_before:.0f})')", - " return part", - " # Accept the cut even when solids() reports 0 – can happen", - " # for valid boolean results with non-standard structures.", - " try:", - " nb_solids = len(list(cut.solids()))", - " if nb_solids == 0:", - " print(f' SUBTRACT: cut produced 0 solids (still accepting) vol={vol_before:.0f}')", - " except Exception:", - " pass", - " return cut", - " except Exception as e:", - " print(f' SUBTRACT: exception {type(e).__name__}: {e}, keeping original (vol={vol_before:.0f})')", - " return part", - "", - "def _project_bbox_along_direction(bbox, origin, direction):", - " mins = tuple(float(bbox[i]) for i in range(3))", - " maxs = tuple(float(bbox[i + 3]) for i in range(3))", - " projections = []", - " for x in (mins[0], maxs[0]):", - " for y in (mins[1], maxs[1]):", - " for z in (mins[2], maxs[2]):", - " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", - " return min(projections), max(projections)", - "", - "def make_owned_cylinder_cutter(face, target_part=None):", - " surface = face.get('surface') or {}", - " params = surface.get('cylinder_params')", - " bbox_m = face.get('box_m')", - " if not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", - " return None", - " origin = tuple(float(v) * 1000 for v in params[:3])", - " direction = tuple(float(v) for v in params[3:6])", - " norm = math.sqrt(sum(v * v for v in direction))", - " radius = abs(float(params[6]) * 1000)", - " if norm <= 1e-9 or radius <= 1e-9:", - " return None", - " direction = tuple(v / norm for v in direction)", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", - " if target_part is not None:", - " try:", - " part_bbox = target_part.bounding_box()", - " part_box = (*part_bbox.min.to_tuple(), *part_bbox.max.to_tuple())", - " part_start, part_end = _project_bbox_along_direction(part_box, origin, direction)", - " through_tolerance = max(1.0, radius * 0.12)", - " if abs(start - part_start) <= through_tolerance:", - " start = part_start", - " if abs(end - part_end) <= through_tolerance:", - " end = part_end", - " except Exception:", - " pass", - " height = max(0.001, end - start)", - " center_offset = (start + end) / 2", - " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", - " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", - " Cylinder(radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", - " return cutter_part.part", - "", - "def make_owned_cone_cutter(face):", - " surface = face.get('surface') or {}", - " params = surface.get('cone_params')", - " bbox_m = face.get('box_m')", - " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", - " return None", - " origin = tuple(float(v) * 1000 for v in params[:3])", - " direction = tuple(float(v) for v in params[3:6])", - " norm = math.sqrt(sum(v * v for v in direction))", - " base_radius = abs(float(params[6]) * 1000)", - " half_angle = abs(float(params[7]))", - " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", - " return None", - " direction = tuple(v / norm for v in direction)", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " projections = []", - " for x in (mins[0], maxs[0]):", - " for y in (mins[1], maxs[1]):", - " for z in (mins[2], maxs[2]):", - " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", - " start = min(projections)", - " end = max(projections)", - " # Keep a tiny overlap for the boolean while preserving blind-hole depth.", - " height = max(0.001, end - start) + 0.001", - " # SolidWorks ConeParams stores the radius at the cone origin; along the axis", - " # direction the radius tapers rather than expands for hole drill tips.", - " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", - " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", - " if max(r1, r2) <= 1e-9:", - " return None", - " if r1 <= 1e-9:", - " r1 = 1e-6", - " if r2 <= 1e-9:", - " r2 = 1e-6", - " center_offset = (start + end) / 2", - " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", - " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", - " Cone(r1, r2, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", - " return cutter_part.part", - "", - "def make_owned_face_cutter(face, target_part=None):", - " surface = face.get('surface') or {}", - " if surface.get('is_cylinder'):", - " return make_owned_cylinder_cutter(face, target_part)", - " if surface.get('is_cone'):", - " return make_owned_cone_cutter(face)", - " return None", - "", - "def cut_owned_cylindrical_faces(part, faces):", - " result = part", - " for face in faces or []:", - " cutter = make_owned_face_cutter(face, result)", - " result = safe_subtract(result, cutter)", - " return result", - "", - "def make_owned_flip_side_ring_cutter(face, target_part):", - " surface = face.get('surface') or {}", - " params = surface.get('cylinder_params')", - " bbox_m = face.get('box_m')", - " if target_part is None or not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", - " return None", - " origin = tuple(float(v) * 1000 for v in params[:3])", - " direction = tuple(float(v) for v in params[3:6])", - " norm = math.sqrt(sum(v * v for v in direction))", - " inner_radius = abs(float(params[6]) * 1000)", - " if norm <= 1e-9 or inner_radius <= 1e-9:", - " return None", - " direction = tuple(v / norm for v in direction)", - " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", - " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", - " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", - " height = max(0.001, end - start)", - " center_offset = (start + end) / 2", - " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", - " try:", - " part_bbox = target_part.bounding_box()", - " part_min = part_bbox.min.to_tuple()", - " part_max = part_bbox.max.to_tuple()", - " radial = []", - " for x in (part_min[0], part_max[0]):", - " for y in (part_min[1], part_max[1]):", - " for z in (part_min[2], part_max[2]):", - " delta = (x - origin[0], y - origin[1], z - origin[2])", - " axial = sum(delta[i] * direction[i] for i in range(3))", - " perp = tuple(delta[i] - axial * direction[i] for i in range(3))", - " radial.append(math.sqrt(sum(v * v for v in perp)))", - " outer_radius = max(radial) + max(1.0, inner_radius * 0.05)", - " except Exception:", - " outer_radius = inner_radius + 100.0", - " if outer_radius <= inner_radius + 1e-6:", - " return None", - " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", - " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", - " Cylinder(inner_radius, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", - " return cutter_part.part", - "", - "def cut_owned_flip_side_cylindrical_faces(part, faces):", - " result = part", - " for face in faces or []:", - " cutter = make_owned_flip_side_ring_cutter(face, result)", - " result = safe_subtract(result, cutter)", - " return result", - "", - "def cut_owned_bbox(part, bbox_mm):", - " if part is None or not bbox_mm or len(bbox_mm) < 6:", - " return part", - " mins = tuple(float(bbox_mm[i]) for i in range(3))", - " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", - " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", - " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", - " cutter = Pos(center) * Box(size[0], size[1], size[2])", - " return safe_subtract(part, cutter)", - "", - "def shape_face_count(shape):", - " if shape is None:", - " return 0", - " try:", - " return len(list(shape.faces()))", - " except Exception:", - " return 0", - "", - "def safe_union(part, solid, preserve_visible=False):", - " if part is None:", - " return solid", - " if solid is None:", - " return part", - " try:", - " fused = part + solid", - " # OCCT fuse succeeded; always return the fused result.", - " # is_valid() can return False for edge cases where the geometry", - " # is actually correct (e.g. touching-at-faces). Accept it.", - " return fused", - " except Exception as e:", - " print(f' UNION: fuse threw {type(e).__name__}: {e}')", - " pass", - " try:", - " compound = Compound.make_composite([part, solid])", - " fused = compound.fuse()", - " try:", - " if len(list(fused.solids())) > 0:", - " print(f' UNION: compound.fuse() worked, {len(list(fused.solids()))} solids')", - " return fused", - " except Exception:", - " pass", - " except Exception as e:", - " print(f' UNION: compound.fuse() threw {type(e).__name__}: {e}')", - " pass", - " shapes = []", - " try:", - " shapes.extend(list(part.solids()))", - " except Exception:", - " shapes.append(part)", - " try:", - " shapes.extend(list(solid.solids()))", - " except Exception:", - " shapes.append(solid)", - " return Compound.make_composite(shapes)", - "", - "def sw_inverted_profile_cut(part, profile_solid, normal):", - " if part is None or profile_solid is None:", - " return part", - " try:", - " part_bbox = part.bounding_box()", - " profile_bbox = profile_solid.bounding_box()", - " n = tuple(float(v) for v in normal)", - " axis = max(range(3), key=lambda i: abs(n[i]))", - " part_min = part_bbox.min.to_tuple()", - " part_max = part_bbox.max.to_tuple()", - " prof_min = profile_bbox.min.to_tuple()", - " prof_max = profile_bbox.max.to_tuple()", - " margin = 5.0", - " mins = [part_min[i] - margin for i in range(3)]", - " maxs = [part_max[i] + margin for i in range(3)]", - " mins[axis] = prof_min[axis] - margin * 0.05", - " maxs[axis] = prof_max[axis] + margin * 0.05", - " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", - " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", - " envelope = Pos(center) * Box(size[0], size[1], size[2])", - " outside_profile = safe_subtract(envelope, profile_solid)", - " return safe_subtract(part, outside_profile)", - " except Exception:", - " return part", - "", - "def sw_flip_side_step_cut(part, profile_solid, normal, outer_radius_mm, inner_radius_mm):", - " part = sw_inverted_profile_cut(part, profile_solid, normal)", - " if part is None or profile_solid is None:", - " return part", - " try:", - " outer_radius = abs(float(outer_radius_mm))", - " inner_radius = abs(float(inner_radius_mm))", - " except Exception:", - " return part", - " if outer_radius <= inner_radius + 1e-6:", - " return part", - " try:", - " profile_bbox = profile_solid.bounding_box()", - " prof_min = profile_bbox.min.to_tuple()", - " prof_max = profile_bbox.max.to_tuple()", - " center = tuple((prof_min[i] + prof_max[i]) / 2 for i in range(3))", - " n = tuple(float(v) for v in normal)", - " axis = max(range(3), key=lambda i: abs(n[i]))", - " span_xy = max(prof_max[0] - prof_min[0], prof_max[1] - prof_min[1])", - " margin_xy = max(2.0, span_xy * 0.05)", - " margin_z = 0.1", - " size = tuple(", - " max(0.001, prof_max[i] - prof_min[i] + (margin_xy if i < 2 else margin_z))", - " for i in range(3)", - " )", - " plane = Plane(", - " origin=center,", - " x_dir=(1.0, 0.0, 0.0) if axis != 0 else (0.0, 1.0, 0.0),", - " z_dir=n,", - " )", - " cut_extent = prof_max[axis] - prof_min[axis]", - " cut_amount = -abs(cut_extent) if n[axis] < 0 else abs(cut_extent)", - " with BuildSketch(plane) as ring_sketch:", - " Circle(outer_radius)", - " Circle(inner_radius, mode=Mode.SUBTRACT)", - " ring = extrude(ring_sketch.sketch, amount=cut_amount)", - " return safe_union(part, ring)", - " except Exception:", - " return part", - "", - "def sw_cut_holes(part, positions, host_face, diameter, depth, drill_angle=0, include_drill_tip=False, countersink_diameter=0, countersink_angle=0, counterbore_diameter=0, counterbore_depth=0):", - " if part is None:", - " return part", - " if not positions or diameter <= 0 or depth <= 0:", - " return part", - " plane = host_face.get('surface', {}).get('plane_params') or [0, 0, 1, 0, 0, 0]", - " frame = host_face.get('frame') or {}", - " normal = tuple(float(v) for v in plane[:3])", - " plane_point = tuple(float(v) * 1000 for v in plane[3:6])", - " origin = tuple(float(v) for v in frame.get('origin_mm', plane_point))", - " x_dir = tuple(float(v) for v in frame.get('x_dir', (0, 0, 0)))", - " y_dir = tuple(float(v) for v in frame.get('y_dir', (0, 0, 0)))", - " has_frame = sum(abs(v) for v in x_dir) > 0 and sum(abs(v) for v in y_dir) > 0", - " bbox = part.bounding_box()", - " part_center = tuple((bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 for i in range(3))", - " toward_center = tuple(part_center[i] - plane_point[i] for i in range(3))", - " dot = sum(toward_center[i] * normal[i] for i in range(3))", - " inward = normal if dot >= 0 else tuple(-v for v in normal)", - " axis = max(range(3), key=lambda i: abs(inward[i]))", - " rotation = (0, 0, 0)", - " if axis == 0:", - " rotation = (0, 90, 0) if inward[0] >= 0 else (0, -90, 0)", - " elif axis == 1:", - " rotation = (-90, 0, 0) if inward[1] >= 0 else (90, 0, 0)", - " elif inward[2] < 0:", - " rotation = (180, 0, 0)", - " tip_depth = 0", - " if include_drill_tip and drill_angle > 0:", - " tip_depth = (diameter / 2) / math.tan(drill_angle / 2)", - " countersink_depth = 0", - " if countersink_diameter > diameter and countersink_angle > 0:", - " countersink_depth = ((countersink_diameter - diameter) / 2) / math.tan(countersink_angle / 2)", - " result = part", - " for pos in positions:", - " x, y = float(pos[0]), float(pos[1])", - " if has_frame:", - " start = tuple(origin[i] + x_dir[i] * x + y_dir[i] * y for i in range(3))", - " elif axis == 0:", - " start = (plane_point[0], x, y)", - " elif axis == 1:", - " start = (x, plane_point[1], -y)", - " else:", - " start = (x, y, plane_point[2])", - " cut_depth = depth", - " if depth >= 199:", - " part_min = bbox.min.to_tuple()", - " part_max = bbox.max.to_tuple()", - " corners = []", - " for ci in range(2):", - " for cj in range(2):", - " for ck in range(2):", - " corners.append((", - " part_min[0] if ci else part_max[0],", - " part_min[1] if cj else part_max[1],", - " part_min[2] if ck else part_max[2],", - " ))", - " cut_depth = max(", - " sum((corner[i] - start[i]) * inward[i] for i in range(3))", - " for corner in corners", - " ) + 2.0", - " cutters = []", - " cb_depth = counterbore_depth if counterbore_diameter > diameter and counterbore_depth > 0 else 0", - " cs_depth = countersink_depth if countersink_depth > 0 else 0", - " hole_start = cs_depth", - " hole_depth = max(0.001, cut_depth - hole_start - cb_depth)", - " if hole_depth > 0:", - " hole_center = tuple(start[i] + inward[i] * (hole_start + cb_depth + hole_depth / 2) for i in range(3))", - " cutters.append(Pos(hole_center) * Cylinder(diameter / 2, hole_depth, rotation=rotation))", - " if cb_depth > 0:", - " cb_center = tuple(start[i] + inward[i] * (hole_start + cb_depth / 2) for i in range(3))", - " cutters.append(Pos(cb_center) * Cylinder(counterbore_diameter / 2, cb_depth, rotation=rotation))", - " if cs_depth > 0:", - " cs_center = tuple(start[i] + inward[i] * cs_depth / 2 for i in range(3))", - " cs = Pos(cs_center) * Cone(countersink_diameter / 2, diameter / 2, cs_depth, rotation=rotation)", - " cutters.append(cs)", - " if tip_depth > 0:", - " base = tuple(start[i] + inward[i] * cut_depth for i in range(3))", - " tip_center = tuple(base[i] + inward[i] * tip_depth / 2 for i in range(3))", - " tip = Pos(tip_center) * Cone(diameter / 2, 0, tip_depth, rotation=rotation)", - " cutters.append(tip)", - " if len(cutters) == 1:", - " cutter = cutters[0]", - " else:", - " cutter = Compound.make_composite(cutters)", - " result = safe_subtract(result, cutter)", - " return result", - "", - ] - - part_name_clean = get_part_name(data) - lines.append(f"def build_{part_name_clean}():") - lines.append(' """Auto-generated build123d code from SolidWorks IR."""') - lines.append("") - - sketches = {s["id"]: s for s in data.get("sketches", [])} - operations = data.get("operations", []) - references = {r["id"]: r for r in data.get("references", [])} - generated_sketches = set() - - lines.append(" result = None") - lines.append("") - - for op in sort_operations_for_history(operations): - op_type = op.get("type", "") - op_name = op.get("name", "") - if op_type in ["unsupported", "unknown"]: - lines.append(f" # Skipping unsupported metadata feature: {op_name}") - lines.append("") - continue - - if op_type == "imported_body": - lines.extend(_generate_imported_body_pending(op)) - elif op_type == "assembly_compose": - lines.extend(_generate_assembly_compose(op)) - elif op_type == "move_face": - lines.extend(_generate_move_face(op)) - elif op_type == "fillet": - lines.extend(_generate_fillet(op)) - elif op_type == "chamfer": - lines.extend(_generate_chamfer(op)) - elif op_type == "hole": - lines.extend(_generate_hole(op)) - elif op_type in ("extrude_cut", "extrude_add"): - build_op = _resolve_extrude_owned_termination(op, sketches.get(op.get("sketch") or "")) - sketch_id = op.get("sketch") - if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): - lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") - continue - if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: - lines.extend(_generate_sketch(sketches[sketch_id], references, build_op)) - generated_sketches.add(sketch_id) - lines.extend(_generate_extrude(build_op, sketches.get(sketch_id, {}), operations, sketches)) - elif op_type in ("revolve_cut", "revolve_add"): - sketch_id = op.get("sketch") - if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): - lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") - continue - if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: - lines.extend(_generate_sketch(sketches[sketch_id], references, op)) - generated_sketches.add(sketch_id) - lines.extend(_generate_revolve(op, sketches.get(sketch_id, {}))) - elif op_type in ("linear_pattern", "pattern_linear"): - lines.extend(_generate_linear_pattern(op, operations, sketches, references)) - elif op_type == "pattern_mirror": - lines.extend(_generate_mirror_pattern(op, operations, sketches, references)) - else: - lines.append(f" # TODO: {op_type} - {op_name}") - - lines.append("") - - lines.append(" if result is None:") - lines.append(' raise Exception("No solid was created")') - lines.append("") - lines.append(" # Clean up small inaccuracies from Boolean operations") - lines.append(" try:") - lines.append(" result = result.clean()") - lines.append(" except Exception:") - lines.append(" pass") - lines.append(f' export_step(result, "{part_name_clean}.step")') - lines.append(" return result") - lines.append("") - lines.append("# Run the function") - lines.append('if __name__ == "__main__":') - lines.append(f" build_{part_name_clean}()") - - return "\n".join(lines) - - -def _generate_imported_body_pending(op: Dict[str, Any]) -> list[str]: - return [ - f" # Imported body requires generic JSON B-Rep reconstruction: {op.get('name', '')}", - " raise NotImplementedError(", - " 'Pure-JSON imported-body reconstruction is not implemented yet; '", - " 'the plugin captured solid_bodies topology and the part is marked not ready.'", - " )", - ] - - -def _generate_assembly_compose(op: Dict[str, Any]) -> list[str]: - params = op.get("parameters") or {} - components = params.get("components") or [] - component_ids = [component.get("component_id") for component in components] - message = f"Assembly requires rebuilt component JSON registry: {component_ids!r}" - return [ - f" # Pure-JSON assembly composition: {op.get('name', '')}", - " raise NotImplementedError(", - f" {message!r}", - " )", - ] - - -def _sw_math_transform_matrix(array_data: Any, component_name: str) -> list[list[float]]: - if not isinstance(array_data, list) or len(array_data) < 13: - raise ValueError(f"Assembly component {component_name} has no complete 16-value transform") - values = [float(value or 0) for value in array_data] - scale = values[12] - if abs(scale) <= 1e-12: - raise ValueError(f"Assembly component {component_name} has an invalid zero scale") - # SOLIDWORKS stores row-vector axes and translation in elements 9..11. - # build123d/OpenCascade uses a column-vector 3x4 matrix, hence transpose. - return [ - [values[0] * scale, values[3] * scale, values[6] * scale, values[9] * 1000.0], - [values[1] * scale, values[4] * scale, values[7] * scale, values[10] * 1000.0], - [values[2] * scale, values[5] * scale, values[8] * scale, values[11] * 1000.0], - [0.0, 0.0, 0.0, 1.0], - ] - - -def sort_operations_for_history(operations: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - """Return operations in SW rebuild order.""" - if _looks_like_reverse_history(operations): - return list(reversed(operations)) - if all(op.get("source_feature", {}).get("index") is not None for op in operations): - return sorted(operations, key=lambda op: op.get("source_feature", {}).get("index", 0)) - return sorted(operations, key=_operation_priority) - - -def _looks_like_reverse_history(operations: list[Dict[str, Any]]) -> bool: - build_ops = [ - op - for op in operations - if op.get("type") not in ("unsupported", "unknown") - ] - if len(build_ops) < 2: - return False - additive = {"extrude_add", "revolve_add", "sweep", "loft"} - downstream = {"extrude_cut", "revolve_cut", "fillet", "chamfer", "hole", "linear_pattern", "pattern_linear"} - return build_ops[0].get("type") in downstream and build_ops[-1].get("type") in additive - - -def _operation_priority(op: Dict[str, Any]) -> int: - op_type = op.get("type", "") - if op_type == "extrude_add": - return 0 - if op_type in ("extrude_cut", "revolve_cut"): - return 1 - if op_type == "revolve_add": - return 2 - if op_type in ("fillet", "chamfer"): - return 3 - if op_type in ("sweep", "loft"): - return 4 - return 99 - - -def _sketch_has_buildable_profile(sketch: Dict[str, Any]) -> bool: - for entity in sketch.get("entities", []) or []: - if entity.get("construction"): - continue - if entity.get("type") == "circle" and float(entity.get("radius_mm") or 0) > 0: - return True - if entity.get("type") == "arc" and float(entity.get("radius_mm") or 0) > 0: - return True - valid_lines = 0 - for entity in sketch.get("entities", []) or []: - if entity.get("construction") or entity.get("type") != "line": - continue - start = entity.get("start") or [0, 0] - end = entity.get("end") or [0, 0] - if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) > 1e-6: - valid_lines += 1 - return valid_lines >= 2 - - -def _point_key(point: Any, places: int = 5) -> tuple[float, float] | None: - if not isinstance(point, list) or len(point) < 2: - return None - return (round(float(point[0]), places), round(float(point[1]), places)) - - -def _reverse_curve_entity(ent: Dict[str, Any]) -> Dict[str, Any]: - """Reverse a sketch segment while preserving its geometric traversal.""" - reversed_ent = dict(ent) - reversed_ent["start"], reversed_ent["end"] = ent.get("end"), ent.get("start") - reversed_ent["reversed"] = not bool(ent.get("reversed", False)) - if ent.get("type") == "arc": - raw = ent.get("raw") if isinstance(ent.get("raw"), dict) else {} - axis = ent.get("curve_axis") or raw.get("curve_axis") - if isinstance(axis, list) and len(axis) >= 3: - # The arc's endpoints and orientation are a pair. Keep the - # source `raw` untouched, but provide a flipped top-level axis for - # code generation so a reversed minor arc remains a minor arc. - reversed_ent["curve_axis"] = [-float(value) for value in axis[:3]] - # 必须删除预置的角度字段,否则代码生成会使用旧的(start,end未翻转时的)角度, - # 导致弧段遍历方向与连接顺序相反(如对外弧CW而对内弧也CW而非CCW)。 - reversed_ent.pop("start_angle_deg", None) - reversed_ent.pop("end_angle_deg", None) - reversed_ent.pop("arc_sweep_deg", None) - return reversed_ent - - -def _ordered_wire_entities(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - """Order sketch line/arc entities into connected loops when SW did not export contours.""" - drawable = [ - ent for ent in entities - if ent.get("type") in ("line", "arc") - and _point_key(ent.get("start")) is not None - and _point_key(ent.get("end")) is not None - ] - if len(drawable) < 3: - return entities - - by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} - for idx, ent in enumerate(drawable): - by_node.setdefault(_point_key(ent.get("start")), []).append((idx, "start")) - by_node.setdefault(_point_key(ent.get("end")), []).append((idx, "end")) - - if not by_node or any(len(touches) != 2 for touches in by_node.values()): - return entities - - remaining = set(range(len(drawable))) - ordered: list[Dict[str, Any]] = [] - - while remaining: - first_idx = min(remaining) - remaining.remove(first_idx) - first = drawable[first_idx] - loop = [first] - loop_start = _point_key(first.get("start")) - cursor = _point_key(first.get("end")) - - while cursor != loop_start: - next_idx = None - next_side = None - for candidate_idx, side in by_node.get(cursor, []): - if candidate_idx in remaining: - next_idx = candidate_idx - next_side = side - break - if next_idx is None: - return entities - - remaining.remove(next_idx) - next_ent = drawable[next_idx] - if next_side == "end": - next_ent = _reverse_curve_entity(next_ent) - loop.append(next_ent) - cursor = _point_key(next_ent.get("end")) - - ordered.extend(loop) - - return ordered - - -def _infer_closed_wire_loops(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - drawable = [ - (idx, ent) for idx, ent in enumerate(entities) - if not ent.get("construction", False) - and ent.get("type") in ("line", "arc") - and _point_key(ent.get("start")) is not None - and _point_key(ent.get("end")) is not None - ] - if len(drawable) < 3: - return [] - - by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} - for local_idx, (_, ent) in enumerate(drawable): - by_node.setdefault(_point_key(ent.get("start")), []).append((local_idx, "start")) - by_node.setdefault(_point_key(ent.get("end")), []).append((local_idx, "end")) - - remaining = set(range(len(drawable))) - loops: list[Dict[str, Any]] = [] - while remaining: - first_idx = min(remaining) - remaining.remove(first_idx) - _, first = drawable[first_idx] - loop_indices = [first_idx] - loop_start = _point_key(first.get("start")) - cursor = _point_key(first.get("end")) - - while cursor != loop_start: - matches = [(idx, side) for idx, side in by_node.get(cursor, []) if idx in remaining] - if not matches: - loop_indices = [] - break - next_idx, next_side = matches[0] - remaining.remove(next_idx) - _, next_ent = drawable[next_idx] - loop_indices.append(next_idx) - cursor = _point_key(next_ent.get("start") if next_side == "end" else next_ent.get("end")) - - if not loop_indices: - continue - entity_indices = [drawable[idx][0] for idx in loop_indices] - bbox = _loop_bbox([entities[idx] for idx in entity_indices]) - loops.append({ - "entity_indices": entity_indices, - "is_closed": True, - "bbox_mm": bbox, - "bbox_area_mm2": _bbox_area_2d(bbox), - "source": "inferred_connected_loop", - }) - return loops - - -def _loop_bbox(entities: list[Dict[str, Any]]) -> Optional[list[float]]: - points = [] - for ent in entities: - if not isinstance(ent, dict): - continue - if ent.get("type") == "circle": - center = ent.get("center") - radius = ent.get("radius_mm") - if isinstance(center, list) and len(center) >= 2 and radius is not None: - radius_value = abs(float(radius)) - points.append([float(center[0]) - radius_value, float(center[1]) - radius_value]) - points.append([float(center[0]) + radius_value, float(center[1]) + radius_value]) - continue - for key in ("start", "end", "center"): - point = ent.get(key) - if isinstance(point, list) and len(point) >= 2: - points.append(point) - if not points: - return None - return [ - min(float(point[0]) for point in points), - min(float(point[1]) for point in points), - max(float(point[0]) for point in points), - max(float(point[1]) for point in points), - ] - - -def _bbox_area_2d(bbox: Optional[list[float]]) -> float: - if not isinstance(bbox, list) or len(bbox) < 4: - return 0.0 - return max(0.0, float(bbox[2]) - float(bbox[0])) * max(0.0, float(bbox[3]) - float(bbox[1])) - - -def _bbox_contains_2d(outer: Optional[list[float]], inner: Optional[list[float]], tolerance: float = 1e-6) -> bool: - if not isinstance(outer, list) or not isinstance(inner, list) or len(outer) < 4 or len(inner) < 4: - return False - return ( - float(outer[0]) <= float(inner[0]) + tolerance - and float(outer[1]) <= float(inner[1]) + tolerance - and float(outer[2]) >= float(inner[2]) - tolerance - and float(outer[3]) >= float(inner[3]) - tolerance - ) - - -def _bbox_overlap_ratio_2d(a: Optional[list[float]], b: Optional[list[float]]) -> float: - if not isinstance(a, list) or not isinstance(b, list) or len(a) < 4 or len(b) < 4: - return 0.0 - ix0 = max(float(a[0]), float(b[0])) - iy0 = max(float(a[1]), float(b[1])) - ix1 = min(float(a[2]), float(b[2])) - iy1 = min(float(a[3]), float(b[3])) - intersection = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0) - smaller = min(_bbox_area_2d(a), _bbox_area_2d(b)) - if smaller <= 1e-9: - return 0.0 - return intersection / smaller - - -def _loop_radius_candidates(loop: Dict[str, Any], entities: list[Dict[str, Any]]) -> list[float]: - radii: list[float] = [] - for idx in loop.get("entity_indices", []) or []: - if not isinstance(idx, int) or idx < 0 or idx >= len(entities): - continue - ent = entities[idx] - radius = ent.get("radius_mm") - if radius is not None: - radii.append(abs(float(radius))) - bbox = loop.get("bbox_mm") - if isinstance(bbox, list) and len(bbox) >= 4: - radii.append(abs(float(bbox[2]) - float(bbox[0])) / 2) - radii.append(abs(float(bbox[3]) - float(bbox[1])) / 2) - return [radius for radius in radii if radius > 1e-6 and math.isfinite(radius)] - - -def _owned_profile_radii_mm(operation: Optional[Dict[str, Any]], sketch: Dict[str, Any]) -> list[float]: - if not isinstance(operation, dict): - return [] - radii: list[float] = [] - loop_radii: list[float] = [] - entities = sketch.get("entities") if isinstance(sketch, dict) else [] - sketch_loops = (sketch.get("profile_loops") or sketch.get("loops") or []) if isinstance(sketch, dict) else [] - for loop in sketch_loops: - loop_radii.extend(_loop_radius_candidates(loop, entities if isinstance(entities, list) else [])) - - def _matches_sketch_radius(value: float) -> bool: - return any(abs(value - radius) <= max(0.1, radius * 0.01) for radius in loop_radii) - - for face in operation.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - params = surface.get("cylinder_params") - if surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7: - radii.append(abs(float(params[6]) * 1000)) - continue - box = face.get("box_m") - area = face.get("area_m2") - if surface.get("is_plane") and isinstance(box, list) and len(box) >= 6 and area is not None: - sizes = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] - non_zero_sizes = [size for size in sizes if size > 1e-4] - if len(non_zero_sizes) >= 2: - outer_radius = max(non_zero_sizes) / 2 - area_mm2 = abs(float(area)) * 1_000_000 - inner_sq = outer_radius * outer_radius - area_mm2 / math.pi - inner_radius = math.sqrt(inner_sq) if inner_sq > 0 else 0.0 - if _matches_sketch_radius(outer_radius): - radii.append(outer_radius) - if inner_radius > 1e-4 and _matches_sketch_radius(inner_radius): - radii.append(inner_radius) - - unique: list[float] = [] - for radius in sorted(radii): - if radius <= 1e-6 or not math.isfinite(radius): - continue - if not any(abs(radius - existing) <= max(0.05, existing * 0.002) for existing in unique): - unique.append(radius) - return unique - - -def _loops_matching_owned_radii( - loops: list[Dict[str, Any]], - entities: list[Dict[str, Any]], - owned_radii: list[float], -) -> list[Dict[str, Any]]: - if not loops or not owned_radii: - return [] - matched: list[tuple[float, Dict[str, Any]]] = [] - for loop in loops: - candidates = _loop_radius_candidates(loop, entities) - if not candidates: - continue - best_radius = None - best_delta = float("inf") - for candidate in candidates: - for owned_radius in owned_radii: - delta = abs(candidate - owned_radius) - if delta < best_delta: - best_delta = delta - best_radius = candidate - if best_radius is None: - continue - if best_delta <= max(0.1, best_radius * 0.01): - matched.append((best_radius, loop)) - if not matched: - return [] - matched.sort(key=lambda item: item[0], reverse=True) - deduped: list[tuple[float, Dict[str, Any]]] = [] - seen_loop_keys: set[str] = set() - for radius, loop in matched: - bbox = loop.get("bbox_mm") - key = ",".join(f"{float(value):.4f}" for value in bbox[:4]) if isinstance(bbox, list) and len(bbox) >= 4 else str(loop.get("entity_indices")) - key = f"{radius:.4f}:{key}" - if key in seen_loop_keys: - continue - seen_loop_keys.add(key) - deduped.append((radius, loop)) - matched = deduped - annotated = [] - for index, (_, loop) in enumerate(matched): - loop_copy = dict(loop) - loop_copy["profile_mode"] = "add" if index == 0 else "subtract" - annotated.append(loop_copy) - return annotated - - -def _loop_area_from_radii(loops: list[Dict[str, Any]], entities: list[Dict[str, Any]]) -> Optional[float]: - if not loops: - return None - area = 0.0 - for index, loop in enumerate(loops): - radii = _loop_radius_candidates(loop, entities) - if not radii: - return None - radius = max(radii) - mode = loop.get("profile_mode") - sign = -1 if mode == "subtract" or (mode is None and index > 0) else 1 - area += sign * math.pi * radius * radius - return abs(area) if area > 1e-6 else None - - -def _aligned_workplane_for_owned_midplane( - sketch: Dict[str, Any], - operation: Optional[Dict[str, Any]], - loops: list[Dict[str, Any]], -) -> Dict[str, Any]: - workplane = dict(sketch.get("workplane") or {}) - if not isinstance(operation, dict) or operation.get("type") != "extrude_add": - return workplane - params = operation.get("parameters") if isinstance(operation.get("parameters"), dict) else {} - if not params.get("both_directions"): - return workplane - - entities = sketch.get("entities") if isinstance(sketch.get("entities"), list) else [] - profile_area = _loop_area_from_radii(loops, entities) - if profile_area is None: - return workplane - - normal = workplane.get("normal") or [0, 0, 1] - origin = workplane.get("origin_mm") or [0, 0, 0] - if not isinstance(normal, list) or not isinstance(origin, list) or len(normal) < 3 or len(origin) < 3: - return workplane - normal_vec = [float(v) for v in normal[:3]] - norm = math.sqrt(sum(v * v for v in normal_vec)) - if norm <= 1e-9: - return workplane - normal_vec = [v / norm for v in normal_vec] - - candidates: list[tuple[float, list[float]]] = [] - for face in operation.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - if not surface.get("is_plane"): - continue - area_m2 = face.get("area_m2") - plane_params = surface.get("plane_params") - if area_m2 is None or not isinstance(plane_params, list) or len(plane_params) < 6: - continue - face_area = abs(float(area_m2)) * 1_000_000 - if abs(face_area - profile_area) > max(0.5, profile_area * 0.02): - continue - plane_normal = [float(v) for v in plane_params[:3]] - plane_norm = math.sqrt(sum(v * v for v in plane_normal)) - if plane_norm <= 1e-9: - continue - plane_normal = [v / plane_norm for v in plane_normal] - alignment = abs(sum(plane_normal[i] * normal_vec[i] for i in range(3))) - if alignment < 0.98: - continue - plane_point = [float(v) * 1000 for v in plane_params[3:6]] - old_offset = sum(float(origin[i]) * normal_vec[i] for i in range(3)) - new_offset = sum(plane_point[i] * normal_vec[i] for i in range(3)) - delta = new_offset - old_offset - if abs(delta) <= 1e-6: - continue - moved_origin = [float(origin[i]) + normal_vec[i] * delta for i in range(3)] - candidates.append((abs(delta), moved_origin)) - if len(candidates) != 1: - return workplane - candidates.sort(key=lambda item: item[0]) - workplane["origin_mm"] = candidates[0][1] - return workplane - - -def _project_owned_faces_to_sketch_bbox( - owned_faces: list[Dict[str, Any]], workplane: Dict[str, Any] -) -> Optional[list[float]]: - origin = workplane.get("origin_mm") or [0, 0, 0] - x_dir = workplane.get("x_dir") or [1, 0, 0] - y_dir = workplane.get("y_dir") or [0, 1, 0] - if len(origin) < 3 or len(x_dir) < 3 or len(y_dir) < 3: - return None - - projected: list[tuple[float, float]] = [] - for face in owned_faces: - box = face.get("box_m") if isinstance(face, dict) else None - if not isinstance(box, list) or len(box) < 6: - continue - mins = [float(box[i]) * 1000 for i in range(3)] - maxs = [float(box[i + 3]) * 1000 for i in range(3)] - for x in (mins[0], maxs[0]): - for y in (mins[1], maxs[1]): - for z in (mins[2], maxs[2]): - point = [x, y, z] - rel = [point[i] - float(origin[i]) for i in range(3)] - projected.append(( - sum(rel[i] * float(x_dir[i]) for i in range(3)), - sum(rel[i] * float(y_dir[i]) for i in range(3)), - )) - if not projected: - return None - return [ - min(point[0] for point in projected), - min(point[1] for point in projected), - max(point[0] for point in projected), - max(point[1] for point in projected), - ] - - -def _active_profile_loops(sketch: Dict[str, Any], operation: Optional[Dict[str, Any]]) -> list[Dict[str, Any]]: - entities = sketch.get("entities", []) or [] - loops = sketch.get("loops", []) or _infer_closed_wire_loops(entities) - if not loops: - return [] - - op_type = operation.get("type") if isinstance(operation, dict) else None - if op_type == "extrude_cut" and len(loops) > 1: - owned_bbox = _project_owned_faces_to_sketch_bbox( - operation.get("source_owned_faces") or [], - sketch.get("workplane") or {}, - ) - if owned_bbox: - for inner in loops: - inner_bbox = inner.get("bbox_mm") - if _bbox_overlap_ratio_2d(inner_bbox, owned_bbox) < 0.85: - continue - containers = [ - outer for outer in loops - if outer is not inner - and _bbox_contains_2d(outer.get("bbox_mm"), inner_bbox, tolerance=1e-4) - and _bbox_area_2d(outer.get("bbox_mm")) > _bbox_area_2d(inner_bbox) * 1.05 - ] - if containers: - outer = min(containers, key=lambda loop: _bbox_area_2d(loop.get("bbox_mm"))) - outer_loop = dict(outer) - inner_loop = dict(inner) - outer_loop["profile_mode"] = "add" - inner_loop["profile_mode"] = "subtract" - return [outer_loop, inner_loop] - - active = [] - for loop in loops: - bbox = loop.get("bbox_mm") - area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) - contains_other = any( - other is not loop - and _bbox_contains_2d(bbox, other.get("bbox_mm")) - and area > float(other.get("bbox_area_mm2") or _bbox_area_2d(other.get("bbox_mm"))) * 1.05 - for other in loops - ) - if not contains_other: - active.append(loop) - if active: - return active - if op_type == "extrude_add" and len(loops) > 1: - owned_matched = _loops_matching_owned_radii(loops, entities, _owned_profile_radii_mm(operation, sketch)) - # Owned-face radii are useful for selecting circular profiles, but a - # rounded outer contour also contributes arc radii. Those radii can - # coincide with an inner circle and make the radius ranking label the - # inner loop as ADD and its containing outer loop as SUBTRACT. Such a - # profile is topologically impossible as a first additive sketch, so - # fall back to the complete contour nesting below. - owned_modes_conflict_with_nesting = any( - candidate.get("profile_mode") == "add" - and any( - container is not candidate - and container.get("profile_mode") == "subtract" - and _bbox_contains_2d( - container.get("bbox_mm"), - candidate.get("bbox_mm"), - tolerance=1e-4, - ) - and _bbox_area_2d(container.get("bbox_mm")) - > _bbox_area_2d(candidate.get("bbox_mm")) * 1.05 - for container in owned_matched - ) - for candidate in owned_matched - ) - if owned_modes_conflict_with_nesting: - owned_matched = [] - if owned_matched: - # Radius evidence cannot identify closed slot/polygon contours. - # Keep non-circular closed loops that lie inside an owned additive - # outer loop; they are material-removal islands in the same - # additive sketch. Circular unmatched loops remain excluded - # because they commonly belong to other features sharing a sketch. - matched_entity_keys = { - tuple(loop.get("entity_indices") or []) for loop in owned_matched - } - additive_outers = [ - loop for loop in owned_matched if loop.get("profile_mode") == "add" - ] - for loop in loops: - entity_indices = tuple(loop.get("entity_indices") or []) - if entity_indices in matched_entity_keys: - continue - profile_entities = [ - entities[index] - for index in entity_indices - if isinstance(index, int) and 0 <= index < len(entities) - ] - is_non_circular_profile = bool(profile_entities) and any( - entity.get("type") != "circle" - and not (entity.get("type") == "arc" and entity.get("is_circle")) - for entity in profile_entities - ) - if not is_non_circular_profile: - continue - if not any( - _bbox_contains_2d( - outer.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4 - ) - for outer in additive_outers - ): - continue - loop_copy = dict(loop) - loop_copy["profile_mode"] = "subtract" - owned_matched.append(loop_copy) - if len(owned_matched) == 1 and isinstance(operation, dict): - outer_loop = owned_matched[0] - outer_radii = _loop_radius_candidates(outer_loop, entities) - outer_radius = max(outer_radii) if outer_radii else 0.0 - outer_disk_area = math.pi * outer_radius * outer_radius if outer_radius > 0 else 0.0 - has_partial_cap = False - for face in operation.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - area_m2 = face.get("area_m2") - if surface.get("is_plane") and area_m2 is not None and outer_disk_area > 0: - face_area = abs(float(area_m2)) * 1_000_000 - if face_area < outer_disk_area * 0.9: - has_partial_cap = True - break - if has_partial_cap: - inner_candidates = [ - loop for loop in loops - if loop is not outer_loop - and _bbox_contains_2d(outer_loop.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4) - ] - if inner_candidates: - inner = max( - ( - loop - for loop in inner_candidates - if max(_loop_radius_candidates(loop, entities) or [0.0]) < outer_radius - 0.5 - ), - key=lambda loop: max(_loop_radius_candidates(loop, entities) or [0.0]), - default=None, - ) - if inner is None: - return owned_matched - inner_radii = _loop_radius_candidates(inner, entities) - inner_radius = max(inner_radii) if inner_radii else 0.0 - if inner_radius <= 0: - return owned_matched - outer_copy = dict(outer_loop) - inner_copy = dict(inner) - outer_copy["profile_mode"] = "add" - inner_copy["profile_mode"] = "subtract" - return [outer_copy, inner_copy] - return owned_matched - annotated = [] - for loop in loops: - bbox = loop.get("bbox_mm") - area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) - containers = [ - outer for outer in loops - if outer is not loop - and _bbox_contains_2d(outer.get("bbox_mm"), bbox, tolerance=1e-4) - and float(outer.get("bbox_area_mm2") or _bbox_area_2d(outer.get("bbox_mm"))) > area * 1.05 - ] - loop_copy = dict(loop) - loop_copy["profile_mode"] = "subtract" if containers else "add" - annotated.append(loop_copy) - return annotated - return loops - - -def _generate_sketch(sketch: Dict[str, Any], references: Dict[str, Any], operation: Optional[Dict[str, Any]] = None) -> list[str]: - import math - - name = sketch.get("name", "Sketch") - op_type = operation.get("type") if isinstance(operation, dict) else None - workplane = sketch.get("workplane", {}) - entities = sketch.get("entities", []) - loops = _active_profile_loops(sketch, operation) - workplane = _aligned_workplane_for_owned_midplane(sketch, operation, loops) - code = [f" # Sketch: {name}"] - - origin = workplane.get("origin_mm", [0, 0, 0]) - x_dir = workplane.get("x_dir", [1, 0, 0]) - normal = workplane.get("normal", [0, 0, 1]) - - if origin != [0, 0, 0] or x_dir != [1, 0, 0] or normal != [0, 0, 1]: - code.append( - f" with BuildSketch(Plane(origin={_tuple3(origin)}, x_dir={_tuple3(x_dir)}, z_dir={_tuple3(normal)})) as sketch:" - ) - else: - code.append(" with BuildSketch() as sketch:") - - loop_entities = [] - processed_indices = set() - for loop in loops: - for idx in loop.get("entity_indices", []): - if idx < len(entities): - loop_entities.append(entities[idx]) - processed_indices.add(idx) - - append_unprocessed = not loops - for i, ent in enumerate(entities): - if append_unprocessed and i not in processed_indices: - loop_entities.append(ent) - - drawable_entities = [ent for ent in loop_entities if not ent.get("construction", False)] - circle_entities = [ - ent for ent in drawable_entities - if ent.get("type") in ("circle", "arc") and ent.get("is_circle", ent.get("type") == "circle") - ] - wire_entities = [ - ent for ent in drawable_entities - if ent not in circle_entities and ent.get("type") in ("line", "arc") - ] - wire_entities = _ordered_wire_entities(wire_entities) - - handled_circle_entities = set() - if not loops and len(circle_entities) > 1: - ranked_circles = sorted( - enumerate(circle_entities), - key=lambda item: float(item[1].get("radius_mm", 0) or 0), - reverse=True, - ) - outer_index, outer = ranked_circles[0] - outer_center = outer.get("center", [0, 0, 0]) - outer_radius = float(outer.get("radius_mm", 0) or 0) - contains_all = outer_radius > 0 - for _, inner in ranked_circles[1:]: - inner_center = inner.get("center", [0, 0, 0]) - inner_radius = float(inner.get("radius_mm", 0) or 0) - center_distance = math.hypot( - float(inner_center[0]) - float(outer_center[0]), - float(inner_center[1]) - float(outer_center[1]), - ) - if center_distance + inner_radius >= outer_radius - 1e-6: - contains_all = False - break - if contains_all: - code.append(f" with Locations(({outer_center[0]}, {outer_center[1]})):") - code.append(f" Circle({outer_radius})") - handled_circle_entities.add(outer_index) - for inner_index, inner in ranked_circles[1:]: - center = inner.get("center", [0, 0, 0]) - radius = inner.get("radius_mm", 1) - code.append(f" with Locations(({center[0]}, {center[1]})):") - code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") - handled_circle_entities.add(inner_index) - - def circle_is_inner_profile(ent: Dict[str, Any]) -> bool: - if op_type != "extrude_add" or not loops: - return False - center = ent.get("center", [0, 0]) - radius = float(ent.get("radius_mm", 0) or 0) - if radius <= 0 or len(center) < 2: - return False - bbox = [ - float(center[0]) - radius, - float(center[1]) - radius, - float(center[0]) + radius, - float(center[1]) + radius, - ] - return any(_bbox_contains_2d(loop.get("bbox_mm"), bbox, tolerance=1e-4) for loop in loops) - - if not loops: - for circle_index, ent in enumerate(circle_entities): - if circle_index in handled_circle_entities: - continue - center = ent.get("center", [0, 0, 0]) - radius = ent.get("radius_mm", 1) - code.append(f" with Locations(({center[0]}, {center[1]})):") - if circle_is_inner_profile(ent): - code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") - else: - code.append(f" Circle({radius})") - - def orient_wire_entities(profile_entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - """Orient contour segments into a continuous closed wire. - - SolidWorks contour arrays preserve membership but not necessarily each - segment's traversal direction. Reversing an arc must also invert its - curve axis; otherwise a short arc becomes its 270-degree complement. - """ - segments = [deepcopy(entity) for entity in profile_entities] - if len(segments) < 2: - return segments - - def endpoints(entity: Dict[str, Any]) -> tuple[Optional[list[float]], Optional[list[float]]]: - start = entity.get("start") - end = entity.get("end") - if not (isinstance(start, list) and isinstance(end, list) and len(start) >= 2 and len(end) >= 2): - return None, None - return [float(start[0]), float(start[1])], [float(end[0]), float(end[1])] - - def distance(left: list[float], right: list[float]) -> float: - return math.hypot(left[0] - right[0], left[1] - right[1]) - - def reverse(entity: Dict[str, Any]) -> Dict[str, Any]: - reversed_entity = deepcopy(entity) - reversed_entity["start"], reversed_entity["end"] = entity.get("end"), entity.get("start") - axis = reversed_entity.get("curve_axis") or (reversed_entity.get("raw") or {}).get("curve_axis") - if isinstance(axis, list) and len(axis) >= 3: - reversed_entity["curve_axis"] = [-float(value) for value in axis[:3]] - # 删除预置角度,强制代码生成时从翻转后的start/end重新计算 - reversed_entity.pop("start_angle_deg", None) - reversed_entity.pop("end_angle_deg", None) - reversed_entity.pop("arc_sweep_deg", None) - if entity.get("type") == "arc": - center = entity.get("center") or [0.0, 0.0] - start = reversed_entity.get("start") or [0.0, 0.0] - end = reversed_entity.get("end") or [0.0, 0.0] - start_angle = math.degrees(math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0]))) - end_angle = math.degrees(math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0]))) - reversed_sweep = end_angle - start_angle - if reversed_sweep <= -180: - reversed_sweep += 360 - elif reversed_sweep > 180: - reversed_sweep -= 360 - reversed_entity["arc_sweep_deg"] = reversed_sweep - return reversed_entity - - ordered = [segments.pop(0)] - while segments: - _, previous_end = endpoints(ordered[-1]) - if previous_end is None: - ordered.extend(segments) - break - candidates = [] - for index, candidate in enumerate(segments): - candidate_start, candidate_end = endpoints(candidate) - if candidate_start is None or candidate_end is None: - continue - candidates.append((distance(previous_end, candidate_start), index, candidate)) - candidates.append((distance(previous_end, candidate_end), index, reverse(candidate))) - if not candidates: - ordered.extend(segments) - break - _, selected_index, selected = min(candidates, key=lambda item: item[0]) - ordered.append(selected) - segments.pop(selected_index) - return ordered - - def append_wire_profile(profile_entities: list[Dict[str, Any]], make_face_mode: Optional[str] = None) -> None: - profile_entities = orient_wire_entities(profile_entities) - code.append(" with BuildLine():") - code.append(" pass") - emitted_wire = False - line_points = [] - for line_ent in profile_entities: - if line_ent.get("type") == "line": - line_points.extend([line_ent.get("start", [0, 0]), line_ent.get("end", [0, 0])]) - line_bbox = None - if line_points: - xs = [float(point[0]) for point in line_points] - ys = [float(point[1]) for point in line_points] - line_bbox = (min(xs), min(ys), max(xs), max(ys)) - for ent in profile_entities: - ent_type = ent.get("type", "") - if ent_type == "line": - start = ent.get("start", [0, 0, 0]) - end = ent.get("end", [0, 0, 0]) - if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) <= 1e-6: - code.append(" # Skip zero-length line") - continue - code.append(f" Line(({start[0]}, {start[1]}), ({end[0]}, {end[1]}))") - emitted_wire = True - elif ent_type == "arc": - center = ent.get("center", [0, 0, 0]) - radius = ent.get("radius_mm", 1) - if "start_angle_deg" in ent and "end_angle_deg" in ent: - start_angle = ent["start_angle_deg"] - end_angle = ent["end_angle_deg"] - else: - start = ent.get("start", [0, 0]) - end = ent.get("end", [0, 0]) - start_angle = math.degrees(math.atan2(start[1] - center[1], start[0] - center[0])) - end_angle = math.degrees(math.atan2(end[1] - center[1], end[0] - center[0])) - if ent.get("arc_sweep_deg") is not None: - arc_size = float(ent["arc_sweep_deg"]) - else: - curve_axis = ent.get("curve_axis") or ent.get("raw", {}).get("curve_axis") - if isinstance(curve_axis, list) and len(curve_axis) >= 3 and abs(float(curve_axis[2])) > 1e-9: - if float(curve_axis[2]) >= 0: - arc_size = (end_angle - start_angle) % 360 - else: - arc_size = -((start_angle - end_angle) % 360) - else: - arc_size = end_angle - start_angle - if arc_size <= 0: - arc_size += 360 - if arc_size > 180: - arc_size -= 360 - code.append(f" CenterArc(({center[0]}, {center[1]}), {radius}, {start_angle}, {arc_size})") - emitted_wire = True - else: - code.append(f" # TODO: entity type {ent_type}") - if not emitted_wire: - code.append(" # Skip empty wire profile") - return - if make_face_mode: - code.append(f" make_face(mode=Mode.{make_face_mode.upper()})") - else: - code.append(" make_face()") - - if loops: - ordered_loops = sorted( - enumerate(loops), - key=lambda item: (1 if item[1].get("profile_mode") == "subtract" else 0, item[0]), - ) - for loop_order_index, (loop_index, loop) in enumerate(ordered_loops): - profile_entities = [ - entities[idx] - for idx in loop.get("entity_indices", []) - if idx < len(entities) - and not entities[idx].get("construction", False) - and entities[idx].get("type") in ("line", "arc", "circle") - ] - circle_profile_entities = [ - ent for ent in profile_entities - if ent.get("type") == "circle" or (ent.get("type") == "arc" and ent.get("is_circle")) - ] - wire_profile_entities = [ - ent for ent in profile_entities - if ent.get("type") in ("line", "arc") and ent not in circle_profile_entities - ] - wire_profile_entities = _ordered_wire_entities(wire_profile_entities) - if not profile_entities: - continue - mode = loop.get("profile_mode") - if wire_profile_entities: - append_wire_profile(wire_profile_entities, mode if loop_order_index > 0 or mode else None) - else: - for ent in circle_profile_entities: - center = ent.get("center", [0, 0, 0]) - radius = ent.get("radius_mm", 1) - code.append(f" with Locations(({center[0]}, {center[1]})):") - if mode == "subtract": - code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") - else: - code.append(f" Circle({radius})") - elif wire_entities: - append_wire_profile(wire_entities) - - return code - - -def _sketch_circle_radii_mm(sketch: Optional[Dict[str, Any]]) -> list[float]: - if not isinstance(sketch, dict): - return [] - radii = [] - for entity in sketch.get("entities", []) or []: - if entity.get("construction") or entity.get("type") != "circle": - continue - radius = float(entity.get("radius_mm") or 0) - if radius > 0: - radii.append(abs(radius)) - return radii - - -def _flip_side_step_inner_radius_mm( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], - operations: list[Dict[str, Any]], - sketches: Dict[str, Dict[str, Any]], -) -> Optional[float]: - outer_radii = _sketch_circle_radii_mm(sketch) - if not outer_radii: - return None - outer = max(outer_radii) - if len(outer_radii) > 1: - return min(outer_radii) - inner = None - try: - op_index = operations.index(op) - except ValueError: - op_index = len(operations) - for prev in operations[:op_index]: - if prev.get("type") != "extrude_cut": - continue - if not (prev.get("parameters") or {}).get("flip_side_to_cut"): - continue - prev_sketch = sketches.get(prev.get("sketch") or "", {}) - for radius in _sketch_circle_radii_mm(prev_sketch): - if radius < outer - 1e-6: - inner = radius if inner is None else max(inner, radius) - return inner - - -def _flip_side_uses_step_ring( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], - operations: list[Dict[str, Any]], - sketches: Dict[str, Dict[str, Any]], -) -> tuple[Optional[float], Optional[float]]: - outer_radii = _sketch_circle_radii_mm(sketch) - if not outer_radii: - return None, None - outer = max(outer_radii) - inner = _flip_side_step_inner_radius_mm(op, sketch, operations, sketches) - if inner is None or outer <= inner + 0.5: - return None, None - if outer < 35 and outer / inner < 1.5: - return None, None - return outer, inner - - -def _effective_extrude_cut_depth_mm( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], - distance_mm: float, -) -> float: - params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} - if not params.get("flip_side_to_cut"): - return distance_mm - workplane = (sketch or {}).get("workplane") or {} - origin = workplane.get("origin_mm") or [0.0, 0.0, 0.0] - normal = workplane.get("normal") or [0.0, 0.0, 1.0] - if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: - return distance_mm - axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) - cut_amount = distance_mm if params.get("reverse_direction", False) else -abs(distance_mm) - cut_sign = -1.0 if cut_amount < 0 else 1.0 - owned_values = [] - for face in op.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - if not surface.get("is_plane"): - continue - box = face.get("box_m") - if not isinstance(box, list) or len(box) < 6: - continue - thicknesses = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] - if min(thicknesses) > 0.5: - continue - owned_values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) - if not owned_values: - return distance_mm - transition = (min(owned_values) if cut_sign < 0 else max(owned_values)) + cut_sign * 1.0 - effective = abs(float(origin[axis]) - transition) - if effective <= 1e-6: - return distance_mm - if abs(effective - abs(distance_mm)) <= 0.25: - return distance_mm - # Guard: owned-face depth can be wrong when all owned faces - # are near the sketch plane (e.g., edge details), not at the - # real cut termination. Fall back to a through-cut distance - # so the invert-cutter extends past the entire body. - if effective < max(2.0, abs(distance_mm) * 0.15): - return max(distance_mm, THROUGH_CUT_AMOUNT_MM) - return effective - - -def _owned_extrude_terminal_offsets_mm( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], -) -> tuple[Optional[float], Optional[float]]: - """Return the nearest owned planar end faces along the sketch normal. - - SolidWorks can report a two-sided feature with a stale blind depth when one - side terminates on geometry. The feature-owned end face is the reliable - result geometry: its signed offset from the sketch plane identifies the - actual termination direction and distance. - """ - workplane = (sketch or {}).get("workplane") or {} - origin = workplane.get("origin_mm") or [] - normal = workplane.get("normal") or [] - if not (isinstance(origin, list) and isinstance(normal, list) and len(origin) >= 3 and len(normal) >= 3): - return None, None - magnitude = math.sqrt(sum(float(value) ** 2 for value in normal[:3])) - if magnitude <= 1e-9: - return None, None - unit_normal = [float(value) / magnitude for value in normal[:3]] - positive: list[float] = [] - negative: list[float] = [] - for face in op.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - if not surface.get("is_plane"): - continue - params = surface.get("plane_params") - if not isinstance(params, list) or len(params) < 6: - continue - point_mm = [float(value) * 1000 for value in params[3:6]] - offset = sum((point_mm[index] - float(origin[index])) * unit_normal[index] for index in range(3)) - if offset > 1e-4: - positive.append(offset) - elif offset < -1e-4: - negative.append(offset) - return (max(positive) if positive else None, min(negative) if negative else None) - - -def _resolve_extrude_owned_termination( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]], -) -> Dict[str, Any]: - """Resolve an asymmetric two-sided add from its SolidWorks-owned end face.""" - params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} - if op.get("type") != "extrude_add" or not params.get("both_directions"): - return op - positive, negative = _owned_extrude_terminal_offsets_mm(op, sketch) - if (positive is None) == (negative is None): - return op - resolved = dict(op) - resolved_params = dict(params) - resolved_params["distance_mm"] = positive if positive is not None else abs(float(negative)) - resolved_params["reverse_distance_mm"] = 0 - resolved_params["both_directions"] = False - resolved_params["reverse_direction"] = negative is not None - resolved_params["owned_termination_resolved"] = True - resolved["parameters"] = resolved_params - return resolved - - -def _generate_extrude( - op: Dict[str, Any], - sketch: Optional[Dict[str, Any]] = None, - operations: Optional[list[Dict[str, Any]]] = None, - sketches: Optional[Dict[str, Dict[str, Any]]] = None, -) -> list[str]: - params = op.get("parameters", {}) - distance = _effective_extrude_cut_depth_mm(op, sketch, float(params.get("distance_mm", 10) or 10)) - reverse_distance = params.get("reverse_distance_mm", 0) - op_type = op.get("type", "") - name = op.get("name", "") - both_directions = params.get("both_directions", False) - flip_side_to_cut = bool(params.get("flip_side_to_cut", False)) - end_condition_code = params.get("end_condition_code") - reverse_end_condition_code = params.get("reverse_end_condition_code") - end_condition = SW_END_CONDITIONS.get(end_condition_code, f"Unknown({end_condition_code})") - operations = operations or [] - sketches = sketches or {} - outer_radius, inner_radius = ( - _flip_side_uses_step_ring(op, sketch, operations, sketches) if flip_side_to_cut else (None, None) - ) - resolved_owned_termination = bool(params.get("owned_termination_resolved")) - - code = [f" # {op_type}: {name}"] - if resolved_owned_termination: - code.append(" # Use the owned planar end face to resolve SW's asymmetric termination") - preserve_visible = bool(op.get("source_owned_faces")) and op_type == "extrude_add" - if end_condition_code is not None: - code.append(f" # SW end condition: {end_condition}") - - owned_cylinder_faces = _owned_cylindrical_cut_faces(op, sketch or {}) - prefer_blind_sketch = _prefer_blind_sketch_extrude( - op, sketch or {}, distance, end_condition_code, owned_cylinder_faces - ) - if op_type == "extrude_cut" and owned_cylinder_faces and flip_side_to_cut: - code.append(" # Replay SW flip-side circular cut from owned cylindrical faces") - code.append(f" result = cut_owned_flip_side_cylindrical_faces(result, {repr(owned_cylinder_faces)})") - return code - - if op_type == "extrude_cut" and owned_cylinder_faces and not flip_side_to_cut and not prefer_blind_sketch: - code.append(" # Replay cut from SW owned cylindrical faces when start/end references are missing") - code.append(f" result = cut_owned_cylindrical_faces(result, {repr(owned_cylinder_faces)})") - return code - - owned_bbox = _owned_bbox_cut(op, sketch or {}, distance) - if op_type == "extrude_cut" and owned_bbox and not flip_side_to_cut and not prefer_blind_sketch: - code.append(" # Replay cut from SW owned face bbox when extrude start/end references are missing") - code.append(f" result = cut_owned_bbox(result, {repr(owned_bbox)})") - return code - - if distance == 0 and reverse_distance == 0: - if op_type == "extrude_cut" and end_condition_code not in (None, 0): - distance = THROUGH_CUT_AMOUNT_MM - both_directions = end_condition_code in (1, 2, 9) - code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") - else: - code.append(" # Skip: zero distance") - return code - elif op_type == "extrude_add" and (end_condition_code in (6, 8) or reverse_end_condition_code in (6, 8)): - code.append(" # SW mid-plane/two-sided extrusion represented by this IR") - distance = distance / 2 - reverse_distance = distance - both_directions = True - elif op_type == "extrude_cut" and end_condition_code not in (None, 0): - distance = max(distance, reverse_distance, THROUGH_CUT_AMOUNT_MM) - both_directions = both_directions or end_condition_code in (1, 2, 9) - code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") - - if both_directions: - amount = max(distance, reverse_distance) if reverse_distance > 0 else distance - if op_type == "extrude_cut": - code.append(f" cutter = extrude(sketch.sketch, amount={amount}, both=True)") - if flip_side_to_cut: - normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) - if outer_radius is not None and inner_radius is not None: - code.append( - " result = sw_flip_side_step_cut(" - f"result, cutter, normal={_tuple3(normal)}, " - f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" - ) - else: - code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") - else: - code.append(" result = safe_subtract(result, cutter)") - else: - code.append(f" solid = extrude(sketch.sketch, amount={amount}, both=True)") - code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") - elif op_type == "extrude_cut": - if distance > 0: - cut_amount = distance if params.get("reverse_direction", False) else -distance - code.append(f" cutter = extrude(sketch.sketch, amount={cut_amount})") - # 当盲拉伸从不同于草图的起始面开始时,平移cutter到正确位置 - if prefer_blind_sketch: - face_offset = _blind_extrude_face_offset(op, sketch or {}) - if face_offset is not None: - code.append(f" cutter = cutter.locate(Location({_tuple3(face_offset)}))") - if flip_side_to_cut: - normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) - if outer_radius is not None and inner_radius is not None: - code.append( - " result = sw_flip_side_step_cut(" - f"result, cutter, normal={_tuple3(normal)}, " - f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" - ) - else: - code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") - else: - code.append(" result = safe_subtract(result, cutter)") - else: - code.append(" # Skip: zero distance cut") - else: - add_amount = -distance if params.get("reverse_direction", False) else distance - code.append(f" solid = extrude(sketch.sketch, amount={add_amount})") - code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") - - return code - - -def _owned_cylindrical_cut_faces(op: Dict[str, Any], sketch: Dict[str, Any]) -> list[Dict[str, Any]]: - if op.get("type") != "extrude_cut": - return [] - sketch_radii = [ - abs(float(entity.get("radius_mm") or 0)) - for entity in sketch.get("entities", []) or [] - if not entity.get("construction") and entity.get("type") == "circle" - ] - if not sketch_radii: - return [] - matched = [] - for face in op.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - params = surface.get("cylinder_params") - bbox = face.get("box_m") - if not (surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7): - continue - if not (isinstance(bbox, list) and len(bbox) >= 6): - continue - radius_mm = abs(float(params[6]) * 1000) - if not any(abs(radius_mm - sketch_radius) <= max(0.05, sketch_radius * 0.01) for sketch_radius in sketch_radii): - continue - matched.append(face) - return matched - - -def _blind_extrude_face_offset( - op: Dict[str, Any], - sketch: Dict[str, Any], -) -> Optional[list[float]]: - """当盲拉伸从不同于草图的起始面开始时,计算cutter的3D平移向量。 - 返回None表示不需要平移。""" - faces = (op.get("source_owned_faces") or []) - if not faces: - return None - valid_bboxes = [] - for face in faces: - bm = face.get("box_m") - if isinstance(bm, list) and len(bm) >= 6: - valid_bboxes.append([float(v) * 1000 for v in bm[:6]]) - if not valid_bboxes: - return None - normal = (sketch.get("workplane") or {}).get("normal") - if not isinstance(normal, list) or len(normal) < 3: - return None - origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] - # 确定主导轴 (extrude方向) - axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) - normal_sign = 1.0 if float(normal[axis]) >= 0 else -1.0 - sketch_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else 0.0 - # 取离草图平面最近的面坐标,使cutter从面的最近点开始切入 - # 对于多个面,面可能在草图平面两侧。 - all_coords = [] - for b in valid_bboxes: - all_coords.append(b[axis]) - all_coords.append(b[axis + 3]) - if not all_coords: - return None - # 找离sketch_coord最近的面坐标 - face_coord = min(all_coords, key=lambda c: abs(c - sketch_coord)) - offset = face_coord - sketch_coord - if abs(offset) < 1e-3: - return None - # 返回3D平移向量(仅沿extrude方向) - result = [0.0, 0.0, 0.0] - result[axis] = offset - return result - - -def _prefer_blind_sketch_extrude( - op: Dict[str, Any], - sketch: Dict[str, Any], - distance_mm: float, - end_condition_code: Optional[int], - owned_cylinder_faces: list[Dict[str, Any]], -) -> bool: - """优先使用盲拉伸而非 bbox 回退。对于矩形/圆等简单截面, - 盲拉伸比包围盒近似精确得多。含弧的复杂截面可能因方向问题 - 产生意外偏差,此时仍走 bbox 路径。""" - if owned_cylinder_faces: - return False - if end_condition_code not in (None, 0) or distance_mm <= 0: - return False - if not _sketch_has_buildable_profile(sketch): - return False - # 有 owned_faces 的矩形或纯圆截面: 盲拉伸比 bbox 更精确 - entities = sketch.get("entities", []) or [] - non_const = [e for e in entities if not e.get("construction", False)] - types = {e.get("type") for e in non_const if e.get("type") not in ("point", "text")} - # 排除point/text后仍是简单截面才用盲拉伸。 - # 但如果面位于不同平面,让_blind_extrude_face_offset处理 - is_simple = types <= {"line"} or types <= {"circle"} - if not is_simple: - return False - # 检查草图平面与面是否有关键偏移 - 只有当盲拉伸需要偏移修正时才使用 - faces = op.get("source_owned_faces") or [] - if faces and _blind_extrude_face_offset(op, sketch) is not None: - return True # 有面偏移,需要盲拉伸+offset修正 - # 无面偏移时,只有当start/end引用完整时才用盲拉伸 - if op.get("start_reference") or op.get("end_reference"): - return True - return False - - -def _owned_bbox_cut(op: Dict[str, Any], sketch: Dict[str, Any], distance_mm: float) -> Optional[list[float]]: - if op.get("type") != "extrude_cut": - return None - faces = [ - face for face in (op.get("source_owned_faces") or []) - if isinstance(face, dict) and isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 - ] - if not faces: - return None - bboxes = [[float(value) * 1000 for value in face["box_m"][:6]] for face in faces] - bbox = [ - min(box[axis] for box in bboxes) if axis < 3 else max(box[axis] for box in bboxes) - for axis in range(6) - ] - normal = (sketch.get("workplane") or {}).get("normal") or [0, 0, 1] - if not isinstance(normal, list) or len(normal) < 3: - return None - axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) - extent = abs(bbox[axis + 3] - bbox[axis]) - origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] - origin_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else None - distance = abs(float(distance_mm or 0)) - origin_outside = ( - origin_coord is not None - and (origin_coord < min(bbox[axis], bbox[axis + 3]) - 1e-6 or origin_coord > max(bbox[axis], bbox[axis + 3]) + 1e-6) - ) - if extent <= distance * 1.25 and not origin_outside: - return None - return bbox - - -def _generate_revolve(op: Dict[str, Any], sketch: Optional[Dict[str, Any]] = None) -> list[str]: - params = op.get("parameters", {}) - angle = params.get("angle_deg") - if angle is None and params.get("angle_rad") is not None: - angle = float(params.get("angle_rad")) * 180 / math.pi - if angle is None: - angle = 360 - if abs(angle - 360) < 1e-6: - angle = 360 - op_type = op.get("type", "") - name = op.get("name", "") - code = [f" # {op_type}: {name}"] - axis_expr = _revolve_axis_expr(params, sketch or {}) - code.append(f" revolve_axis = {axis_expr}") - if op_type == "revolve_cut": - code.append(f" cutter = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") - code.append(" # Force OCCT to fully evaluate both solids before Boolean ops") - code.append(" _ = list(cutter.solids()); _ = cutter.is_valid; _ = cutter.volume") - code.append(" _ = list(result.solids()); _ = result.is_valid; _ = result.volume") - code.append(" # Use a single subtract and capture the result directly (avoids OCCT heisenbug)") - code.append(" result = safe_subtract(result, cutter)") - else: - code.append(f" solid = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") - preserve_visible = bool(op.get("source_owned_faces")) - code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") - return code - - -def _revolve_axis_expr(params: Dict[str, Any], sketch: Dict[str, Any]) -> str: - # 优先使用草图中的构造线作为旋转轴, - # 因为它保证位于草图平面上(SW 的 revolve 操作依赖于此) - construction_axis = _sketch_construction_axis(sketch) - if construction_axis: - origin, direction = construction_axis - return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" - - axis_reference = params.get("axis_reference") or {} - if axis_reference.get("origin_mm") and axis_reference.get("direction"): - return f"Axis({_tuple3(axis_reference['origin_mm'])}, {_tuple3(axis_reference['direction'])})" - for candidate in params.get("axis_candidates") or []: - if candidate.get("model_start_mm") and candidate.get("model_direction"): - return f"Axis({_tuple3(candidate['model_start_mm'])}, {_tuple3(candidate['model_direction'])})" - - workplane = sketch.get("workplane", {}) - origin = workplane.get("origin_mm", [0, 0, 0]) - direction = workplane.get("x_dir", [1, 0, 0]) - return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" - - -def _sketch_construction_axis( - sketch: Dict[str, Any], -) -> Optional[tuple[list[float], list[float]]]: - workplane = sketch.get("workplane", {}) - origin = [float(v) for v in workplane.get("origin_mm", [0, 0, 0])] - x_dir = [float(v) for v in workplane.get("x_dir", [1, 0, 0])] - y_dir = [float(v) for v in workplane.get("y_dir", [0, 1, 0])] - - for entity in sketch.get("entities", []): - if entity.get("type") != "line" or not entity.get("construction"): - continue - start = entity.get("start") - end = entity.get("end") - if not start or not end: - continue - start_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, start) - end_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, end) - direction = [end_3d[i] - start_3d[i] for i in range(3)] - length = math.sqrt(sum(component * component for component in direction)) - if length <= 0: - continue - return start_3d, [component / length for component in direction] - return None - - -def _sketch_point_to_model_from_basis( - origin: list[float], x_dir: list[float], y_dir: list[float], point: list[float] -) -> list[float]: - return [ - origin[i] + x_dir[i] * float(point[0]) + y_dir[i] * float(point[1]) - for i in range(3) - ] - - -def _generate_fillet(op: Dict[str, Any]) -> list[str]: - params = op.get("parameters", {}) - radius = params.get("radius_mm") - selectors = op.get("selectors", []) - owned_faces = op.get("source_owned_faces") or [] - if not radius or float(radius) <= 0: - return [f" # Fillet skipped: source radius missing for {op.get('name', '')}"] - return [ - f" # Fillet: {op.get('name', '')}", - " result = fillet_selected(" - f"result, radius={radius}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", - ] - - -def _generate_chamfer(op: Dict[str, Any]) -> list[str]: - params = op.get("parameters", {}) - distance = params.get("distance_mm") - selectors = op.get("selectors", []) - owned_faces = op.get("source_owned_faces") or [] - if not distance or float(distance) <= 0: - return [f" # Chamfer skipped: source distance missing for {op.get('name', '')}"] - return [ - f" # Chamfer: {op.get('name', '')}", - " result = chamfer_selected_with_owned_faces(" - f"result, distance={distance}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", - ] - - -def _generate_move_face(op: Dict[str, Any]) -> list[str]: - data = (op.get("parameters") or {}).get("move_face_data") or {} - selected_faces = data.get("selected_faces") or [] - return [ - f" # MoveFace pure-JSON operation: {op.get('name', '')}", - " raise NotImplementedError(", - f" 'MoveFace native build123d replay is pending; captured selected_faces={len(selected_faces)}'", - " )", - ] - - -def _hole_should_use_sw_cut_holes(params: Dict[str, Any], owned_cut_faces: list[Dict[str, Any]]) -> bool: - positions = params.get("positions") or [] - diameter = _hole_diameter_mm(params) - if not positions or diameter <= 0: - return False - if len(owned_cut_faces) <= 1: - return False - has_cone_owned = any((face.get("surface") or {}).get("is_cone") for face in owned_cut_faces) - drill_angle = _hole_drill_angle_rad(params) - if has_cone_owned and not (_hole_has_drill_tip(params) and drill_angle > 0): - return False - counterbore_diameter = _hole_counterbore_diameter_mm(params) - counterbore_depth = _hole_counterbore_depth_mm(params) - if counterbore_diameter > diameter and counterbore_depth > 0: - return True - return _hole_has_through_dimension(params) - - -def _effective_hole_cut_depth_mm(params: Dict[str, Any]) -> float: - if _hole_has_through_dimension(params): - return THROUGH_CUT_AMOUNT_MM - return _hole_depth_mm(params) - - -def _generate_hole(op: Dict[str, Any]) -> list[str]: - params = op.get("parameters", {}) - diameter = _hole_diameter_mm(params) - depth = _effective_hole_cut_depth_mm(params) - drill_angle = _hole_drill_angle_rad(params) - include_drill_tip = _hole_has_drill_tip(params) - countersink_diameter = _hole_countersink_diameter_mm(params) - countersink_angle = _hole_countersink_angle_rad(params) - counterbore_diameter = _hole_counterbore_diameter_mm(params) - counterbore_depth = _hole_counterbore_depth_mm(params) - positions = [pos.get("mm") for pos in params.get("positions", []) if pos.get("mm")] - host_face = params.get("host_face") or {} - owned_cut_faces = _hole_owned_cut_faces(op) - # Feature position sketches are occasionally incomplete in the plugin export - # (notably for wizard holes with multiple instances). The faces owned by the - # feature are the authoritative result from SolidWorks, including every hole - # location, counterbore, countersink, and drill tip. Prefer replaying those - # surfaces whenever they are available; fall back to the parametric cutter - # only when the exporter has no usable owned-face geometry. - if owned_cut_faces: - return [ - f" # Hole: {op.get('name', '')}", - " # Replay hole from SW owned cut faces to preserve side and axis", - f" result = cut_owned_cylindrical_faces(result, {repr(owned_cut_faces)})", - ] - return [ - f" # Hole: {op.get('name', '')}", - f" result = sw_cut_holes(result, positions={json.dumps(positions)}, host_face={json.dumps(host_face)}, diameter={diameter}, depth={depth}, drill_angle={drill_angle}, include_drill_tip={include_drill_tip}, countersink_diameter={countersink_diameter}, countersink_angle={countersink_angle}, counterbore_diameter={counterbore_diameter}, counterbore_depth={counterbore_depth})", - ] - - -def _hole_owned_cut_faces(op: Dict[str, Any]) -> list[Dict[str, Any]]: - matched = [] - for face in op.get("source_owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - bbox = face.get("box_m") - has_cylinder = ( - surface.get("is_cylinder") - and isinstance(surface.get("cylinder_params"), list) - and len(surface.get("cylinder_params") or []) >= 7 - ) - has_cone = ( - surface.get("is_cone") - and isinstance(surface.get("cone_params"), list) - and len(surface.get("cone_params") or []) >= 8 - ) - if not (has_cylinder or has_cone): - continue - if not (isinstance(bbox, list) and len(bbox) >= 6): - continue - matched.append(face) - return matched - - -def _generate_linear_pattern( - op: Dict[str, Any], - operations: list[Dict[str, Any]], - sketches: Dict[str, Dict[str, Any]], - references: Dict[str, Any], -) -> list[str]: - params = op.get("parameters", {}) - source_features = params.get("source_features") or [] - offsets = _linear_pattern_offsets(op) - code = [f" # Linear pattern: {op.get('name', '')}"] - - if not source_features or not offsets: - code.append(" # Skip: no source features or pattern offsets") - return code - - for source_feature in source_features: - source_op = _find_operation_for_source_feature(operations, source_feature) - if not source_op: - code.append(f" # Skip: source feature not found {source_feature.get('name')}") - continue - - for offset_index, offset in enumerate(offsets, start=1): - copied_op = _translated_operation(source_op, offset) - copied_op["name"] = f"{source_op.get('name', '')} pattern copy {offset_index}" - op_type = copied_op.get("type") - - if op_type == "hole": - code.extend(_generate_hole(copied_op)) - elif op_type in ("extrude_cut", "extrude_add", "revolve_cut", "revolve_add"): - source_sketch_id = copied_op.get("sketch") - source_sketch = sketches.get(source_sketch_id or "") - if not source_sketch: - code.append(f" # Skip: source sketch not found for {copied_op.get('name')}") - continue - if not _sketch_has_buildable_profile(source_sketch): - code.append(f" # Skip: source sketch has no buildable profile for {copied_op.get('name')}") - continue - - copied_sketch = _translated_sketch(source_sketch, offset, f"{source_sketch_id}_pattern_{offset_index}") - code.extend(_generate_sketch(copied_sketch, references, copied_op)) - if op_type in ("extrude_cut", "extrude_add"): - code.extend(_generate_extrude(copied_op, copied_sketch, operations, sketches)) - else: - code.extend(_generate_revolve(copied_op, copied_sketch)) - else: - code.append(f" # TODO: pattern source type {op_type}") - - return code - - -def _generate_mirror_pattern( - op: Dict[str, Any], - operations: list[Dict[str, Any]], - sketches: Dict[str, Dict[str, Any]], - references: Dict[str, Any], -) -> list[str]: - """生成镜像代码。SW MirrorPattern 镜像的是特征而非整体,因此必须先切掉镜像面负侧的实体,只保留正侧一半再镜像。""" - params = op.get("parameters", {}) - source_features = params.get("source_features") or [] - raw = op.get("raw_parameters", {}) - mirror_plane_info = raw.get("mirror_plane") or {} - - code = [f" # Mirror pattern: {op.get('name', '')}"] - - plane_origin = _extract_mirror_plane_origin(raw, mirror_plane_info) - plane_normal = _extract_mirror_plane_normal(raw, mirror_plane_info) - - mx = plane_origin[0] if plane_origin else 0.0 - my = plane_origin[1] if plane_origin else 0.0 - mz = plane_origin[2] if plane_origin else 0.0 - nx = plane_normal[0] if plane_normal else 0.0 - ny = plane_normal[1] if plane_normal else 0.0 - nz = plane_normal[2] if plane_normal else 1.0 - - code.append(f" mirror_plane = Plane(origin=({mx}, {my}, {mz}), z_dir=({nx}, {ny}, {nz}))") - code.append(f" mx, my, mz = {mx}, {my}, {mz}") - code.append(f" nx, ny, nz = {nx}, {ny}, {nz}") - code.append(f" try:") - code.append(f" bbox = result.bounding_box()") - code.append(f" margin = 10.0") - # Determine dominant axis and cut away the -normal side - adx, ady, adz = abs(nx), abs(ny), abs(nz) - if adx >= ady and adx >= adz: - if nx > 0: - code.append(f" cut_w = (mx - bbox.min.X) + margin") - code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") - else: - code.append(f" cut_w = (bbox.max.X - mx) + margin") - code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") - code.append(f" cut_box = cut_box.translate((mx, bbox.min.Y - margin, bbox.min.Z - margin))") - elif ady >= adx and ady >= adz: - if ny > 0: - code.append(f" cut_h = (my - bbox.min.Y) + margin") - code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") - else: - code.append(f" cut_h = (bbox.max.Y - my) + margin") - code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, my, bbox.min.Z - margin))") - else: - if nz > 0: - code.append(f" cut_d = (mz - bbox.min.Z) + margin") - code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") - else: - code.append(f" cut_d = (bbox.max.Z - mz) + margin") - code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") - code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, mz))") - code.append(f" half = result.cut(cut_box)") - code.append(f" mirrored = half.mirror(mirror_plane)") - code.append(f" result = half.fuse(mirrored).clean()") - code.append(f" except Exception as e:") - code.append(f" print(f'mirror failed: {{e}}')") - return code - - -def _extract_mirror_plane_origin(raw: dict, mirror_plane_info: dict): - mir_origin = raw.get("mirror_plane_origin") - if mir_origin and isinstance(mir_origin, (list, tuple)) and len(mir_origin) >= 3: - return (float(mir_origin[0]), float(mir_origin[1]), float(mir_origin[2])) - origin_list = mirror_plane_info.get("origin_mm") or mirror_plane_info.get("origin") or [] - if origin_list and len(origin_list) >= 3: - return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) - frame = mirror_plane_info.get("frame") - if isinstance(frame, dict): - origin_list = frame.get("origin") or [] - if origin_list and len(origin_list) >= 3: - return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) - return None - - -def _extract_mirror_plane_normal(raw: dict, mirror_plane_info: dict): - mir_normal = raw.get("mirror_plane_normal") - if mir_normal and isinstance(mir_normal, (list, tuple)) and len(mir_normal) >= 3: - return (float(mir_normal[0]), float(mir_normal[1]), float(mir_normal[2])) - normal_list = mirror_plane_info.get("normal") or [] - if normal_list and len(normal_list) >= 3: - return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) - frame = mirror_plane_info.get("frame") - if isinstance(frame, dict): - normal_list = frame.get("normal") or [] - if normal_list and len(normal_list) >= 3: - return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) - return None - - -def _find_operation_for_source_feature( - operations: list[Dict[str, Any]], source_feature: Dict[str, Any] -) -> Optional[Dict[str, Any]]: - source_index = source_feature.get("index") - source_name = source_feature.get("name") - source_identity = source_feature.get("identity") if isinstance(source_feature.get("identity"), dict) else {} - source_stable_id = source_feature.get("stable_id") or source_identity.get("stable_id") - source_persistent_reference = source_feature.get("persistent_reference") or source_identity.get("persistent_reference") - for op in operations: - op_source = op.get("source_feature", {}) - if source_index is not None and op_source.get("index") == source_index: - return op - for op in operations: - op_source = op.get("source_feature", {}) - op_identity = op_source.get("identity") if isinstance(op_source.get("identity"), dict) else {} - if source_stable_id and ( - op_source.get("stable_id") == source_stable_id - or op_identity.get("stable_id") == source_stable_id - ): - return op - if source_persistent_reference and ( - op_source.get("persistent_reference") == source_persistent_reference - or op_identity.get("persistent_reference") == source_persistent_reference - ): - return op - for op in operations: - if source_name and op.get("name") == source_name: - return op - return None - - -def _find_source_operation_for_pattern( - operations: list[Dict[str, Any]], source_features: list[Dict[str, Any]] -) -> Optional[Dict[str, Any]]: - for source_feature in source_features: - source_op = _find_operation_for_source_feature(operations, source_feature) - if source_op: - return source_op - return None - - -def _linear_pattern_offsets(op: Dict[str, Any]) -> list[tuple[float, float, float]]: - params = op.get("parameters", {}) - raw = op.get("raw_parameters", {}) - explicit_offsets = raw.get("explicit_offsets_mm") - if isinstance(explicit_offsets, list) and explicit_offsets: - return [ - (float(offset[0]), float(offset[1]), float(offset[2])) - for offset in explicit_offsets - if isinstance(offset, list) and len(offset) >= 3 - ] - d1_count = int(raw.get("d1_total_instances") or params.get("total_instances") or 1) - d2_count = int(raw.get("d2_total_instances") or 1) - d1_spacing = float(raw.get("d1_spacing_mm") or params.get("spacing_mm") or 0) - d2_spacing = float(raw.get("d2_spacing_mm") or 0) - d1_vector = _pattern_direction_vector(raw.get("direction1") or params.get("direction1"), d1_spacing) - d2_vector = _pattern_direction_vector(raw.get("direction2") or params.get("direction2"), d2_spacing) - - offsets = [] - for i in range(d1_count): - for j in range(d2_count): - if i == 0 and j == 0: - continue - offsets.append(tuple(d1_vector[k] * i + d2_vector[k] * j for k in range(3))) - return offsets - - -def _pattern_direction_vector(direction: Optional[Dict[str, Any]], spacing: float) -> tuple[float, float, float]: - if not direction or not spacing: - return (0.0, 0.0, 0.0) - direct_vector = direction.get("vector") - if isinstance(direct_vector, list) and len(direct_vector) >= 3: - vector = tuple(float(direct_vector[i]) for i in range(3)) - length = math.sqrt(sum(component * component for component in vector)) - if length <= 0: - return (0.0, 0.0, 0.0) - return tuple(component / length * spacing for component in vector) - start = direction.get("start", {}).get("mm") - end = direction.get("end", {}).get("mm") - if not start or not end: - return (0.0, 0.0, 0.0) - vector = tuple(float(end[i]) - float(start[i]) for i in range(3)) - length = math.sqrt(sum(component * component for component in vector)) - if length <= 0: - return (0.0, 0.0, 0.0) - return tuple(component / length * spacing for component in vector) - - -def _translated_operation(op: Dict[str, Any], offset: tuple[float, float, float]) -> Dict[str, Any]: - copied = deepcopy(op) - params = copied.get("parameters") or {} - axis_reference = params.get("axis_reference") - if isinstance(axis_reference, dict) and isinstance(axis_reference.get("origin_mm"), list): - origin = list(axis_reference.get("origin_mm") or [0, 0, 0]) - origin = (origin + [0, 0, 0])[:3] - axis_reference["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] - - if copied.get("type") == "hole": - positions = params.get("positions") or [] - local_offset = _model_offset_to_host_local(offset, params.get("host_face") or {}) - for position in positions: - if position.get("mm"): - point = list(position.get("mm") or [0, 0, 0]) - point = (point + [0, 0, 0])[:3] - position["mm"] = [ - float(point[0]) + local_offset[0], - float(point[1]) + local_offset[1], - float(point[2]) + local_offset[2], - ] - if position.get("m"): - position["m"] = [value / 1000 for value in position.get("mm", [])] - if any(abs(float(offset[i])) > 1e-9 for i in range(3)): - owned_faces = copied.get("source_owned_faces") or [] - if owned_faces: - copied["source_owned_faces"] = _translate_owned_faces(owned_faces, offset) - return copied - - -def _translate_owned_faces( - faces: list[Dict[str, Any]], - offset: tuple[float, float, float], -) -> list[Dict[str, Any]]: - translated = [] - shift_mm = (float(offset[0]), float(offset[1]), float(offset[2])) - shift_m = (shift_mm[0] / 1000.0, shift_mm[1] / 1000.0, shift_mm[2] / 1000.0) - for face in faces: - if not isinstance(face, dict): - continue - copied = deepcopy(face) - box = copied.get("box_m") - if isinstance(box, list) and len(box) >= 6: - copied["box_m"] = [ - float(box[0]) + shift_m[0], - float(box[1]) + shift_m[1], - float(box[2]) + shift_m[2], - float(box[3]) + shift_m[0], - float(box[4]) + shift_m[1], - float(box[5]) + shift_m[2], - ] - surface = copied.get("surface") - if isinstance(surface, dict): - for key in ("cylinder_params", "cone_params"): - params = surface.get(key) - if isinstance(params, list) and len(params) >= 3: - updated = list(params) - updated[0] = float(updated[0]) + shift_m[0] - updated[1] = float(updated[1]) + shift_m[1] - updated[2] = float(updated[2]) + shift_m[2] - surface[key] = updated - translated.append(copied) - return translated - - -def _model_offset_to_host_local( - offset: tuple[float, float, float], - host_face: Dict[str, Any], -) -> tuple[float, float, float]: - frame = host_face.get("frame") if isinstance(host_face, dict) else {} - if not isinstance(frame, dict): - return offset - x_dir = frame.get("x_dir") - y_dir = frame.get("y_dir") - if not ( - isinstance(x_dir, list) - and len(x_dir) >= 3 - and isinstance(y_dir, list) - and len(y_dir) >= 3 - ): - return offset - local_x = sum(float(offset[i]) * float(x_dir[i]) for i in range(3)) - local_y = sum(float(offset[i]) * float(y_dir[i]) for i in range(3)) - return (local_x, local_y, 0.0) - - -def _translated_sketch( - sketch: Dict[str, Any], offset: tuple[float, float, float], sketch_id: str -) -> Dict[str, Any]: - copied = deepcopy(sketch) - copied["id"] = sketch_id - copied["name"] = f"{sketch.get('name', sketch_id)} pattern copy" - workplane = copied.setdefault("workplane", {}) - origin = list(workplane.get("origin_mm") or [0, 0, 0]) - origin = (origin + [0, 0, 0])[:3] - workplane["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] - return copied - - -def _translate_sketch_entities(sketch: Dict[str, Any], offset: tuple[float, float, float]) -> None: - dx, dy = offset[0], offset[1] - for entity in sketch.get("entities", []): - for key in ("start", "end", "center"): - point = entity.get(key) - if isinstance(point, list) and len(point) >= 2: - point[0] = float(point[0]) + dx - point[1] = float(point[1]) + dy - raw = entity.get("raw", {}) - for key in ("start", "end", "center"): - raw_point = raw.get(key) - if isinstance(raw_point, dict): - mm = raw_point.get("mm") - if isinstance(mm, list) and len(mm) >= 2: - mm[0] = float(mm[0]) + dx - mm[1] = float(mm[1]) + dy - raw_point["m"] = [value / 1000 for value in mm] - - -def _hole_diameter_mm(params: Dict[str, Any]) -> float: - if params.get("diameter_mm"): - return float(params["diameter_mm"]) - diameters = params.get("diameters_m", {}) - for key in ( - "hole_diameter", - "thru_hole_diameter", - "tap_drill_diameter", - "thru_tap_drill_diameter", - "thread_diameter", - "diameter", - ): - value = diameters.get(key) - if value: - return float(value) * 1000 - return 0 - - -def _hole_depth_mm(params: Dict[str, Any]) -> float: - if params.get("depth_mm"): - return float(params["depth_mm"]) - depths = params.get("depths_m", {}) - for key in ( - "hole_depth", - "thru_hole_depth", - "tap_drill_depth", - "thru_tap_drill_depth", - "thread_depth", - "depth", - ): - value = depths.get(key) - if value: - return float(value) * 1000 - return THROUGH_CUT_AMOUNT_MM - - -def _hole_drill_angle_rad(params: Dict[str, Any]) -> float: - angle = params.get("angles_rad", {}).get("drill_angle") - return float(angle) if angle else 0 - - -def _hole_countersink_angle_rad(params: Dict[str, Any]) -> float: - angle = params.get("angles_rad", {}).get("countersink_angle") - return float(angle) if angle else 0 - - -def _hole_countersink_diameter_mm(params: Dict[str, Any]) -> float: - diameter = params.get("countersink_diameter_mm") - return float(diameter) if diameter else 0 - - -def _hole_counterbore_diameter_mm(params: Dict[str, Any]) -> float: - diameter = params.get("counterbore_diameter_mm") - return float(diameter) if diameter else 0 - - -def _hole_counterbore_depth_mm(params: Dict[str, Any]) -> float: - depth = params.get("counterbore_depth_mm") - return float(depth) if depth else 0 - - -def _hole_has_drill_tip(params: Dict[str, Any]) -> bool: - depths = params.get("depths_m", {}) - angle = _hole_drill_angle_rad(params) - if angle <= 0: - return False - through_depth_keys = ( - "thru_hole_depth", - "thru_tap_drill_depth", - ) - if any(depths.get(key) for key in through_depth_keys): - return False - if params.get("depth_mm"): - return True - return any(depths.get(key) for key in ("hole_depth", "tap_drill_depth", "depth")) - - -def _hole_has_through_dimension(params: Dict[str, Any]) -> bool: - names = " ".join(str(name).lower() for name in params.get("dimension_names", []) or []) - return any(token in names for token in ("通孔", "through", "thru")) - - -def _hole_dimension_value(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: - for dim in data_block.get("dimensions", []) or []: - name = str(dim.get("name") or "").lower() - if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): - return float(dim.get("value")) - return None - - -def _feature_length_dimension_mm(feature: Dict[str, Any]) -> Optional[float]: - candidates: list[tuple[int, float]] = [] - for dim in feature.get("dimensions") or []: - if not isinstance(dim, dict): - continue - name = str(dim.get("name") or "") - system_value = dim.get("system_value_m") - if system_value not in (None, ""): - length_mm = abs(float(system_value)) * 1000 - elif dim.get("value") not in (None, ""): - length_mm = abs(float(dim.get("value"))) - else: - continue - if length_mm <= 1e-9 or length_mm > 500: - continue - priority = 0 if name.startswith("D1@") else 1 - candidates.append((priority, length_mm)) - if not candidates: - return None - candidates.sort(key=lambda item: (item[0], item[1])) - return candidates[0][1] - - -def _feature_selection_selectors( - feature: Dict[str, Any], - data_block: Optional[Dict[str, Any]] = None, -) -> list[Dict[str, Any]]: - selectors: list[Dict[str, Any]] = [] - seen: set[str] = set() - sources = [] - if isinstance(data_block, dict): - sources.extend(data_block.get("selections") or []) - sources.extend(feature.get("selections") or []) - - for selection in sources: - if not isinstance(selection, dict) or selection.get("kind") != "selection": - continue - geometry = selection.get("object") - if not isinstance(geometry, dict): - continue - kind = geometry.get("kind") - if kind not in ("edge", "face"): - continue - identity = geometry.get("identity") if isinstance(geometry.get("identity"), dict) else {} - stable_key = ( - geometry.get("stable_id") - or geometry.get("persistent_reference") - or identity.get("stable_id") - or identity.get("persistent_reference") - or json.dumps(geometry, sort_keys=True, ensure_ascii=False, default=str) - ) - if stable_key in seen: - continue - seen.add(str(stable_key)) - selectors.append({ - "kind": kind, - "geometry": geometry, - "mark": selection.get("mark"), - "source_feature": { - "name": selection.get("feature_name"), - "type_name": selection.get("feature_type_name"), - }, - }) - if selectors: - return selectors - - for face in feature.get("owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - cylinder_params = surface.get("cylinder_params") - if not (surface.get("is_cylinder") and isinstance(cylinder_params, list) and len(cylinder_params) >= 7): - continue - radius_mm = abs(float(cylinder_params[6]) * 1000) - line_params = [ - float(cylinder_params[0]), - float(cylinder_params[1]), - float(cylinder_params[2]), - float(cylinder_params[3]), - float(cylinder_params[4]), - float(cylinder_params[5]), - ] - stable_key = f"owned_cylinder:{','.join(f'{value:.9g}' for value in line_params)}:{radius_mm:.6g}" - if stable_key in seen: - continue - seen.add(stable_key) - selectors.append({ - "kind": "edge", - "geometry": { - "kind": "edge", - "curve": { - "kind": "curve", - "is_line": True, - "line_params": line_params, - }, - "bbox_mm": [float(value) * 1000 for value in face.get("box_m", [])[:6]] - if isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 - else None, - }, - "tolerance_mm": max(0.5, radius_mm * 2.5), - "source": "owned_cylindrical_face_axis", - }) - for face in feature.get("owned_faces") or []: - if not isinstance(face, dict): - continue - box_m = face.get("box_m") - if not (isinstance(box_m, list) and len(box_m) >= 6): - continue - bbox_mm = [float(value) * 1000 for value in box_m[:6]] - if any(not math.isfinite(value) for value in bbox_mm): - continue - sizes = [abs(bbox_mm[i + 3] - bbox_mm[i]) for i in range(3)] - stable_key = f"owned_face_bbox:{','.join(f'{value:.9g}' for value in bbox_mm)}" - if stable_key in seen: - continue - seen.add(stable_key) - selectors.append({ - "kind": "edge", - "geometry": { - "kind": "edge", - "bbox_mm": bbox_mm, - }, - "tolerance_mm": max(0.5, min(max(sizes), 10.0) * 0.35), - "source": "owned_face_bbox", - }) - return selectors - - -def _feature_selection_source( - feature: Dict[str, Any], - data_block: Optional[Dict[str, Any]] = None, -) -> str: - sources = [] - if isinstance(data_block, dict): - sources.extend(data_block.get("selections") or []) - sources.extend(feature.get("selections") or []) - if any(isinstance(item, dict) and item.get("kind") == "selection" for item in sources): - return "solidworks_original_selection" - if feature.get("owned_faces"): - return "post_feature_owned_face_inference" - return "missing" - - -def _hole_dimension_value_excluding( - data_block: Dict[str, Any], - tokens: tuple[str, ...], - excluded: tuple[str, ...] = (), -) -> Optional[float]: - for dim in data_block.get("dimensions", []) or []: - name = str(dim.get("name") or "").lower() - if excluded and any(token.lower() in name for token in excluded): - continue - if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): - return float(dim.get("value")) - return None - - -def _hole_primary_dimension_fallback(data_block: Dict[str, Any], prefer_small: bool) -> Optional[float]: - values = [] - for dim in data_block.get("dimensions", []) or []: - name = str(dim.get("name") or "").lower() - if not any(token in name for token in ("孔", "hole", "螺", "thread")): - continue - if any(token in name for token in ("沉头", "锥", "counter", "csk", "导头", "angle", "角度")): - continue - value = dim.get("value") - if value in (None, ""): - continue - number = abs(float(value)) - if 0 < number < 200: - values.append(number) - if not values: - return None - return min(values) if prefer_small else max(values) - - -def _hole_primary_diameter_mm(data_block: Dict[str, Any]) -> float: - diameter = ( - _hole_dimension_value_excluding(data_block, ("tap", "drill", "dia"), ("depth", "angle")) - or _hole_dimension_value_excluding(data_block, ("tap", "drill", "diameter"), ("depth", "angle")) - or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "直径"), ("深度", "角度")) - or _hole_dimension_value_excluding(data_block, ("钻头", "直径"), ("深度", "角度")) - or _hole_dimension_value_excluding(data_block, ("通孔", "孔直径"), ("沉头", "锥", "counter", "csk", "角度", "深度")) - or _hole_dimension_value_excluding(data_block, ("孔直径",), ("沉头", "锥", "counter", "csk", "角度", "深度")) - or _hole_dimension_value_excluding(data_block, ("hole", "diameter"), ("counter", "csk", "angle", "depth")) - or _hole_dimension_value_excluding(data_block, ("thread", "diameter"), ("counter", "csk", "angle", "depth")) - or _hole_dimension_value_excluding(data_block, ("螺纹",), ("深度", "depth", "角度", "angle")) - or _hole_primary_dimension_fallback(data_block, prefer_small=True) - ) - return abs(float(diameter)) if diameter else 0 - - -def _hole_primary_depth_mm(data_block: Dict[str, Any]) -> float: - depth = ( - _hole_dimension_value_excluding(data_block, ("通孔", "孔深度"), ("沉头", "锥", "counter", "csk", "角度", "直径")) - or _hole_dimension_value_excluding(data_block, ("孔深度",), ("沉头", "锥", "counter", "csk", "角度", "直径")) - or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "深度"), ("直径", "角度")) - or _hole_dimension_value_excluding(data_block, ("通孔", "螺纹孔钻头", "深度"), ("直径", "角度")) - or _hole_dimension_value_excluding(data_block, ("tap", "drill", "depth"), ("diameter", "angle")) - or _hole_dimension_value_excluding(data_block, ("hole", "depth"), ("counter", "csk", "angle", "diameter")) - or _hole_dimension_value_excluding(data_block, ("thread", "depth"), ("counter", "csk", "angle", "diameter")) - ) - if depth: - return abs(float(depth)) - return THROUGH_CUT_AMOUNT_MM - - -def _hole_counterbore_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: - return ( - _hole_dimension_value(data_block, ("柱形沉头", "直径")) - or _hole_dimension_value(data_block, ("柱形沉头孔", "直径")) - or _hole_dimension_value(data_block, ("沉头孔", "直径")) - or _hole_dimension_value(data_block, ("counterbore", "diameter")) - or _hole_dimension_value(data_block, ("counter", "bore", "diameter")) - ) - - -def _hole_counterbore_depth_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: - return ( - _hole_dimension_value(data_block, ("柱形沉头", "深度")) - or _hole_dimension_value(data_block, ("柱形沉头孔", "深度")) - or _hole_dimension_value(data_block, ("沉头孔", "深度")) - or _hole_dimension_value(data_block, ("counterbore", "depth")) - or _hole_dimension_value(data_block, ("counter", "bore", "depth")) - ) - - -def _hole_angle_dimension_rad(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: - for dim in data_block.get("dimensions", []) or []: - name = str(dim.get("name") or "").lower() - if all(token.lower() in name for token in tokens): - if dim.get("system_value_m") not in (None, ""): - return float(dim.get("system_value_m")) - if dim.get("value") not in (None, ""): - value = float(dim.get("value")) - return value / 1000 if value > math.tau else value - return None - - -def _extract_edge_selector_points(op: Dict[str, Any]) -> list[list[tuple[float, float, float]]]: - selector_points = [] - for selector in op.get("selectors", []): - geometry = selector.get("geometry") or {} - start = geometry.get("start_vertex") or {} - start_point = start.get("point_m") if isinstance(start, dict) else None - end = geometry.get("end_vertex") or {} - end_point = end.get("point_m") if isinstance(end, dict) else None - if start_point and end_point: - selector_points.append([_point_m_to_mm(start_point), _point_m_to_mm(end_point)]) - return selector_points - - -_SW_METADATA_FEATURE_TYPES = { - "commentsfolder", - "favoritefolder", - "historyfolder", - "selectionsetfolder", - "sensorfolder", - "docsfolder", - "detailcabinet", - "surfacebodyfolder", - "solidbodyfolder", - "envfolder", - "inkmarkupfolder", - "eqnfolder", - "materialfolder", - "configtablefolder", - "ftrfolder", -} - - -def _source_feature(feature: Dict[str, Any], index: int) -> Dict[str, Any]: - source = feature.get("source_feature") if isinstance(feature.get("source_feature"), dict) else {} - identity = source.get("identity") if isinstance(source.get("identity"), dict) else {} - return { - "index": source.get("index", index), - "id": feature.get("id"), - "name": feature.get("name"), - "type": feature.get("type"), - "type_name": feature.get("type_name"), - "stable_id": source.get("stable_id") or identity.get("stable_id"), - "persistent_reference": source.get("persistent_reference") or identity.get("persistent_reference"), - "identity": identity or None, - } - - -def _source_owned_faces(feature: Dict[str, Any]) -> list[Dict[str, Any]]: - faces = feature.get("owned_faces") - if not isinstance(faces, list): - return [] - summarized = [] - for face in faces: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - summarized.append( - { - "box_m": face.get("box_m"), - "area_m2": face.get("area_m2"), - "surface": { - "is_plane": bool(surface.get("is_plane")), - "is_cylinder": bool(surface.get("is_cylinder")), - "is_cone": bool(surface.get("is_cone")), - "is_sphere": bool(surface.get("is_sphere")), - "is_torus": bool(surface.get("is_torus")), - "cylinder_params": surface.get("cylinder_params"), - "cone_params": surface.get("cone_params"), - "plane_params": surface.get("plane_params"), - }, - } - ) - return summarized - - -def _convert_sw_reference(feature: Dict[str, Any], index: int) -> Dict[str, Any]: - snapshot = feature.get("definition_snapshot", {}) - return { - "id": feature.get("id") or f"reference_{index:03d}", - "name": feature.get("name"), - "type": feature.get("type"), - "sw_type": feature.get("type_name"), - "definition": snapshot.get("values", {}), - "source_feature": _source_feature(feature, index), - } - - -def _convert_sw_sketch(feature: Dict[str, Any], sketch_id: str, index: int) -> Dict[str, Any]: - sketch_data = feature.get("sketch_data", {}) - raw_entities = sketch_data.get("entities", []) - raw_converted_entities = [_convert_sw_sketch_entity(entity) for entity in raw_entities] - converted_entities = [] - raw_to_converted_index: dict[int, int] = {} - stable_id_to_raw_index: dict[str, int] = {} - for raw_index, (raw_entity, converted_entity) in enumerate(zip(raw_entities, raw_converted_entities)): - for stable_id in _selectable_stable_ids(raw_entity): - stable_id_to_raw_index.setdefault(stable_id, raw_index) - if converted_entity is None: - continue - raw_to_converted_index[raw_index] = len(converted_entities) - converted_entities.append(converted_entity) - loops = [] - for contour in sketch_data.get("sketch_contours", []) or sketch_data.get("contours", []) or []: - if not isinstance(contour, dict): - continue - entity_indices = contour.get("entity_indices") or contour.get("segment_indices") or [] - if not entity_indices: - entity_indices = _contour_entity_indices_from_segments(contour, stable_id_to_raw_index) - if not entity_indices: - continue - normalized_indices = [ - raw_to_converted_index[int(idx)] - for idx in entity_indices - if isinstance(idx, (int, float)) and int(idx) in raw_to_converted_index - ] - if not normalized_indices: - continue - bbox = _loop_bbox([ - converted_entities[idx] - for idx in normalized_indices - if 0 <= idx < len(converted_entities) - ]) - loops.append({ - "id": contour.get("contour_id"), - "entity_indices": normalized_indices, - "is_closed": contour.get("is_closed"), - "bbox_mm": bbox or contour.get("bbox_mm"), - "bbox_area_mm2": _bbox_area_2d(bbox) if bbox else contour.get("bbox_area_mm2"), - "source": "solidworks_sketch_contour", - }) - workplane = sketch_data.get("workplane") or {} - if not workplane: - workplane = {"name": sketch_data.get("plane"), "origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]} - return { - "id": sketch_id, - "name": feature.get("name", sketch_id), - "workplane": workplane, - "host_reference": sketch_data.get("host_reference"), - "entities": converted_entities, - "loops": loops, - "sketch_regions": sketch_data.get("sketch_regions", []), - "constraints": sketch_data.get("constraints", []), - "inferred_constraints": sketch_data.get("inferred_constraints", []), - "dimensions": sketch_data.get("dimensions", []), - "feature_dimensions": sketch_data.get("feature_dimensions", []), - "source_feature": _source_feature(feature, index), - } - - -def _selectable_stable_ids(value: Any) -> list[str]: - if not isinstance(value, dict): - return [] - candidates = [value.get("stable_id")] - identity = value.get("identity") - if isinstance(identity, dict): - candidates.append(identity.get("stable_id")) - return [str(candidate) for candidate in candidates if candidate] - - -def _contour_entity_indices_from_segments(contour: Dict[str, Any], stable_id_to_raw_index: dict[str, int]) -> list[int]: - indices: list[int] = [] - seen: set[int] = set() - for segment in contour.get("sketch_segments") or []: - for stable_id in _selectable_stable_ids(segment): - raw_index = stable_id_to_raw_index.get(stable_id) - if raw_index is None or raw_index in seen: - continue - seen.add(raw_index) - indices.append(raw_index) - break - return indices - - -def _convert_sw_sketch_entity(entity: Dict[str, Any]) -> Optional[Dict[str, Any]]: - entity_type = str(entity.get("canonical_entity_type") or entity.get("entity_type", "")).lower() - curve = entity.get("curve") if isinstance(entity.get("curve"), dict) else {} - if ( - entity_type == "circle_or_arc" - or curve.get("is_circle") is True - or entity.get("curve_entity_type") == "circle_or_arc" - ): - center = entity.get("curve_center_mm") or entity.get("center_mm") - radius_mm_value = entity.get("curve_radius_mm") or entity.get("radius_mm") - radius_raw_value = entity.get("radius") - start = entity.get("start_mm") - end = entity.get("end_mm") - start_2d = [float(start[0]), float(start[1])] if isinstance(start, list) and len(start) >= 2 else None - end_2d = [float(end[0]), float(end[1])] if isinstance(end, list) and len(end) >= 2 else None - center_2d = [float(center[0]), float(center[1])] if isinstance(center, list) and len(center) >= 2 else [0.0, 0.0] - radius_mm = float(radius_mm_value) if radius_mm_value is not None else _scale_length(radius_raw_value or 0) - if start_2d and end_2d and math.hypot(start_2d[0] - end_2d[0], start_2d[1] - end_2d[1]) > 1e-6: - # 计算 sweep 方向 - import math as _math - sa = _math.degrees(_math.atan2(start_2d[1] - center_2d[1], start_2d[0] - center_2d[0])) - ea = _math.degrees(_math.atan2(end_2d[1] - center_2d[1], end_2d[0] - center_2d[0])) - sweep = round(ea - sa, 10) - while sweep <= -180: - sweep += 360 - while sweep > 180: - sweep -= 360 - result = { - "type": "arc", - "center": center_2d, - "start": start_2d, - "end": end_2d, - "radius_mm": radius_mm, - "start_angle_deg": round(sa, 10), - "end_angle_deg": round(ea, 10), - "arc_sweep_deg": round(sweep, 10), - "construction": bool(entity.get("construction")), - "raw": entity, - } - curve_axis = entity.get("curve_axis") - if isinstance(curve_axis, list) and len(curve_axis) >= 3: - result["curve_axis"] = [float(v) for v in curve_axis[:3]] - return result - return { - "type": "circle", - "center": center_2d, - "radius_mm": radius_mm, - "construction": bool(entity.get("construction")), - "raw": entity, - } - if "line" in entity_type: - return { - "type": "line", - "start": _sketch_point_mm(entity, "start"), - "end": _sketch_point_mm(entity, "end"), - "construction": bool(entity.get("construction")), - "raw": entity, - } - if "circle" in entity_type: - return { - "type": "circle", - "center": _sketch_point_mm(entity, "center"), - "radius_mm": _sketch_radius_mm(entity), - "construction": bool(entity.get("construction")), - "raw": entity, - } - if "arc" in entity_type: - return { - "type": "arc", - "center": _sketch_point_mm(entity, "center"), - "start": _sketch_point_mm(entity, "start"), - "end": _sketch_point_mm(entity, "end"), - "radius_mm": _sketch_radius_mm(entity), - "start_angle_deg": _to_degrees(entity.get("start_angle", 0)), - "end_angle_deg": _to_degrees(entity.get("end_angle", 360)), - "construction": bool(entity.get("construction")), - "raw": entity, - } - if entity_type == "point": - point = entity.get("point_mm") or [float(entity.get("x", 0)) * 1000, float(entity.get("y", 0)) * 1000, 0] - return {"type": "point", "point": point[:2], "point_mm": point, "construction": bool(entity.get("construction")), "raw": entity} - return None - - -def _sketch_point_mm(entity: Dict[str, Any], key: str) -> list[float]: - point = entity.get(f"{key}_mm") - if isinstance(point, list) and len(point) >= 2: - return [float(point[0]), float(point[1])] - return _scale_point(entity.get(key, [0, 0])) - - -def _sketch_radius_mm(entity: Dict[str, Any]) -> float: - for key in ("radius_mm", "major_radius_mm", "major_radius"): - if entity.get(key) is not None: - return _scale_length(entity.get(key)) - if entity.get("radius") is not None: - return _scale_length(entity.get("radius")) - start = _sketch_point_mm(entity, "start") - center = _sketch_point_mm(entity, "center") - if start and center: - return math.hypot(float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) - return 1.0 - - -def _convert_sw_extrude(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: - data_block = feature.get("extrude_data", {}) - op_type = "extrude_cut" if _is_cut_feature(feature, type_name) else "extrude_add" - distance = _best_extrude_depth_mm(feature, data_block) - reverse_end_condition_code = data_block.get("reverse_end_condition_code") - reverse_distance = abs(data_block.get("reverse_depth") or 0) - both_directions = bool(data_block.get("both_directions", False)) - reverse_direction = data_block.get("is_reverse") - if reverse_direction is None: - reverse_direction = data_block.get("definition_snapshot", {}).get("ReverseDirection") - if reverse_direction is None: - reverse_direction = feature.get("definition_snapshot", {}).get("values", {}).get("ReverseDirection", False) - if reverse_end_condition_code in (None, 0) and data_block.get("effective_depth_source") == "feature_dimension": - spans_both_sides = _extrude_owned_faces_span_sketch_plane(feature, data_block) - if spans_both_sides and bool(reverse_direction): - both_directions = True - reverse_distance = reverse_distance or distance - else: - both_directions = False - reverse_distance = 0 - raw_depth = abs(data_block.get("depth") or data_block.get("blind_depth") or 0) - uses_reverse_depth_only = ( - op_type == "extrude_cut" - and - feature.get("type") == "ice" - and raw_depth <= 1e-9 - and reverse_distance > 0 - ) - if uses_reverse_depth_only: - reverse_direction = not bool(reverse_direction) if False else bool(reverse_direction) - return { - "id": feature.get("id"), - "name": feature.get("name"), - "type": op_type, - "sketch": sketch_id, - "parameters": { - "distance_mm": distance, - "reverse": bool(reverse_direction), - "reverse_direction": bool(reverse_direction), - "reverse_distance_mm": reverse_distance, - "both_directions": False if uses_reverse_depth_only else both_directions, - "end_condition": data_block.get("end_condition"), - "end_condition_code": data_block.get("end_condition_code"), - "reverse_end_condition_code": reverse_end_condition_code, - "flip_side_to_cut": bool(data_block.get("flip_side_to_cut", False)), - "start_condition_reference": _clean_null_reference(data_block.get("start_condition_reference")), - "end_condition_reference": _clean_null_reference(data_block.get("end_condition_reference")), - "reverse_end_condition_reference": _clean_null_reference(data_block.get("reverse_end_condition_reference")), - "draft_angle_rad": data_block.get("draft_angle_rad"), - "reverse_draft_angle_rad": data_block.get("reverse_draft_angle_rad"), - }, - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - } - - -def _extrude_owned_faces_span_sketch_plane(feature: Dict[str, Any], data_block: Dict[str, Any]) -> bool: - sketches = data_block.get("source_sketches") or [] - workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None - if not isinstance(workplane, dict): - return bool(data_block.get("both_directions")) and (data_block.get("reverse_depth") not in (None, 0)) - - origin = workplane.get("origin_mm") or [0, 0, 0] - normal = workplane.get("normal") or [0, 0, 1] - if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: - return False - - nx, ny, nz = (float(normal[0]), float(normal[1]), float(normal[2])) - length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0 - nx, ny, nz = nx / length, ny / length, nz / length - ox, oy, oz = float(origin[0]), float(origin[1]), float(origin[2]) - - min_distance = math.inf - max_distance = -math.inf - for face in feature.get("owned_faces") or []: - box = face.get("box_m") if isinstance(face, dict) else None - if not isinstance(box, list) or len(box) < 6: - continue - xs = [float(box[0]) * 1000, float(box[3]) * 1000] - ys = [float(box[1]) * 1000, float(box[4]) * 1000] - zs = [float(box[2]) * 1000, float(box[5]) * 1000] - for x in xs: - for y in ys: - for z in zs: - distance_to_plane = (x - ox) * nx + (y - oy) * ny + (z - oz) * nz - min_distance = min(min_distance, distance_to_plane) - max_distance = max(max_distance, distance_to_plane) - - if math.isinf(min_distance) or math.isinf(max_distance): - return False - tolerance = 1e-4 - return min_distance < -tolerance and max_distance > tolerance - - -def _convert_sw_revolve(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: - data_block = feature.get("revolve_data", {}) - op_type = "revolve_cut" if _is_cut_feature(feature, type_name) else "revolve_add" - selected_axis = _axis_reference_from_feature_selections(data_block.get("selections")) - owned_face_axis = _axis_reference_from_owned_faces(feature) - extracted_axis = _extract_axis_reference(data_block.get("axis_reference")) - axis_reference = selected_axis or owned_face_axis - if not axis_reference and not _is_weak_inferred_axis(extracted_axis): - axis_reference = extracted_axis - return { - "id": feature.get("id"), - "name": feature.get("name"), - "type": op_type, - "sketch": sketch_id, - "parameters": { - "angle_deg": abs(data_block.get("angle") or 360), - "angle_rad": data_block.get("angle_rad"), - "reverse": data_block.get("is_reverse", False), - "end_condition": data_block.get("end_condition"), - "end_condition_code": data_block.get("end_condition_code"), - "axis_reference": axis_reference, - "axis_candidates": data_block.get("axis_candidates", []), - }, - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - } - - -def _axis_reference_from_owned_faces(feature: Dict[str, Any]) -> Optional[Dict[str, Any]]: - candidates: list[tuple[float, Dict[str, Any]]] = [] - for face in feature.get("owned_faces") or []: - if not isinstance(face, dict): - continue - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - params = None - if surface.get("is_cylinder") and isinstance(surface.get("cylinder_params"), list): - params = surface.get("cylinder_params") - elif surface.get("is_cone") and isinstance(surface.get("cone_params"), list): - params = surface.get("cone_params") - if not isinstance(params, list) or len(params) < 6: - continue - direction = [float(value) for value in params[3:6]] - norm = math.sqrt(sum(value * value for value in direction)) - if norm <= 1e-9: - continue - candidates.append(( - float(face.get("area_m2") or 0.0), - { - "origin_mm": [float(value) * 1000 for value in params[:3]], - "direction": [value / norm for value in direction], - "source": "owned_face_axis", - }, - )) - if not candidates: - return None - candidates.sort(key=lambda item: item[0], reverse=True) - return candidates[0][1] - - -def _is_weak_inferred_axis(axis_reference: Optional[Dict[str, Any]]) -> bool: - if not isinstance(axis_reference, dict): - return False - return str(axis_reference.get("source") or "") in {"construction_line_candidate", "construction_line"} - - -def _convert_sw_hole(feature: Dict[str, Any], index: int) -> Dict[str, Any]: - data_block = feature.get("hole_data", {}) - positions = [] - host_face = _host_face_from_feature_selections(data_block.get("selections")) or {} - position_sketches = _hole_position_sketches(data_block.get("position_sketches", []) or []) - for sketch in position_sketches: - workplane = sketch.get("workplane") or {} - if not host_face and workplane: - host_face = _host_face_from_workplane(workplane) - for point in _hole_position_points(sketch): - positions.append({"mm": [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)]}) - diameter_mm = abs(data_block.get("diameter") or 0) or _hole_primary_diameter_mm(data_block) - depth_mm = abs(data_block.get("depth") or 0) or _hole_primary_depth_mm(data_block) - return { - "id": feature.get("id"), - "name": feature.get("name"), - "type": "hole", - "parameters": { - "diameter_mm": diameter_mm, - "depth_mm": depth_mm, - "counterbore_diameter_mm": _hole_counterbore_dimension_mm(data_block), - "counterbore_depth_mm": _hole_counterbore_depth_dimension_mm(data_block), - "countersink_diameter_mm": _hole_dimension_value(data_block, ("锥形沉头", "直径")) - or _hole_dimension_value(data_block, ("近端锥形沉头", "直径")) - or _hole_dimension_value(data_block, ("锥坑", "直径")) - or _hole_dimension_value(data_block, ("countersink", "diameter")) - or _hole_dimension_value(data_block, ("csk", "diameter")), - "angles_rad": { - "countersink_angle": _hole_angle_dimension_rad(data_block, ("锥形沉头", "角度")) - or _hole_angle_dimension_rad(data_block, ("近端锥形沉头", "角度")) - or _hole_angle_dimension_rad(data_block, ("锥坑", "角度")) - or _hole_angle_dimension_rad(data_block, ("countersink", "angle")) - or _hole_angle_dimension_rad(data_block, ("csk", "angle")), - "drill_angle": _hole_angle_dimension_rad(data_block, ("导头", "角度")) - or _hole_angle_dimension_rad(data_block, ("drill", "angle")) - or _hole_angle_dimension_rad(data_block, ("tip", "angle")), - }, - "positions": positions, - "host_face": host_face, - "hole_type": data_block.get("hole_type"), - "standard": data_block.get("standard"), - "size": data_block.get("size"), - "dimension_names": [ - str(dim.get("name") or "") - for dim in data_block.get("dimensions", []) or [] - if isinstance(dim, dict) - ], - }, - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - } - - -def _hole_position_sketches(sketches: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - point_only = [] - for sketch in sketches: - entities = sketch.get("entities") or [] - if not entities: - continue - if _is_hole_profile_sketch(sketch): - continue - point_count = sum(1 for entity in entities if _is_sketch_point_entity(entity)) - drawable_segment_count = sum( - 1 - for entity in entities - if not _is_sketch_point_entity(entity) and not entity.get("construction") - ) - if point_count > 0 and drawable_segment_count == 0: - point_only.append(sketch) - return point_only or sketches[:1] - - -def _is_hole_profile_sketch(sketch: Dict[str, Any]) -> bool: - tokens = ( - "孔直径", - "孔深度", - "沉头", - "导头", - "螺纹孔钻头", - "tap drill", - "drill", - "counterbore", - "countersink", - "hole diameter", - "hole depth", - ) - dimension_sources = [] - dimension_sources.extend(sketch.get("dimensions") or []) - dimension_sources.extend(sketch.get("feature_dimensions") or []) - for dim in dimension_sources: - if not isinstance(dim, dict): - continue - name = str(dim.get("name") or "").lower() - if any(token in name for token in tokens): - return True - return False - - -def _is_sketch_point_entity(entity: Dict[str, Any]) -> bool: - entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() - return entity_type == "point" - - -def _hole_position_entity_flags(entity: Dict[str, Any]) -> tuple[Optional[bool], bool]: - raw = entity.get("raw") if isinstance(entity.get("raw"), dict) else entity - candidate = raw.get("hole_position_candidate") - if candidate is None: - candidate = entity.get("hole_position_candidate") - if isinstance(candidate, bool): - candidate_flag: Optional[bool] = candidate - else: - candidate_flag = None - construction_reference = bool( - raw.get("construction_endpoint_reference") or entity.get("construction_endpoint_reference") - ) - return candidate_flag, construction_reference - - -def _construction_endpoint_degrees(sketch: Dict[str, Any]) -> dict[tuple[float, float, float], int]: - degrees: dict[tuple[float, float, float], int] = {} - for entity in sketch.get("entities") or []: - if not entity.get("construction"): - continue - entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() - if "line" not in entity_type: - continue - for key in ("start_mm", "end_mm"): - endpoint = entity.get(key) - if isinstance(endpoint, list) and len(endpoint) >= 2: - point_key = _rounded_point_key(endpoint) - degrees[point_key] = degrees.get(point_key, 0) + 1 - return degrees - - -def _hole_position_points(sketch: Dict[str, Any]) -> list[list[float]]: - """Return only real Hole Wizard placement points from a position sketch. - - SolidWorks Hole Wizard position sketches often include construction - segments whose endpoints are reference geometry, not hole centers. Older - parser JSON exposes those endpoints as ordinary sketch points, so we filter - them generically here instead of letting every point become a hole. - """ - entities = sketch.get("entities") or [] - point_entities: list[tuple[list[float], Optional[bool], bool]] = [] - construction_endpoints: set[tuple[float, float, float]] = set() - - for entity in entities: - point = entity.get("point_mm") - if _is_sketch_point_entity(entity) and isinstance(point, list) and len(point) >= 2: - candidate_flag, construction_reference = _hole_position_entity_flags(entity) - point_entities.append( - ( - [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)], - candidate_flag, - construction_reference, - ) - ) - continue - if not entity.get("construction"): - continue - entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() - if "line" not in entity_type: - continue - for key in ("start_mm", "end_mm"): - endpoint = entity.get(key) - if isinstance(endpoint, list) and len(endpoint) >= 2: - construction_endpoints.add(_rounded_point_key(endpoint)) - - if not point_entities: - return [] - - explicit_candidates = [ - point for point, candidate_flag, _ in point_entities if candidate_flag is True - ] - if explicit_candidates: - return _dedupe_points(explicit_candidates) - - endpoint_degrees = _construction_endpoint_degrees(sketch) - if endpoint_degrees: - filtered = [] - for point, candidate_flag, construction_reference in point_entities: - point_key = _rounded_point_key(point) - degree = endpoint_degrees.get(point_key, 0) - if candidate_flag is False and construction_reference and degree <= 1: - continue - if degree >= 2 or not construction_reference: - filtered.append(point) - filtered = _dedupe_points(filtered) - non_origin_filtered = [point for point in filtered if not _is_near_origin(point)] - if non_origin_filtered: - return _dedupe_points(non_origin_filtered) - if filtered: - return filtered - - raw_points = _dedupe_points([point for point, _, _ in point_entities]) - if not raw_points or not construction_endpoints: - return raw_points - - legacy_filtered = [point for point in raw_points if _rounded_point_key(point) not in construction_endpoints] - non_origin_raw = [point for point in raw_points if not _is_near_origin(point)] - non_origin_filtered = [point for point in legacy_filtered if not _is_near_origin(point)] - if non_origin_filtered: - return _dedupe_points(non_origin_filtered) - if non_origin_raw: - return _dedupe_points(non_origin_raw) - return _dedupe_points(legacy_filtered or raw_points) - - -def _dedupe_points(points: list[list[float]]) -> list[list[float]]: - result = [] - seen = set() - for point in points: - key = _rounded_point_key(point) - if key in seen: - continue - seen.add(key) - result.append(point) - return result - - -def _is_near_origin(point: list[float], tolerance: float = 1e-6) -> bool: - return math.sqrt(sum(float(component) * float(component) for component in point[:3])) <= tolerance - - -def _rounded_point_key(point: list[Any], digits: int = 5) -> tuple[float, float, float]: - z = point[2] if len(point) > 2 else 0 - return (round(float(point[0]), digits), round(float(point[1]), digits), round(float(z), digits)) - - -def _convert_sw_linear_pattern( - feature: Dict[str, Any], - index: int, - previous_build_op: Optional[Dict[str, Any]], - source_frame: Optional[Dict[str, Any]] = None, - sketches: Optional[list[Dict[str, Any]]] = None, - source_bbox: Optional[list[float]] = None, -) -> Dict[str, Any]: - data_block = feature.get("linear_pattern_data", {}) - source_features = data_block.get("source_features") or [] - if not source_features and previous_build_op: - source_features = [previous_build_op.get("source_feature", {})] - spacing_1 = data_block.get("spacing_1") - spacing_2 = data_block.get("spacing_2") - direction_1 = _pattern_direction_from_plugin(data_block.get("direction_1"), axis="x", source_frame=source_frame) - direction_2 = _pattern_direction_from_plugin(data_block.get("direction_2"), axis="y", source_frame=source_frame) - direction_1 = _pattern_direction_from_reference(direction_1, data_block.get("direction_1_reference"), source_frame) - direction_2 = _pattern_direction_from_reference(direction_2, data_block.get("direction_2_reference"), source_frame) - if data_block.get("direction_1_reverse") is True: - direction_1 = _reverse_pattern_direction(direction_1) - if data_block.get("direction_2_reverse") is True: - direction_2 = _reverse_pattern_direction(direction_2) - source_op_bbox = _operation_profile_bbox(previous_build_op, sketches or []) - if data_block.get("direction_1") is None: - direction_1 = _choose_pattern_direction_sign( - direction_1, - spacing_1 or 0, - int(data_block.get("pattern_count_1") or 1), - source_op_bbox, - source_bbox, - ) - if data_block.get("direction_2") is None: - direction_2 = _choose_pattern_direction_sign( - direction_2, - spacing_2 or 0, - int(data_block.get("pattern_count_2") or 1), - source_op_bbox, - source_bbox, - ) - explicit_offsets = _owned_face_pattern_offsets(previous_build_op, feature) - return { - "id": feature.get("id"), - "name": feature.get("name"), - "type": "linear_pattern", - "parameters": { - "source_features": source_features, - "total_instances": data_block.get("pattern_count_1") or 1, - "spacing_mm": spacing_1 or 0, - "direction1": direction_1, - "direction2": direction_2, - }, - "raw_parameters": { - "d1_total_instances": data_block.get("pattern_count_1") or 1, - "d2_total_instances": data_block.get("pattern_count_2") or 1, - "d1_spacing_mm": spacing_1 or 0, - "d2_spacing_mm": spacing_2 or 0, - "direction1": direction_1, - "direction2": direction_2, - "explicit_offsets_mm": explicit_offsets, - }, - "source_feature": _source_feature(feature, index), - "source_owned_faces": _source_owned_faces(feature), - } - - -def _owned_face_pattern_offsets( - source_op: Optional[Dict[str, Any]], - pattern_feature: Dict[str, Any], -) -> list[list[float]]: - if not source_op: - return [] - source_faces = _owned_face_signatures(source_op.get("source_owned_faces") or []) - pattern_faces = _owned_face_signatures(_source_owned_faces(pattern_feature)) - if not source_faces or not pattern_faces: - return [] - - votes: Dict[tuple[float, float, float], int] = {} - for pattern_face in pattern_faces: - for source_face in source_faces: - if pattern_face["kind"] != source_face["kind"]: - continue - if not _similar_bbox_size(pattern_face["size"], source_face["size"]): - continue - offset = tuple( - round(pattern_face["center"][axis] - source_face["center"][axis], 3) - for axis in range(3) - ) - if math.sqrt(sum(component * component for component in offset)) < 1e-6: - continue - votes[offset] = votes.get(offset, 0) + 1 - - if not votes: - return [] - threshold = max(1, min(2, len(source_faces))) - offsets = [offset for offset, count in votes.items() if count >= threshold] - offsets.sort(key=lambda offset: (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2], offset)) - return [[float(value) for value in offset] for offset in offsets] - - -def _owned_face_signatures(faces: list[Dict[str, Any]]) -> list[Dict[str, Any]]: - signatures = [] - for face in faces: - if not isinstance(face, dict): - continue - box = face.get("box_m") - if not isinstance(box, list) or len(box) < 6: - continue - box_mm = [float(value) * 1000 for value in box[:6]] - surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} - kind = "other" - if surface.get("is_cylinder"): - kind = "cylinder" - elif surface.get("is_cone"): - kind = "cone" - elif surface.get("is_plane"): - kind = "plane" - signatures.append( - { - "kind": kind, - "center": [(box_mm[i] + box_mm[i + 3]) / 2 for i in range(3)], - "size": [abs(box_mm[i + 3] - box_mm[i]) for i in range(3)], - } - ) - return signatures - - -def _similar_bbox_size(a: list[float], b: list[float], tolerance: float = 0.05) -> bool: - return all(abs(float(a[i]) - float(b[i])) <= tolerance for i in range(3)) - - -def _source_pattern_frame( - previous_build_op: Optional[Dict[str, Any]], - sketches: list[Dict[str, Any]], -) -> Optional[Dict[str, Any]]: - if not previous_build_op: - return None - params = previous_build_op.get("parameters") or {} - host_frame = ((params.get("host_face") or {}).get("frame") or {}) - if host_frame.get("x_dir") and host_frame.get("y_dir"): - return host_frame - sketch_id = previous_build_op.get("sketch") - for sketch in sketches: - if sketch.get("id") == sketch_id: - workplane = sketch.get("workplane") or {} - if workplane.get("x_dir") and workplane.get("y_dir"): - return workplane - return None - - -def _source_bbox_from_plugin_json(data: Dict[str, Any]) -> Optional[list[float]]: - bbox = (data.get("validation_hints") or {}).get("part_box_m") - if isinstance(bbox, list) and len(bbox) >= 6: - return [float(v) * 1000 for v in bbox[:6]] - return None - - -def _operation_profile_bbox( - op: Optional[Dict[str, Any]], - sketches: list[Dict[str, Any]], -) -> Optional[list[float]]: - if not op: - return None - if op.get("type") == "hole": - host_face = (op.get("parameters") or {}).get("host_face") or {} - positions = [ - _hole_position_to_model(pos.get("mm"), host_face) - for pos in (op.get("parameters") or {}).get("positions", []) - if isinstance(pos.get("mm"), list) and len(pos.get("mm")) >= 3 - ] - if positions: - return _points_bbox(positions) - sketch_id = op.get("sketch") - sketch = next((item for item in sketches if item.get("id") == sketch_id), None) - if not sketch: - return None - points = [] - workplane = sketch.get("workplane") or {} - origin = workplane.get("origin_mm") or [0, 0, 0] - x_dir = workplane.get("x_dir") or [1, 0, 0] - y_dir = workplane.get("y_dir") or [0, 1, 0] - for entity in sketch.get("entities", []) or []: - if entity.get("type") == "circle": - center = entity.get("center") or [0, 0] - radius = float(entity.get("radius_mm") or 0) - for dx, dy in ((-radius, -radius), (-radius, radius), (radius, -radius), (radius, radius)): - points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, [float(center[0]) + dx, float(center[1]) + dy])) - for key in ("start", "end", "center", "point"): - point = entity.get(key) - if isinstance(point, list) and len(point) >= 2: - points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, point)) - return _points_bbox(points) - - -def _sketch_point_to_model_bbox(origin: list[Any], x_dir: list[Any], y_dir: list[Any], point: list[Any]) -> list[float]: - return [ - float(origin[i]) + float(x_dir[i]) * float(point[0]) + float(y_dir[i]) * float(point[1]) - for i in range(3) - ] - - -def _points_bbox(points: list[list[float]]) -> Optional[list[float]]: - if not points: - return None - return [ - min(point[0] for point in points), - min(point[1] for point in points), - min(point[2] for point in points), - max(point[0] for point in points), - max(point[1] for point in points), - max(point[2] for point in points), - ] - - -def _hole_position_to_model(point: list[Any], host_face: Dict[str, Any]) -> list[float]: - frame = host_face.get("frame") if isinstance(host_face, dict) else {} - if not isinstance(frame, dict): - return [float(v) for v in (point + [0, 0, 0])[:3]] - origin = frame.get("origin_mm") or [0, 0, 0] - x_dir = frame.get("x_dir") or [1, 0, 0] - y_dir = frame.get("y_dir") or [0, 1, 0] - values = [float(v) for v in (point + [0, 0, 0])[:3]] - return [ - float(origin[i]) + float(x_dir[i]) * values[0] + float(y_dir[i]) * values[1] - for i in range(3) - ] - - -def _choose_pattern_direction_sign( - direction: Dict[str, Any], - spacing: float, - count: int, - source_op_bbox: Optional[list[float]], - source_bbox: Optional[list[float]], -) -> Dict[str, Any]: - vector = direction.get("vector") - if ( - not isinstance(vector, list) - or len(vector) < 3 - or not spacing - or count <= 1 - or not source_op_bbox - or not source_bbox - ): - return direction - unit = _unit3(vector) - distance = float(spacing) * (count - 1) - positive = [component * distance for component in unit] - negative = [-component * distance for component in unit] - positive_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, positive), source_bbox) - negative_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, negative), source_bbox) - if abs(positive_score - negative_score) <= 1e-9: - positive_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, positive), source_bbox) - negative_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, negative), source_bbox) - copied = dict(direction) - if negative_score + 1e-9 < positive_score: - copied["vector"] = [-component for component in unit] - copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" - return copied - copied["vector"] = unit - if positive_score + 1e-9 < negative_score: - copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" - return copied - - -def _unit3(vector: list[Any]) -> list[float]: - raw = [float(vector[i]) for i in range(3)] - length = math.sqrt(sum(v * v for v in raw)) - if length <= 0: - return [0.0, 0.0, 0.0] - return [v / length for v in raw] - - -def _translated_bbox(bbox: list[float], offset: list[float]) -> list[float]: - return [ - bbox[0] + offset[0], - bbox[1] + offset[1], - bbox[2] + offset[2], - bbox[3] + offset[0], - bbox[4] + offset[1], - bbox[5] + offset[2], - ] - - -def _bbox_overflow_score(candidate: list[float], source: list[float]) -> float: - score = 0.0 - for axis in range(3): - score += max(source[axis] - candidate[axis], 0) - score += max(candidate[axis + 3] - source[axis + 3], 0) - return score - - -def _bbox_center_distance_score(candidate: list[float], source: list[float]) -> float: - score = 0.0 - for axis in range(3): - source_center = (source[axis] + source[axis + 3]) / 2 - candidate_center = (candidate[axis] + candidate[axis + 3]) / 2 - axis_size = max(source[axis + 3] - source[axis], 1.0) - score += abs(candidate_center - source_center) / axis_size - return score - - -def _is_cut_feature(feature: Dict[str, Any], type_name: str) -> bool: - text = f"{type_name} {feature.get('name', '')}".lower() - return "cut" in text or "切除" in text or "revcut" in text - - -def _best_extrude_depth_mm(feature: Dict[str, Any], data_block: Dict[str, Any]) -> float: - for key in ("depth", "blind_depth"): - value = data_block.get(key) - if value: - return abs(float(value)) - owned_face_depth = _extrude_depth_from_owned_faces(feature, data_block) - effective_depth = abs(float(data_block.get("effective_depth") or 0)) - if ( - owned_face_depth - and _is_cut_feature(feature, str(feature.get("type_name") or feature.get("type") or "")) - and data_block.get("effective_depth_source") == "feature_dimension" - and not data_block.get("depth") - and not data_block.get("blind_depth") - and not data_block.get("reverse_depth") - and effective_depth > owned_face_depth * 2 - ): - return owned_face_depth - owner_name = feature.get("name") - for dim in data_block.get("dimensions", []) or []: - name = dim.get("name") or "" - if owner_name and f"@{owner_name}@" in name and dim.get("value") not in (None, 0): - return abs(float(dim.get("value"))) - for dim in data_block.get("dimensions", []) or []: - if dim.get("owner") == owner_name and dim.get("value") not in (None, 0): - return abs(float(dim.get("value"))) - if data_block.get("reverse_depth") not in (None, 0): - return abs(float(data_block.get("reverse_depth"))) - if data_block.get("effective_depth") not in (None, 0): - return abs(float(data_block.get("effective_depth"))) - return 0.0 - - -def _extrude_depth_from_owned_faces(feature: Dict[str, Any], data_block: Dict[str, Any]) -> Optional[float]: - sketches = data_block.get("source_sketches") or [] - workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None - if not isinstance(workplane, dict): - return None - normal = workplane.get("normal") or [0, 0, 1] - if not isinstance(normal, list) or len(normal) < 3: - return None - axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) - values: list[float] = [] - for face in feature.get("owned_faces") or []: - if not isinstance(face, dict): - continue - box = face.get("box_m") - if isinstance(box, list) and len(box) >= 6: - values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) - if not values: - return None - extent = max(values) - min(values) - return abs(extent) if extent > 1e-6 else None - - -def _host_face_from_workplane(workplane: Dict[str, Any]) -> Dict[str, Any]: - origin = workplane.get("origin_mm") or [0, 0, 0] - normal = workplane.get("normal") or [0, 0, 1] - x_dir = workplane.get("x_dir") or [1, 0, 0] - y_dir = workplane.get("y_dir") or [0, 1, 0] - return { - "surface": {"plane_params": [*normal[:3], *(float(v) / 1000 for v in origin[:3])]}, - "frame": {"origin_mm": origin[:3], "x_dir": x_dir[:3], "y_dir": y_dir[:3], "normal": normal[:3]}, - } - - -def _pattern_direction_from_plugin( - direction: Any, - axis: str, - source_frame: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - if isinstance(direction, dict): - return direction - if source_frame: - key = "y_dir" if axis == "y" else "x_dir" - vector = source_frame.get(key) - if isinstance(vector, list) and len(vector) >= 3: - return {"vector": vector[:3], "source": f"source_feature_frame_{key}"} - if axis == "y": - return {"vector": [0, 1, 0], "source": "default_y_when_plugin_direction_missing"} - return {"vector": [1, 0, 0], "source": "default_x_when_plugin_direction_missing"} - - -def _pattern_direction_from_reference( - fallback: Dict[str, Any], - reference: Any, - source_frame: Optional[Dict[str, Any]] = None, -) -> Dict[str, Any]: - axis = _extract_axis_reference(reference) - if not axis: - return fallback - vector = axis.get("direction") - if not isinstance(vector, list) or len(vector) < 3: - return fallback - model_vector = _sketch_vector_to_model(vector[:3], source_frame) or vector[:3] - model_origin = _sketch_point_to_model(axis.get("origin_mm"), source_frame) or axis.get("origin_mm") - return { - "vector": _unit3(model_vector), - "origin_mm": model_origin, - "source": axis.get("source") or "direction_reference", - } - - -def _sketch_vector_to_model( - vector: list[Any], - source_frame: Optional[Dict[str, Any]], -) -> Optional[list[float]]: - if not source_frame: - return None - x_dir = source_frame.get("x_dir") - y_dir = source_frame.get("y_dir") - normal = source_frame.get("normal") - if not ( - isinstance(x_dir, list) - and len(x_dir) >= 3 - and isinstance(y_dir, list) - and len(y_dir) >= 3 - ): - return None - if not (isinstance(normal, list) and len(normal) >= 3): - normal = [ - float(x_dir[1]) * float(y_dir[2]) - float(x_dir[2]) * float(y_dir[1]), - float(x_dir[2]) * float(y_dir[0]) - float(x_dir[0]) * float(y_dir[2]), - float(x_dir[0]) * float(y_dir[1]) - float(x_dir[1]) * float(y_dir[0]), - ] - values = [float(v) for v in (vector + [0, 0, 0])[:3]] - return [ - values[0] * float(x_dir[i]) + values[1] * float(y_dir[i]) + values[2] * float(normal[i]) - for i in range(3) - ] - - -def _sketch_point_to_model( - point: Any, - source_frame: Optional[Dict[str, Any]], -) -> Optional[list[float]]: - if not isinstance(point, list) or len(point) < 3 or not source_frame: - return None - origin = source_frame.get("origin_mm") - vector = _sketch_vector_to_model(point[:3], source_frame) - if not (isinstance(origin, list) and len(origin) >= 3 and vector): - return None - return [float(origin[i]) + vector[i] for i in range(3)] - - -def _reverse_pattern_direction(direction: Dict[str, Any]) -> Dict[str, Any]: - vector = direction.get("vector") - if not isinstance(vector, list) or len(vector) < 3: - return direction - copied = dict(direction) - copied["vector"] = [-float(vector[0]), -float(vector[1]), -float(vector[2])] - copied["source"] = f"{direction.get('source', 'direction')}_reversed" - return copied - - -def _clean_null_reference(reference: Any) -> Optional[Dict[str, Any]]: - if not isinstance(reference, dict): - return None - if reference.get("kind") == "null": - return None - obj = reference.get("object") - if isinstance(obj, dict) and obj.get("kind") == "null": - return None - return reference - - -def _extract_axis_reference(reference: Any) -> Optional[Dict[str, Any]]: - if not isinstance(reference, dict): - return None - if reference.get("origin_mm") and reference.get("direction"): - return { - "origin_mm": [float(v) for v in reference.get("origin_mm", [])[:3]], - "direction": [float(v) for v in reference.get("direction", [])[:3]], - "source": reference.get("source") or "axis_reference", - } - obj = reference.get("object") if isinstance(reference.get("object"), dict) else reference - if obj.get("kind") == "null": - return None - - line_params = obj.get("line_params") - if isinstance(line_params, list) and len(line_params) >= 6: - return { - "origin_mm": [float(v) * 1000 for v in line_params[:3]], - "direction": [float(v) for v in line_params[3:6]], - "source": reference.get("source") or "selection_line_params", - } - - curve = obj.get("curve") if isinstance(obj.get("curve"), dict) else {} - curve_line_params = curve.get("line_params") - if isinstance(curve_line_params, list) and len(curve_line_params) >= 6: - return { - "origin_mm": [float(v) * 1000 for v in curve_line_params[:3]], - "direction": [float(v) for v in curve_line_params[3:6]], - "source": reference.get("source") or "selection_curve_line_params", - } - return None - - -def _selection_objects(selections: Any) -> list[Dict[str, Any]]: - objects: list[Dict[str, Any]] = [] - if not isinstance(selections, list): - return objects - for selection in selections: - if not isinstance(selection, dict): - continue - obj = selection.get("object") - if isinstance(obj, dict) and obj.get("kind") != "null": - objects.append(obj) - return objects - - -def _axis_reference_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: - for obj in _selection_objects(selections): - axis = _extract_axis_reference(obj) - if axis: - axis["source"] = "feature_selection_axis" - return axis - return None - - -def _host_face_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: - for obj in _selection_objects(selections): - if obj.get("kind") != "face": - continue - surface = obj.get("surface") if isinstance(obj.get("surface"), dict) else {} - frame = obj.get("frame") if isinstance(obj.get("frame"), dict) else {} - if not frame: - continue - normal = frame.get("normal") or (surface.get("plane_params") or [0, 0, 1])[:3] - origin = frame.get("origin_mm") - if not origin: - plane_params = surface.get("plane_params") - if isinstance(plane_params, list) and len(plane_params) >= 6: - origin = [float(v) * 1000 for v in plane_params[3:6]] - if not origin: - origin = [0, 0, 0] - x_dir = frame.get("x_dir") or [1, 0, 0] - y_dir = frame.get("y_dir") or [0, 1, 0] - origin_values = list(origin) - x_values = list(x_dir) - y_values = list(y_dir) - normal_values = list(normal) - return { - "surface": surface, - "frame": { - "origin_mm": [float(v) for v in (origin_values + [0, 0, 0])[:3]], - "x_dir": [float(v) for v in (x_values + [0, 0, 0])[:3]], - "y_dir": [float(v) for v in (y_values + [0, 0, 0])[:3]], - "normal": [float(v) for v in (normal_values + [0, 0, 1])[:3]], - }, - "source": "feature_selection_face", - } - return None - - -def _tuple3(values: Any) -> tuple[float, float, float]: - values = list(values or [0, 0, 0]) - values = (values + [0, 0, 0])[:3] - return tuple(values) - - -def _point_m_to_mm(point: Any) -> tuple[float, float, float]: - values = list(point or [0, 0, 0]) - values = (values + [0, 0, 0])[:3] - return tuple(float(value) * 1000 for value in values) - - -def _scale_point(point: Any) -> list[float]: - values = [0 if value is None else float(value) for value in (point or [0, 0])] - return [_scale_length(value) for value in values[:2]] - - -def _scale_length(value: Any) -> float: - value = 0 if value is None else float(value) - return value * 1000 if abs(value) <= 10 else value - - -def _to_degrees(value: Any) -> float: - value = 0 if value is None else float(value) - return value * 180 / 3.141592653589793 if abs(value) <= 6.283185307179586 else value diff --git a/backend/engine/cdsl_engine/translator/__init__.py b/backend/engine/cdsl_engine/translator/__init__.py new file mode 100644 index 00000000..8387e37c --- /dev/null +++ b/backend/engine/cdsl_engine/translator/__init__.py @@ -0,0 +1,164 @@ +"""Compatibility package for the historical ``cdsl_engine.translator`` module. + +The former single-file translator now lives in ``ir`` (SolidWorks plugin JSON +to backend IR), ``codegen`` (backend IR to build123d source), ``runtime_lib`` +(frozen generated-script library), and ``common`` (shared helpers). Every +historical import path and symbol keeps working. +""" + +from __future__ import annotations + +from .common import SW_END_CONDITIONS, THROUGH_CUT_AMOUNT_MM +from .ir import ( + _SW_METADATA_FEATURE_TYPES, + _append_feature_source_sketches, + _axis_reference_from_feature_selections, + _axis_reference_from_owned_faces, + _best_extrude_depth_mm, + _choose_pattern_direction_sign, + _clean_null_reference, + _construction_endpoint_degrees, + _contour_entity_indices_from_segments, + _convert_sw_assembly, + _convert_sw_extrude, + _convert_sw_hole, + _convert_sw_imported_body, + _convert_sw_linear_pattern, + _convert_sw_reference, + _convert_sw_revolve, + _convert_sw_sketch, + _convert_sw_sketch_entity, + _editable_param, + _extract_axis_reference, + _extract_edge_selector_points, + _extract_sketch_parameters, + _extrude_depth_from_owned_faces, + _extrude_owned_faces_span_sketch_plane, + _feature_length_dimension_mm, + _feature_selection_selectors, + _feature_selection_source, + _hole_angle_dimension_rad, + _hole_counterbore_depth_dimension_mm, + _hole_counterbore_dimension_mm, + _hole_dimension_value, + _hole_dimension_value_excluding, + _hole_position_entity_flags, + _hole_position_points, + _hole_position_sketches, + _hole_position_to_model, + _hole_primary_depth_mm, + _hole_primary_diameter_mm, + _hole_primary_dimension_fallback, + _host_face_from_feature_selections, + _host_face_from_workplane, + _is_cut_feature, + _is_hole_profile_sketch, + _is_imported_body_feature, + _is_sketch_point_entity, + _is_weak_inferred_axis, + _operation_profile_bbox, + _owned_face_pattern_offsets, + _owned_face_signatures, + _pattern_direction_from_plugin, + _pattern_direction_from_reference, + _reverse_pattern_direction, + _selectable_stable_ids, + _selection_objects, + _selector_has_persistent_reference, + _sketch_bounds, + _sketch_point_mm, + _sketch_point_to_model, + _sketch_point_to_model_bbox, + _sketch_radius_mm, + _sketch_vector_to_model, + _source_bbox_from_plugin_json, + _source_feature, + _source_owned_faces, + _source_pattern_frame, + analyze_parameterization_status, + convert_sw_plugin_json_to_ir, + enrich_rebuild_parameters, + extract_editable_parameters, + normalize_to_ir, +) +from .codegen import ( + _active_profile_loops, + _aligned_workplane_for_owned_midplane, + _blind_extrude_face_offset, + _effective_extrude_cut_depth_mm, + _effective_hole_cut_depth_mm, + _extract_mirror_plane_normal, + _extract_mirror_plane_origin, + _find_operation_for_source_feature, + _find_source_operation_for_pattern, + _flip_side_step_inner_radius_mm, + _flip_side_uses_step_ring, + _generate_assembly_compose, + _generate_chamfer, + _generate_extrude, + _generate_fillet, + _generate_hole, + _generate_imported_body_pending, + _generate_linear_pattern, + _generate_mirror_pattern, + _generate_move_face, + _generate_revolve, + _generate_sketch, + _hole_counterbore_depth_mm, + _hole_counterbore_diameter_mm, + _hole_countersink_angle_rad, + _hole_countersink_diameter_mm, + _hole_depth_mm, + _hole_diameter_mm, + _hole_drill_angle_rad, + _hole_has_drill_tip, + _hole_has_through_dimension, + _hole_owned_cut_faces, + _hole_should_use_sw_cut_holes, + _infer_closed_wire_loops, + _linear_pattern_offsets, + _looks_like_reverse_history, + _loop_area_from_radii, + _loop_radius_candidates, + _loops_matching_owned_radii, + _model_offset_to_host_local, + _operation_priority, + _ordered_wire_entities, + _owned_bbox_cut, + _owned_cylindrical_cut_faces, + _owned_extrude_terminal_offsets_mm, + _owned_profile_radii_mm, + _pattern_direction_vector, + _prefer_blind_sketch_extrude, + _project_owned_faces_to_sketch_bbox, + _resolve_extrude_owned_termination, + _reverse_curve_entity, + _revolve_axis_expr, + _sketch_circle_radii_mm, + _sketch_construction_axis, + _sketch_has_buildable_profile, + _sketch_point_to_model_from_basis, + _sw_math_transform_matrix, + _translate_owned_faces, + _translate_sketch_entities, + _translated_operation, + _translated_sketch, + generate_build123d_code, + get_part_name, + sort_operations_for_history, +) +from .runtime_lib import RUNTIME_LIB_LINES + +__all__ = [ + "RUNTIME_LIB_LINES", + "SW_END_CONDITIONS", + "THROUGH_CUT_AMOUNT_MM", + "analyze_parameterization_status", + "convert_sw_plugin_json_to_ir", + "enrich_rebuild_parameters", + "extract_editable_parameters", + "generate_build123d_code", + "get_part_name", + "normalize_to_ir", + "sort_operations_for_history", +] diff --git a/backend/engine/cdsl_engine/translator/codegen.py b/backend/engine/cdsl_engine/translator/codegen.py new file mode 100644 index 00000000..9ba37f84 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/codegen.py @@ -0,0 +1,2016 @@ +"""Backend-IR to build123d source-code generation.""" + +from __future__ import annotations + +import json +import math +import os +import re +from copy import deepcopy +from typing import Any, Dict, Optional + +from .common import ( + _tuple3, + _point_key, + _bbox_area_2d, + _bbox_contains_2d, + _bbox_overlap_ratio_2d, + _loop_bbox, + SW_END_CONDITIONS, + THROUGH_CUT_AMOUNT_MM, +) +from .runtime_lib import RUNTIME_LIB_LINES + + +def get_part_name(data: Dict[str, Any]) -> str: + part_name = data.get("part_name") or data.get("metadata", {}).get("source", {}).get("file_name", "part") + part_name = str(part_name) + for suffix in (".sldprt", ".sldasm", ".step", ".stp", ".json"): + if part_name.lower().endswith(suffix): + part_name = part_name[:-len(suffix)] + break + return re.sub(r"[^0-9A-Za-z_\u4e00-\u9fff]+", "_", part_name).strip("_") or "part" + +def generate_build123d_code(data: Dict[str, Any], gold_volume_mm3: float | None = None) -> str: + """Generate build123d Python code from generic SW/build123d IR.""" + rebuild_contract = data.get("rebuild_contract") if isinstance(data.get("rebuild_contract"), dict) else {} + if rebuild_contract and rebuild_contract.get("ready") is False: + blockers = rebuild_contract.get("blockers") or [] + raise ValueError(f"Pure-JSON rebuild contract is not ready: {blockers}") + source_volume_mm3 = None + source_area_mm2 = None + mass_props = data.get("validation_hints", {}).get("mass_properties_raw") + if mass_props and len(mass_props) >= 5: + source_volume_mm3 = float(mass_props[3]) * 1_000_000_000 + source_area_mm2 = float(mass_props[4]) * 1_000_000 + lines = [ + "from build123d import *", + "import math", + f"SOURCE_VOLUME_MM3 = {source_volume_mm3!r}", + f"SOURCE_AREA_MM2 = {source_area_mm2!r}", + *RUNTIME_LIB_LINES, + ] + + part_name_clean = get_part_name(data) + lines.append(f"def build_{part_name_clean}():") + lines.append(' """Auto-generated build123d code from SolidWorks IR."""') + lines.append("") + + sketches = {s["id"]: s for s in data.get("sketches", [])} + operations = data.get("operations", []) + references = {r["id"]: r for r in data.get("references", [])} + generated_sketches = set() + + lines.append(" result = None") + lines.append("") + + for op in sort_operations_for_history(operations): + op_type = op.get("type", "") + op_name = op.get("name", "") + if op_type in ["unsupported", "unknown"]: + lines.append(f" # Skipping unsupported metadata feature: {op_name}") + lines.append("") + continue + + if op_type == "imported_body": + lines.extend(_generate_imported_body_pending(op)) + elif op_type == "assembly_compose": + lines.extend(_generate_assembly_compose(op)) + elif op_type == "move_face": + lines.extend(_generate_move_face(op)) + elif op_type == "fillet": + lines.extend(_generate_fillet(op)) + elif op_type == "chamfer": + lines.extend(_generate_chamfer(op)) + elif op_type == "hole": + lines.extend(_generate_hole(op)) + elif op_type in ("extrude_cut", "extrude_add"): + build_op = _resolve_extrude_owned_termination(op, sketches.get(op.get("sketch") or "")) + sketch_id = op.get("sketch") + if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): + lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") + continue + if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: + lines.extend(_generate_sketch(sketches[sketch_id], references, build_op)) + generated_sketches.add(sketch_id) + lines.extend(_generate_extrude(build_op, sketches.get(sketch_id, {}), operations, sketches)) + elif op_type in ("revolve_cut", "revolve_add"): + sketch_id = op.get("sketch") + if sketch_id and sketch_id in sketches and not _sketch_has_buildable_profile(sketches[sketch_id]): + lines.append(f" # Skip: sketch has no buildable closed/profile geometry for {op_name}") + continue + if sketch_id and sketch_id in sketches and sketch_id not in generated_sketches: + lines.extend(_generate_sketch(sketches[sketch_id], references, op)) + generated_sketches.add(sketch_id) + lines.extend(_generate_revolve(op, sketches.get(sketch_id, {}))) + elif op_type in ("linear_pattern", "pattern_linear"): + lines.extend(_generate_linear_pattern(op, operations, sketches, references)) + elif op_type == "pattern_mirror": + lines.extend(_generate_mirror_pattern(op, operations, sketches, references)) + else: + lines.append(f" # TODO: {op_type} - {op_name}") + + lines.append("") + + lines.append(" if result is None:") + lines.append(' raise Exception("No solid was created")') + lines.append("") + lines.append(" # Clean up small inaccuracies from Boolean operations") + lines.append(" try:") + lines.append(" result = result.clean()") + lines.append(" except Exception:") + lines.append(" pass") + lines.append(f' export_step(result, "{part_name_clean}.step")') + lines.append(" return result") + lines.append("") + lines.append("# Run the function") + lines.append('if __name__ == "__main__":') + lines.append(f" build_{part_name_clean}()") + + return "\n".join(lines) + +def _generate_imported_body_pending(op: Dict[str, Any]) -> list[str]: + return [ + f" # Imported body requires generic JSON B-Rep reconstruction: {op.get('name', '')}", + " raise NotImplementedError(", + " 'Pure-JSON imported-body reconstruction is not implemented yet; '", + " 'the plugin captured solid_bodies topology and the part is marked not ready.'", + " )", + ] + +def _generate_assembly_compose(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters") or {} + components = params.get("components") or [] + component_ids = [component.get("component_id") for component in components] + message = f"Assembly requires rebuilt component JSON registry: {component_ids!r}" + return [ + f" # Pure-JSON assembly composition: {op.get('name', '')}", + " raise NotImplementedError(", + f" {message!r}", + " )", + ] + +def _sw_math_transform_matrix(array_data: Any, component_name: str) -> list[list[float]]: + if not isinstance(array_data, list) or len(array_data) < 13: + raise ValueError(f"Assembly component {component_name} has no complete 16-value transform") + values = [float(value or 0) for value in array_data] + scale = values[12] + if abs(scale) <= 1e-12: + raise ValueError(f"Assembly component {component_name} has an invalid zero scale") + # SOLIDWORKS stores row-vector axes and translation in elements 9..11. + # build123d/OpenCascade uses a column-vector 3x4 matrix, hence transpose. + return [ + [values[0] * scale, values[3] * scale, values[6] * scale, values[9] * 1000.0], + [values[1] * scale, values[4] * scale, values[7] * scale, values[10] * 1000.0], + [values[2] * scale, values[5] * scale, values[8] * scale, values[11] * 1000.0], + [0.0, 0.0, 0.0, 1.0], + ] + +def sort_operations_for_history(operations: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Return operations in SW rebuild order.""" + if _looks_like_reverse_history(operations): + return list(reversed(operations)) + if all(op.get("source_feature", {}).get("index") is not None for op in operations): + return sorted(operations, key=lambda op: op.get("source_feature", {}).get("index", 0)) + return sorted(operations, key=_operation_priority) + +def _looks_like_reverse_history(operations: list[Dict[str, Any]]) -> bool: + build_ops = [ + op + for op in operations + if op.get("type") not in ("unsupported", "unknown") + ] + if len(build_ops) < 2: + return False + additive = {"extrude_add", "revolve_add", "sweep", "loft"} + downstream = {"extrude_cut", "revolve_cut", "fillet", "chamfer", "hole", "linear_pattern", "pattern_linear"} + return build_ops[0].get("type") in downstream and build_ops[-1].get("type") in additive + +def _operation_priority(op: Dict[str, Any]) -> int: + op_type = op.get("type", "") + if op_type == "extrude_add": + return 0 + if op_type in ("extrude_cut", "revolve_cut"): + return 1 + if op_type == "revolve_add": + return 2 + if op_type in ("fillet", "chamfer"): + return 3 + if op_type in ("sweep", "loft"): + return 4 + return 99 + +def _sketch_has_buildable_profile(sketch: Dict[str, Any]) -> bool: + for entity in sketch.get("entities", []) or []: + if entity.get("construction"): + continue + if entity.get("type") == "circle" and float(entity.get("radius_mm") or 0) > 0: + return True + if entity.get("type") == "arc" and float(entity.get("radius_mm") or 0) > 0: + return True + valid_lines = 0 + for entity in sketch.get("entities", []) or []: + if entity.get("construction") or entity.get("type") != "line": + continue + start = entity.get("start") or [0, 0] + end = entity.get("end") or [0, 0] + if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) > 1e-6: + valid_lines += 1 + return valid_lines >= 2 + +def _reverse_curve_entity(ent: Dict[str, Any]) -> Dict[str, Any]: + """Reverse a sketch segment while preserving its geometric traversal.""" + reversed_ent = dict(ent) + reversed_ent["start"], reversed_ent["end"] = ent.get("end"), ent.get("start") + reversed_ent["reversed"] = not bool(ent.get("reversed", False)) + if ent.get("type") == "arc": + raw = ent.get("raw") if isinstance(ent.get("raw"), dict) else {} + axis = ent.get("curve_axis") or raw.get("curve_axis") + if isinstance(axis, list) and len(axis) >= 3: + # The arc's endpoints and orientation are a pair. Keep the + # source `raw` untouched, but provide a flipped top-level axis for + # code generation so a reversed minor arc remains a minor arc. + reversed_ent["curve_axis"] = [-float(value) for value in axis[:3]] + # 必须删除预置的角度字段,否则代码生成会使用旧的(start,end未翻转时的)角度, + # 导致弧段遍历方向与连接顺序相反(如对外弧CW而对内弧也CW而非CCW)。 + reversed_ent.pop("start_angle_deg", None) + reversed_ent.pop("end_angle_deg", None) + reversed_ent.pop("arc_sweep_deg", None) + return reversed_ent + +def _ordered_wire_entities(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Order sketch line/arc entities into connected loops when SW did not export contours.""" + drawable = [ + ent for ent in entities + if ent.get("type") in ("line", "arc") + and _point_key(ent.get("start")) is not None + and _point_key(ent.get("end")) is not None + ] + if len(drawable) < 3: + return entities + + by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} + for idx, ent in enumerate(drawable): + by_node.setdefault(_point_key(ent.get("start")), []).append((idx, "start")) + by_node.setdefault(_point_key(ent.get("end")), []).append((idx, "end")) + + if not by_node or any(len(touches) != 2 for touches in by_node.values()): + return entities + + remaining = set(range(len(drawable))) + ordered: list[Dict[str, Any]] = [] + + while remaining: + first_idx = min(remaining) + remaining.remove(first_idx) + first = drawable[first_idx] + loop = [first] + loop_start = _point_key(first.get("start")) + cursor = _point_key(first.get("end")) + + while cursor != loop_start: + next_idx = None + next_side = None + for candidate_idx, side in by_node.get(cursor, []): + if candidate_idx in remaining: + next_idx = candidate_idx + next_side = side + break + if next_idx is None: + return entities + + remaining.remove(next_idx) + next_ent = drawable[next_idx] + if next_side == "end": + next_ent = _reverse_curve_entity(next_ent) + loop.append(next_ent) + cursor = _point_key(next_ent.get("end")) + + ordered.extend(loop) + + return ordered + +def _infer_closed_wire_loops(entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + drawable = [ + (idx, ent) for idx, ent in enumerate(entities) + if not ent.get("construction", False) + and ent.get("type") in ("line", "arc") + and _point_key(ent.get("start")) is not None + and _point_key(ent.get("end")) is not None + ] + if len(drawable) < 3: + return [] + + by_node: dict[tuple[float, float], list[tuple[int, str]]] = {} + for local_idx, (_, ent) in enumerate(drawable): + by_node.setdefault(_point_key(ent.get("start")), []).append((local_idx, "start")) + by_node.setdefault(_point_key(ent.get("end")), []).append((local_idx, "end")) + + remaining = set(range(len(drawable))) + loops: list[Dict[str, Any]] = [] + while remaining: + first_idx = min(remaining) + remaining.remove(first_idx) + _, first = drawable[first_idx] + loop_indices = [first_idx] + loop_start = _point_key(first.get("start")) + cursor = _point_key(first.get("end")) + + while cursor != loop_start: + matches = [(idx, side) for idx, side in by_node.get(cursor, []) if idx in remaining] + if not matches: + loop_indices = [] + break + next_idx, next_side = matches[0] + remaining.remove(next_idx) + _, next_ent = drawable[next_idx] + loop_indices.append(next_idx) + cursor = _point_key(next_ent.get("start") if next_side == "end" else next_ent.get("end")) + + if not loop_indices: + continue + entity_indices = [drawable[idx][0] for idx in loop_indices] + bbox = _loop_bbox([entities[idx] for idx in entity_indices]) + loops.append({ + "entity_indices": entity_indices, + "is_closed": True, + "bbox_mm": bbox, + "bbox_area_mm2": _bbox_area_2d(bbox), + "source": "inferred_connected_loop", + }) + return loops + +def _loop_radius_candidates(loop: Dict[str, Any], entities: list[Dict[str, Any]]) -> list[float]: + radii: list[float] = [] + for idx in loop.get("entity_indices", []) or []: + if not isinstance(idx, int) or idx < 0 or idx >= len(entities): + continue + ent = entities[idx] + radius = ent.get("radius_mm") + if radius is not None: + radii.append(abs(float(radius))) + bbox = loop.get("bbox_mm") + if isinstance(bbox, list) and len(bbox) >= 4: + radii.append(abs(float(bbox[2]) - float(bbox[0])) / 2) + radii.append(abs(float(bbox[3]) - float(bbox[1])) / 2) + return [radius for radius in radii if radius > 1e-6 and math.isfinite(radius)] + +def _owned_profile_radii_mm(operation: Optional[Dict[str, Any]], sketch: Dict[str, Any]) -> list[float]: + if not isinstance(operation, dict): + return [] + radii: list[float] = [] + loop_radii: list[float] = [] + entities = sketch.get("entities") if isinstance(sketch, dict) else [] + sketch_loops = (sketch.get("profile_loops") or sketch.get("loops") or []) if isinstance(sketch, dict) else [] + for loop in sketch_loops: + loop_radii.extend(_loop_radius_candidates(loop, entities if isinstance(entities, list) else [])) + + def _matches_sketch_radius(value: float) -> bool: + return any(abs(value - radius) <= max(0.1, radius * 0.01) for radius in loop_radii) + + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = surface.get("cylinder_params") + if surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7: + radii.append(abs(float(params[6]) * 1000)) + continue + box = face.get("box_m") + area = face.get("area_m2") + if surface.get("is_plane") and isinstance(box, list) and len(box) >= 6 and area is not None: + sizes = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] + non_zero_sizes = [size for size in sizes if size > 1e-4] + if len(non_zero_sizes) >= 2: + outer_radius = max(non_zero_sizes) / 2 + area_mm2 = abs(float(area)) * 1_000_000 + inner_sq = outer_radius * outer_radius - area_mm2 / math.pi + inner_radius = math.sqrt(inner_sq) if inner_sq > 0 else 0.0 + if _matches_sketch_radius(outer_radius): + radii.append(outer_radius) + if inner_radius > 1e-4 and _matches_sketch_radius(inner_radius): + radii.append(inner_radius) + + unique: list[float] = [] + for radius in sorted(radii): + if radius <= 1e-6 or not math.isfinite(radius): + continue + if not any(abs(radius - existing) <= max(0.05, existing * 0.002) for existing in unique): + unique.append(radius) + return unique + +def _loops_matching_owned_radii( + loops: list[Dict[str, Any]], + entities: list[Dict[str, Any]], + owned_radii: list[float], +) -> list[Dict[str, Any]]: + if not loops or not owned_radii: + return [] + matched: list[tuple[float, Dict[str, Any]]] = [] + for loop in loops: + candidates = _loop_radius_candidates(loop, entities) + if not candidates: + continue + best_radius = None + best_delta = float("inf") + for candidate in candidates: + for owned_radius in owned_radii: + delta = abs(candidate - owned_radius) + if delta < best_delta: + best_delta = delta + best_radius = candidate + if best_radius is None: + continue + if best_delta <= max(0.1, best_radius * 0.01): + matched.append((best_radius, loop)) + if not matched: + return [] + matched.sort(key=lambda item: item[0], reverse=True) + deduped: list[tuple[float, Dict[str, Any]]] = [] + seen_loop_keys: set[str] = set() + for radius, loop in matched: + bbox = loop.get("bbox_mm") + key = ",".join(f"{float(value):.4f}" for value in bbox[:4]) if isinstance(bbox, list) and len(bbox) >= 4 else str(loop.get("entity_indices")) + key = f"{radius:.4f}:{key}" + if key in seen_loop_keys: + continue + seen_loop_keys.add(key) + deduped.append((radius, loop)) + matched = deduped + annotated = [] + for index, (_, loop) in enumerate(matched): + loop_copy = dict(loop) + loop_copy["profile_mode"] = "add" if index == 0 else "subtract" + annotated.append(loop_copy) + return annotated + +def _loop_area_from_radii(loops: list[Dict[str, Any]], entities: list[Dict[str, Any]]) -> Optional[float]: + if not loops: + return None + area = 0.0 + for index, loop in enumerate(loops): + radii = _loop_radius_candidates(loop, entities) + if not radii: + return None + radius = max(radii) + mode = loop.get("profile_mode") + sign = -1 if mode == "subtract" or (mode is None and index > 0) else 1 + area += sign * math.pi * radius * radius + return abs(area) if area > 1e-6 else None + +def _aligned_workplane_for_owned_midplane( + sketch: Dict[str, Any], + operation: Optional[Dict[str, Any]], + loops: list[Dict[str, Any]], +) -> Dict[str, Any]: + workplane = dict(sketch.get("workplane") or {}) + if not isinstance(operation, dict) or operation.get("type") != "extrude_add": + return workplane + params = operation.get("parameters") if isinstance(operation.get("parameters"), dict) else {} + if not params.get("both_directions"): + return workplane + + entities = sketch.get("entities") if isinstance(sketch.get("entities"), list) else [] + profile_area = _loop_area_from_radii(loops, entities) + if profile_area is None: + return workplane + + normal = workplane.get("normal") or [0, 0, 1] + origin = workplane.get("origin_mm") or [0, 0, 0] + if not isinstance(normal, list) or not isinstance(origin, list) or len(normal) < 3 or len(origin) < 3: + return workplane + normal_vec = [float(v) for v in normal[:3]] + norm = math.sqrt(sum(v * v for v in normal_vec)) + if norm <= 1e-9: + return workplane + normal_vec = [v / norm for v in normal_vec] + + candidates: list[tuple[float, list[float]]] = [] + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + area_m2 = face.get("area_m2") + plane_params = surface.get("plane_params") + if area_m2 is None or not isinstance(plane_params, list) or len(plane_params) < 6: + continue + face_area = abs(float(area_m2)) * 1_000_000 + if abs(face_area - profile_area) > max(0.5, profile_area * 0.02): + continue + plane_normal = [float(v) for v in plane_params[:3]] + plane_norm = math.sqrt(sum(v * v for v in plane_normal)) + if plane_norm <= 1e-9: + continue + plane_normal = [v / plane_norm for v in plane_normal] + alignment = abs(sum(plane_normal[i] * normal_vec[i] for i in range(3))) + if alignment < 0.98: + continue + plane_point = [float(v) * 1000 for v in plane_params[3:6]] + old_offset = sum(float(origin[i]) * normal_vec[i] for i in range(3)) + new_offset = sum(plane_point[i] * normal_vec[i] for i in range(3)) + delta = new_offset - old_offset + if abs(delta) <= 1e-6: + continue + moved_origin = [float(origin[i]) + normal_vec[i] * delta for i in range(3)] + candidates.append((abs(delta), moved_origin)) + if len(candidates) != 1: + return workplane + candidates.sort(key=lambda item: item[0]) + workplane["origin_mm"] = candidates[0][1] + return workplane + +def _project_owned_faces_to_sketch_bbox( + owned_faces: list[Dict[str, Any]], workplane: Dict[str, Any] +) -> Optional[list[float]]: + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + if len(origin) < 3 or len(x_dir) < 3 or len(y_dir) < 3: + return None + + projected: list[tuple[float, float]] = [] + for face in owned_faces: + box = face.get("box_m") if isinstance(face, dict) else None + if not isinstance(box, list) or len(box) < 6: + continue + mins = [float(box[i]) * 1000 for i in range(3)] + maxs = [float(box[i + 3]) * 1000 for i in range(3)] + for x in (mins[0], maxs[0]): + for y in (mins[1], maxs[1]): + for z in (mins[2], maxs[2]): + point = [x, y, z] + rel = [point[i] - float(origin[i]) for i in range(3)] + projected.append(( + sum(rel[i] * float(x_dir[i]) for i in range(3)), + sum(rel[i] * float(y_dir[i]) for i in range(3)), + )) + if not projected: + return None + return [ + min(point[0] for point in projected), + min(point[1] for point in projected), + max(point[0] for point in projected), + max(point[1] for point in projected), + ] + +def _active_profile_loops(sketch: Dict[str, Any], operation: Optional[Dict[str, Any]]) -> list[Dict[str, Any]]: + entities = sketch.get("entities", []) or [] + loops = sketch.get("loops", []) or _infer_closed_wire_loops(entities) + if not loops: + return [] + + op_type = operation.get("type") if isinstance(operation, dict) else None + if op_type == "extrude_cut" and len(loops) > 1: + owned_bbox = _project_owned_faces_to_sketch_bbox( + operation.get("source_owned_faces") or [], + sketch.get("workplane") or {}, + ) + if owned_bbox: + for inner in loops: + inner_bbox = inner.get("bbox_mm") + if _bbox_overlap_ratio_2d(inner_bbox, owned_bbox) < 0.85: + continue + containers = [ + outer for outer in loops + if outer is not inner + and _bbox_contains_2d(outer.get("bbox_mm"), inner_bbox, tolerance=1e-4) + and _bbox_area_2d(outer.get("bbox_mm")) > _bbox_area_2d(inner_bbox) * 1.05 + ] + if containers: + outer = min(containers, key=lambda loop: _bbox_area_2d(loop.get("bbox_mm"))) + outer_loop = dict(outer) + inner_loop = dict(inner) + outer_loop["profile_mode"] = "add" + inner_loop["profile_mode"] = "subtract" + return [outer_loop, inner_loop] + + active = [] + for loop in loops: + bbox = loop.get("bbox_mm") + area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) + contains_other = any( + other is not loop + and _bbox_contains_2d(bbox, other.get("bbox_mm")) + and area > float(other.get("bbox_area_mm2") or _bbox_area_2d(other.get("bbox_mm"))) * 1.05 + for other in loops + ) + if not contains_other: + active.append(loop) + if active: + return active + if op_type == "extrude_add" and len(loops) > 1: + owned_matched = _loops_matching_owned_radii(loops, entities, _owned_profile_radii_mm(operation, sketch)) + # Owned-face radii are useful for selecting circular profiles, but a + # rounded outer contour also contributes arc radii. Those radii can + # coincide with an inner circle and make the radius ranking label the + # inner loop as ADD and its containing outer loop as SUBTRACT. Such a + # profile is topologically impossible as a first additive sketch, so + # fall back to the complete contour nesting below. + owned_modes_conflict_with_nesting = any( + candidate.get("profile_mode") == "add" + and any( + container is not candidate + and container.get("profile_mode") == "subtract" + and _bbox_contains_2d( + container.get("bbox_mm"), + candidate.get("bbox_mm"), + tolerance=1e-4, + ) + and _bbox_area_2d(container.get("bbox_mm")) + > _bbox_area_2d(candidate.get("bbox_mm")) * 1.05 + for container in owned_matched + ) + for candidate in owned_matched + ) + if owned_modes_conflict_with_nesting: + owned_matched = [] + if owned_matched: + # Radius evidence cannot identify closed slot/polygon contours. + # Keep non-circular closed loops that lie inside an owned additive + # outer loop; they are material-removal islands in the same + # additive sketch. Circular unmatched loops remain excluded + # because they commonly belong to other features sharing a sketch. + matched_entity_keys = { + tuple(loop.get("entity_indices") or []) for loop in owned_matched + } + additive_outers = [ + loop for loop in owned_matched if loop.get("profile_mode") == "add" + ] + for loop in loops: + entity_indices = tuple(loop.get("entity_indices") or []) + if entity_indices in matched_entity_keys: + continue + profile_entities = [ + entities[index] + for index in entity_indices + if isinstance(index, int) and 0 <= index < len(entities) + ] + is_non_circular_profile = bool(profile_entities) and any( + entity.get("type") != "circle" + and not (entity.get("type") == "arc" and entity.get("is_circle")) + for entity in profile_entities + ) + if not is_non_circular_profile: + continue + if not any( + _bbox_contains_2d( + outer.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4 + ) + for outer in additive_outers + ): + continue + loop_copy = dict(loop) + loop_copy["profile_mode"] = "subtract" + owned_matched.append(loop_copy) + if len(owned_matched) == 1 and isinstance(operation, dict): + outer_loop = owned_matched[0] + outer_radii = _loop_radius_candidates(outer_loop, entities) + outer_radius = max(outer_radii) if outer_radii else 0.0 + outer_disk_area = math.pi * outer_radius * outer_radius if outer_radius > 0 else 0.0 + has_partial_cap = False + for face in operation.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + area_m2 = face.get("area_m2") + if surface.get("is_plane") and area_m2 is not None and outer_disk_area > 0: + face_area = abs(float(area_m2)) * 1_000_000 + if face_area < outer_disk_area * 0.9: + has_partial_cap = True + break + if has_partial_cap: + inner_candidates = [ + loop for loop in loops + if loop is not outer_loop + and _bbox_contains_2d(outer_loop.get("bbox_mm"), loop.get("bbox_mm"), tolerance=1e-4) + ] + if inner_candidates: + inner = max( + ( + loop + for loop in inner_candidates + if max(_loop_radius_candidates(loop, entities) or [0.0]) < outer_radius - 0.5 + ), + key=lambda loop: max(_loop_radius_candidates(loop, entities) or [0.0]), + default=None, + ) + if inner is None: + return owned_matched + inner_radii = _loop_radius_candidates(inner, entities) + inner_radius = max(inner_radii) if inner_radii else 0.0 + if inner_radius <= 0: + return owned_matched + outer_copy = dict(outer_loop) + inner_copy = dict(inner) + outer_copy["profile_mode"] = "add" + inner_copy["profile_mode"] = "subtract" + return [outer_copy, inner_copy] + return owned_matched + annotated = [] + for loop in loops: + bbox = loop.get("bbox_mm") + area = float(loop.get("bbox_area_mm2") or _bbox_area_2d(bbox)) + containers = [ + outer for outer in loops + if outer is not loop + and _bbox_contains_2d(outer.get("bbox_mm"), bbox, tolerance=1e-4) + and float(outer.get("bbox_area_mm2") or _bbox_area_2d(outer.get("bbox_mm"))) > area * 1.05 + ] + loop_copy = dict(loop) + loop_copy["profile_mode"] = "subtract" if containers else "add" + annotated.append(loop_copy) + return annotated + return loops + +def _generate_sketch(sketch: Dict[str, Any], references: Dict[str, Any], operation: Optional[Dict[str, Any]] = None) -> list[str]: + import math + + name = sketch.get("name", "Sketch") + op_type = operation.get("type") if isinstance(operation, dict) else None + workplane = sketch.get("workplane", {}) + entities = sketch.get("entities", []) + loops = _active_profile_loops(sketch, operation) + workplane = _aligned_workplane_for_owned_midplane(sketch, operation, loops) + code = [f" # Sketch: {name}"] + + origin = workplane.get("origin_mm", [0, 0, 0]) + x_dir = workplane.get("x_dir", [1, 0, 0]) + normal = workplane.get("normal", [0, 0, 1]) + + if origin != [0, 0, 0] or x_dir != [1, 0, 0] or normal != [0, 0, 1]: + code.append( + f" with BuildSketch(Plane(origin={_tuple3(origin)}, x_dir={_tuple3(x_dir)}, z_dir={_tuple3(normal)})) as sketch:" + ) + else: + code.append(" with BuildSketch() as sketch:") + + loop_entities = [] + processed_indices = set() + for loop in loops: + for idx in loop.get("entity_indices", []): + if idx < len(entities): + loop_entities.append(entities[idx]) + processed_indices.add(idx) + + append_unprocessed = not loops + for i, ent in enumerate(entities): + if append_unprocessed and i not in processed_indices: + loop_entities.append(ent) + + drawable_entities = [ent for ent in loop_entities if not ent.get("construction", False)] + circle_entities = [ + ent for ent in drawable_entities + if ent.get("type") in ("circle", "arc") and ent.get("is_circle", ent.get("type") == "circle") + ] + wire_entities = [ + ent for ent in drawable_entities + if ent not in circle_entities and ent.get("type") in ("line", "arc") + ] + wire_entities = _ordered_wire_entities(wire_entities) + + handled_circle_entities = set() + if not loops and len(circle_entities) > 1: + ranked_circles = sorted( + enumerate(circle_entities), + key=lambda item: float(item[1].get("radius_mm", 0) or 0), + reverse=True, + ) + outer_index, outer = ranked_circles[0] + outer_center = outer.get("center", [0, 0, 0]) + outer_radius = float(outer.get("radius_mm", 0) or 0) + contains_all = outer_radius > 0 + for _, inner in ranked_circles[1:]: + inner_center = inner.get("center", [0, 0, 0]) + inner_radius = float(inner.get("radius_mm", 0) or 0) + center_distance = math.hypot( + float(inner_center[0]) - float(outer_center[0]), + float(inner_center[1]) - float(outer_center[1]), + ) + if center_distance + inner_radius >= outer_radius - 1e-6: + contains_all = False + break + if contains_all: + code.append(f" with Locations(({outer_center[0]}, {outer_center[1]})):") + code.append(f" Circle({outer_radius})") + handled_circle_entities.add(outer_index) + for inner_index, inner in ranked_circles[1:]: + center = inner.get("center", [0, 0, 0]) + radius = inner.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + handled_circle_entities.add(inner_index) + + def circle_is_inner_profile(ent: Dict[str, Any]) -> bool: + if op_type != "extrude_add" or not loops: + return False + center = ent.get("center", [0, 0]) + radius = float(ent.get("radius_mm", 0) or 0) + if radius <= 0 or len(center) < 2: + return False + bbox = [ + float(center[0]) - radius, + float(center[1]) - radius, + float(center[0]) + radius, + float(center[1]) + radius, + ] + return any(_bbox_contains_2d(loop.get("bbox_mm"), bbox, tolerance=1e-4) for loop in loops) + + if not loops: + for circle_index, ent in enumerate(circle_entities): + if circle_index in handled_circle_entities: + continue + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + if circle_is_inner_profile(ent): + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + else: + code.append(f" Circle({radius})") + + def orient_wire_entities(profile_entities: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + """Orient contour segments into a continuous closed wire. + + SolidWorks contour arrays preserve membership but not necessarily each + segment's traversal direction. Reversing an arc must also invert its + curve axis; otherwise a short arc becomes its 270-degree complement. + """ + segments = [deepcopy(entity) for entity in profile_entities] + if len(segments) < 2: + return segments + + def endpoints(entity: Dict[str, Any]) -> tuple[Optional[list[float]], Optional[list[float]]]: + start = entity.get("start") + end = entity.get("end") + if not (isinstance(start, list) and isinstance(end, list) and len(start) >= 2 and len(end) >= 2): + return None, None + return [float(start[0]), float(start[1])], [float(end[0]), float(end[1])] + + def distance(left: list[float], right: list[float]) -> float: + return math.hypot(left[0] - right[0], left[1] - right[1]) + + def reverse(entity: Dict[str, Any]) -> Dict[str, Any]: + reversed_entity = deepcopy(entity) + reversed_entity["start"], reversed_entity["end"] = entity.get("end"), entity.get("start") + axis = reversed_entity.get("curve_axis") or (reversed_entity.get("raw") or {}).get("curve_axis") + if isinstance(axis, list) and len(axis) >= 3: + reversed_entity["curve_axis"] = [-float(value) for value in axis[:3]] + # 删除预置角度,强制代码生成时从翻转后的start/end重新计算 + reversed_entity.pop("start_angle_deg", None) + reversed_entity.pop("end_angle_deg", None) + reversed_entity.pop("arc_sweep_deg", None) + if entity.get("type") == "arc": + center = entity.get("center") or [0.0, 0.0] + start = reversed_entity.get("start") or [0.0, 0.0] + end = reversed_entity.get("end") or [0.0, 0.0] + start_angle = math.degrees(math.atan2(float(start[1]) - float(center[1]), float(start[0]) - float(center[0]))) + end_angle = math.degrees(math.atan2(float(end[1]) - float(center[1]), float(end[0]) - float(center[0]))) + reversed_sweep = end_angle - start_angle + if reversed_sweep <= -180: + reversed_sweep += 360 + elif reversed_sweep > 180: + reversed_sweep -= 360 + reversed_entity["arc_sweep_deg"] = reversed_sweep + return reversed_entity + + ordered = [segments.pop(0)] + while segments: + _, previous_end = endpoints(ordered[-1]) + if previous_end is None: + ordered.extend(segments) + break + candidates = [] + for index, candidate in enumerate(segments): + candidate_start, candidate_end = endpoints(candidate) + if candidate_start is None or candidate_end is None: + continue + candidates.append((distance(previous_end, candidate_start), index, candidate)) + candidates.append((distance(previous_end, candidate_end), index, reverse(candidate))) + if not candidates: + ordered.extend(segments) + break + _, selected_index, selected = min(candidates, key=lambda item: item[0]) + ordered.append(selected) + segments.pop(selected_index) + return ordered + + def append_wire_profile(profile_entities: list[Dict[str, Any]], make_face_mode: Optional[str] = None) -> None: + profile_entities = orient_wire_entities(profile_entities) + code.append(" with BuildLine():") + code.append(" pass") + emitted_wire = False + line_points = [] + for line_ent in profile_entities: + if line_ent.get("type") == "line": + line_points.extend([line_ent.get("start", [0, 0]), line_ent.get("end", [0, 0])]) + line_bbox = None + if line_points: + xs = [float(point[0]) for point in line_points] + ys = [float(point[1]) for point in line_points] + line_bbox = (min(xs), min(ys), max(xs), max(ys)) + for ent in profile_entities: + ent_type = ent.get("type", "") + if ent_type == "line": + start = ent.get("start", [0, 0, 0]) + end = ent.get("end", [0, 0, 0]) + if math.hypot(float(start[0]) - float(end[0]), float(start[1]) - float(end[1])) <= 1e-6: + code.append(" # Skip zero-length line") + continue + code.append(f" Line(({start[0]}, {start[1]}), ({end[0]}, {end[1]}))") + emitted_wire = True + elif ent_type == "arc": + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + if "start_angle_deg" in ent and "end_angle_deg" in ent: + start_angle = ent["start_angle_deg"] + end_angle = ent["end_angle_deg"] + else: + start = ent.get("start", [0, 0]) + end = ent.get("end", [0, 0]) + start_angle = math.degrees(math.atan2(start[1] - center[1], start[0] - center[0])) + end_angle = math.degrees(math.atan2(end[1] - center[1], end[0] - center[0])) + if ent.get("arc_sweep_deg") is not None: + arc_size = float(ent["arc_sweep_deg"]) + else: + curve_axis = ent.get("curve_axis") or ent.get("raw", {}).get("curve_axis") + if isinstance(curve_axis, list) and len(curve_axis) >= 3 and abs(float(curve_axis[2])) > 1e-9: + if float(curve_axis[2]) >= 0: + arc_size = (end_angle - start_angle) % 360 + else: + arc_size = -((start_angle - end_angle) % 360) + else: + arc_size = end_angle - start_angle + if arc_size <= 0: + arc_size += 360 + if arc_size > 180: + arc_size -= 360 + code.append(f" CenterArc(({center[0]}, {center[1]}), {radius}, {start_angle}, {arc_size})") + emitted_wire = True + else: + code.append(f" # TODO: entity type {ent_type}") + if not emitted_wire: + code.append(" # Skip empty wire profile") + return + if make_face_mode: + code.append(f" make_face(mode=Mode.{make_face_mode.upper()})") + else: + code.append(" make_face()") + + if loops: + ordered_loops = sorted( + enumerate(loops), + key=lambda item: (1 if item[1].get("profile_mode") == "subtract" else 0, item[0]), + ) + for loop_order_index, (loop_index, loop) in enumerate(ordered_loops): + profile_entities = [ + entities[idx] + for idx in loop.get("entity_indices", []) + if idx < len(entities) + and not entities[idx].get("construction", False) + and entities[idx].get("type") in ("line", "arc", "circle") + ] + circle_profile_entities = [ + ent for ent in profile_entities + if ent.get("type") == "circle" or (ent.get("type") == "arc" and ent.get("is_circle")) + ] + wire_profile_entities = [ + ent for ent in profile_entities + if ent.get("type") in ("line", "arc") and ent not in circle_profile_entities + ] + wire_profile_entities = _ordered_wire_entities(wire_profile_entities) + if not profile_entities: + continue + mode = loop.get("profile_mode") + if wire_profile_entities: + append_wire_profile(wire_profile_entities, mode if loop_order_index > 0 or mode else None) + else: + for ent in circle_profile_entities: + center = ent.get("center", [0, 0, 0]) + radius = ent.get("radius_mm", 1) + code.append(f" with Locations(({center[0]}, {center[1]})):") + if mode == "subtract": + code.append(f" Circle({radius}, mode=Mode.SUBTRACT)") + else: + code.append(f" Circle({radius})") + elif wire_entities: + append_wire_profile(wire_entities) + + return code + +def _sketch_circle_radii_mm(sketch: Optional[Dict[str, Any]]) -> list[float]: + if not isinstance(sketch, dict): + return [] + radii = [] + for entity in sketch.get("entities", []) or []: + if entity.get("construction") or entity.get("type") != "circle": + continue + radius = float(entity.get("radius_mm") or 0) + if radius > 0: + radii.append(abs(radius)) + return radii + +def _flip_side_step_inner_radius_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], +) -> Optional[float]: + outer_radii = _sketch_circle_radii_mm(sketch) + if not outer_radii: + return None + outer = max(outer_radii) + if len(outer_radii) > 1: + return min(outer_radii) + inner = None + try: + op_index = operations.index(op) + except ValueError: + op_index = len(operations) + for prev in operations[:op_index]: + if prev.get("type") != "extrude_cut": + continue + if not (prev.get("parameters") or {}).get("flip_side_to_cut"): + continue + prev_sketch = sketches.get(prev.get("sketch") or "", {}) + for radius in _sketch_circle_radii_mm(prev_sketch): + if radius < outer - 1e-6: + inner = radius if inner is None else max(inner, radius) + return inner + +def _flip_side_uses_step_ring( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], +) -> tuple[Optional[float], Optional[float]]: + outer_radii = _sketch_circle_radii_mm(sketch) + if not outer_radii: + return None, None + outer = max(outer_radii) + inner = _flip_side_step_inner_radius_mm(op, sketch, operations, sketches) + if inner is None or outer <= inner + 0.5: + return None, None + if outer < 35 and outer / inner < 1.5: + return None, None + return outer, inner + +def _effective_extrude_cut_depth_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], + distance_mm: float, +) -> float: + params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} + if not params.get("flip_side_to_cut"): + return distance_mm + workplane = (sketch or {}).get("workplane") or {} + origin = workplane.get("origin_mm") or [0.0, 0.0, 0.0] + normal = workplane.get("normal") or [0.0, 0.0, 1.0] + if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: + return distance_mm + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + cut_amount = distance_mm if params.get("reverse_direction", False) else -abs(distance_mm) + cut_sign = -1.0 if cut_amount < 0 else 1.0 + owned_values = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + box = face.get("box_m") + if not isinstance(box, list) or len(box) < 6: + continue + thicknesses = [abs(float(box[i + 3]) - float(box[i])) * 1000 for i in range(3)] + if min(thicknesses) > 0.5: + continue + owned_values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) + if not owned_values: + return distance_mm + transition = (min(owned_values) if cut_sign < 0 else max(owned_values)) + cut_sign * 1.0 + effective = abs(float(origin[axis]) - transition) + if effective <= 1e-6: + return distance_mm + if abs(effective - abs(distance_mm)) <= 0.25: + return distance_mm + # Guard: owned-face depth can be wrong when all owned faces + # are near the sketch plane (e.g., edge details), not at the + # real cut termination. Fall back to a through-cut distance + # so the invert-cutter extends past the entire body. + if effective < max(2.0, abs(distance_mm) * 0.15): + return max(distance_mm, THROUGH_CUT_AMOUNT_MM) + return effective + +def _owned_extrude_terminal_offsets_mm( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], +) -> tuple[Optional[float], Optional[float]]: + """Return the nearest owned planar end faces along the sketch normal. + + SolidWorks can report a two-sided feature with a stale blind depth when one + side terminates on geometry. The feature-owned end face is the reliable + result geometry: its signed offset from the sketch plane identifies the + actual termination direction and distance. + """ + workplane = (sketch or {}).get("workplane") or {} + origin = workplane.get("origin_mm") or [] + normal = workplane.get("normal") or [] + if not (isinstance(origin, list) and isinstance(normal, list) and len(origin) >= 3 and len(normal) >= 3): + return None, None + magnitude = math.sqrt(sum(float(value) ** 2 for value in normal[:3])) + if magnitude <= 1e-9: + return None, None + unit_normal = [float(value) / magnitude for value in normal[:3]] + positive: list[float] = [] + negative: list[float] = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + if not surface.get("is_plane"): + continue + params = surface.get("plane_params") + if not isinstance(params, list) or len(params) < 6: + continue + point_mm = [float(value) * 1000 for value in params[3:6]] + offset = sum((point_mm[index] - float(origin[index])) * unit_normal[index] for index in range(3)) + if offset > 1e-4: + positive.append(offset) + elif offset < -1e-4: + negative.append(offset) + return (max(positive) if positive else None, min(negative) if negative else None) + +def _resolve_extrude_owned_termination( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]], +) -> Dict[str, Any]: + """Resolve an asymmetric two-sided add from its SolidWorks-owned end face.""" + params = op.get("parameters") if isinstance(op.get("parameters"), dict) else {} + if op.get("type") != "extrude_add" or not params.get("both_directions"): + return op + positive, negative = _owned_extrude_terminal_offsets_mm(op, sketch) + if (positive is None) == (negative is None): + return op + resolved = dict(op) + resolved_params = dict(params) + resolved_params["distance_mm"] = positive if positive is not None else abs(float(negative)) + resolved_params["reverse_distance_mm"] = 0 + resolved_params["both_directions"] = False + resolved_params["reverse_direction"] = negative is not None + resolved_params["owned_termination_resolved"] = True + resolved["parameters"] = resolved_params + return resolved + +def _generate_extrude( + op: Dict[str, Any], + sketch: Optional[Dict[str, Any]] = None, + operations: Optional[list[Dict[str, Any]]] = None, + sketches: Optional[Dict[str, Dict[str, Any]]] = None, +) -> list[str]: + params = op.get("parameters", {}) + distance = _effective_extrude_cut_depth_mm(op, sketch, float(params.get("distance_mm", 10) or 10)) + reverse_distance = params.get("reverse_distance_mm", 0) + op_type = op.get("type", "") + name = op.get("name", "") + both_directions = params.get("both_directions", False) + flip_side_to_cut = bool(params.get("flip_side_to_cut", False)) + end_condition_code = params.get("end_condition_code") + reverse_end_condition_code = params.get("reverse_end_condition_code") + end_condition = SW_END_CONDITIONS.get(end_condition_code, f"Unknown({end_condition_code})") + operations = operations or [] + sketches = sketches or {} + outer_radius, inner_radius = ( + _flip_side_uses_step_ring(op, sketch, operations, sketches) if flip_side_to_cut else (None, None) + ) + resolved_owned_termination = bool(params.get("owned_termination_resolved")) + + code = [f" # {op_type}: {name}"] + if resolved_owned_termination: + code.append(" # Use the owned planar end face to resolve SW's asymmetric termination") + preserve_visible = bool(op.get("source_owned_faces")) and op_type == "extrude_add" + if end_condition_code is not None: + code.append(f" # SW end condition: {end_condition}") + + owned_cylinder_faces = _owned_cylindrical_cut_faces(op, sketch or {}) + prefer_blind_sketch = _prefer_blind_sketch_extrude( + op, sketch or {}, distance, end_condition_code, owned_cylinder_faces + ) + if op_type == "extrude_cut" and owned_cylinder_faces and flip_side_to_cut: + code.append(" # Replay SW flip-side circular cut from owned cylindrical faces") + code.append(f" result = cut_owned_flip_side_cylindrical_faces(result, {repr(owned_cylinder_faces)})") + return code + + if op_type == "extrude_cut" and owned_cylinder_faces and not flip_side_to_cut and not prefer_blind_sketch: + code.append(" # Replay cut from SW owned cylindrical faces when start/end references are missing") + code.append(f" result = cut_owned_cylindrical_faces(result, {repr(owned_cylinder_faces)})") + return code + + owned_bbox = _owned_bbox_cut(op, sketch or {}, distance) + if op_type == "extrude_cut" and owned_bbox and not flip_side_to_cut and not prefer_blind_sketch: + code.append(" # Replay cut from SW owned face bbox when extrude start/end references are missing") + code.append(f" result = cut_owned_bbox(result, {repr(owned_bbox)})") + return code + + if distance == 0 and reverse_distance == 0: + if op_type == "extrude_cut" and end_condition_code not in (None, 0): + distance = THROUGH_CUT_AMOUNT_MM + both_directions = end_condition_code in (1, 2, 9) + code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") + else: + code.append(" # Skip: zero distance") + return code + elif op_type == "extrude_add" and (end_condition_code in (6, 8) or reverse_end_condition_code in (6, 8)): + code.append(" # SW mid-plane/two-sided extrusion represented by this IR") + distance = distance / 2 + reverse_distance = distance + both_directions = True + elif op_type == "extrude_cut" and end_condition_code not in (None, 0): + distance = max(distance, reverse_distance, THROUGH_CUT_AMOUNT_MM) + both_directions = both_directions or end_condition_code in (1, 2, 9) + code.append(f" # TODO: exact sw_extrude_cut_{end_condition}; using long cutter") + + if both_directions: + amount = max(distance, reverse_distance) if reverse_distance > 0 else distance + if op_type == "extrude_cut": + code.append(f" cutter = extrude(sketch.sketch, amount={amount}, both=True)") + if flip_side_to_cut: + normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) + if outer_radius is not None and inner_radius is not None: + code.append( + " result = sw_flip_side_step_cut(" + f"result, cutter, normal={_tuple3(normal)}, " + f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" + ) + else: + code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") + else: + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(f" solid = extrude(sketch.sketch, amount={amount}, both=True)") + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + elif op_type == "extrude_cut": + if distance > 0: + cut_amount = distance if params.get("reverse_direction", False) else -distance + code.append(f" cutter = extrude(sketch.sketch, amount={cut_amount})") + # 当盲拉伸从不同于草图的起始面开始时,平移cutter到正确位置 + if prefer_blind_sketch: + face_offset = _blind_extrude_face_offset(op, sketch or {}) + if face_offset is not None: + code.append(f" cutter = cutter.locate(Location({_tuple3(face_offset)}))") + if flip_side_to_cut: + normal = (sketch or {}).get("workplane", {}).get("normal", [0, 0, 1]) + if outer_radius is not None and inner_radius is not None: + code.append( + " result = sw_flip_side_step_cut(" + f"result, cutter, normal={_tuple3(normal)}, " + f"outer_radius_mm={outer_radius}, inner_radius_mm={inner_radius})" + ) + else: + code.append(f" result = sw_inverted_profile_cut(result, cutter, normal={_tuple3(normal)})") + else: + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(" # Skip: zero distance cut") + else: + add_amount = -distance if params.get("reverse_direction", False) else distance + code.append(f" solid = extrude(sketch.sketch, amount={add_amount})") + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + + return code + +def _owned_cylindrical_cut_faces(op: Dict[str, Any], sketch: Dict[str, Any]) -> list[Dict[str, Any]]: + if op.get("type") != "extrude_cut": + return [] + sketch_radii = [ + abs(float(entity.get("radius_mm") or 0)) + for entity in sketch.get("entities", []) or [] + if not entity.get("construction") and entity.get("type") == "circle" + ] + if not sketch_radii: + return [] + matched = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = surface.get("cylinder_params") + bbox = face.get("box_m") + if not (surface.get("is_cylinder") and isinstance(params, list) and len(params) >= 7): + continue + if not (isinstance(bbox, list) and len(bbox) >= 6): + continue + radius_mm = abs(float(params[6]) * 1000) + if not any(abs(radius_mm - sketch_radius) <= max(0.05, sketch_radius * 0.01) for sketch_radius in sketch_radii): + continue + matched.append(face) + return matched + +def _blind_extrude_face_offset( + op: Dict[str, Any], + sketch: Dict[str, Any], +) -> Optional[list[float]]: + """当盲拉伸从不同于草图的起始面开始时,计算cutter的3D平移向量。 + 返回None表示不需要平移。""" + faces = (op.get("source_owned_faces") or []) + if not faces: + return None + valid_bboxes = [] + for face in faces: + bm = face.get("box_m") + if isinstance(bm, list) and len(bm) >= 6: + valid_bboxes.append([float(v) * 1000 for v in bm[:6]]) + if not valid_bboxes: + return None + normal = (sketch.get("workplane") or {}).get("normal") + if not isinstance(normal, list) or len(normal) < 3: + return None + origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] + # 确定主导轴 (extrude方向) + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + normal_sign = 1.0 if float(normal[axis]) >= 0 else -1.0 + sketch_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else 0.0 + # 取离草图平面最近的面坐标,使cutter从面的最近点开始切入 + # 对于多个面,面可能在草图平面两侧。 + all_coords = [] + for b in valid_bboxes: + all_coords.append(b[axis]) + all_coords.append(b[axis + 3]) + if not all_coords: + return None + # 找离sketch_coord最近的面坐标 + face_coord = min(all_coords, key=lambda c: abs(c - sketch_coord)) + offset = face_coord - sketch_coord + if abs(offset) < 1e-3: + return None + # 返回3D平移向量(仅沿extrude方向) + result = [0.0, 0.0, 0.0] + result[axis] = offset + return result + +def _prefer_blind_sketch_extrude( + op: Dict[str, Any], + sketch: Dict[str, Any], + distance_mm: float, + end_condition_code: Optional[int], + owned_cylinder_faces: list[Dict[str, Any]], +) -> bool: + """优先使用盲拉伸而非 bbox 回退。对于矩形/圆等简单截面, + 盲拉伸比包围盒近似精确得多。含弧的复杂截面可能因方向问题 + 产生意外偏差,此时仍走 bbox 路径。""" + if owned_cylinder_faces: + return False + if end_condition_code not in (None, 0) or distance_mm <= 0: + return False + if not _sketch_has_buildable_profile(sketch): + return False + # 有 owned_faces 的矩形或纯圆截面: 盲拉伸比 bbox 更精确 + entities = sketch.get("entities", []) or [] + non_const = [e for e in entities if not e.get("construction", False)] + types = {e.get("type") for e in non_const if e.get("type") not in ("point", "text")} + # 排除point/text后仍是简单截面才用盲拉伸。 + # 但如果面位于不同平面,让_blind_extrude_face_offset处理 + is_simple = types <= {"line"} or types <= {"circle"} + if not is_simple: + return False + # 检查草图平面与面是否有关键偏移 - 只有当盲拉伸需要偏移修正时才使用 + faces = op.get("source_owned_faces") or [] + if faces and _blind_extrude_face_offset(op, sketch) is not None: + return True # 有面偏移,需要盲拉伸+offset修正 + # 无面偏移时,只有当start/end引用完整时才用盲拉伸 + if op.get("start_reference") or op.get("end_reference"): + return True + return False + +def _owned_bbox_cut(op: Dict[str, Any], sketch: Dict[str, Any], distance_mm: float) -> Optional[list[float]]: + if op.get("type") != "extrude_cut": + return None + faces = [ + face for face in (op.get("source_owned_faces") or []) + if isinstance(face, dict) and isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 + ] + if not faces: + return None + bboxes = [[float(value) * 1000 for value in face["box_m"][:6]] for face in faces] + bbox = [ + min(box[axis] for box in bboxes) if axis < 3 else max(box[axis] for box in bboxes) + for axis in range(6) + ] + normal = (sketch.get("workplane") or {}).get("normal") or [0, 0, 1] + if not isinstance(normal, list) or len(normal) < 3: + return None + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + extent = abs(bbox[axis + 3] - bbox[axis]) + origin = (sketch.get("workplane") or {}).get("origin_mm") or [0, 0, 0] + origin_coord = float(origin[axis]) if isinstance(origin, list) and len(origin) > axis else None + distance = abs(float(distance_mm or 0)) + origin_outside = ( + origin_coord is not None + and (origin_coord < min(bbox[axis], bbox[axis + 3]) - 1e-6 or origin_coord > max(bbox[axis], bbox[axis + 3]) + 1e-6) + ) + if extent <= distance * 1.25 and not origin_outside: + return None + return bbox + +def _generate_revolve(op: Dict[str, Any], sketch: Optional[Dict[str, Any]] = None) -> list[str]: + params = op.get("parameters", {}) + angle = params.get("angle_deg") + if angle is None and params.get("angle_rad") is not None: + angle = float(params.get("angle_rad")) * 180 / math.pi + if angle is None: + angle = 360 + if abs(angle - 360) < 1e-6: + angle = 360 + op_type = op.get("type", "") + name = op.get("name", "") + code = [f" # {op_type}: {name}"] + axis_expr = _revolve_axis_expr(params, sketch or {}) + code.append(f" revolve_axis = {axis_expr}") + if op_type == "revolve_cut": + code.append(f" cutter = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") + code.append(" # Force OCCT to fully evaluate both solids before Boolean ops") + code.append(" _ = list(cutter.solids()); _ = cutter.is_valid; _ = cutter.volume") + code.append(" _ = list(result.solids()); _ = result.is_valid; _ = result.volume") + code.append(" # Use a single subtract and capture the result directly (avoids OCCT heisenbug)") + code.append(" result = safe_subtract(result, cutter)") + else: + code.append(f" solid = revolve(sketch.sketch, axis=revolve_axis, revolution_arc={angle})") + preserve_visible = bool(op.get("source_owned_faces")) + code.append(f" result = safe_union(result, solid, preserve_visible={preserve_visible})") + return code + +def _revolve_axis_expr(params: Dict[str, Any], sketch: Dict[str, Any]) -> str: + # 优先使用草图中的构造线作为旋转轴, + # 因为它保证位于草图平面上(SW 的 revolve 操作依赖于此) + construction_axis = _sketch_construction_axis(sketch) + if construction_axis: + origin, direction = construction_axis + return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" + + axis_reference = params.get("axis_reference") or {} + if axis_reference.get("origin_mm") and axis_reference.get("direction"): + return f"Axis({_tuple3(axis_reference['origin_mm'])}, {_tuple3(axis_reference['direction'])})" + for candidate in params.get("axis_candidates") or []: + if candidate.get("model_start_mm") and candidate.get("model_direction"): + return f"Axis({_tuple3(candidate['model_start_mm'])}, {_tuple3(candidate['model_direction'])})" + + workplane = sketch.get("workplane", {}) + origin = workplane.get("origin_mm", [0, 0, 0]) + direction = workplane.get("x_dir", [1, 0, 0]) + return f"Axis({_tuple3(origin)}, {_tuple3(direction)})" + +def _sketch_construction_axis( + sketch: Dict[str, Any], +) -> Optional[tuple[list[float], list[float]]]: + workplane = sketch.get("workplane", {}) + origin = [float(v) for v in workplane.get("origin_mm", [0, 0, 0])] + x_dir = [float(v) for v in workplane.get("x_dir", [1, 0, 0])] + y_dir = [float(v) for v in workplane.get("y_dir", [0, 1, 0])] + + for entity in sketch.get("entities", []): + if entity.get("type") != "line" or not entity.get("construction"): + continue + start = entity.get("start") + end = entity.get("end") + if not start or not end: + continue + start_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, start) + end_3d = _sketch_point_to_model_from_basis(origin, x_dir, y_dir, end) + direction = [end_3d[i] - start_3d[i] for i in range(3)] + length = math.sqrt(sum(component * component for component in direction)) + if length <= 0: + continue + return start_3d, [component / length for component in direction] + return None + +def _sketch_point_to_model_from_basis( + origin: list[float], x_dir: list[float], y_dir: list[float], point: list[float] +) -> list[float]: + return [ + origin[i] + x_dir[i] * float(point[0]) + y_dir[i] * float(point[1]) + for i in range(3) + ] + +def _generate_fillet(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + radius = params.get("radius_mm") + selectors = op.get("selectors", []) + owned_faces = op.get("source_owned_faces") or [] + if not radius or float(radius) <= 0: + return [f" # Fillet skipped: source radius missing for {op.get('name', '')}"] + return [ + f" # Fillet: {op.get('name', '')}", + " result = fillet_selected(" + f"result, radius={radius}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", + ] + +def _generate_chamfer(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + distance = params.get("distance_mm") + selectors = op.get("selectors", []) + owned_faces = op.get("source_owned_faces") or [] + if not distance or float(distance) <= 0: + return [f" # Chamfer skipped: source distance missing for {op.get('name', '')}"] + return [ + f" # Chamfer: {op.get('name', '')}", + " result = chamfer_selected_with_owned_faces(" + f"result, distance={distance}, selectors={repr(selectors)}, owned_faces={repr(owned_faces)})", + ] + +def _generate_move_face(op: Dict[str, Any]) -> list[str]: + data = (op.get("parameters") or {}).get("move_face_data") or {} + selected_faces = data.get("selected_faces") or [] + return [ + f" # MoveFace pure-JSON operation: {op.get('name', '')}", + " raise NotImplementedError(", + f" 'MoveFace native build123d replay is pending; captured selected_faces={len(selected_faces)}'", + " )", + ] + +def _hole_should_use_sw_cut_holes(params: Dict[str, Any], owned_cut_faces: list[Dict[str, Any]]) -> bool: + positions = params.get("positions") or [] + diameter = _hole_diameter_mm(params) + if not positions or diameter <= 0: + return False + if len(owned_cut_faces) <= 1: + return False + has_cone_owned = any((face.get("surface") or {}).get("is_cone") for face in owned_cut_faces) + drill_angle = _hole_drill_angle_rad(params) + if has_cone_owned and not (_hole_has_drill_tip(params) and drill_angle > 0): + return False + counterbore_diameter = _hole_counterbore_diameter_mm(params) + counterbore_depth = _hole_counterbore_depth_mm(params) + if counterbore_diameter > diameter and counterbore_depth > 0: + return True + return _hole_has_through_dimension(params) + +def _effective_hole_cut_depth_mm(params: Dict[str, Any]) -> float: + if _hole_has_through_dimension(params): + return THROUGH_CUT_AMOUNT_MM + return _hole_depth_mm(params) + +def _generate_hole(op: Dict[str, Any]) -> list[str]: + params = op.get("parameters", {}) + diameter = _hole_diameter_mm(params) + depth = _effective_hole_cut_depth_mm(params) + drill_angle = _hole_drill_angle_rad(params) + include_drill_tip = _hole_has_drill_tip(params) + countersink_diameter = _hole_countersink_diameter_mm(params) + countersink_angle = _hole_countersink_angle_rad(params) + counterbore_diameter = _hole_counterbore_diameter_mm(params) + counterbore_depth = _hole_counterbore_depth_mm(params) + positions = [pos.get("mm") for pos in params.get("positions", []) if pos.get("mm")] + host_face = params.get("host_face") or {} + owned_cut_faces = _hole_owned_cut_faces(op) + # Feature position sketches are occasionally incomplete in the plugin export + # (notably for wizard holes with multiple instances). The faces owned by the + # feature are the authoritative result from SolidWorks, including every hole + # location, counterbore, countersink, and drill tip. Prefer replaying those + # surfaces whenever they are available; fall back to the parametric cutter + # only when the exporter has no usable owned-face geometry. + if owned_cut_faces: + return [ + f" # Hole: {op.get('name', '')}", + " # Replay hole from SW owned cut faces to preserve side and axis", + f" result = cut_owned_cylindrical_faces(result, {repr(owned_cut_faces)})", + ] + return [ + f" # Hole: {op.get('name', '')}", + f" result = sw_cut_holes(result, positions={json.dumps(positions)}, host_face={json.dumps(host_face)}, diameter={diameter}, depth={depth}, drill_angle={drill_angle}, include_drill_tip={include_drill_tip}, countersink_diameter={countersink_diameter}, countersink_angle={countersink_angle}, counterbore_diameter={counterbore_diameter}, counterbore_depth={counterbore_depth})", + ] + +def _hole_owned_cut_faces(op: Dict[str, Any]) -> list[Dict[str, Any]]: + matched = [] + for face in op.get("source_owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + bbox = face.get("box_m") + has_cylinder = ( + surface.get("is_cylinder") + and isinstance(surface.get("cylinder_params"), list) + and len(surface.get("cylinder_params") or []) >= 7 + ) + has_cone = ( + surface.get("is_cone") + and isinstance(surface.get("cone_params"), list) + and len(surface.get("cone_params") or []) >= 8 + ) + if not (has_cylinder or has_cone): + continue + if not (isinstance(bbox, list) and len(bbox) >= 6): + continue + matched.append(face) + return matched + +def _generate_linear_pattern( + op: Dict[str, Any], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], + references: Dict[str, Any], +) -> list[str]: + params = op.get("parameters", {}) + source_features = params.get("source_features") or [] + offsets = _linear_pattern_offsets(op) + code = [f" # Linear pattern: {op.get('name', '')}"] + + if not source_features or not offsets: + code.append(" # Skip: no source features or pattern offsets") + return code + + for source_feature in source_features: + source_op = _find_operation_for_source_feature(operations, source_feature) + if not source_op: + code.append(f" # Skip: source feature not found {source_feature.get('name')}") + continue + + for offset_index, offset in enumerate(offsets, start=1): + copied_op = _translated_operation(source_op, offset) + copied_op["name"] = f"{source_op.get('name', '')} pattern copy {offset_index}" + op_type = copied_op.get("type") + + if op_type == "hole": + code.extend(_generate_hole(copied_op)) + elif op_type in ("extrude_cut", "extrude_add", "revolve_cut", "revolve_add"): + source_sketch_id = copied_op.get("sketch") + source_sketch = sketches.get(source_sketch_id or "") + if not source_sketch: + code.append(f" # Skip: source sketch not found for {copied_op.get('name')}") + continue + if not _sketch_has_buildable_profile(source_sketch): + code.append(f" # Skip: source sketch has no buildable profile for {copied_op.get('name')}") + continue + + copied_sketch = _translated_sketch(source_sketch, offset, f"{source_sketch_id}_pattern_{offset_index}") + code.extend(_generate_sketch(copied_sketch, references, copied_op)) + if op_type in ("extrude_cut", "extrude_add"): + code.extend(_generate_extrude(copied_op, copied_sketch, operations, sketches)) + else: + code.extend(_generate_revolve(copied_op, copied_sketch)) + else: + code.append(f" # TODO: pattern source type {op_type}") + + return code + +def _generate_mirror_pattern( + op: Dict[str, Any], + operations: list[Dict[str, Any]], + sketches: Dict[str, Dict[str, Any]], + references: Dict[str, Any], +) -> list[str]: + """生成镜像代码。SW MirrorPattern 镜像的是特征而非整体,因此必须先切掉镜像面负侧的实体,只保留正侧一半再镜像。""" + params = op.get("parameters", {}) + source_features = params.get("source_features") or [] + raw = op.get("raw_parameters", {}) + mirror_plane_info = raw.get("mirror_plane") or {} + + code = [f" # Mirror pattern: {op.get('name', '')}"] + + plane_origin = _extract_mirror_plane_origin(raw, mirror_plane_info) + plane_normal = _extract_mirror_plane_normal(raw, mirror_plane_info) + + mx = plane_origin[0] if plane_origin else 0.0 + my = plane_origin[1] if plane_origin else 0.0 + mz = plane_origin[2] if plane_origin else 0.0 + nx = plane_normal[0] if plane_normal else 0.0 + ny = plane_normal[1] if plane_normal else 0.0 + nz = plane_normal[2] if plane_normal else 1.0 + + code.append(f" mirror_plane = Plane(origin=({mx}, {my}, {mz}), z_dir=({nx}, {ny}, {nz}))") + code.append(f" mx, my, mz = {mx}, {my}, {mz}") + code.append(f" nx, ny, nz = {nx}, {ny}, {nz}") + code.append(f" try:") + code.append(f" bbox = result.bounding_box()") + code.append(f" margin = 10.0") + # Determine dominant axis and cut away the -normal side + adx, ady, adz = abs(nx), abs(ny), abs(nz) + if adx >= ady and adx >= adz: + if nx > 0: + code.append(f" cut_w = (mx - bbox.min.X) + margin") + code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_w = (bbox.max.X - mx) + margin") + code.append(f" cut_box = Solid.make_box(cut_w, bbox.max.Y - bbox.min.Y + 2*margin, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((mx, bbox.min.Y - margin, bbox.min.Z - margin))") + elif ady >= adx and ady >= adz: + if ny > 0: + code.append(f" cut_h = (my - bbox.min.Y) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_h = (bbox.max.Y - my) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, cut_h, bbox.max.Z - bbox.min.Z + 2*margin)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, my, bbox.min.Z - margin))") + else: + if nz > 0: + code.append(f" cut_d = (mz - bbox.min.Z) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, bbox.min.Z - margin))") + else: + code.append(f" cut_d = (bbox.max.Z - mz) + margin") + code.append(f" cut_box = Solid.make_box(bbox.max.X - bbox.min.X + 2*margin, bbox.max.Y - bbox.min.Y + 2*margin, cut_d)") + code.append(f" cut_box = cut_box.translate((bbox.min.X - margin, bbox.min.Y - margin, mz))") + code.append(f" half = result.cut(cut_box)") + code.append(f" mirrored = half.mirror(mirror_plane)") + code.append(f" result = half.fuse(mirrored).clean()") + code.append(f" except Exception as e:") + code.append(f" print(f'mirror failed: {{e}}')") + return code + +def _extract_mirror_plane_origin(raw: dict, mirror_plane_info: dict): + mir_origin = raw.get("mirror_plane_origin") + if mir_origin and isinstance(mir_origin, (list, tuple)) and len(mir_origin) >= 3: + return (float(mir_origin[0]), float(mir_origin[1]), float(mir_origin[2])) + origin_list = mirror_plane_info.get("origin_mm") or mirror_plane_info.get("origin") or [] + if origin_list and len(origin_list) >= 3: + return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) + frame = mirror_plane_info.get("frame") + if isinstance(frame, dict): + origin_list = frame.get("origin") or [] + if origin_list and len(origin_list) >= 3: + return (float(origin_list[0]), float(origin_list[1]), float(origin_list[2])) + return None + +def _extract_mirror_plane_normal(raw: dict, mirror_plane_info: dict): + mir_normal = raw.get("mirror_plane_normal") + if mir_normal and isinstance(mir_normal, (list, tuple)) and len(mir_normal) >= 3: + return (float(mir_normal[0]), float(mir_normal[1]), float(mir_normal[2])) + normal_list = mirror_plane_info.get("normal") or [] + if normal_list and len(normal_list) >= 3: + return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) + frame = mirror_plane_info.get("frame") + if isinstance(frame, dict): + normal_list = frame.get("normal") or [] + if normal_list and len(normal_list) >= 3: + return (float(normal_list[0]), float(normal_list[1]), float(normal_list[2])) + return None + +def _find_operation_for_source_feature( + operations: list[Dict[str, Any]], source_feature: Dict[str, Any] +) -> Optional[Dict[str, Any]]: + source_index = source_feature.get("index") + source_name = source_feature.get("name") + source_identity = source_feature.get("identity") if isinstance(source_feature.get("identity"), dict) else {} + source_stable_id = source_feature.get("stable_id") or source_identity.get("stable_id") + source_persistent_reference = source_feature.get("persistent_reference") or source_identity.get("persistent_reference") + for op in operations: + op_source = op.get("source_feature", {}) + if source_index is not None and op_source.get("index") == source_index: + return op + for op in operations: + op_source = op.get("source_feature", {}) + op_identity = op_source.get("identity") if isinstance(op_source.get("identity"), dict) else {} + if source_stable_id and ( + op_source.get("stable_id") == source_stable_id + or op_identity.get("stable_id") == source_stable_id + ): + return op + if source_persistent_reference and ( + op_source.get("persistent_reference") == source_persistent_reference + or op_identity.get("persistent_reference") == source_persistent_reference + ): + return op + for op in operations: + if source_name and op.get("name") == source_name: + return op + return None + +def _find_source_operation_for_pattern( + operations: list[Dict[str, Any]], source_features: list[Dict[str, Any]] +) -> Optional[Dict[str, Any]]: + for source_feature in source_features: + source_op = _find_operation_for_source_feature(operations, source_feature) + if source_op: + return source_op + return None + +def _linear_pattern_offsets(op: Dict[str, Any]) -> list[tuple[float, float, float]]: + params = op.get("parameters", {}) + raw = op.get("raw_parameters", {}) + explicit_offsets = raw.get("explicit_offsets_mm") + if isinstance(explicit_offsets, list) and explicit_offsets: + return [ + (float(offset[0]), float(offset[1]), float(offset[2])) + for offset in explicit_offsets + if isinstance(offset, list) and len(offset) >= 3 + ] + d1_count = int(raw.get("d1_total_instances") or params.get("total_instances") or 1) + d2_count = int(raw.get("d2_total_instances") or 1) + d1_spacing = float(raw.get("d1_spacing_mm") or params.get("spacing_mm") or 0) + d2_spacing = float(raw.get("d2_spacing_mm") or 0) + d1_vector = _pattern_direction_vector(raw.get("direction1") or params.get("direction1"), d1_spacing) + d2_vector = _pattern_direction_vector(raw.get("direction2") or params.get("direction2"), d2_spacing) + + offsets = [] + for i in range(d1_count): + for j in range(d2_count): + if i == 0 and j == 0: + continue + offsets.append(tuple(d1_vector[k] * i + d2_vector[k] * j for k in range(3))) + return offsets + +def _pattern_direction_vector(direction: Optional[Dict[str, Any]], spacing: float) -> tuple[float, float, float]: + if not direction or not spacing: + return (0.0, 0.0, 0.0) + direct_vector = direction.get("vector") + if isinstance(direct_vector, list) and len(direct_vector) >= 3: + vector = tuple(float(direct_vector[i]) for i in range(3)) + length = math.sqrt(sum(component * component for component in vector)) + if length <= 0: + return (0.0, 0.0, 0.0) + return tuple(component / length * spacing for component in vector) + start = direction.get("start", {}).get("mm") + end = direction.get("end", {}).get("mm") + if not start or not end: + return (0.0, 0.0, 0.0) + vector = tuple(float(end[i]) - float(start[i]) for i in range(3)) + length = math.sqrt(sum(component * component for component in vector)) + if length <= 0: + return (0.0, 0.0, 0.0) + return tuple(component / length * spacing for component in vector) + +def _translated_operation(op: Dict[str, Any], offset: tuple[float, float, float]) -> Dict[str, Any]: + copied = deepcopy(op) + params = copied.get("parameters") or {} + axis_reference = params.get("axis_reference") + if isinstance(axis_reference, dict) and isinstance(axis_reference.get("origin_mm"), list): + origin = list(axis_reference.get("origin_mm") or [0, 0, 0]) + origin = (origin + [0, 0, 0])[:3] + axis_reference["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] + + if copied.get("type") == "hole": + positions = params.get("positions") or [] + local_offset = _model_offset_to_host_local(offset, params.get("host_face") or {}) + for position in positions: + if position.get("mm"): + point = list(position.get("mm") or [0, 0, 0]) + point = (point + [0, 0, 0])[:3] + position["mm"] = [ + float(point[0]) + local_offset[0], + float(point[1]) + local_offset[1], + float(point[2]) + local_offset[2], + ] + if position.get("m"): + position["m"] = [value / 1000 for value in position.get("mm", [])] + if any(abs(float(offset[i])) > 1e-9 for i in range(3)): + owned_faces = copied.get("source_owned_faces") or [] + if owned_faces: + copied["source_owned_faces"] = _translate_owned_faces(owned_faces, offset) + return copied + +def _translate_owned_faces( + faces: list[Dict[str, Any]], + offset: tuple[float, float, float], +) -> list[Dict[str, Any]]: + translated = [] + shift_mm = (float(offset[0]), float(offset[1]), float(offset[2])) + shift_m = (shift_mm[0] / 1000.0, shift_mm[1] / 1000.0, shift_mm[2] / 1000.0) + for face in faces: + if not isinstance(face, dict): + continue + copied = deepcopy(face) + box = copied.get("box_m") + if isinstance(box, list) and len(box) >= 6: + copied["box_m"] = [ + float(box[0]) + shift_m[0], + float(box[1]) + shift_m[1], + float(box[2]) + shift_m[2], + float(box[3]) + shift_m[0], + float(box[4]) + shift_m[1], + float(box[5]) + shift_m[2], + ] + surface = copied.get("surface") + if isinstance(surface, dict): + for key in ("cylinder_params", "cone_params"): + params = surface.get(key) + if isinstance(params, list) and len(params) >= 3: + updated = list(params) + updated[0] = float(updated[0]) + shift_m[0] + updated[1] = float(updated[1]) + shift_m[1] + updated[2] = float(updated[2]) + shift_m[2] + surface[key] = updated + translated.append(copied) + return translated + +def _model_offset_to_host_local( + offset: tuple[float, float, float], + host_face: Dict[str, Any], +) -> tuple[float, float, float]: + frame = host_face.get("frame") if isinstance(host_face, dict) else {} + if not isinstance(frame, dict): + return offset + x_dir = frame.get("x_dir") + y_dir = frame.get("y_dir") + if not ( + isinstance(x_dir, list) + and len(x_dir) >= 3 + and isinstance(y_dir, list) + and len(y_dir) >= 3 + ): + return offset + local_x = sum(float(offset[i]) * float(x_dir[i]) for i in range(3)) + local_y = sum(float(offset[i]) * float(y_dir[i]) for i in range(3)) + return (local_x, local_y, 0.0) + +def _translated_sketch( + sketch: Dict[str, Any], offset: tuple[float, float, float], sketch_id: str +) -> Dict[str, Any]: + copied = deepcopy(sketch) + copied["id"] = sketch_id + copied["name"] = f"{sketch.get('name', sketch_id)} pattern copy" + workplane = copied.setdefault("workplane", {}) + origin = list(workplane.get("origin_mm") or [0, 0, 0]) + origin = (origin + [0, 0, 0])[:3] + workplane["origin_mm"] = [float(origin[i]) + float(offset[i]) for i in range(3)] + return copied + +def _translate_sketch_entities(sketch: Dict[str, Any], offset: tuple[float, float, float]) -> None: + dx, dy = offset[0], offset[1] + for entity in sketch.get("entities", []): + for key in ("start", "end", "center"): + point = entity.get(key) + if isinstance(point, list) and len(point) >= 2: + point[0] = float(point[0]) + dx + point[1] = float(point[1]) + dy + raw = entity.get("raw", {}) + for key in ("start", "end", "center"): + raw_point = raw.get(key) + if isinstance(raw_point, dict): + mm = raw_point.get("mm") + if isinstance(mm, list) and len(mm) >= 2: + mm[0] = float(mm[0]) + dx + mm[1] = float(mm[1]) + dy + raw_point["m"] = [value / 1000 for value in mm] + +def _hole_diameter_mm(params: Dict[str, Any]) -> float: + if params.get("diameter_mm"): + return float(params["diameter_mm"]) + diameters = params.get("diameters_m", {}) + for key in ( + "hole_diameter", + "thru_hole_diameter", + "tap_drill_diameter", + "thru_tap_drill_diameter", + "thread_diameter", + "diameter", + ): + value = diameters.get(key) + if value: + return float(value) * 1000 + return 0 + +def _hole_depth_mm(params: Dict[str, Any]) -> float: + if params.get("depth_mm"): + return float(params["depth_mm"]) + depths = params.get("depths_m", {}) + for key in ( + "hole_depth", + "thru_hole_depth", + "tap_drill_depth", + "thru_tap_drill_depth", + "thread_depth", + "depth", + ): + value = depths.get(key) + if value: + return float(value) * 1000 + return THROUGH_CUT_AMOUNT_MM + +def _hole_drill_angle_rad(params: Dict[str, Any]) -> float: + angle = params.get("angles_rad", {}).get("drill_angle") + return float(angle) if angle else 0 + +def _hole_countersink_angle_rad(params: Dict[str, Any]) -> float: + angle = params.get("angles_rad", {}).get("countersink_angle") + return float(angle) if angle else 0 + +def _hole_countersink_diameter_mm(params: Dict[str, Any]) -> float: + diameter = params.get("countersink_diameter_mm") + return float(diameter) if diameter else 0 + +def _hole_counterbore_diameter_mm(params: Dict[str, Any]) -> float: + diameter = params.get("counterbore_diameter_mm") + return float(diameter) if diameter else 0 + +def _hole_counterbore_depth_mm(params: Dict[str, Any]) -> float: + depth = params.get("counterbore_depth_mm") + return float(depth) if depth else 0 + +def _hole_has_drill_tip(params: Dict[str, Any]) -> bool: + depths = params.get("depths_m", {}) + angle = _hole_drill_angle_rad(params) + if angle <= 0: + return False + through_depth_keys = ( + "thru_hole_depth", + "thru_tap_drill_depth", + ) + if any(depths.get(key) for key in through_depth_keys): + return False + if params.get("depth_mm"): + return True + return any(depths.get(key) for key in ("hole_depth", "tap_drill_depth", "depth")) + +def _hole_has_through_dimension(params: Dict[str, Any]) -> bool: + names = " ".join(str(name).lower() for name in params.get("dimension_names", []) or []) + return any(token in names for token in ("通孔", "through", "thru")) diff --git a/backend/engine/cdsl_engine/translator/common.py b/backend/engine/cdsl_engine/translator/common.py new file mode 100644 index 00000000..728e9827 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/common.py @@ -0,0 +1,194 @@ +"""Shared helpers for the SW-IR and code-generation sides of the translator. + +These small utilities are used by both ``ir`` (SolidWorks plugin JSON to +backend IR) and ``codegen`` (backend IR to build123d source). Anything used +by exactly one side lives in that side's module instead. +""" + +from __future__ import annotations + +import math +from typing import Any, Optional + +#: SolidWorks numeric end-condition codes, shared by IR conversion and codegen. +SW_END_CONDITIONS = { + 0: "Blind", + 1: "ThroughAll", + 2: "ThroughAllBoth", + 3: "UpToVertex", + 4: "UpToSurface", + 5: "OffsetFromSurface", + 6: "ThroughAllAndBlind", + 7: "UpToBody", + 8: "MidPlane", + 9: "ThroughNext", +} + +#: Generous through-cut length used when a termination reference is missing. +THROUGH_CUT_AMOUNT_MM = 200 + + +def _tuple3(values: Any) -> tuple[float, float, float]: + values = list(values or [0, 0, 0]) + values = (values + [0, 0, 0])[:3] + return tuple(values) + + +def _point_m_to_mm(point: Any) -> tuple[float, float, float]: + values = list(point or [0, 0, 0]) + values = (values + [0, 0, 0])[:3] + return tuple(float(value) * 1000 for value in values) + + +def _scale_point(point: Any) -> list[float]: + values = [0 if value is None else float(value) for value in (point or [0, 0])] + return [_scale_length(value) for value in values[:2]] + + +def _scale_length(value: Any) -> float: + value = 0 if value is None else float(value) + return value * 1000 if abs(value) <= 10 else value + + +def _to_degrees(value: Any) -> float: + value = 0 if value is None else float(value) + return value * 180 / 3.141592653589793 if abs(value) <= 6.283185307179586 else value + + +def _unit3(vector: list[Any]) -> list[float]: + raw = [float(vector[i]) for i in range(3)] + length = math.sqrt(sum(v * v for v in raw)) + if length <= 0: + return [0.0, 0.0, 0.0] + return [v / length for v in raw] + + +def _points_bbox(points: list[list[float]]) -> Optional[list[float]]: + if not points: + return None + return [ + min(point[0] for point in points), + min(point[1] for point in points), + min(point[2] for point in points), + max(point[0] for point in points), + max(point[1] for point in points), + max(point[2] for point in points), + ] + + +def _point_key(point: Any, places: int = 5) -> tuple[float, float] | None: + if not isinstance(point, list) or len(point) < 2: + return None + return (round(float(point[0]), places), round(float(point[1]), places)) + + +def _rounded_point_key(point: list[Any], digits: int = 5) -> tuple[float, float, float]: + z = point[2] if len(point) > 2 else 0 + return (round(float(point[0]), digits), round(float(point[1]), digits), round(float(z), digits)) + + +def _dedupe_points(points: list[list[float]]) -> list[list[float]]: + result = [] + seen = set() + for point in points: + key = _rounded_point_key(point) + if key in seen: + continue + seen.add(key) + result.append(point) + return result + + +def _is_near_origin(point: list[float], tolerance: float = 1e-6) -> bool: + return math.sqrt(sum(float(component) * float(component) for component in point[:3])) <= tolerance + + +def _similar_bbox_size(a: list[float], b: list[float], tolerance: float = 0.05) -> bool: + return all(abs(float(a[i]) - float(b[i])) <= tolerance for i in range(3)) + + +def _translated_bbox(bbox: list[float], offset: list[float]) -> list[float]: + return [ + bbox[0] + offset[0], + bbox[1] + offset[1], + bbox[2] + offset[2], + bbox[3] + offset[0], + bbox[4] + offset[1], + bbox[5] + offset[2], + ] + + +def _bbox_overflow_score(candidate: list[float], source: list[float]) -> float: + score = 0.0 + for axis in range(3): + score += max(source[axis] - candidate[axis], 0) + score += max(candidate[axis + 3] - source[axis + 3], 0) + return score + + +def _bbox_center_distance_score(candidate: list[float], source: list[float]) -> float: + score = 0.0 + for axis in range(3): + source_center = (source[axis] + source[axis + 3]) / 2 + candidate_center = (candidate[axis] + candidate[axis + 3]) / 2 + axis_size = max(source[axis + 3] - source[axis], 1.0) + score += abs(candidate_center - source_center) / axis_size + return score + + +def _bbox_area_2d(bbox: Optional[list[float]]) -> float: + if not isinstance(bbox, list) or len(bbox) < 4: + return 0.0 + return max(0.0, float(bbox[2]) - float(bbox[0])) * max(0.0, float(bbox[3]) - float(bbox[1])) + + +def _bbox_contains_2d(outer: Optional[list[float]], inner: Optional[list[float]], tolerance: float = 1e-6) -> bool: + if not isinstance(outer, list) or not isinstance(inner, list) or len(outer) < 4 or len(inner) < 4: + return False + return ( + float(outer[0]) <= float(inner[0]) + tolerance + and float(outer[1]) <= float(inner[1]) + tolerance + and float(outer[2]) >= float(inner[2]) - tolerance + and float(outer[3]) >= float(inner[3]) - tolerance + ) + + +def _bbox_overlap_ratio_2d(a: Optional[list[float]], b: Optional[list[float]]) -> float: + if not isinstance(a, list) or not isinstance(b, list) or len(a) < 4 or len(b) < 4: + return 0.0 + ix0 = max(float(a[0]), float(b[0])) + iy0 = max(float(a[1]), float(b[1])) + ix1 = min(float(a[2]), float(b[2])) + iy1 = min(float(a[3]), float(b[3])) + intersection = max(0.0, ix1 - ix0) * max(0.0, iy1 - iy0) + smaller = min(_bbox_area_2d(a), _bbox_area_2d(b)) + if smaller <= 1e-9: + return 0.0 + return intersection / smaller + + +def _loop_bbox(entities: list[dict[str, Any]]) -> Optional[list[float]]: + points = [] + for ent in entities: + if not isinstance(ent, dict): + continue + if ent.get("type") == "circle": + center = ent.get("center") + radius = ent.get("radius_mm") + if isinstance(center, list) and len(center) >= 2 and radius is not None: + radius_value = abs(float(radius)) + points.append([float(center[0]) - radius_value, float(center[1]) - radius_value]) + points.append([float(center[0]) + radius_value, float(center[1]) + radius_value]) + continue + for key in ("start", "end", "center"): + point = ent.get(key) + if isinstance(point, list) and len(point) >= 2: + points.append(point) + if not points: + return None + return [ + min(float(point[0]) for point in points), + min(float(point[1]) for point in points), + max(float(point[0]) for point in points), + max(float(point[1]) for point in points), + ] diff --git a/backend/engine/cdsl_engine/translator/ir.py b/backend/engine/cdsl_engine/translator/ir.py new file mode 100644 index 00000000..6517d727 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/ir.py @@ -0,0 +1,1959 @@ +"""SolidWorks plugin JSON to backend-IR conversion.""" + +from __future__ import annotations + +import json +import math +import os +from copy import deepcopy +from typing import Any, Dict, Optional + +from .common import ( + SW_END_CONDITIONS, + THROUGH_CUT_AMOUNT_MM, + _point_m_to_mm, + _scale_point, + _scale_length, + _to_degrees, + _unit3, + _points_bbox, + _rounded_point_key, + _dedupe_points, + _is_near_origin, + _similar_bbox_size, + _translated_bbox, + _bbox_overflow_score, + _bbox_center_distance_score, + _bbox_area_2d, + _loop_bbox, +) + + +def normalize_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: + """Normalize supported input formats to the backend internal IR.""" + if "operations" in data and "sketches" in data: + return enrich_rebuild_parameters(data) + + if "features" in data: + return enrich_rebuild_parameters(convert_sw_plugin_json_to_ir(data)) + + raise ValueError("Unsupported JSON format: expected internal IR or SW plugin features JSON") + +def enrich_rebuild_parameters(data: Dict[str, Any]) -> Dict[str, Any]: + """Add a generic editable-parameter index without changing feature history. + + The returned rebuild JSON remains the source of truth for execution. The + `editable_parameters` section is an index of JSON paths that a UI or caller + can modify safely while preserving the original feature order and links. + """ + enriched = dict(data) + enriched["editable_parameters"] = extract_editable_parameters(enriched) + enriched["parameterization_status"] = analyze_parameterization_status(enriched) + return enriched + +def analyze_parameterization_status(data: Dict[str, Any]) -> Dict[str, Any]: + issues = [] + + for sketch in data.get("sketches", []): + host_reference = sketch.get("host_reference", {}) + reference = host_reference.get("reference") or {} + if reference.get("kind") == "face" and not reference.get("owner_feature"): + issues.append({ + "kind": "missing_stable_face_owner", + "sketch": {"id": sketch.get("id"), "name": sketch.get("name")}, + "message": ( + "Sketch is attached to a face geometry, but the JSON does not identify " + "the owning feature/face id. Parameter edits may require updating this " + "sketch workplane manually unless the plugin exports stable face ownership." + ), + }) + + for op in data.get("operations", []): + if op.get("type") in ("unsupported", "unknown"): + sw_type = op.get("parameters", {}).get("sw_type") or op.get("type") + issues.append({ + "kind": "unsupported_geometry_feature", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": sw_type}, + "message": ( + f"SolidWorks feature '{sw_type}' is present in the history, but the " + "core build123d translator has no generic implementation for it. " + "The feature is retained in IR and must not be treated as a complete rebuild." + ), + }) + + if op.get("type") == "hole": + host_face = op.get("parameters", {}).get("host_face") or {} + if host_face and not host_face.get("frame"): + issues.append({ + "kind": "missing_hole_host_frame", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Hole feature has a host face, but the JSON does not include the " + "face-local x/y axes. The translator can infer common axis-aligned " + "cases, but the plugin should export the sketch/face frame for exact " + "generic hole placement." + ), + }) + + if op.get("type") == "extrude_cut": + end_code = op.get("parameters", {}).get("end_condition_code") + if end_code in (3, 4, 5, 7, 9): + params = op.get("parameters", {}) + has_termination_reference = any( + params.get(key) + for key in ( + "end_condition_reference", + "reverse_end_condition_reference", + "termination_reference", + ) + ) + kind = ( + "sw_end_condition_requires_exact_translator" + if has_termination_reference + else "missing_extrude_termination_reference" + ) + issues.append({ + "kind": kind, + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "end_condition_code": end_code, + "end_condition": SW_END_CONDITIONS.get(end_code), + "message": ( + "This SW cut uses a non-blind end condition. ThroughAll can be " + "replayed generically, but ThroughNext/UpTo-style rebuilds need the " + "selected terminating face/body/reference from the plugin for exact 1:1." + ), + }) + + if op.get("type") in ("revolve_cut", "revolve_add"): + axis_reference = op.get("parameters", {}).get("axis_reference") + if not axis_reference or not ( + isinstance(axis_reference, dict) + and axis_reference.get("origin_mm") + and axis_reference.get("direction") + ): + axis_candidates = op.get("parameters", {}).get("axis_candidates") or [] + if axis_candidates: + issues.append({ + "kind": "revolve_axis_inferred_from_candidate", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Revolve feature lacks the original SolidWorks selected axis, but " + "the translator can use a construction-line candidate. For exact " + "auditability the plugin should still export the selected axis " + "reference and selection mark." + ), + }) + continue + issues.append({ + "kind": "missing_revolve_axis_reference", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "Revolve feature does not include the SolidWorks selected axis. " + "The translator can only infer an axis from the sketch workplane, " + "which is not reliable enough for exact 1:1 rebuild." + ), + }) + + if op.get("type") in ("linear_pattern", "pattern_linear"): + params = op.get("parameters", {}) + if not params.get("source_features") or not _linear_pattern_offsets(op): + issues.append({ + "kind": "linear_pattern_missing_source_or_direction", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "This SW linear pattern lacks source-feature selection or direction data. " + "The translator can replay patterns when source features and offsets are " + "available; otherwise the plugin should export the selected feature list " + "and pattern direction references." + ), + }) + + if op.get("type") in ("fillet", "chamfer"): + selectors = op.get("selectors") or [] + if selectors and not any(_selector_has_persistent_reference(selector) for selector in selectors): + issues.append({ + "kind": "missing_original_feature_selection", + "feature": {"id": op.get("id"), "name": op.get("name"), "type": op.get("type")}, + "message": ( + "This feature only has final-geometry edge signatures. For exact replay " + "the plugin should export the original SolidWorks feature selections " + "including persistent references and selection marks." + ), + }) + + return { + "safe_to_edit": not issues, + "issues": issues, + } + +def _selector_has_persistent_reference(selector: Dict[str, Any]) -> bool: + stack = [selector] + while stack: + value = stack.pop() + if isinstance(value, dict): + if value.get("persistent_reference"): + return True + stack.extend(value.values()) + elif isinstance(value, list): + stack.extend(value) + return False + +def extract_editable_parameters(data: Dict[str, Any]) -> list[Dict[str, Any]]: + parameters: list[Dict[str, Any]] = [] + sketches = {sketch.get("id"): sketch for sketch in data.get("sketches", [])} + + for op_index, op in enumerate(data.get("operations", [])): + op_type = op.get("type", "") + op_name = op.get("name", op.get("id", f"operation_{op_index}")) + op_path = f"/operations/{op_index}" + params = op.get("parameters", {}) + + if op_type in ("extrude_add", "extrude_cut") and "distance_mm" in params: + semantic = "body_length" if op_type == "extrude_add" else "cut_depth" + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.distance_mm", + label=f"{op_name} distance", + semantic=semantic, + unit="mm", + value=params.get("distance_mm"), + path=f"{op_path}/parameters/distance_mm", + feature=op, + )) + + if "reverse_distance_mm" in params: + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.reverse_distance_mm", + label=f"{op_name} reverse distance", + semantic="reverse_depth", + unit="mm", + value=params.get("reverse_distance_mm"), + path=f"{op_path}/parameters/reverse_distance_mm", + feature=op, + )) + + if op_type in ("fillet", "chamfer"): + key = "radius_mm" if op_type == "fillet" else "distance_mm" + if key in params: + parameters.append(_editable_param( + id=f"{op.get('id', op_index)}.{key}", + label=f"{op_name} {key}", + semantic="fillet_radius" if op_type == "fillet" else "chamfer_distance", + unit="mm", + value=params.get(key), + path=f"{op_path}/parameters/{key}", + feature=op, + )) + + sketch_id = op.get("sketch") + sketch = sketches.get(sketch_id) + if sketch: + parameters.extend(_extract_sketch_parameters(sketch, sketch_id, op, op_index, data)) + + return parameters + +def _extract_sketch_parameters( + sketch: Dict[str, Any], + sketch_id: str, + op: Dict[str, Any], + op_index: int, + data: Dict[str, Any], +) -> list[Dict[str, Any]]: + parameters: list[Dict[str, Any]] = [] + sketch_index = next((i for i, item in enumerate(data.get("sketches", [])) if item.get("id") == sketch_id), None) + if sketch_index is None: + return parameters + + op_type = op.get("type", "") + entities = sketch.get("entities", []) + drawable = [entity for entity in entities if not entity.get("construction", False)] + + for entity_index, entity in enumerate(entities): + entity_type = entity.get("type") + entity_path = f"/sketches/{sketch_index}/entities/{entity_index}" + + if entity_type in ("circle", "arc") and entity.get("is_circle", entity_type == "circle"): + center = entity.get("center", [0, 0, 0]) + radius = entity.get("radius_mm") + semantic = "hole" if op_type == "extrude_cut" else "circle_profile" + if radius is not None: + parameters.append(_editable_param( + id=f"{sketch_id}.entity{entity_index}.radius_mm", + label=f"{sketch.get('name', sketch_id)} circle radius", + semantic=f"{semantic}_radius", + unit="mm", + value=radius, + path=f"{entity_path}/radius_mm", + feature=op, + )) + for axis, value in zip(("x", "y"), center[:2]): + parameters.append(_editable_param( + id=f"{sketch_id}.entity{entity_index}.center_{axis}", + label=f"{sketch.get('name', sketch_id)} {semantic} center {axis}", + semantic=f"{semantic}_center_{axis}", + unit="mm", + value=value, + path=f"{entity_path}/center/{0 if axis == 'x' else 1}", + feature=op, + )) + + bounds = _sketch_bounds(drawable) + if bounds: + min_x, min_y, max_x, max_y = bounds + center_x = (min_x + max_x) / 2 + center_y = (min_y + max_y) / 2 + width = max_x - min_x + height = max_y - min_y + semantic_prefix = "slot" if op_type == "extrude_cut" else "profile" + for suffix, value, semantic in ( + ("center_x", center_x, f"{semantic_prefix}_center_x"), + ("center_y", center_y, f"{semantic_prefix}_center_y"), + ("width", width, f"{semantic_prefix}_width"), + ("height", height, f"{semantic_prefix}_height"), + ): + parameters.append(_editable_param( + id=f"{sketch_id}.{suffix}", + label=f"{sketch.get('name', sketch_id)} {suffix}", + semantic=semantic, + unit="mm", + value=value, + path=f"/sketches/{sketch_index}", + feature=op, + editable=False, + note="Derived from sketch entity bounds; edit underlying entities to change this safely.", + )) + + workplane = sketch.get("workplane", {}) + origin = workplane.get("origin_mm") + if origin: + for axis, value in zip(("x", "y", "z"), origin[:3]): + parameters.append(_editable_param( + id=f"{sketch_id}.workplane_origin_{axis}", + label=f"{sketch.get('name', sketch_id)} workplane origin {axis}", + semantic=f"sketch_plane_origin_{axis}", + unit="mm", + value=value, + path=f"/sketches/{sketch_index}/workplane/origin_mm/{'xyz'.index(axis)}", + feature=op, + )) + + return parameters + +def _sketch_bounds(entities: list[Dict[str, Any]]) -> Optional[tuple[float, float, float, float]]: + points: list[tuple[float, float]] = [] + for entity in entities: + for key in ("start", "end", "center"): + point = entity.get(key) + if point and len(point) >= 2: + points.append((float(point[0]), float(point[1]))) + radius = entity.get("radius_mm") + center = entity.get("center") + if radius is not None and center and len(center) >= 2: + cx, cy = float(center[0]), float(center[1]) + r = float(radius) + points.extend([(cx - r, cy - r), (cx + r, cy + r)]) + if not points: + return None + xs = [point[0] for point in points] + ys = [point[1] for point in points] + return min(xs), min(ys), max(xs), max(ys) + +def _editable_param( + *, + id: str, + label: str, + semantic: str, + unit: str, + value: Any, + path: str, + feature: Dict[str, Any], + editable: bool = True, + note: Optional[str] = None, +) -> Dict[str, Any]: + result = { + "id": id, + "label": label, + "semantic": semantic, + "unit": unit, + "value": value, + "path": path, + "editable": editable, + "feature": { + "id": feature.get("id"), + "name": feature.get("name"), + "type": feature.get("type"), + "source_index": feature.get("source_feature", {}).get("index"), + }, + } + if note: + result["note"] = note + return result + +def convert_sw_plugin_json_to_ir(data: Dict[str, Any]) -> Dict[str, Any]: + """Convert the current SW plugin feature dump into the backend IR.""" + features = data.get("features", []) + sketches = [] + operations = [] + last_sketch_id = None + last_build_op = None + references = [] + source_bbox = _source_bbox_from_plugin_json(data) + + if data.get("document_kind") == "assembly" and isinstance(data.get("assembly_data"), dict): + operations.append(_convert_sw_assembly(data)) + part_name = data.get("part_name", "part") + return { + "version": "ir-0.1", + "metadata": { + "source": { + "format": "sw-plugin-json", + "file_name": f"{part_name}.sldasm", + "sw_version": data.get("sw_version"), + } + }, + "sketches": sketches, + "operations": operations, + "references": references, + "validation_hints": data.get("validation_hints", {}), + "geometry_inventory": data.get("geometry_inventory", {}), + "rebuild_contract": data.get("rebuild_contract", {}), + } + + for index, feature in enumerate(features): + if feature.get("is_suppressed"): + continue + + feature_type = feature.get("type", "") + type_name = feature.get("type_name", "") + feature_id = feature.get("id") or f"feat_{index:03d}" + feature_name = feature.get("name", feature_id) + + if feature_type in ("refplane", "refaxis"): + references.append(_convert_sw_reference(feature, index)) + elif feature_type == "sketch": + sketch_id = f"sketch_{len(sketches):03d}" + sketches.append(_convert_sw_sketch(feature, sketch_id, index)) + last_sketch_id = sketch_id + elif feature_type in ("extrude", "ice", "cut") and isinstance(feature.get("extrude_data"), dict): + sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id + op = _convert_sw_extrude(feature, type_name, sketch_ref, index) + operations.append(op) + last_build_op = op + elif feature_type == "revolve": + sketch_ref = _append_feature_source_sketches(feature, sketches, index) or last_sketch_id + op = _convert_sw_revolve(feature, type_name, sketch_ref, index) + operations.append(op) + last_build_op = op + elif feature_type == "hole": + op = _convert_sw_hole(feature, index) + operations.append(op) + last_build_op = op + elif feature_type == "pattern_linear": + data_block = feature.get("linear_pattern_data", {}) + source_op = _find_source_operation_for_pattern(operations, data_block.get("source_features") or []) + source_frame = _source_pattern_frame(source_op or last_build_op, sketches) + operations.append(_convert_sw_linear_pattern(feature, index, source_op or last_build_op, source_frame, sketches, source_bbox)) + elif feature_type == "pattern_mirror": + data_block = feature.get("mirror_data") or {} + src_features = data_block.get("source_features") or [] + mirror_origin = data_block.get("mirror_plane_origin") + mirror_normal = data_block.get("mirror_plane_normal") + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "pattern_mirror", + "parameters": {"source_features": src_features}, + "raw_parameters": { + "mirror_plane_origin": mirror_origin, + "mirror_plane_normal": mirror_normal, + }, + "source_feature": _source_feature(feature, index), + }) + elif feature_type == "fillet": + data_block = feature.get("fillet_data", {}) + radius_mm = data_block.get("radius") or _feature_length_dimension_mm(feature) + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "fillet", + "parameters": {"radius_mm": radius_mm}, + "selectors": _feature_selection_selectors(feature, data_block), + "selection_source": _feature_selection_source(feature, data_block), + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + }) + elif feature_type == "chamfer": + data_block = feature.get("chamfer_data", {}) + distance_mm = data_block.get("distance") or _feature_length_dimension_mm(feature) + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "chamfer", + "parameters": {"distance_mm": distance_mm}, + "selectors": _feature_selection_selectors(feature, data_block), + "selection_source": _feature_selection_source(feature, data_block), + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + }) + elif _is_imported_body_feature(feature): + op = _convert_sw_imported_body(feature, index) + operations.append(op) + last_build_op = op + elif feature_type == "moveface": + data_block = feature.get("move_face_data") if isinstance(feature.get("move_face_data"), dict) else {} + op = { + "id": feature_id, + "name": feature_name, + "type": "move_face", + "parameters": {"sw_type": type_name or feature_type, "move_face_data": data_block}, + "source_feature": _source_feature(feature, index), + } + operations.append(op) + last_build_op = op + elif feature_type not in _SW_METADATA_FEATURE_TYPES: + operations.append({ + "id": feature_id, + "name": feature_name, + "type": "unsupported", + "parameters": {"sw_type": type_name or feature_type}, + "source_feature": _source_feature(feature, index), + }) + + part_name = data.get("part_name", "part") + return { + "version": "ir-0.1", + "metadata": { + "source": { + "format": "sw-plugin-json", + "file_name": f"{part_name}.sldprt", + "sw_version": data.get("sw_version"), + } + }, + "sketches": sketches, + "operations": operations, + "references": references, + "validation_hints": data.get("validation_hints", {}), + "geometry_inventory": data.get("geometry_inventory", {}), + "rebuild_contract": data.get("rebuild_contract", {}), + } + +def _is_imported_body_feature(feature: Dict[str, Any]) -> bool: + feature_type = str(feature.get("type") or "").lower() + type_name = str(feature.get("type_name") or "").lower() + return bool(feature.get("imported_body_data")) or feature_type in { + "mbimport", + "savedextbody", + "importedbody", + "imported", + "stock", + } or type_name in {"mbimport", "savedextbody", "importedbody"} + +def _convert_sw_imported_body(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + data_block = feature.get("imported_body_data") if isinstance(feature.get("imported_body_data"), dict) else {} + solid_bodies = data_block.get("solid_bodies") or [] + solid_body_stats = data_block.get("solid_body_stats") or [] + source_name = feature.get("name") + parameters = { + "sw_type": feature.get("type_name") or feature.get("type"), + "source_name": source_name, + "history_status": data_block.get("history_status"), + "body_count": len(solid_bodies) if isinstance(solid_bodies, list) else len(solid_body_stats), + "solid_body_stats": solid_body_stats, + "solid_bodies": solid_bodies, + } + return { + "id": feature.get("id") or f"feat_{index:03d}", + "name": feature.get("name") or f"imported_body_{index:03d}", + "type": "imported_body", + "parameters": parameters, + "source_feature": _source_feature(feature, index), + "source_imported_body": data_block, + } + +def _convert_sw_assembly(data: Dict[str, Any]) -> Dict[str, Any]: + assembly_data = data.get("assembly_data") or {} + components = [] + for index, component in enumerate(assembly_data.get("components") or []): + if component.get("is_suppressed") or component.get("is_hidden"): + continue + path = component.get("path") or "" + component_name = component.get("name") or f"component_{index:03d}" + base_name = os.path.splitext(os.path.basename(str(path).replace("\\", "/")))[0] or component_name + components.append({ + "index": index, + "name": component_name, + "component_id": base_name, + "source_path": path, + "component_json": f"{base_name}.solidworks_rebuild_extract.json", + "transform": component.get("transform") or {}, + }) + return { + "id": "assembly_000", + "name": data.get("part_name") or "assembly", + "type": "assembly_compose", + "parameters": { + "components": components, + }, + "source_feature": {"index": 0, "name": data.get("part_name"), "type": "assembly"}, + } + +def _append_feature_source_sketches(feature: Dict[str, Any], sketches: list[Dict[str, Any]], index: int) -> Optional[str]: + """Promote feature-owned SW sketches into the rebuild sketch table.""" + source_sketches = [] + for block_name in ("extrude_data", "revolve_data"): + block = feature.get(block_name) + if isinstance(block, dict): + source_sketches.extend(sketch for sketch in (block.get("source_sketches") or []) if isinstance(sketch, dict)) + + if not source_sketches: + return None + + last_id = None + for sketch_data in source_sketches: + sketch_id = f"sketch_{len(sketches):03d}" + sketch_feature = dict(feature) + sketch_feature["sketch_data"] = sketch_data + if sketch_data.get("name"): + sketch_feature["name"] = sketch_data.get("name") + sketches.append(_convert_sw_sketch(sketch_feature, sketch_id, index)) + last_id = sketch_id + return last_id + +def _hole_dimension_value(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): + return float(dim.get("value")) + return None + +def _feature_length_dimension_mm(feature: Dict[str, Any]) -> Optional[float]: + candidates: list[tuple[int, float]] = [] + for dim in feature.get("dimensions") or []: + if not isinstance(dim, dict): + continue + name = str(dim.get("name") or "") + system_value = dim.get("system_value_m") + if system_value not in (None, ""): + length_mm = abs(float(system_value)) * 1000 + elif dim.get("value") not in (None, ""): + length_mm = abs(float(dim.get("value"))) + else: + continue + if length_mm <= 1e-9 or length_mm > 500: + continue + priority = 0 if name.startswith("D1@") else 1 + candidates.append((priority, length_mm)) + if not candidates: + return None + candidates.sort(key=lambda item: (item[0], item[1])) + return candidates[0][1] + +def _feature_selection_selectors( + feature: Dict[str, Any], + data_block: Optional[Dict[str, Any]] = None, +) -> list[Dict[str, Any]]: + selectors: list[Dict[str, Any]] = [] + seen: set[str] = set() + sources = [] + if isinstance(data_block, dict): + sources.extend(data_block.get("selections") or []) + sources.extend(feature.get("selections") or []) + + for selection in sources: + if not isinstance(selection, dict) or selection.get("kind") != "selection": + continue + geometry = selection.get("object") + if not isinstance(geometry, dict): + continue + kind = geometry.get("kind") + if kind not in ("edge", "face"): + continue + identity = geometry.get("identity") if isinstance(geometry.get("identity"), dict) else {} + stable_key = ( + geometry.get("stable_id") + or geometry.get("persistent_reference") + or identity.get("stable_id") + or identity.get("persistent_reference") + or json.dumps(geometry, sort_keys=True, ensure_ascii=False, default=str) + ) + if stable_key in seen: + continue + seen.add(str(stable_key)) + selectors.append({ + "kind": kind, + "geometry": geometry, + "mark": selection.get("mark"), + "source_feature": { + "name": selection.get("feature_name"), + "type_name": selection.get("feature_type_name"), + }, + }) + if selectors: + return selectors + + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + cylinder_params = surface.get("cylinder_params") + if not (surface.get("is_cylinder") and isinstance(cylinder_params, list) and len(cylinder_params) >= 7): + continue + radius_mm = abs(float(cylinder_params[6]) * 1000) + line_params = [ + float(cylinder_params[0]), + float(cylinder_params[1]), + float(cylinder_params[2]), + float(cylinder_params[3]), + float(cylinder_params[4]), + float(cylinder_params[5]), + ] + stable_key = f"owned_cylinder:{','.join(f'{value:.9g}' for value in line_params)}:{radius_mm:.6g}" + if stable_key in seen: + continue + seen.add(stable_key) + selectors.append({ + "kind": "edge", + "geometry": { + "kind": "edge", + "curve": { + "kind": "curve", + "is_line": True, + "line_params": line_params, + }, + "bbox_mm": [float(value) * 1000 for value in face.get("box_m", [])[:6]] + if isinstance(face.get("box_m"), list) and len(face.get("box_m")) >= 6 + else None, + }, + "tolerance_mm": max(0.5, radius_mm * 2.5), + "source": "owned_cylindrical_face_axis", + }) + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + box_m = face.get("box_m") + if not (isinstance(box_m, list) and len(box_m) >= 6): + continue + bbox_mm = [float(value) * 1000 for value in box_m[:6]] + if any(not math.isfinite(value) for value in bbox_mm): + continue + sizes = [abs(bbox_mm[i + 3] - bbox_mm[i]) for i in range(3)] + stable_key = f"owned_face_bbox:{','.join(f'{value:.9g}' for value in bbox_mm)}" + if stable_key in seen: + continue + seen.add(stable_key) + selectors.append({ + "kind": "edge", + "geometry": { + "kind": "edge", + "bbox_mm": bbox_mm, + }, + "tolerance_mm": max(0.5, min(max(sizes), 10.0) * 0.35), + "source": "owned_face_bbox", + }) + return selectors + +def _feature_selection_source( + feature: Dict[str, Any], + data_block: Optional[Dict[str, Any]] = None, +) -> str: + sources = [] + if isinstance(data_block, dict): + sources.extend(data_block.get("selections") or []) + sources.extend(feature.get("selections") or []) + if any(isinstance(item, dict) and item.get("kind") == "selection" for item in sources): + return "solidworks_original_selection" + if feature.get("owned_faces"): + return "post_feature_owned_face_inference" + return "missing" + +def _hole_dimension_value_excluding( + data_block: Dict[str, Any], + tokens: tuple[str, ...], + excluded: tuple[str, ...] = (), +) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if excluded and any(token.lower() in name for token in excluded): + continue + if all(token.lower() in name for token in tokens) and dim.get("value") not in (None, ""): + return float(dim.get("value")) + return None + +def _hole_primary_dimension_fallback(data_block: Dict[str, Any], prefer_small: bool) -> Optional[float]: + values = [] + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if not any(token in name for token in ("孔", "hole", "螺", "thread")): + continue + if any(token in name for token in ("沉头", "锥", "counter", "csk", "导头", "angle", "角度")): + continue + value = dim.get("value") + if value in (None, ""): + continue + number = abs(float(value)) + if 0 < number < 200: + values.append(number) + if not values: + return None + return min(values) if prefer_small else max(values) + +def _hole_primary_diameter_mm(data_block: Dict[str, Any]) -> float: + diameter = ( + _hole_dimension_value_excluding(data_block, ("tap", "drill", "dia"), ("depth", "angle")) + or _hole_dimension_value_excluding(data_block, ("tap", "drill", "diameter"), ("depth", "angle")) + or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "直径"), ("深度", "角度")) + or _hole_dimension_value_excluding(data_block, ("钻头", "直径"), ("深度", "角度")) + or _hole_dimension_value_excluding(data_block, ("通孔", "孔直径"), ("沉头", "锥", "counter", "csk", "角度", "深度")) + or _hole_dimension_value_excluding(data_block, ("孔直径",), ("沉头", "锥", "counter", "csk", "角度", "深度")) + or _hole_dimension_value_excluding(data_block, ("hole", "diameter"), ("counter", "csk", "angle", "depth")) + or _hole_dimension_value_excluding(data_block, ("thread", "diameter"), ("counter", "csk", "angle", "depth")) + or _hole_dimension_value_excluding(data_block, ("螺纹",), ("深度", "depth", "角度", "angle")) + or _hole_primary_dimension_fallback(data_block, prefer_small=True) + ) + return abs(float(diameter)) if diameter else 0 + +def _hole_primary_depth_mm(data_block: Dict[str, Any]) -> float: + depth = ( + _hole_dimension_value_excluding(data_block, ("通孔", "孔深度"), ("沉头", "锥", "counter", "csk", "角度", "直径")) + or _hole_dimension_value_excluding(data_block, ("孔深度",), ("沉头", "锥", "counter", "csk", "角度", "直径")) + or _hole_dimension_value_excluding(data_block, ("螺纹孔钻头", "深度"), ("直径", "角度")) + or _hole_dimension_value_excluding(data_block, ("通孔", "螺纹孔钻头", "深度"), ("直径", "角度")) + or _hole_dimension_value_excluding(data_block, ("tap", "drill", "depth"), ("diameter", "angle")) + or _hole_dimension_value_excluding(data_block, ("hole", "depth"), ("counter", "csk", "angle", "diameter")) + or _hole_dimension_value_excluding(data_block, ("thread", "depth"), ("counter", "csk", "angle", "diameter")) + ) + if depth: + return abs(float(depth)) + return THROUGH_CUT_AMOUNT_MM + +def _hole_counterbore_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: + return ( + _hole_dimension_value(data_block, ("柱形沉头", "直径")) + or _hole_dimension_value(data_block, ("柱形沉头孔", "直径")) + or _hole_dimension_value(data_block, ("沉头孔", "直径")) + or _hole_dimension_value(data_block, ("counterbore", "diameter")) + or _hole_dimension_value(data_block, ("counter", "bore", "diameter")) + ) + +def _hole_counterbore_depth_dimension_mm(data_block: Dict[str, Any]) -> Optional[float]: + return ( + _hole_dimension_value(data_block, ("柱形沉头", "深度")) + or _hole_dimension_value(data_block, ("柱形沉头孔", "深度")) + or _hole_dimension_value(data_block, ("沉头孔", "深度")) + or _hole_dimension_value(data_block, ("counterbore", "depth")) + or _hole_dimension_value(data_block, ("counter", "bore", "depth")) + ) + +def _hole_angle_dimension_rad(data_block: Dict[str, Any], tokens: tuple[str, ...]) -> Optional[float]: + for dim in data_block.get("dimensions", []) or []: + name = str(dim.get("name") or "").lower() + if all(token.lower() in name for token in tokens): + if dim.get("system_value_m") not in (None, ""): + return float(dim.get("system_value_m")) + if dim.get("value") not in (None, ""): + value = float(dim.get("value")) + return value / 1000 if value > math.tau else value + return None + +def _extract_edge_selector_points(op: Dict[str, Any]) -> list[list[tuple[float, float, float]]]: + selector_points = [] + for selector in op.get("selectors", []): + geometry = selector.get("geometry") or {} + start = geometry.get("start_vertex") or {} + start_point = start.get("point_m") if isinstance(start, dict) else None + end = geometry.get("end_vertex") or {} + end_point = end.get("point_m") if isinstance(end, dict) else None + if start_point and end_point: + selector_points.append([_point_m_to_mm(start_point), _point_m_to_mm(end_point)]) + return selector_points + +_SW_METADATA_FEATURE_TYPES = { + "commentsfolder", + "favoritefolder", + "historyfolder", + "selectionsetfolder", + "sensorfolder", + "docsfolder", + "detailcabinet", + "surfacebodyfolder", + "solidbodyfolder", + "envfolder", + "inkmarkupfolder", + "eqnfolder", + "materialfolder", + "configtablefolder", + "ftrfolder", +} + +def _source_feature(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + source = feature.get("source_feature") if isinstance(feature.get("source_feature"), dict) else {} + identity = source.get("identity") if isinstance(source.get("identity"), dict) else {} + return { + "index": source.get("index", index), + "id": feature.get("id"), + "name": feature.get("name"), + "type": feature.get("type"), + "type_name": feature.get("type_name"), + "stable_id": source.get("stable_id") or identity.get("stable_id"), + "persistent_reference": source.get("persistent_reference") or identity.get("persistent_reference"), + "identity": identity or None, + } + +def _source_owned_faces(feature: Dict[str, Any]) -> list[Dict[str, Any]]: + faces = feature.get("owned_faces") + if not isinstance(faces, list): + return [] + summarized = [] + for face in faces: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + summarized.append( + { + "box_m": face.get("box_m"), + "area_m2": face.get("area_m2"), + "surface": { + "is_plane": bool(surface.get("is_plane")), + "is_cylinder": bool(surface.get("is_cylinder")), + "is_cone": bool(surface.get("is_cone")), + "is_sphere": bool(surface.get("is_sphere")), + "is_torus": bool(surface.get("is_torus")), + "cylinder_params": surface.get("cylinder_params"), + "cone_params": surface.get("cone_params"), + "plane_params": surface.get("plane_params"), + }, + } + ) + return summarized + +def _convert_sw_reference(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + snapshot = feature.get("definition_snapshot", {}) + return { + "id": feature.get("id") or f"reference_{index:03d}", + "name": feature.get("name"), + "type": feature.get("type"), + "sw_type": feature.get("type_name"), + "definition": snapshot.get("values", {}), + "source_feature": _source_feature(feature, index), + } + +def _convert_sw_sketch(feature: Dict[str, Any], sketch_id: str, index: int) -> Dict[str, Any]: + sketch_data = feature.get("sketch_data", {}) + raw_entities = sketch_data.get("entities", []) + raw_converted_entities = [_convert_sw_sketch_entity(entity) for entity in raw_entities] + converted_entities = [] + raw_to_converted_index: dict[int, int] = {} + stable_id_to_raw_index: dict[str, int] = {} + for raw_index, (raw_entity, converted_entity) in enumerate(zip(raw_entities, raw_converted_entities)): + for stable_id in _selectable_stable_ids(raw_entity): + stable_id_to_raw_index.setdefault(stable_id, raw_index) + if converted_entity is None: + continue + raw_to_converted_index[raw_index] = len(converted_entities) + converted_entities.append(converted_entity) + loops = [] + for contour in sketch_data.get("sketch_contours", []) or sketch_data.get("contours", []) or []: + if not isinstance(contour, dict): + continue + entity_indices = contour.get("entity_indices") or contour.get("segment_indices") or [] + if not entity_indices: + entity_indices = _contour_entity_indices_from_segments(contour, stable_id_to_raw_index) + if not entity_indices: + continue + normalized_indices = [ + raw_to_converted_index[int(idx)] + for idx in entity_indices + if isinstance(idx, (int, float)) and int(idx) in raw_to_converted_index + ] + if not normalized_indices: + continue + bbox = _loop_bbox([ + converted_entities[idx] + for idx in normalized_indices + if 0 <= idx < len(converted_entities) + ]) + loops.append({ + "id": contour.get("contour_id"), + "entity_indices": normalized_indices, + "is_closed": contour.get("is_closed"), + "bbox_mm": bbox or contour.get("bbox_mm"), + "bbox_area_mm2": _bbox_area_2d(bbox) if bbox else contour.get("bbox_area_mm2"), + "source": "solidworks_sketch_contour", + }) + workplane = sketch_data.get("workplane") or {} + if not workplane: + workplane = {"name": sketch_data.get("plane"), "origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0]} + return { + "id": sketch_id, + "name": feature.get("name", sketch_id), + "workplane": workplane, + "host_reference": sketch_data.get("host_reference"), + "entities": converted_entities, + "loops": loops, + "sketch_regions": sketch_data.get("sketch_regions", []), + "constraints": sketch_data.get("constraints", []), + "inferred_constraints": sketch_data.get("inferred_constraints", []), + "dimensions": sketch_data.get("dimensions", []), + "feature_dimensions": sketch_data.get("feature_dimensions", []), + "source_feature": _source_feature(feature, index), + } + +def _selectable_stable_ids(value: Any) -> list[str]: + if not isinstance(value, dict): + return [] + candidates = [value.get("stable_id")] + identity = value.get("identity") + if isinstance(identity, dict): + candidates.append(identity.get("stable_id")) + return [str(candidate) for candidate in candidates if candidate] + +def _contour_entity_indices_from_segments(contour: Dict[str, Any], stable_id_to_raw_index: dict[str, int]) -> list[int]: + indices: list[int] = [] + seen: set[int] = set() + for segment in contour.get("sketch_segments") or []: + for stable_id in _selectable_stable_ids(segment): + raw_index = stable_id_to_raw_index.get(stable_id) + if raw_index is None or raw_index in seen: + continue + seen.add(raw_index) + indices.append(raw_index) + break + return indices + +def _convert_sw_sketch_entity(entity: Dict[str, Any]) -> Optional[Dict[str, Any]]: + entity_type = str(entity.get("canonical_entity_type") or entity.get("entity_type", "")).lower() + curve = entity.get("curve") if isinstance(entity.get("curve"), dict) else {} + if ( + entity_type == "circle_or_arc" + or curve.get("is_circle") is True + or entity.get("curve_entity_type") == "circle_or_arc" + ): + center = entity.get("curve_center_mm") or entity.get("center_mm") + radius_mm_value = entity.get("curve_radius_mm") or entity.get("radius_mm") + radius_raw_value = entity.get("radius") + start = entity.get("start_mm") + end = entity.get("end_mm") + start_2d = [float(start[0]), float(start[1])] if isinstance(start, list) and len(start) >= 2 else None + end_2d = [float(end[0]), float(end[1])] if isinstance(end, list) and len(end) >= 2 else None + center_2d = [float(center[0]), float(center[1])] if isinstance(center, list) and len(center) >= 2 else [0.0, 0.0] + radius_mm = float(radius_mm_value) if radius_mm_value is not None else _scale_length(radius_raw_value or 0) + if start_2d and end_2d and math.hypot(start_2d[0] - end_2d[0], start_2d[1] - end_2d[1]) > 1e-6: + # 计算 sweep 方向 + import math as _math + sa = _math.degrees(_math.atan2(start_2d[1] - center_2d[1], start_2d[0] - center_2d[0])) + ea = _math.degrees(_math.atan2(end_2d[1] - center_2d[1], end_2d[0] - center_2d[0])) + sweep = round(ea - sa, 10) + while sweep <= -180: + sweep += 360 + while sweep > 180: + sweep -= 360 + result = { + "type": "arc", + "center": center_2d, + "start": start_2d, + "end": end_2d, + "radius_mm": radius_mm, + "start_angle_deg": round(sa, 10), + "end_angle_deg": round(ea, 10), + "arc_sweep_deg": round(sweep, 10), + "construction": bool(entity.get("construction")), + "raw": entity, + } + curve_axis = entity.get("curve_axis") + if isinstance(curve_axis, list) and len(curve_axis) >= 3: + result["curve_axis"] = [float(v) for v in curve_axis[:3]] + return result + return { + "type": "circle", + "center": center_2d, + "radius_mm": radius_mm, + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "line" in entity_type: + return { + "type": "line", + "start": _sketch_point_mm(entity, "start"), + "end": _sketch_point_mm(entity, "end"), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "circle" in entity_type: + return { + "type": "circle", + "center": _sketch_point_mm(entity, "center"), + "radius_mm": _sketch_radius_mm(entity), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if "arc" in entity_type: + return { + "type": "arc", + "center": _sketch_point_mm(entity, "center"), + "start": _sketch_point_mm(entity, "start"), + "end": _sketch_point_mm(entity, "end"), + "radius_mm": _sketch_radius_mm(entity), + "start_angle_deg": _to_degrees(entity.get("start_angle", 0)), + "end_angle_deg": _to_degrees(entity.get("end_angle", 360)), + "construction": bool(entity.get("construction")), + "raw": entity, + } + if entity_type == "point": + point = entity.get("point_mm") or [float(entity.get("x", 0)) * 1000, float(entity.get("y", 0)) * 1000, 0] + return {"type": "point", "point": point[:2], "point_mm": point, "construction": bool(entity.get("construction")), "raw": entity} + return None + +def _sketch_point_mm(entity: Dict[str, Any], key: str) -> list[float]: + point = entity.get(f"{key}_mm") + if isinstance(point, list) and len(point) >= 2: + return [float(point[0]), float(point[1])] + return _scale_point(entity.get(key, [0, 0])) + +def _sketch_radius_mm(entity: Dict[str, Any]) -> float: + for key in ("radius_mm", "major_radius_mm", "major_radius"): + if entity.get(key) is not None: + return _scale_length(entity.get(key)) + if entity.get("radius") is not None: + return _scale_length(entity.get("radius")) + start = _sketch_point_mm(entity, "start") + center = _sketch_point_mm(entity, "center") + if start and center: + return math.hypot(float(start[0]) - float(center[0]), float(start[1]) - float(center[1])) + return 1.0 + +def _convert_sw_extrude(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: + data_block = feature.get("extrude_data", {}) + op_type = "extrude_cut" if _is_cut_feature(feature, type_name) else "extrude_add" + distance = _best_extrude_depth_mm(feature, data_block) + reverse_end_condition_code = data_block.get("reverse_end_condition_code") + reverse_distance = abs(data_block.get("reverse_depth") or 0) + both_directions = bool(data_block.get("both_directions", False)) + reverse_direction = data_block.get("is_reverse") + if reverse_direction is None: + reverse_direction = data_block.get("definition_snapshot", {}).get("ReverseDirection") + if reverse_direction is None: + reverse_direction = feature.get("definition_snapshot", {}).get("values", {}).get("ReverseDirection", False) + if reverse_end_condition_code in (None, 0) and data_block.get("effective_depth_source") == "feature_dimension": + spans_both_sides = _extrude_owned_faces_span_sketch_plane(feature, data_block) + if spans_both_sides and bool(reverse_direction): + both_directions = True + reverse_distance = reverse_distance or distance + else: + both_directions = False + reverse_distance = 0 + raw_depth = abs(data_block.get("depth") or data_block.get("blind_depth") or 0) + uses_reverse_depth_only = ( + op_type == "extrude_cut" + and + feature.get("type") == "ice" + and raw_depth <= 1e-9 + and reverse_distance > 0 + ) + if uses_reverse_depth_only: + reverse_direction = not bool(reverse_direction) if False else bool(reverse_direction) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": op_type, + "sketch": sketch_id, + "parameters": { + "distance_mm": distance, + "reverse": bool(reverse_direction), + "reverse_direction": bool(reverse_direction), + "reverse_distance_mm": reverse_distance, + "both_directions": False if uses_reverse_depth_only else both_directions, + "end_condition": data_block.get("end_condition"), + "end_condition_code": data_block.get("end_condition_code"), + "reverse_end_condition_code": reverse_end_condition_code, + "flip_side_to_cut": bool(data_block.get("flip_side_to_cut", False)), + "start_condition_reference": _clean_null_reference(data_block.get("start_condition_reference")), + "end_condition_reference": _clean_null_reference(data_block.get("end_condition_reference")), + "reverse_end_condition_reference": _clean_null_reference(data_block.get("reverse_end_condition_reference")), + "draft_angle_rad": data_block.get("draft_angle_rad"), + "reverse_draft_angle_rad": data_block.get("reverse_draft_angle_rad"), + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + +def _extrude_owned_faces_span_sketch_plane(feature: Dict[str, Any], data_block: Dict[str, Any]) -> bool: + sketches = data_block.get("source_sketches") or [] + workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None + if not isinstance(workplane, dict): + return bool(data_block.get("both_directions")) and (data_block.get("reverse_depth") not in (None, 0)) + + origin = workplane.get("origin_mm") or [0, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + if not isinstance(origin, list) or not isinstance(normal, list) or len(origin) < 3 or len(normal) < 3: + return False + + nx, ny, nz = (float(normal[0]), float(normal[1]), float(normal[2])) + length = math.sqrt(nx * nx + ny * ny + nz * nz) or 1.0 + nx, ny, nz = nx / length, ny / length, nz / length + ox, oy, oz = float(origin[0]), float(origin[1]), float(origin[2]) + + min_distance = math.inf + max_distance = -math.inf + for face in feature.get("owned_faces") or []: + box = face.get("box_m") if isinstance(face, dict) else None + if not isinstance(box, list) or len(box) < 6: + continue + xs = [float(box[0]) * 1000, float(box[3]) * 1000] + ys = [float(box[1]) * 1000, float(box[4]) * 1000] + zs = [float(box[2]) * 1000, float(box[5]) * 1000] + for x in xs: + for y in ys: + for z in zs: + distance_to_plane = (x - ox) * nx + (y - oy) * ny + (z - oz) * nz + min_distance = min(min_distance, distance_to_plane) + max_distance = max(max_distance, distance_to_plane) + + if math.isinf(min_distance) or math.isinf(max_distance): + return False + tolerance = 1e-4 + return min_distance < -tolerance and max_distance > tolerance + +def _convert_sw_revolve(feature: Dict[str, Any], type_name: str, sketch_id: Optional[str], index: int) -> Dict[str, Any]: + data_block = feature.get("revolve_data", {}) + op_type = "revolve_cut" if _is_cut_feature(feature, type_name) else "revolve_add" + selected_axis = _axis_reference_from_feature_selections(data_block.get("selections")) + owned_face_axis = _axis_reference_from_owned_faces(feature) + extracted_axis = _extract_axis_reference(data_block.get("axis_reference")) + axis_reference = selected_axis or owned_face_axis + if not axis_reference and not _is_weak_inferred_axis(extracted_axis): + axis_reference = extracted_axis + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": op_type, + "sketch": sketch_id, + "parameters": { + "angle_deg": abs(data_block.get("angle") or 360), + "angle_rad": data_block.get("angle_rad"), + "reverse": data_block.get("is_reverse", False), + "end_condition": data_block.get("end_condition"), + "end_condition_code": data_block.get("end_condition_code"), + "axis_reference": axis_reference, + "axis_candidates": data_block.get("axis_candidates", []), + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + +def _axis_reference_from_owned_faces(feature: Dict[str, Any]) -> Optional[Dict[str, Any]]: + candidates: list[tuple[float, Dict[str, Any]]] = [] + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + params = None + if surface.get("is_cylinder") and isinstance(surface.get("cylinder_params"), list): + params = surface.get("cylinder_params") + elif surface.get("is_cone") and isinstance(surface.get("cone_params"), list): + params = surface.get("cone_params") + if not isinstance(params, list) or len(params) < 6: + continue + direction = [float(value) for value in params[3:6]] + norm = math.sqrt(sum(value * value for value in direction)) + if norm <= 1e-9: + continue + candidates.append(( + float(face.get("area_m2") or 0.0), + { + "origin_mm": [float(value) * 1000 for value in params[:3]], + "direction": [value / norm for value in direction], + "source": "owned_face_axis", + }, + )) + if not candidates: + return None + candidates.sort(key=lambda item: item[0], reverse=True) + return candidates[0][1] + +def _is_weak_inferred_axis(axis_reference: Optional[Dict[str, Any]]) -> bool: + if not isinstance(axis_reference, dict): + return False + return str(axis_reference.get("source") or "") in {"construction_line_candidate", "construction_line"} + +def _convert_sw_hole(feature: Dict[str, Any], index: int) -> Dict[str, Any]: + data_block = feature.get("hole_data", {}) + positions = [] + host_face = _host_face_from_feature_selections(data_block.get("selections")) or {} + position_sketches = _hole_position_sketches(data_block.get("position_sketches", []) or []) + for sketch in position_sketches: + workplane = sketch.get("workplane") or {} + if not host_face and workplane: + host_face = _host_face_from_workplane(workplane) + for point in _hole_position_points(sketch): + positions.append({"mm": [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)]}) + diameter_mm = abs(data_block.get("diameter") or 0) or _hole_primary_diameter_mm(data_block) + depth_mm = abs(data_block.get("depth") or 0) or _hole_primary_depth_mm(data_block) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": "hole", + "parameters": { + "diameter_mm": diameter_mm, + "depth_mm": depth_mm, + "counterbore_diameter_mm": _hole_counterbore_dimension_mm(data_block), + "counterbore_depth_mm": _hole_counterbore_depth_dimension_mm(data_block), + "countersink_diameter_mm": _hole_dimension_value(data_block, ("锥形沉头", "直径")) + or _hole_dimension_value(data_block, ("近端锥形沉头", "直径")) + or _hole_dimension_value(data_block, ("锥坑", "直径")) + or _hole_dimension_value(data_block, ("countersink", "diameter")) + or _hole_dimension_value(data_block, ("csk", "diameter")), + "angles_rad": { + "countersink_angle": _hole_angle_dimension_rad(data_block, ("锥形沉头", "角度")) + or _hole_angle_dimension_rad(data_block, ("近端锥形沉头", "角度")) + or _hole_angle_dimension_rad(data_block, ("锥坑", "角度")) + or _hole_angle_dimension_rad(data_block, ("countersink", "angle")) + or _hole_angle_dimension_rad(data_block, ("csk", "angle")), + "drill_angle": _hole_angle_dimension_rad(data_block, ("导头", "角度")) + or _hole_angle_dimension_rad(data_block, ("drill", "angle")) + or _hole_angle_dimension_rad(data_block, ("tip", "angle")), + }, + "positions": positions, + "host_face": host_face, + "hole_type": data_block.get("hole_type"), + "standard": data_block.get("standard"), + "size": data_block.get("size"), + "dimension_names": [ + str(dim.get("name") or "") + for dim in data_block.get("dimensions", []) or [] + if isinstance(dim, dict) + ], + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + +def _hole_position_sketches(sketches: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + point_only = [] + for sketch in sketches: + entities = sketch.get("entities") or [] + if not entities: + continue + if _is_hole_profile_sketch(sketch): + continue + point_count = sum(1 for entity in entities if _is_sketch_point_entity(entity)) + drawable_segment_count = sum( + 1 + for entity in entities + if not _is_sketch_point_entity(entity) and not entity.get("construction") + ) + if point_count > 0 and drawable_segment_count == 0: + point_only.append(sketch) + return point_only or sketches[:1] + +def _is_hole_profile_sketch(sketch: Dict[str, Any]) -> bool: + tokens = ( + "孔直径", + "孔深度", + "沉头", + "导头", + "螺纹孔钻头", + "tap drill", + "drill", + "counterbore", + "countersink", + "hole diameter", + "hole depth", + ) + dimension_sources = [] + dimension_sources.extend(sketch.get("dimensions") or []) + dimension_sources.extend(sketch.get("feature_dimensions") or []) + for dim in dimension_sources: + if not isinstance(dim, dict): + continue + name = str(dim.get("name") or "").lower() + if any(token in name for token in tokens): + return True + return False + +def _is_sketch_point_entity(entity: Dict[str, Any]) -> bool: + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + return entity_type == "point" + +def _hole_position_entity_flags(entity: Dict[str, Any]) -> tuple[Optional[bool], bool]: + raw = entity.get("raw") if isinstance(entity.get("raw"), dict) else entity + candidate = raw.get("hole_position_candidate") + if candidate is None: + candidate = entity.get("hole_position_candidate") + if isinstance(candidate, bool): + candidate_flag: Optional[bool] = candidate + else: + candidate_flag = None + construction_reference = bool( + raw.get("construction_endpoint_reference") or entity.get("construction_endpoint_reference") + ) + return candidate_flag, construction_reference + +def _construction_endpoint_degrees(sketch: Dict[str, Any]) -> dict[tuple[float, float, float], int]: + degrees: dict[tuple[float, float, float], int] = {} + for entity in sketch.get("entities") or []: + if not entity.get("construction"): + continue + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + if "line" not in entity_type: + continue + for key in ("start_mm", "end_mm"): + endpoint = entity.get(key) + if isinstance(endpoint, list) and len(endpoint) >= 2: + point_key = _rounded_point_key(endpoint) + degrees[point_key] = degrees.get(point_key, 0) + 1 + return degrees + +def _hole_position_points(sketch: Dict[str, Any]) -> list[list[float]]: + """Return only real Hole Wizard placement points from a position sketch. + + SolidWorks Hole Wizard position sketches often include construction + segments whose endpoints are reference geometry, not hole centers. Older + parser JSON exposes those endpoints as ordinary sketch points, so we filter + them generically here instead of letting every point become a hole. + """ + entities = sketch.get("entities") or [] + point_entities: list[tuple[list[float], Optional[bool], bool]] = [] + construction_endpoints: set[tuple[float, float, float]] = set() + + for entity in entities: + point = entity.get("point_mm") + if _is_sketch_point_entity(entity) and isinstance(point, list) and len(point) >= 2: + candidate_flag, construction_reference = _hole_position_entity_flags(entity) + point_entities.append( + ( + [float(point[0]), float(point[1]), float(point[2] if len(point) > 2 else 0)], + candidate_flag, + construction_reference, + ) + ) + continue + if not entity.get("construction"): + continue + entity_type = str(entity.get("entity_type") or entity.get("type") or "").lower() + if "line" not in entity_type: + continue + for key in ("start_mm", "end_mm"): + endpoint = entity.get(key) + if isinstance(endpoint, list) and len(endpoint) >= 2: + construction_endpoints.add(_rounded_point_key(endpoint)) + + if not point_entities: + return [] + + explicit_candidates = [ + point for point, candidate_flag, _ in point_entities if candidate_flag is True + ] + if explicit_candidates: + return _dedupe_points(explicit_candidates) + + endpoint_degrees = _construction_endpoint_degrees(sketch) + if endpoint_degrees: + filtered = [] + for point, candidate_flag, construction_reference in point_entities: + point_key = _rounded_point_key(point) + degree = endpoint_degrees.get(point_key, 0) + if candidate_flag is False and construction_reference and degree <= 1: + continue + if degree >= 2 or not construction_reference: + filtered.append(point) + filtered = _dedupe_points(filtered) + non_origin_filtered = [point for point in filtered if not _is_near_origin(point)] + if non_origin_filtered: + return _dedupe_points(non_origin_filtered) + if filtered: + return filtered + + raw_points = _dedupe_points([point for point, _, _ in point_entities]) + if not raw_points or not construction_endpoints: + return raw_points + + legacy_filtered = [point for point in raw_points if _rounded_point_key(point) not in construction_endpoints] + non_origin_raw = [point for point in raw_points if not _is_near_origin(point)] + non_origin_filtered = [point for point in legacy_filtered if not _is_near_origin(point)] + if non_origin_filtered: + return _dedupe_points(non_origin_filtered) + if non_origin_raw: + return _dedupe_points(non_origin_raw) + return _dedupe_points(legacy_filtered or raw_points) + +def _convert_sw_linear_pattern( + feature: Dict[str, Any], + index: int, + previous_build_op: Optional[Dict[str, Any]], + source_frame: Optional[Dict[str, Any]] = None, + sketches: Optional[list[Dict[str, Any]]] = None, + source_bbox: Optional[list[float]] = None, +) -> Dict[str, Any]: + data_block = feature.get("linear_pattern_data", {}) + source_features = data_block.get("source_features") or [] + if not source_features and previous_build_op: + source_features = [previous_build_op.get("source_feature", {})] + spacing_1 = data_block.get("spacing_1") + spacing_2 = data_block.get("spacing_2") + direction_1 = _pattern_direction_from_plugin(data_block.get("direction_1"), axis="x", source_frame=source_frame) + direction_2 = _pattern_direction_from_plugin(data_block.get("direction_2"), axis="y", source_frame=source_frame) + direction_1 = _pattern_direction_from_reference(direction_1, data_block.get("direction_1_reference"), source_frame) + direction_2 = _pattern_direction_from_reference(direction_2, data_block.get("direction_2_reference"), source_frame) + if data_block.get("direction_1_reverse") is True: + direction_1 = _reverse_pattern_direction(direction_1) + if data_block.get("direction_2_reverse") is True: + direction_2 = _reverse_pattern_direction(direction_2) + source_op_bbox = _operation_profile_bbox(previous_build_op, sketches or []) + if data_block.get("direction_1") is None: + direction_1 = _choose_pattern_direction_sign( + direction_1, + spacing_1 or 0, + int(data_block.get("pattern_count_1") or 1), + source_op_bbox, + source_bbox, + ) + if data_block.get("direction_2") is None: + direction_2 = _choose_pattern_direction_sign( + direction_2, + spacing_2 or 0, + int(data_block.get("pattern_count_2") or 1), + source_op_bbox, + source_bbox, + ) + explicit_offsets = _owned_face_pattern_offsets(previous_build_op, feature) + return { + "id": feature.get("id"), + "name": feature.get("name"), + "type": "linear_pattern", + "parameters": { + "source_features": source_features, + "total_instances": data_block.get("pattern_count_1") or 1, + "spacing_mm": spacing_1 or 0, + "direction1": direction_1, + "direction2": direction_2, + }, + "raw_parameters": { + "d1_total_instances": data_block.get("pattern_count_1") or 1, + "d2_total_instances": data_block.get("pattern_count_2") or 1, + "d1_spacing_mm": spacing_1 or 0, + "d2_spacing_mm": spacing_2 or 0, + "direction1": direction_1, + "direction2": direction_2, + "explicit_offsets_mm": explicit_offsets, + }, + "source_feature": _source_feature(feature, index), + "source_owned_faces": _source_owned_faces(feature), + } + +def _owned_face_pattern_offsets( + source_op: Optional[Dict[str, Any]], + pattern_feature: Dict[str, Any], +) -> list[list[float]]: + if not source_op: + return [] + source_faces = _owned_face_signatures(source_op.get("source_owned_faces") or []) + pattern_faces = _owned_face_signatures(_source_owned_faces(pattern_feature)) + if not source_faces or not pattern_faces: + return [] + + votes: Dict[tuple[float, float, float], int] = {} + for pattern_face in pattern_faces: + for source_face in source_faces: + if pattern_face["kind"] != source_face["kind"]: + continue + if not _similar_bbox_size(pattern_face["size"], source_face["size"]): + continue + offset = tuple( + round(pattern_face["center"][axis] - source_face["center"][axis], 3) + for axis in range(3) + ) + if math.sqrt(sum(component * component for component in offset)) < 1e-6: + continue + votes[offset] = votes.get(offset, 0) + 1 + + if not votes: + return [] + threshold = max(1, min(2, len(source_faces))) + offsets = [offset for offset, count in votes.items() if count >= threshold] + offsets.sort(key=lambda offset: (offset[0] * offset[0] + offset[1] * offset[1] + offset[2] * offset[2], offset)) + return [[float(value) for value in offset] for offset in offsets] + +def _owned_face_signatures(faces: list[Dict[str, Any]]) -> list[Dict[str, Any]]: + signatures = [] + for face in faces: + if not isinstance(face, dict): + continue + box = face.get("box_m") + if not isinstance(box, list) or len(box) < 6: + continue + box_mm = [float(value) * 1000 for value in box[:6]] + surface = face.get("surface") if isinstance(face.get("surface"), dict) else {} + kind = "other" + if surface.get("is_cylinder"): + kind = "cylinder" + elif surface.get("is_cone"): + kind = "cone" + elif surface.get("is_plane"): + kind = "plane" + signatures.append( + { + "kind": kind, + "center": [(box_mm[i] + box_mm[i + 3]) / 2 for i in range(3)], + "size": [abs(box_mm[i + 3] - box_mm[i]) for i in range(3)], + } + ) + return signatures + +def _source_pattern_frame( + previous_build_op: Optional[Dict[str, Any]], + sketches: list[Dict[str, Any]], +) -> Optional[Dict[str, Any]]: + if not previous_build_op: + return None + params = previous_build_op.get("parameters") or {} + host_frame = ((params.get("host_face") or {}).get("frame") or {}) + if host_frame.get("x_dir") and host_frame.get("y_dir"): + return host_frame + sketch_id = previous_build_op.get("sketch") + for sketch in sketches: + if sketch.get("id") == sketch_id: + workplane = sketch.get("workplane") or {} + if workplane.get("x_dir") and workplane.get("y_dir"): + return workplane + return None + +def _source_bbox_from_plugin_json(data: Dict[str, Any]) -> Optional[list[float]]: + bbox = (data.get("validation_hints") or {}).get("part_box_m") + if isinstance(bbox, list) and len(bbox) >= 6: + return [float(v) * 1000 for v in bbox[:6]] + return None + +def _operation_profile_bbox( + op: Optional[Dict[str, Any]], + sketches: list[Dict[str, Any]], +) -> Optional[list[float]]: + if not op: + return None + if op.get("type") == "hole": + host_face = (op.get("parameters") or {}).get("host_face") or {} + positions = [ + _hole_position_to_model(pos.get("mm"), host_face) + for pos in (op.get("parameters") or {}).get("positions", []) + if isinstance(pos.get("mm"), list) and len(pos.get("mm")) >= 3 + ] + if positions: + return _points_bbox(positions) + sketch_id = op.get("sketch") + sketch = next((item for item in sketches if item.get("id") == sketch_id), None) + if not sketch: + return None + points = [] + workplane = sketch.get("workplane") or {} + origin = workplane.get("origin_mm") or [0, 0, 0] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + for entity in sketch.get("entities", []) or []: + if entity.get("type") == "circle": + center = entity.get("center") or [0, 0] + radius = float(entity.get("radius_mm") or 0) + for dx, dy in ((-radius, -radius), (-radius, radius), (radius, -radius), (radius, radius)): + points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, [float(center[0]) + dx, float(center[1]) + dy])) + for key in ("start", "end", "center", "point"): + point = entity.get(key) + if isinstance(point, list) and len(point) >= 2: + points.append(_sketch_point_to_model_bbox(origin, x_dir, y_dir, point)) + return _points_bbox(points) + +def _sketch_point_to_model_bbox(origin: list[Any], x_dir: list[Any], y_dir: list[Any], point: list[Any]) -> list[float]: + return [ + float(origin[i]) + float(x_dir[i]) * float(point[0]) + float(y_dir[i]) * float(point[1]) + for i in range(3) + ] + +def _hole_position_to_model(point: list[Any], host_face: Dict[str, Any]) -> list[float]: + frame = host_face.get("frame") if isinstance(host_face, dict) else {} + if not isinstance(frame, dict): + return [float(v) for v in (point + [0, 0, 0])[:3]] + origin = frame.get("origin_mm") or [0, 0, 0] + x_dir = frame.get("x_dir") or [1, 0, 0] + y_dir = frame.get("y_dir") or [0, 1, 0] + values = [float(v) for v in (point + [0, 0, 0])[:3]] + return [ + float(origin[i]) + float(x_dir[i]) * values[0] + float(y_dir[i]) * values[1] + for i in range(3) + ] + +def _choose_pattern_direction_sign( + direction: Dict[str, Any], + spacing: float, + count: int, + source_op_bbox: Optional[list[float]], + source_bbox: Optional[list[float]], +) -> Dict[str, Any]: + vector = direction.get("vector") + if ( + not isinstance(vector, list) + or len(vector) < 3 + or not spacing + or count <= 1 + or not source_op_bbox + or not source_bbox + ): + return direction + unit = _unit3(vector) + distance = float(spacing) * (count - 1) + positive = [component * distance for component in unit] + negative = [-component * distance for component in unit] + positive_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, positive), source_bbox) + negative_score = _bbox_overflow_score(_translated_bbox(source_op_bbox, negative), source_bbox) + if abs(positive_score - negative_score) <= 1e-9: + positive_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, positive), source_bbox) + negative_score += _bbox_center_distance_score(_translated_bbox(source_op_bbox, negative), source_bbox) + copied = dict(direction) + if negative_score + 1e-9 < positive_score: + copied["vector"] = [-component for component in unit] + copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" + return copied + copied["vector"] = unit + if positive_score + 1e-9 < negative_score: + copied["source"] = f"{direction.get('source', 'missing_direction')}_sign_from_source_bbox" + return copied + +def _is_cut_feature(feature: Dict[str, Any], type_name: str) -> bool: + text = f"{type_name} {feature.get('name', '')}".lower() + return "cut" in text or "切除" in text or "revcut" in text + +def _best_extrude_depth_mm(feature: Dict[str, Any], data_block: Dict[str, Any]) -> float: + for key in ("depth", "blind_depth"): + value = data_block.get(key) + if value: + return abs(float(value)) + owned_face_depth = _extrude_depth_from_owned_faces(feature, data_block) + effective_depth = abs(float(data_block.get("effective_depth") or 0)) + if ( + owned_face_depth + and _is_cut_feature(feature, str(feature.get("type_name") or feature.get("type") or "")) + and data_block.get("effective_depth_source") == "feature_dimension" + and not data_block.get("depth") + and not data_block.get("blind_depth") + and not data_block.get("reverse_depth") + and effective_depth > owned_face_depth * 2 + ): + return owned_face_depth + owner_name = feature.get("name") + for dim in data_block.get("dimensions", []) or []: + name = dim.get("name") or "" + if owner_name and f"@{owner_name}@" in name and dim.get("value") not in (None, 0): + return abs(float(dim.get("value"))) + for dim in data_block.get("dimensions", []) or []: + if dim.get("owner") == owner_name and dim.get("value") not in (None, 0): + return abs(float(dim.get("value"))) + if data_block.get("reverse_depth") not in (None, 0): + return abs(float(data_block.get("reverse_depth"))) + if data_block.get("effective_depth") not in (None, 0): + return abs(float(data_block.get("effective_depth"))) + return 0.0 + +def _extrude_depth_from_owned_faces(feature: Dict[str, Any], data_block: Dict[str, Any]) -> Optional[float]: + sketches = data_block.get("source_sketches") or [] + workplane = sketches[0].get("workplane") if sketches and isinstance(sketches[0], dict) else None + if not isinstance(workplane, dict): + return None + normal = workplane.get("normal") or [0, 0, 1] + if not isinstance(normal, list) or len(normal) < 3: + return None + axis = max(range(3), key=lambda idx: abs(float(normal[idx]))) + values: list[float] = [] + for face in feature.get("owned_faces") or []: + if not isinstance(face, dict): + continue + box = face.get("box_m") + if isinstance(box, list) and len(box) >= 6: + values.extend([float(box[axis]) * 1000, float(box[axis + 3]) * 1000]) + if not values: + return None + extent = max(values) - min(values) + return abs(extent) if extent > 1e-6 else None + +def _host_face_from_workplane(workplane: Dict[str, Any]) -> Dict[str, Any]: + origin = workplane.get("origin_mm") or [0, 0, 0] + normal = workplane.get("normal") or [0, 0, 1] + x_dir = workplane.get("x_dir") or [1, 0, 0] + y_dir = workplane.get("y_dir") or [0, 1, 0] + return { + "surface": {"plane_params": [*normal[:3], *(float(v) / 1000 for v in origin[:3])]}, + "frame": {"origin_mm": origin[:3], "x_dir": x_dir[:3], "y_dir": y_dir[:3], "normal": normal[:3]}, + } + +def _pattern_direction_from_plugin( + direction: Any, + axis: str, + source_frame: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + if isinstance(direction, dict): + return direction + if source_frame: + key = "y_dir" if axis == "y" else "x_dir" + vector = source_frame.get(key) + if isinstance(vector, list) and len(vector) >= 3: + return {"vector": vector[:3], "source": f"source_feature_frame_{key}"} + if axis == "y": + return {"vector": [0, 1, 0], "source": "default_y_when_plugin_direction_missing"} + return {"vector": [1, 0, 0], "source": "default_x_when_plugin_direction_missing"} + +def _pattern_direction_from_reference( + fallback: Dict[str, Any], + reference: Any, + source_frame: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + axis = _extract_axis_reference(reference) + if not axis: + return fallback + vector = axis.get("direction") + if not isinstance(vector, list) or len(vector) < 3: + return fallback + model_vector = _sketch_vector_to_model(vector[:3], source_frame) or vector[:3] + model_origin = _sketch_point_to_model(axis.get("origin_mm"), source_frame) or axis.get("origin_mm") + return { + "vector": _unit3(model_vector), + "origin_mm": model_origin, + "source": axis.get("source") or "direction_reference", + } + +def _sketch_vector_to_model( + vector: list[Any], + source_frame: Optional[Dict[str, Any]], +) -> Optional[list[float]]: + if not source_frame: + return None + x_dir = source_frame.get("x_dir") + y_dir = source_frame.get("y_dir") + normal = source_frame.get("normal") + if not ( + isinstance(x_dir, list) + and len(x_dir) >= 3 + and isinstance(y_dir, list) + and len(y_dir) >= 3 + ): + return None + if not (isinstance(normal, list) and len(normal) >= 3): + normal = [ + float(x_dir[1]) * float(y_dir[2]) - float(x_dir[2]) * float(y_dir[1]), + float(x_dir[2]) * float(y_dir[0]) - float(x_dir[0]) * float(y_dir[2]), + float(x_dir[0]) * float(y_dir[1]) - float(x_dir[1]) * float(y_dir[0]), + ] + values = [float(v) for v in (vector + [0, 0, 0])[:3]] + return [ + values[0] * float(x_dir[i]) + values[1] * float(y_dir[i]) + values[2] * float(normal[i]) + for i in range(3) + ] + +def _sketch_point_to_model( + point: Any, + source_frame: Optional[Dict[str, Any]], +) -> Optional[list[float]]: + if not isinstance(point, list) or len(point) < 3 or not source_frame: + return None + origin = source_frame.get("origin_mm") + vector = _sketch_vector_to_model(point[:3], source_frame) + if not (isinstance(origin, list) and len(origin) >= 3 and vector): + return None + return [float(origin[i]) + vector[i] for i in range(3)] + +def _reverse_pattern_direction(direction: Dict[str, Any]) -> Dict[str, Any]: + vector = direction.get("vector") + if not isinstance(vector, list) or len(vector) < 3: + return direction + copied = dict(direction) + copied["vector"] = [-float(vector[0]), -float(vector[1]), -float(vector[2])] + copied["source"] = f"{direction.get('source', 'direction')}_reversed" + return copied + +def _clean_null_reference(reference: Any) -> Optional[Dict[str, Any]]: + if not isinstance(reference, dict): + return None + if reference.get("kind") == "null": + return None + obj = reference.get("object") + if isinstance(obj, dict) and obj.get("kind") == "null": + return None + return reference + +def _extract_axis_reference(reference: Any) -> Optional[Dict[str, Any]]: + if not isinstance(reference, dict): + return None + if reference.get("origin_mm") and reference.get("direction"): + return { + "origin_mm": [float(v) for v in reference.get("origin_mm", [])[:3]], + "direction": [float(v) for v in reference.get("direction", [])[:3]], + "source": reference.get("source") or "axis_reference", + } + obj = reference.get("object") if isinstance(reference.get("object"), dict) else reference + if obj.get("kind") == "null": + return None + + line_params = obj.get("line_params") + if isinstance(line_params, list) and len(line_params) >= 6: + return { + "origin_mm": [float(v) * 1000 for v in line_params[:3]], + "direction": [float(v) for v in line_params[3:6]], + "source": reference.get("source") or "selection_line_params", + } + + curve = obj.get("curve") if isinstance(obj.get("curve"), dict) else {} + curve_line_params = curve.get("line_params") + if isinstance(curve_line_params, list) and len(curve_line_params) >= 6: + return { + "origin_mm": [float(v) * 1000 for v in curve_line_params[:3]], + "direction": [float(v) for v in curve_line_params[3:6]], + "source": reference.get("source") or "selection_curve_line_params", + } + return None + +def _selection_objects(selections: Any) -> list[Dict[str, Any]]: + objects: list[Dict[str, Any]] = [] + if not isinstance(selections, list): + return objects + for selection in selections: + if not isinstance(selection, dict): + continue + obj = selection.get("object") + if isinstance(obj, dict) and obj.get("kind") != "null": + objects.append(obj) + return objects + +def _axis_reference_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: + for obj in _selection_objects(selections): + axis = _extract_axis_reference(obj) + if axis: + axis["source"] = "feature_selection_axis" + return axis + return None + +def _host_face_from_feature_selections(selections: Any) -> Optional[Dict[str, Any]]: + for obj in _selection_objects(selections): + if obj.get("kind") != "face": + continue + surface = obj.get("surface") if isinstance(obj.get("surface"), dict) else {} + frame = obj.get("frame") if isinstance(obj.get("frame"), dict) else {} + if not frame: + continue + normal = frame.get("normal") or (surface.get("plane_params") or [0, 0, 1])[:3] + origin = frame.get("origin_mm") + if not origin: + plane_params = surface.get("plane_params") + if isinstance(plane_params, list) and len(plane_params) >= 6: + origin = [float(v) * 1000 for v in plane_params[3:6]] + if not origin: + origin = [0, 0, 0] + x_dir = frame.get("x_dir") or [1, 0, 0] + y_dir = frame.get("y_dir") or [0, 1, 0] + origin_values = list(origin) + x_values = list(x_dir) + y_values = list(y_dir) + normal_values = list(normal) + return { + "surface": surface, + "frame": { + "origin_mm": [float(v) for v in (origin_values + [0, 0, 0])[:3]], + "x_dir": [float(v) for v in (x_values + [0, 0, 0])[:3]], + "y_dir": [float(v) for v in (y_values + [0, 0, 0])[:3]], + "normal": [float(v) for v in (normal_values + [0, 0, 1])[:3]], + }, + "source": "feature_selection_face", + } + return None diff --git a/backend/engine/cdsl_engine/translator/runtime_lib.py b/backend/engine/cdsl_engine/translator/runtime_lib.py new file mode 100644 index 00000000..06497db7 --- /dev/null +++ b/backend/engine/cdsl_engine/translator/runtime_lib.py @@ -0,0 +1,949 @@ +"""Frozen runtime library embedded into generated build123d scripts. + +These lines are appended after the per-model header (imports plus source +volume/area constants) in every generated script. The library is stable: +safe boolean wrappers, selector-based edge matching, and owned-face cutters. +Changes here affect every translator-generated rebuild. +""" + +from __future__ import annotations + +RUNTIME_LIB_LINES: list[str] = [ + "", + "def _dist(a, b):", + " return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))", + "", + "def _owned_face_match_score(shape, expected_faces):", + " if not expected_faces:", + " return 0.0", + " try:", + " available = list(shape.faces())", + " except Exception:", + " return 1e99", + " total = 0.0", + " for expected in expected_faces:", + " bbox_m = expected.get('box_m')", + " if not bbox_m or len(bbox_m) < 6 or not available:", + " total += 1e6", + " continue", + " target_box = [float(v) * 1000 for v in bbox_m[:6]]", + " surface = expected.get('surface') or {}", + " target_type = next((name for name in ('plane', 'cylinder', 'cone', 'sphere', 'torus') if surface.get('is_' + name)), '')", + " target_area = float(expected.get('area_m2') or 0) * 1_000_000", + " ranked = []", + " for index, face in enumerate(available):", + " try:", + " fb = face.bounding_box()", + " face_box = [fb.min.X, fb.min.Y, fb.min.Z, fb.max.X, fb.max.Y, fb.max.Z]", + " geom = face.geom_type() if callable(face.geom_type) else face.geom_type", + " geom_name = getattr(geom, 'name', str(geom)).lower()", + " type_penalty = 0.0 if not target_type or target_type in geom_name else 1000.0", + " bbox_penalty = sum(abs(face_box[i] - target_box[i]) for i in range(6))", + " area_penalty = abs(float(face.area) - target_area) / max(math.sqrt(abs(target_area)), 1.0) if target_area else 0.0", + " ranked.append((type_penalty + bbox_penalty + area_penalty, index))", + " except Exception:", + " continue", + " if not ranked:", + " total += 1e6", + " continue", + " best, index = min(ranked, key=lambda item: item[0])", + " total += best", + " available.pop(index)", + " return total / max(len(expected_faces), 1)", + "", + "def _candidate_score(shape, expected_faces=None):", + " # Owned faces describe this exact SW history step. Final-part mass properties", + " # must not be used to choose an intermediate feature candidate.", + " if expected_faces:", + " return _owned_face_match_score(shape, expected_faces)", + " score = 0", + " if SOURCE_VOLUME_MM3 is not None:", + " try:", + " score += abs(float(shape.volume) - SOURCE_VOLUME_MM3)", + " except Exception:", + " score += 1e99", + " if SOURCE_AREA_MM2 is not None:", + " try:", + " score += abs(float(shape.area) - SOURCE_AREA_MM2) * 0.01", + " except Exception:", + " score += 1e99", + " score += _owned_face_match_score(shape, expected_faces)", + " return score", + "", + "def _edge_endpoints(edge):", + " vertices = [v.to_tuple() for v in edge.vertices()]", + " if len(vertices) != 2:", + " center = edge.center().to_tuple()", + " return center, center", + " return vertices[0], vertices[1]", + "", + "def _edge_match_score(edge, start, end):", + " a, b = _edge_endpoints(edge)", + " endpoint_score = min(_dist(a, start) + _dist(b, end), _dist(a, end) + _dist(b, start))", + " containment_score = edge.distance_to(start) + edge.distance_to(end)", + " return min(endpoint_score, containment_score)", + "", + "def select_edges_by_endpoints(part, selector_points, tolerance=0.5):", + " edges = list(part.edges())", + " selected = []", + " used = set()", + " for selector in selector_points:", + " start, end = selector", + " ranked = sorted(((_edge_match_score(edge, start, end), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " score, index, edge = ranked[0]", + " if score > tolerance:", + " raise ValueError(f\"No edge matched selector {selector}; best score={score:.4f} mm\")", + " if index not in used:", + " selected.append(edge)", + " used.add(index)", + " return selected", + "", + "def _bbox_match_score(edge, bbox_mm):", + " if not bbox_mm or len(bbox_mm) < 6:", + " return float('inf')", + " try:", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " mins = tuple(float(bbox_mm[i]) for i in range(3))", + " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", + " diag = math.sqrt(sum((maxs[i] - mins[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.15)", + " def point_score(point):", + " total = 0.0", + " for axis in range(3):", + " if point[axis] < mins[axis] - pad:", + " total += mins[axis] - pad - point[axis]", + " elif point[axis] > maxs[axis] + pad:", + " total += point[axis] - maxs[axis] - pad", + " return total", + " return min(point_score(mid), (point_score(a) + point_score(b)) / 2)", + " except Exception:", + " return float('inf')", + "", + "def _circle_match_score(edge, circle_params):", + " if not circle_params or len(circle_params) < 7:", + " return float('inf')", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'CIRCLE' not in geom_name:", + " return float('inf')", + " target_center = tuple(float(v) * 1000 for v in circle_params[:3])", + " target_radius = float(circle_params[6]) * 1000", + " edge_center = edge.arc_center.to_tuple()", + " return _dist(edge_center, target_center) + abs(edge.radius - target_radius)", + " except Exception:", + " return float('inf')", + "", + "def _line_match_score(edge, line_params):", + " if not line_params or len(line_params) < 6:", + " return float('inf')", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " return float('inf')", + " target_point = tuple(float(v) * 1000 for v in line_params[:3])", + " target_dir = tuple(float(v) for v in line_params[3:6])", + " a, b = _edge_endpoints(edge)", + " edge_dir_raw = tuple(b[i] - a[i] for i in range(3))", + " length = math.sqrt(sum(v * v for v in edge_dir_raw))", + " if length <= 0:", + " return float('inf')", + " edge_dir = tuple(v / length for v in edge_dir_raw)", + " parallel = 1 - abs(sum(edge_dir[i] * target_dir[i] for i in range(3)))", + " distance = edge.distance_to(target_point)", + " return distance + parallel * 10", + " except Exception:", + " return float('inf')", + "", + "def select_edges_by_selectors(part, selectors, tolerance=0.5):", + " if part is None:", + " return []", + " edges = list(part.edges())", + " selected = []", + " used = set()", + " for selector in selectors or []:", + " geometry = selector.get('geometry', {})", + " start_vertex = geometry.get('start_vertex')", + " end_vertex = geometry.get('end_vertex')", + " start = start_vertex.get('point_m') if start_vertex else None", + " end = end_vertex.get('point_m') if end_vertex else None", + " bbox_mm = geometry.get('bbox_mm')", + " if start and end:", + " start_mm = tuple(float(v) * 1000 for v in start)", + " end_mm = tuple(float(v) * 1000 for v in end)", + " line_params = geometry.get('curve', {}).get('line_params')", + " if line_params:", + " ranked = sorted(((min(_edge_match_score(edge, start_mm, end_mm), _line_match_score(edge, line_params)) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " ranked = sorted(((_edge_match_score(edge, start_mm, end_mm) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " line_params = geometry.get('curve', {}).get('line_params')", + " circle_params = geometry.get('curve', {}).get('circle_params')", + " if line_params:", + " ranked = sorted(((_line_match_score(edge, line_params) + (_bbox_match_score(edge, bbox_mm) if bbox_mm else 0), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " elif bbox_mm:", + " ranked = sorted(((_bbox_match_score(edge, bbox_mm), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " else:", + " ranked = sorted(((_circle_match_score(edge, circle_params), i, edge) for i, edge in enumerate(edges)), key=lambda item: item[0])", + " score, index, edge = ranked[0]", + " selector_tolerance = float(selector.get('tolerance_mm') or tolerance)", + " if score > selector_tolerance:", + " # Skip edges that don't match well enough", + " continue", + " if index not in used:", + " selected.append(edge)", + " used.add(index)", + " return selected", + "", + "def _point_inside_bbox(point, bbox_mm, pad=0.25):", + " return all(float(bbox_mm[i]) - pad <= point[i] <= float(bbox_mm[i + 3]) + pad for i in range(3))", + "", + "def fillet_edges_from_owned_surface_bbox(part, selectors):", + " if part is None:", + " return []", + " boxes = []", + " seen_boxes = set()", + " for selector in selectors or []:", + " if selector.get('source') not in ('owned_cylindrical_face_axis', 'owned_face_bbox'):", + " continue", + " bbox = (selector.get('geometry') or {}).get('bbox_mm')", + " if bbox and len(bbox) >= 6:", + " normalized = [float(v) for v in bbox[:6]]", + " key = tuple(round(v, 6) for v in normalized)", + " if key not in seen_boxes:", + " seen_boxes.add(key)", + " boxes.append(normalized)", + " if len(boxes) < 2:", + " return []", + " selected = []", + " used_keys = set()", + " for box in boxes:", + " diag = math.sqrt(sum((box[i + 3] - box[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.08)", + " sizes = [abs(box[i + 3] - box[i]) for i in range(3)]", + " thin_axes = [i for i, size in enumerate(sizes) if size <= max(1.5, diag * 0.08)]", + " circle_candidates = []", + " if thin_axes:", + " thin_axis = thin_axes[0]", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'CIRCLE' not in geom_name:", + " continue", + " eb = edge.bounding_box()", + " edge_box = [eb.min.X, eb.min.Y, eb.min.Z, eb.max.X, eb.max.Y, eb.max.Z]", + " ok = True", + " score = 0.0", + " for axis in range(3):", + " if axis == thin_axis:", + " plane_delta = min(abs(edge_box[axis] - box[axis]), abs(edge_box[axis] - box[axis + 3]), abs(edge_box[axis + 3] - box[axis]), abs(edge_box[axis + 3] - box[axis + 3]))", + " if plane_delta > pad:", + " ok = False", + " break", + " score += plane_delta", + " else:", + " if edge_box[axis] < box[axis] - pad or edge_box[axis + 3] > box[axis + 3] + pad:", + " ok = False", + " break", + " score += abs(edge_box[axis] - box[axis]) + abs(edge_box[axis + 3] - box[axis + 3])", + " if not ok:", + " continue", + " key = tuple(round(v, 5) for v in edge_box)", + " circle_candidates.append((score, key, edge))", + " except Exception:", + " continue", + " if circle_candidates:", + " circle_candidates.sort(key=lambda item: item[0])", + " for _, key, edge in circle_candidates:", + " if key in used_keys:", + " continue", + " used_keys.add(key)", + " selected.append(edge)", + " break", + " continue", + " box_candidates = []", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " continue", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " if not (_point_inside_bbox(a, box, pad) and _point_inside_bbox(b, box, pad) and _point_inside_bbox(mid, box, pad)):", + " continue", + " key = tuple(round(v, 5) for point in (a, b) for v in point)", + " box_candidates.append((float(edge.length), key, edge))", + " except Exception:", + " continue", + " if not box_candidates:", + " continue", + " box_candidates.sort(key=lambda item: item[0], reverse=True)", + " for _, key, edge in box_candidates:", + " reverse_key = key[3:] + key[:3]", + " if key in used_keys or reverse_key in used_keys:", + " continue", + " used_keys.add(key)", + " selected.append(edge)", + " break", + " if selected:", + " return selected", + " union_bbox = [", + " min(box[i] for box in boxes) if i < 3 else max(box[i] for box in boxes)", + " for i in range(6)", + " ]", + " diag = math.sqrt(sum((union_bbox[i + 3] - union_bbox[i]) ** 2 for i in range(3)))", + " pad = max(0.25, diag * 0.05)", + " candidates = []", + " for edge in part.edges():", + " try:", + " geom_type = edge.geom_type() if callable(edge.geom_type) else edge.geom_type", + " geom_name = getattr(geom_type, 'name', str(geom_type))", + " if 'LINE' not in geom_name:", + " continue", + " a, b = _edge_endpoints(edge)", + " mid = tuple((a[i] + b[i]) / 2 for i in range(3))", + " if not (_point_inside_bbox(a, union_bbox, pad) and _point_inside_bbox(b, union_bbox, pad) and _point_inside_bbox(mid, union_bbox, pad)):", + " continue", + " candidates.append((float(edge.length), edge))", + " except Exception:", + " continue", + " if not candidates:", + " return []", + " candidates.sort(key=lambda item: item[0], reverse=True)", + " return [candidates[0][1]]", + "", + "def fillet_with_tolerance(edges, radius):", + " radii = [float(radius)]", + " shrink = max(0.001, abs(float(radius)) * 0.001)", + " if float(radius) > shrink:", + " radii.append(float(radius) - shrink)", + " radii.append(float(radius) * 0.99)", + " last_error = None", + " for candidate_radius in radii:", + " if candidate_radius <= 0:", + " continue", + " try:", + " return fillet(edges, radius=candidate_radius)", + " except Exception as exc:", + " last_error = exc", + " continue", + " if last_error:", + " raise last_error", + " return fillet(edges, radius=radius)", + "", + "def fillet_selected(part, radius, selectors, owned_faces=None):", + " if part is None:", + " return part", + " if not selectors:", + " # No edge selectors - skip fillet to avoid failing on all edges", + " return part", + " candidates = []", + " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", + " if owned_edges:", + " try:", + " candidates.append(fillet_with_tolerance(owned_edges, radius))", + " except Exception:", + " pass", + " try:", + " target_edges = select_edges_by_selectors(part, selectors)", + " if target_edges:", + " candidates.append(fillet_with_tolerance(target_edges, radius))", + " except Exception:", + " pass", + " result = part", + " applied_any = False", + " for selector in selectors:", + " edges = select_edges_by_selectors(result, [selector])", + " if not edges:", + " continue # Skip selectors that don't match any edge", + " try:", + " result = fillet_with_tolerance([edges[0]], radius)", + " applied_any = True", + " except Exception:", + " # OCC fillets are fragile: one invalid edge/radius should not abort the whole rebuild.", + " continue", + " if applied_any:", + " candidates.append(result)", + " variants = []", + " for selector in selectors:", + " edges = select_edges_by_selectors(part, [selector])", + " if not edges:", + " continue", + " try:", + " variants.append(fillet_with_tolerance([edges[0]], radius))", + " except Exception:", + " continue", + " if variants:", + " try:", + " union_result = part", + " for variant in variants:", + " union_result = union_result + variant", + " candidates.append(union_result)", + " except Exception:", + " pass", + " try:", + " intersection_result = part", + " for variant in variants:", + " intersection_result = intersection_result & variant", + " candidates.append(intersection_result)", + " except Exception:", + " pass", + " if candidates:", + " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", + " return part", + "", + "def chamfer_selected(part, distance, selectors, owned_faces=None):", + " if part is None:", + " return part", + " if not selectors:", + " # No edge selectors available - chamfer would fail on all edges", + " return part", + " candidates = []", + " owned_edges = fillet_edges_from_owned_surface_bbox(part, selectors)", + " if owned_edges:", + " try:", + " candidates.append(chamfer(owned_edges, length=distance))", + " except Exception:", + " pass", + " target_edges = select_edges_by_selectors(part, selectors)", + " if target_edges:", + " try:", + " candidates.append(chamfer(target_edges, length=distance))", + " except Exception:", + " pass", + " result = part", + " applied_any = False", + " for selector in selectors:", + " edges = select_edges_by_selectors(result, [selector])", + " if not edges:", + " continue", + " try:", + " result = chamfer([edges[0]], length=distance)", + " applied_any = True", + " except Exception:", + " continue", + " if applied_any:", + " candidates.append(result)", + " if candidates:", + " return sorted(candidates, key=lambda shape: _candidate_score(shape, owned_faces))[0]", + " return part", + "", + "def is_internal_cone_face(face, part):", + " try:", + " bbox_m = face.get('box_m')", + " surface = face.get('surface') or {}", + " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", + " return False", + " params = surface.get('cone_params')", + " if not params or len(params) < 6:", + " return False", + " direction = tuple(float(v) for v in params[3:6])", + " axis = max(range(3), key=lambda i: abs(direction[i]))", + " radial_axes = tuple(i for i in range(3) if i != axis)", + " part_bbox = part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " tol = 0.25", + " touches_outer = any(", + " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", + " for i in radial_axes", + " )", + " return not touches_outer", + " except Exception:", + " return False", + "", + "def is_external_cone_face(face, part):", + " try:", + " bbox_m = face.get('box_m')", + " surface = face.get('surface') or {}", + " if not (bbox_m and len(bbox_m) >= 6 and surface.get('is_cone')):", + " return False", + " params = surface.get('cone_params')", + " if not params or len(params) < 6:", + " return False", + " direction = tuple(float(v) for v in params[3:6])", + " axis = max(range(3), key=lambda i: abs(direction[i]))", + " radial_axes = tuple(i for i in range(3) if i != axis)", + " part_bbox = part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " tol = 0.25", + " return any(", + " abs(mins[i] - part_min[i]) <= tol or abs(maxs[i] - part_max[i]) <= tol", + " for i in radial_axes", + " )", + " except Exception:", + " return False", + "", + "def make_owned_external_cone_chamfer_cutter(face):", + " surface = face.get('surface') or {}", + " params = surface.get('cone_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " base_radius = abs(float(params[6]) * 1000)", + " half_angle = abs(float(params[7]))", + " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " delta = (x - origin[0], y - origin[1], z - origin[2])", + " axial = sum(delta[i] * direction[i] for i in range(3))", + " projections.append(axial)", + " start = min(projections)", + " end = max(projections)", + " height = max(0.001, end - start)", + " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", + " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", + " outer_radius = max(r1, r2) + 0.001", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " if r1 <= 1e-9:", + " r1 = 1e-6", + " if r2 <= 1e-9:", + " r2 = 1e-6", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " Cone(r1, r2, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", + " return cutter_part.part", + "", + "def chamfer_owned_external_cones(part, faces):", + " if part is None:", + " return part, False", + " result = part", + " applied = False", + " for face in faces or []:", + " if not is_external_cone_face(face, result):", + " continue", + " cutter = make_owned_external_cone_chamfer_cutter(face)", + " new_result = safe_subtract(result, cutter)", + " if new_result is not result:", + " result = new_result", + " applied = True", + " return result, applied", + "", + "def chamfer_owned_internal_cones(part, faces):", + " if part is None:", + " return part, False", + " result = part", + " applied = False", + " for face in faces or []:", + " if not is_internal_cone_face(face, result):", + " continue", + " cutter = make_owned_cone_cutter(face)", + " new_result = safe_subtract(result, cutter)", + " if new_result is not result:", + " result = new_result", + " applied = True", + " return result, applied", + "", + "def chamfer_selected_with_owned_faces(part, distance, selectors, owned_faces):", + " cone_faces = [face for face in (owned_faces or []) if (face.get('surface') or {}).get('is_cone')]", + " if len(cone_faces) == 1 and is_internal_cone_face(cone_faces[0], part):", + " result, applied = chamfer_owned_internal_cones(part, cone_faces)", + " if applied:", + " return result", + " if len(cone_faces) == 1 and is_external_cone_face(cone_faces[0], part):", + " result, applied = chamfer_owned_external_cones(part, cone_faces)", + " if applied:", + " return result", + " return chamfer_selected(part, distance, selectors, owned_faces)", + "", + "def safe_subtract(part, cutter):", + " if part is None or cutter is None:", + " return part", + " try:", + " vol_before = float(part.volume)", + " except Exception:", + " vol_before = -1", + " try:", + " cut = part - cutter", + " if cut is None:", + " print(f' SUBTRACT: cutter resulted in None, keeping original (vol={vol_before:.0f})')", + " return part", + " # Accept the cut even when solids() reports 0 – can happen", + " # for valid boolean results with non-standard structures.", + " try:", + " nb_solids = len(list(cut.solids()))", + " if nb_solids == 0:", + " print(f' SUBTRACT: cut produced 0 solids (still accepting) vol={vol_before:.0f}')", + " except Exception:", + " pass", + " return cut", + " except Exception as e:", + " print(f' SUBTRACT: exception {type(e).__name__}: {e}, keeping original (vol={vol_before:.0f})')", + " return part", + "", + "def _project_bbox_along_direction(bbox, origin, direction):", + " mins = tuple(float(bbox[i]) for i in range(3))", + " maxs = tuple(float(bbox[i + 3]) for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", + " return min(projections), max(projections)", + "", + "def make_owned_cylinder_cutter(face, target_part=None):", + " surface = face.get('surface') or {}", + " params = surface.get('cylinder_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " radius = abs(float(params[6]) * 1000)", + " if norm <= 1e-9 or radius <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", + " if target_part is not None:", + " try:", + " part_bbox = target_part.bounding_box()", + " part_box = (*part_bbox.min.to_tuple(), *part_bbox.max.to_tuple())", + " part_start, part_end = _project_bbox_along_direction(part_box, origin, direction)", + " through_tolerance = max(1.0, radius * 0.12)", + " if abs(start - part_start) <= through_tolerance:", + " start = part_start", + " if abs(end - part_end) <= through_tolerance:", + " end = part_end", + " except Exception:", + " pass", + " height = max(0.001, end - start)", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " return cutter_part.part", + "", + "def make_owned_cone_cutter(face):", + " surface = face.get('surface') or {}", + " params = surface.get('cone_params')", + " bbox_m = face.get('box_m')", + " if not params or len(params) < 8 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " base_radius = abs(float(params[6]) * 1000)", + " half_angle = abs(float(params[7]))", + " if norm <= 1e-9 or base_radius <= 1e-9 or half_angle <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " projections = []", + " for x in (mins[0], maxs[0]):", + " for y in (mins[1], maxs[1]):", + " for z in (mins[2], maxs[2]):", + " projections.append(sum(((x, y, z)[i] - origin[i]) * direction[i] for i in range(3)))", + " start = min(projections)", + " end = max(projections)", + " # Keep a tiny overlap for the boolean while preserving blind-hole depth.", + " height = max(0.001, end - start) + 0.001", + " # SolidWorks ConeParams stores the radius at the cone origin; along the axis", + " # direction the radius tapers rather than expands for hole drill tips.", + " r1 = max(0.0, base_radius - math.tan(half_angle) * start)", + " r2 = max(0.0, base_radius - math.tan(half_angle) * end)", + " if max(r1, r2) <= 1e-9:", + " return None", + " if r1 <= 1e-9:", + " r1 = 1e-6", + " if r2 <= 1e-9:", + " r2 = 1e-6", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cone(r1, r2, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " return cutter_part.part", + "", + "def make_owned_face_cutter(face, target_part=None):", + " surface = face.get('surface') or {}", + " if surface.get('is_cylinder'):", + " return make_owned_cylinder_cutter(face, target_part)", + " if surface.get('is_cone'):", + " return make_owned_cone_cutter(face)", + " return None", + "", + "def cut_owned_cylindrical_faces(part, faces):", + " result = part", + " for face in faces or []:", + " cutter = make_owned_face_cutter(face, result)", + " result = safe_subtract(result, cutter)", + " return result", + "", + "def make_owned_flip_side_ring_cutter(face, target_part):", + " surface = face.get('surface') or {}", + " params = surface.get('cylinder_params')", + " bbox_m = face.get('box_m')", + " if target_part is None or not params or len(params) < 7 or not bbox_m or len(bbox_m) < 6:", + " return None", + " origin = tuple(float(v) * 1000 for v in params[:3])", + " direction = tuple(float(v) for v in params[3:6])", + " norm = math.sqrt(sum(v * v for v in direction))", + " inner_radius = abs(float(params[6]) * 1000)", + " if norm <= 1e-9 or inner_radius <= 1e-9:", + " return None", + " direction = tuple(v / norm for v in direction)", + " mins = tuple(float(bbox_m[i]) * 1000 for i in range(3))", + " maxs = tuple(float(bbox_m[i + 3]) * 1000 for i in range(3))", + " start, end = _project_bbox_along_direction((*mins, *maxs), origin, direction)", + " height = max(0.001, end - start)", + " center_offset = (start + end) / 2", + " center = tuple(origin[i] + direction[i] * center_offset for i in range(3))", + " try:", + " part_bbox = target_part.bounding_box()", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " radial = []", + " for x in (part_min[0], part_max[0]):", + " for y in (part_min[1], part_max[1]):", + " for z in (part_min[2], part_max[2]):", + " delta = (x - origin[0], y - origin[1], z - origin[2])", + " axial = sum(delta[i] * direction[i] for i in range(3))", + " perp = tuple(delta[i] - axial * direction[i] for i in range(3))", + " radial.append(math.sqrt(sum(v * v for v in perp)))", + " outer_radius = max(radial) + max(1.0, inner_radius * 0.05)", + " except Exception:", + " outer_radius = inner_radius + 100.0", + " if outer_radius <= inner_radius + 1e-6:", + " return None", + " with BuildPart(Plane(origin=center, z_dir=direction)) as cutter_part:", + " Cylinder(outer_radius, height, align=(Align.CENTER, Align.CENTER, Align.CENTER))", + " Cylinder(inner_radius, height + 0.002, align=(Align.CENTER, Align.CENTER, Align.CENTER), mode=Mode.SUBTRACT)", + " return cutter_part.part", + "", + "def cut_owned_flip_side_cylindrical_faces(part, faces):", + " result = part", + " for face in faces or []:", + " cutter = make_owned_flip_side_ring_cutter(face, result)", + " result = safe_subtract(result, cutter)", + " return result", + "", + "def cut_owned_bbox(part, bbox_mm):", + " if part is None or not bbox_mm or len(bbox_mm) < 6:", + " return part", + " mins = tuple(float(bbox_mm[i]) for i in range(3))", + " maxs = tuple(float(bbox_mm[i + 3]) for i in range(3))", + " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", + " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", + " cutter = Pos(center) * Box(size[0], size[1], size[2])", + " return safe_subtract(part, cutter)", + "", + "def shape_face_count(shape):", + " if shape is None:", + " return 0", + " try:", + " return len(list(shape.faces()))", + " except Exception:", + " return 0", + "", + "def safe_union(part, solid, preserve_visible=False):", + " if part is None:", + " return solid", + " if solid is None:", + " return part", + " try:", + " fused = part + solid", + " # OCCT fuse succeeded; always return the fused result.", + " # is_valid() can return False for edge cases where the geometry", + " # is actually correct (e.g. touching-at-faces). Accept it.", + " return fused", + " except Exception as e:", + " print(f' UNION: fuse threw {type(e).__name__}: {e}')", + " pass", + " try:", + " compound = Compound.make_composite([part, solid])", + " fused = compound.fuse()", + " try:", + " if len(list(fused.solids())) > 0:", + " print(f' UNION: compound.fuse() worked, {len(list(fused.solids()))} solids')", + " return fused", + " except Exception:", + " pass", + " except Exception as e:", + " print(f' UNION: compound.fuse() threw {type(e).__name__}: {e}')", + " pass", + " shapes = []", + " try:", + " shapes.extend(list(part.solids()))", + " except Exception:", + " shapes.append(part)", + " try:", + " shapes.extend(list(solid.solids()))", + " except Exception:", + " shapes.append(solid)", + " return Compound.make_composite(shapes)", + "", + "def sw_inverted_profile_cut(part, profile_solid, normal):", + " if part is None or profile_solid is None:", + " return part", + " try:", + " part_bbox = part.bounding_box()", + " profile_bbox = profile_solid.bounding_box()", + " n = tuple(float(v) for v in normal)", + " axis = max(range(3), key=lambda i: abs(n[i]))", + " part_min = part_bbox.min.to_tuple()", + " part_max = part_bbox.max.to_tuple()", + " prof_min = profile_bbox.min.to_tuple()", + " prof_max = profile_bbox.max.to_tuple()", + " margin = 5.0", + " mins = [part_min[i] - margin for i in range(3)]", + " maxs = [part_max[i] + margin for i in range(3)]", + " mins[axis] = prof_min[axis] - margin * 0.05", + " maxs[axis] = prof_max[axis] + margin * 0.05", + " center = tuple((mins[i] + maxs[i]) / 2 for i in range(3))", + " size = tuple(max(0.001, maxs[i] - mins[i]) for i in range(3))", + " envelope = Pos(center) * Box(size[0], size[1], size[2])", + " outside_profile = safe_subtract(envelope, profile_solid)", + " return safe_subtract(part, outside_profile)", + " except Exception:", + " return part", + "", + "def sw_flip_side_step_cut(part, profile_solid, normal, outer_radius_mm, inner_radius_mm):", + " part = sw_inverted_profile_cut(part, profile_solid, normal)", + " if part is None or profile_solid is None:", + " return part", + " try:", + " outer_radius = abs(float(outer_radius_mm))", + " inner_radius = abs(float(inner_radius_mm))", + " except Exception:", + " return part", + " if outer_radius <= inner_radius + 1e-6:", + " return part", + " try:", + " profile_bbox = profile_solid.bounding_box()", + " prof_min = profile_bbox.min.to_tuple()", + " prof_max = profile_bbox.max.to_tuple()", + " center = tuple((prof_min[i] + prof_max[i]) / 2 for i in range(3))", + " n = tuple(float(v) for v in normal)", + " axis = max(range(3), key=lambda i: abs(n[i]))", + " span_xy = max(prof_max[0] - prof_min[0], prof_max[1] - prof_min[1])", + " margin_xy = max(2.0, span_xy * 0.05)", + " margin_z = 0.1", + " size = tuple(", + " max(0.001, prof_max[i] - prof_min[i] + (margin_xy if i < 2 else margin_z))", + " for i in range(3)", + " )", + " plane = Plane(", + " origin=center,", + " x_dir=(1.0, 0.0, 0.0) if axis != 0 else (0.0, 1.0, 0.0),", + " z_dir=n,", + " )", + " cut_extent = prof_max[axis] - prof_min[axis]", + " cut_amount = -abs(cut_extent) if n[axis] < 0 else abs(cut_extent)", + " with BuildSketch(plane) as ring_sketch:", + " Circle(outer_radius)", + " Circle(inner_radius, mode=Mode.SUBTRACT)", + " ring = extrude(ring_sketch.sketch, amount=cut_amount)", + " return safe_union(part, ring)", + " except Exception:", + " return part", + "", + "def sw_cut_holes(part, positions, host_face, diameter, depth, drill_angle=0, include_drill_tip=False, countersink_diameter=0, countersink_angle=0, counterbore_diameter=0, counterbore_depth=0):", + " if part is None:", + " return part", + " if not positions or diameter <= 0 or depth <= 0:", + " return part", + " plane = host_face.get('surface', {}).get('plane_params') or [0, 0, 1, 0, 0, 0]", + " frame = host_face.get('frame') or {}", + " normal = tuple(float(v) for v in plane[:3])", + " plane_point = tuple(float(v) * 1000 for v in plane[3:6])", + " origin = tuple(float(v) for v in frame.get('origin_mm', plane_point))", + " x_dir = tuple(float(v) for v in frame.get('x_dir', (0, 0, 0)))", + " y_dir = tuple(float(v) for v in frame.get('y_dir', (0, 0, 0)))", + " has_frame = sum(abs(v) for v in x_dir) > 0 and sum(abs(v) for v in y_dir) > 0", + " bbox = part.bounding_box()", + " part_center = tuple((bbox.min.to_tuple()[i] + bbox.max.to_tuple()[i]) / 2 for i in range(3))", + " toward_center = tuple(part_center[i] - plane_point[i] for i in range(3))", + " dot = sum(toward_center[i] * normal[i] for i in range(3))", + " inward = normal if dot >= 0 else tuple(-v for v in normal)", + " axis = max(range(3), key=lambda i: abs(inward[i]))", + " rotation = (0, 0, 0)", + " if axis == 0:", + " rotation = (0, 90, 0) if inward[0] >= 0 else (0, -90, 0)", + " elif axis == 1:", + " rotation = (-90, 0, 0) if inward[1] >= 0 else (90, 0, 0)", + " elif inward[2] < 0:", + " rotation = (180, 0, 0)", + " tip_depth = 0", + " if include_drill_tip and drill_angle > 0:", + " tip_depth = (diameter / 2) / math.tan(drill_angle / 2)", + " countersink_depth = 0", + " if countersink_diameter > diameter and countersink_angle > 0:", + " countersink_depth = ((countersink_diameter - diameter) / 2) / math.tan(countersink_angle / 2)", + " result = part", + " for pos in positions:", + " x, y = float(pos[0]), float(pos[1])", + " if has_frame:", + " start = tuple(origin[i] + x_dir[i] * x + y_dir[i] * y for i in range(3))", + " elif axis == 0:", + " start = (plane_point[0], x, y)", + " elif axis == 1:", + " start = (x, plane_point[1], -y)", + " else:", + " start = (x, y, plane_point[2])", + " cut_depth = depth", + " if depth >= 199:", + " part_min = bbox.min.to_tuple()", + " part_max = bbox.max.to_tuple()", + " corners = []", + " for ci in range(2):", + " for cj in range(2):", + " for ck in range(2):", + " corners.append((", + " part_min[0] if ci else part_max[0],", + " part_min[1] if cj else part_max[1],", + " part_min[2] if ck else part_max[2],", + " ))", + " cut_depth = max(", + " sum((corner[i] - start[i]) * inward[i] for i in range(3))", + " for corner in corners", + " ) + 2.0", + " cutters = []", + " cb_depth = counterbore_depth if counterbore_diameter > diameter and counterbore_depth > 0 else 0", + " cs_depth = countersink_depth if countersink_depth > 0 else 0", + " hole_start = cs_depth", + " hole_depth = max(0.001, cut_depth - hole_start - cb_depth)", + " if hole_depth > 0:", + " hole_center = tuple(start[i] + inward[i] * (hole_start + cb_depth + hole_depth / 2) for i in range(3))", + " cutters.append(Pos(hole_center) * Cylinder(diameter / 2, hole_depth, rotation=rotation))", + " if cb_depth > 0:", + " cb_center = tuple(start[i] + inward[i] * (hole_start + cb_depth / 2) for i in range(3))", + " cutters.append(Pos(cb_center) * Cylinder(counterbore_diameter / 2, cb_depth, rotation=rotation))", + " if cs_depth > 0:", + " cs_center = tuple(start[i] + inward[i] * cs_depth / 2 for i in range(3))", + " cs = Pos(cs_center) * Cone(countersink_diameter / 2, diameter / 2, cs_depth, rotation=rotation)", + " cutters.append(cs)", + " if tip_depth > 0:", + " base = tuple(start[i] + inward[i] * cut_depth for i in range(3))", + " tip_center = tuple(base[i] + inward[i] * tip_depth / 2 for i in range(3))", + " tip = Pos(tip_center) * Cone(diameter / 2, 0, tip_depth, rotation=rotation)", + " cutters.append(tip)", + " if len(cutters) == 1:", + " cutter = cutters[0]", + " else:", + " cutter = Compound.make_composite(cutters)", + " result = safe_subtract(result, cutter)", + " return result", + "", +] -- 2.52.0 From 95cae203f4f967ca6fea8779d71bf4f560685145 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 14:02:53 +0800 Subject: [PATCH 06/10] refactor(cdsl_engine): freeze legacy engine paths under legacy/ Phase 7 of the decoupling refactor (behavior-preserving move): - legacy/llm_compiler.py, legacy/llm_engine.py: frozen build_pack path (unused by run_cdsl_only), moved with git mv for history - legacy/exact_rebuild.py: _run_parameterized / _run_exact / _apply_geometric_compensations moved out of rebuild.py, including the part-specific compensation table - rebuild.py keeps run_rebuild / run_cdsl_only / compare_with_gold and imports the moved functions - llm_compiler.py / llm_engine.py become compatibility shims Frozen zone: new capability belongs in executors/ + schema contracts. The project-specific compensation no longer sits in the main pipeline. --- backend/engine/cdsl_engine/legacy/__init__.py | 13 + .../cdsl_engine/legacy/exact_rebuild.py | 253 ++++++++ .../engine/cdsl_engine/legacy/llm_compiler.py | 287 +++++++++ .../engine/cdsl_engine/legacy/llm_engine.py | 555 +++++++++++++++++ backend/engine/cdsl_engine/llm_compiler.py | 291 +-------- backend/engine/cdsl_engine/llm_engine.py | 559 +----------------- backend/engine/cdsl_engine/rebuild.py | 315 ++-------- 7 files changed, 1167 insertions(+), 1106 deletions(-) create mode 100644 backend/engine/cdsl_engine/legacy/__init__.py create mode 100644 backend/engine/cdsl_engine/legacy/exact_rebuild.py create mode 100644 backend/engine/cdsl_engine/legacy/llm_compiler.py create mode 100644 backend/engine/cdsl_engine/legacy/llm_engine.py diff --git a/backend/engine/cdsl_engine/legacy/__init__.py b/backend/engine/cdsl_engine/legacy/__init__.py new file mode 100644 index 00000000..9e0d2f3c --- /dev/null +++ b/backend/engine/cdsl_engine/legacy/__init__.py @@ -0,0 +1,13 @@ +"""Frozen legacy engine paths. + +``llm_compiler`` (thin CDSL to build_pack) and ``llm_engine`` (build_pack +executor) predate the session-based runtime and are not used by +``run_cdsl_only``. ``exact_rebuild`` hosts the SolidWorks-exact fallback +rebuilds and the project-specific geometric compensations. + +Do not extend these modules; new capability belongs in the session runtime +(``executors/``) or the schema contracts. The top-level ``llm_compiler.py`` +and ``llm_engine.py`` shims keep every historical import path working. +""" + +from __future__ import annotations diff --git a/backend/engine/cdsl_engine/legacy/exact_rebuild.py b/backend/engine/cdsl_engine/legacy/exact_rebuild.py new file mode 100644 index 00000000..db9db6d9 --- /dev/null +++ b/backend/engine/cdsl_engine/legacy/exact_rebuild.py @@ -0,0 +1,253 @@ +"""SolidWorks-exact fallback rebuilds and project-specific compensations. + +``_run_parameterized`` and ``_run_exact`` replay SolidWorks rebuilds through +generated build123d scripts (``compiler_context``). They are the frozen +exact/parameterized legs of ``rebuild.run_rebuild``; the production path is +``run_cdsl_only`` in the session runtime. + +``_apply_geometric_compensations`` is a project-specific workaround for a +feature the SolidWorks export misses. It intentionally lives beside the +legacy paths so a clean checkout of this engine in another project can drop +it without touching the generic code. +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path +from typing import Any + +from ..sketch_solver import resolve_all_sketches +from ..translator import generate_build123d_code + + +def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: + """参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行 + + 采用双层IR架构: + Learning IR (CDSL) 提供参数化形状、特征结构 + Execution IR (compiler_context) 提供精确坐标 + translator 提供经过充分测试的代码生成 + """ + + import subprocess + import tempfile, os + + t0 = time.time() + + # 1. 获取 compiler_context (Execution IR: 精确坐标) + compiler_context = cdsl.get("compiler_context") or {} + if not compiler_context: + # 从外部文件加载 + ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", "")) + if ctx_file.exists(): + import json as _json + with open(ctx_file, "r", encoding="utf-8") as _f: + compiler_context = _json.load(_f) + if not compiler_context: + raise RuntimeError("CDSL缺少 compiler_context,无法重建") + + part_name = str(cdsl.get("part_id") or out_step.stem) + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = part_name + + # 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用) + # 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR + ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])} + cdsl_sketches = cdsl.get("geometry", {}).get("sketches", []) + for sk in cdsl_sketches: + ctx_sk = ctx_sketches_map.get(sk["id"]) + if ctx_sk: + # 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用 + if not sk.get("entities"): + sk["entities"] = ctx_sk.get("entities", []) + if not sk.get("contour_edges_mm"): + sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", []) + + # 3. 解析 CDSL 的参数化草图 (现在有 entities 可用) + cdsl_resolved = resolve_all_sketches(cdsl) + + # 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context + # translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类 + cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])} + ctx_sketches = list(context.get("sketches", [])) + updated_count = 0 + for i, ctx_sk in enumerate(ctx_sketches): + sk_id = ctx_sk.get("id", "") + cdsl_sk = cdsl_resolved_map.get(sk_id) + if cdsl_sk and cdsl_sk.get("profile"): + ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]} + updated_count += 1 + if cdsl_sk and cdsl_sk.get("profile_from"): + ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]} + updated_count += 1 + context["sketches"] = ctx_sketches + + # 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性) + + # 5. 读取 gold volume + gold_volume_mm3 = None + if gold_step and gold_step.exists(): + try: + from build123d import import_step + gold_solid = import_step(str(gold_step)) + gold_volume_mm3 = float(gold_solid.volume) + except Exception: + pass + + # 6. 用 translator 生成并执行 + code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) + + # 6b. 应用几何补偿 (SW导出缺失的特征) + part_id = str(cdsl.get("part_id") or "") + code = _apply_geometric_compensations(code, part_id) + + out_step.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf: + tf.write(code) + script_path = tf.name + + try: + r = subprocess.run( + ["python", script_path], + capture_output=True, text=True, encoding="utf-8", timeout=120, + env={**os.environ, "PYTHONIOENCODING": "utf-8"}, + ) + if r.returncode != 0: + raise RuntimeError(f"Build script failed:\n{r.stderr}") + finally: + try: + os.unlink(script_path) + except Exception: + pass + + # 7. 读取重建结果 + out_step.parent.mkdir(parents=True, exist_ok=True) + built_step = Path(part_name + ".step") + if not built_step.exists(): + built_step = Path.cwd() / (part_name + ".step") + if built_step.exists(): + import shutil + shutil.copy2(str(built_step), str(out_step)) + built_step.unlink() + else: + raise RuntimeError(f"No STEP output found: {part_name}.step") + + from build123d import import_step + rebuilt = import_step(str(out_step)) + bbox = rebuilt.bounding_box() + bbox_mm = { + "min": [bbox.min.X, bbox.min.Y, bbox.min.Z], + "max": [bbox.max.X, bbox.max.Y, bbox.max.Z], + } + + elapsed = time.time() - t0 + return { + "out_step": str(out_step), + "volume_mm3": float(rebuilt.volume), + "bbox_mm": bbox_mm, + "log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"], + "engine": "parameterized", + "elapsed_s": round(elapsed, 1), + } + + +def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: + """精确路径: generate_build123d_code (后备)""" + + import subprocess + + compiler_context = cdsl.get("compiler_context") or {} + part_name = str(cdsl.get("part_id") or out_step.stem) + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = part_name + + # Read gold volume if available, for chamfer/candidate scoring + gold_volume_mm3 = None + if gold_step and gold_step.exists(): + try: + from build123d import import_step + gold_solid = import_step(str(gold_step)) + gold_volume_mm3 = float(gold_solid.volume) + except Exception: + pass + + out_step.parent.mkdir(parents=True, exist_ok=True) + + # Apply geometric compensations FIRST (may return full replacement code) + part_id = str(cdsl.get("part_id") or "") + compensation_code = _apply_geometric_compensations("", part_id) + + if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code: + # 完整替换代码 (跳过generate_build123d_code) + code = compensation_code + else: + code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) + code = _apply_geometric_compensations(code, part_id) + + t0 = time.time() + script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py" + script_path.parent.mkdir(exist_ok=True) + script_path.write_text(code, encoding="utf-8") + + completed = subprocess.run( + [sys.executable, str(script_path)], + cwd=out_step.parent, + capture_output=True, + text=True, + timeout=600, + ) + + if completed.returncode != 0: + raise RuntimeError( + f"Exact compiler FAILED (rc={completed.returncode})\n" + f"STDOUT:\n{completed.stdout[-2000:]}\n" + f"STDERR:\n{completed.stderr[-3000:]}" + ) + # Print any warnings from safe_subtract + for line in completed.stdout.split('\n'): + if 'SUBTRACT' in line or 'UNION' in line: + print(f" {line.strip()}") + + from build123d import import_step + # 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step" + # 移到 out_step 位置以供后续对比 + actual_step = out_step.parent / f"{part_name}.step" + if actual_step.exists(): + import shutil + shutil.copy2(str(actual_step), str(out_step)) + solid = import_step(str(out_step)) + bb = solid.bounding_box() + elapsed = time.time() - t0 + + return { + "out_step": str(out_step), + "volume_mm3": float(solid.volume), + "bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z]}, + "engine": "exact", + "elapsed_s": round(elapsed, 1), + } + + +# ═══════════════════════════════════════════════════════════════ +# Geometric compensations(项目特例;拷贝到其他项目时可删) +# ═══════════════════════════════════════════════════════════════ + +def _apply_geometric_compensations(code: str, part_id: str) -> str: + """为SW导出中缺失的特征添加几何补偿切操作""" + if part_id == "113246": + if "export_step(result, " not in code: + return code + comp = ( + " # === COMPENSATION: 侧槽 (SW缺失特征) ===\n" + " with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), " + "x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n" + " Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n" + " comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n" + " result = safe_subtract(result, comp_cutter)\n" + ) + code = code.replace("export_step(result, ", comp + " export_step(result, ") + return code diff --git a/backend/engine/cdsl_engine/legacy/llm_compiler.py b/backend/engine/cdsl_engine/legacy/llm_compiler.py new file mode 100644 index 00000000..b3875fe7 --- /dev/null +++ b/backend/engine/cdsl_engine/legacy/llm_compiler.py @@ -0,0 +1,287 @@ +"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。""" + +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path +from typing import Any + +try: + from ..sketch_solver import resolve_all_sketches +except ImportError: + from sketch_solver import resolve_all_sketches + + +REQUIRED = { + "revolve_add": ["angle_deg", "axis"], + "revolve_cut": ["angle_deg", "axis"], + "extrude_add_blind": ["distance_mm"], + "extrude_add_two_sided": ["distance_mm"], + "extrude_cut_blind": ["distance_mm"], + "hole_blind": ["diameter_mm", "depth_mm"], + "hole_countersink": ["diameter_mm", "depth_mm"], + "hole_counterbore": ["diameter_mm", "depth_mm"], + "sphere_add": ["radius_mm", "center_mm"], +} + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None: + if sketch is None: + return None + s = deepcopy(sketch) + wp = s.get("workplane") or {} + o = list(wp.get("origin_mm") or [0, 0, 0]) + wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] + s["workplane"] = wp + edges = [] + for e in s.get("contour_edges_mm") or []: + ne = deepcopy(e) + for key in ("start_mm", "end_mm", "center_mm"): + if key in ne: + p = ne[key] + ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz] + edges.append(ne) + if edges: + s["contour_edges_mm"] = edges + # 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns + return s + + +def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]: + p = deepcopy(params) + if "positions" in p: + for pos in p["positions"]: + mm = pos.get("mm") + if mm: + pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz] + if "axis" in p and isinstance(p["axis"], dict): + o = list(p["axis"].get("origin_mm") or [0, 0, 0]) + p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] + return p + + +def compile_cdsl( + cdsl: dict[str, Any], + atoms_catalog: dict[str, Any] | None = None, + techniques_catalog: dict[str, Any] | None = None, +) -> dict[str, Any]: + allowed = set() + if atoms_catalog: + allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []} + techniques = { + item["technique_id"]: item + for item in (techniques_catalog or {}).get("techniques") or [] + } + + sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} + + # 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm + cdsl = resolve_all_sketches(cdsl) + sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} + steps: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + # feature_id -> list of emitted step dicts (for pattern source) + emitted: dict[str, list[dict[str, Any]]] = {} + + def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]: + atomic = feature["atomic_id"] + if allowed and atomic not in allowed: + raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog") + for dep in feature.get("depends_on") or []: + if dep not in seen_ids and not any(dep in emitted): + # dependency may be ok if earlier + if dep not in seen_ids: + raise ValueError(f"{step_id}: depends_on {dep} not yet defined") + step = { + "step_id": step_id, + "atomic_id": atomic, + "depends_on": list(feature.get("depends_on") or []), + "params": params, + "sketch": sketch, + "source_name": feature.get("name"), + } + steps.append(step) + seen_ids.add(step_id) + return step + + for feat in cdsl.get("features") or []: + fid = feat["id"] + atomic = feat.get("atomic_id") + technique_id = feat.get("technique_id") + if technique_id: + technique = techniques.get(technique_id) + if technique is None: + raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog") + groups = feat.get("params") or {} + expanded: list[dict[str, Any]] = [] + previous_step_id: str | None = None + for index, internal in enumerate(technique.get("internal_steps") or [], start=1): + group_name = internal.get("params_from") + group = deepcopy(groups.get(group_name) or {}) + if not isinstance(group, dict): + raise ValueError(f"{fid}: parameter group {group_name!r} must be an object") + params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group) + sketch_id = group.get("sketch_id") or params.pop("sketch_id", None) + sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None + internal_atomic = internal.get("atomic_id") + if not internal_atomic: + raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step") + for key in REQUIRED.get(internal_atomic, []): + if params.get(key) is None: + raise ValueError( + f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}" + ) + internal_feature = { + "atomic_id": internal_atomic, + "depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []), + "name": f"{feat.get('name') or technique_id}:{group_name or index}", + } + step_id = f"{fid}.t{index}" + expanded.append(emit(internal_feature, params, sketch, step_id)) + previous_step_id = step_id + if len(expanded) < 2: + raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps") + emitted[fid] = expanded + seen_ids.add(fid) + continue + if not atomic: + raise ValueError(f"{fid}: missing atomic_id") + + if atomic == "pattern_linear": + params = feat.get("params") or {} + src_ids = params.get("source_feature_ids") or [] + c1 = int(params.get("pattern_count_1") or 1) + c2 = int(params.get("pattern_count_2") or 1) + s1 = float(params.get("spacing_1_mm") or 0) + s2 = float(params.get("spacing_2_mm") or 0) + d1 = params.get("direction_1") or [1, 0, 0] + d2 = params.get("direction_2") or [0, 1, 0] + if params.get("direction_1_reverse"): + d1 = [-d1[0], -d1[1], -d1[2]] + if params.get("direction_2_reverse"): + d2 = [-d2[0], -d2[1], -d2[2]] + + src_steps: list[dict[str, Any]] = [] + for sid in src_ids: + src_steps.extend(emitted.get(sid) or []) + if not src_steps: + # 无源则跳过并记录 + steps.append( + { + "step_id": fid, + "atomic_id": "noop_pattern", + "depends_on": list(feat.get("depends_on") or []), + "params": params, + "sketch": None, + "note": "pattern source steps missing", + } + ) + seen_ids.add(fid) + continue + + clone_steps = [] + k = 0 + for i in range(c1): + for j in range(c2): + if i == 0 and j == 0: + continue + dx = d1[0] * s1 * i + d2[0] * s2 * j + dy = d1[1] * s1 * i + d2[1] * s2 * j + dz = d1[2] * s1 * i + d2[2] * s2 * j + for src in src_steps: + k += 1 + clone_id = f"{fid}.p{k}" + fake_feat = { + "atomic_id": src["atomic_id"], + "depends_on": [steps[-1]["step_id"]] if steps else [], + "name": f"{src.get('source_name')}_pattern", + } + st = emit( + fake_feat, + _offset_params_positions(src["params"], dx, dy, dz), + _offset_sketch(src.get("sketch"), dx, dy, dz), + clone_id, + ) + clone_steps.append(st) + emitted[fid] = clone_steps + seen_ids.add(fid) + continue + + params = deepcopy(feat.get("params") or {}) + sketch_id = feat.get("sketch_id") or params.get("sketch_id") + sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None + if sketch_id: + params["sketch_id"] = sketch_id + + # Auto-derive revolve axis origin + if "revolve" in atomic and sketch and "axis" in params: + ax = params.get("axis") or {} + # 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标 + wp = sketch.get("workplane") or {} + wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0] + + if ax.get("from_workplane_origin") and "origin_mm" not in ax: + params["axis"] = deepcopy(params["axis"]) + params["axis"]["origin_mm"] = list(wp_origin) + elif "origin_mm" not in ax: + ce = sketch.get("contour_edges_mm") or [] + if ce: + idx = int(ax.get("from_contour_vertex", 0)) + vertex = ce[idx % len(ce)]["start_mm"] + params["axis"] = deepcopy(params["axis"]) + params["axis"]["origin_mm"] = list(vertex) + + for key in REQUIRED.get(atomic, []): + if key == "axis" and "axis" not in params: + raise ValueError(f"{fid}: missing axis") + if key not in ("axis",) and params.get(key) is None and key != "sketch_id": + # positions can be empty temporarily + if key in params: + continue + if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None: + raise ValueError(f"{fid}: missing {key}") + + st = emit(feat, params, sketch, fid) + emitted[fid] = [st] + + # filter noop + steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"] + + return { + "schema": "cad.engine_plan.v1", + "part_id": cdsl.get("part_id"), + "unit": "mm", + "steps": steps, + "compiler_context": deepcopy(cdsl.get("compiler_context")), + "meta": { + "from_cdsl_schema": cdsl.get("schema"), + "compiler": "cad-heard.llm_compiler.v1", + "n_steps": len(steps), + }, + } + + +def main() -> None: + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("--cdsl", type=Path, required=True) + ap.add_argument("--catalog", type=Path, default=None) + ap.add_argument("--techniques", type=Path, default=None) + ap.add_argument("--out", type=Path, required=True) + args = ap.parse_args() + catalog = _load(args.catalog) if args.catalog else None + techniques = _load(args.techniques) if args.techniques else None + pack = compile_cdsl(_load(args.cdsl), catalog, techniques) + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"wrote {args.out} steps={len(pack['steps'])}") + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/legacy/llm_engine.py b/backend/engine/cdsl_engine/legacy/llm_engine.py new file mode 100644 index 00000000..bc913eeb --- /dev/null +++ b/backend/engine/cdsl_engine/legacy/llm_engine.py @@ -0,0 +1,555 @@ +"""build123d 绘图引擎:执行 build_pack → STEP。""" + +from __future__ import annotations + +import builtins +import json +import math +import subprocess +import sys +from pathlib import Path +from typing import Any + +# 保留内置 float,防止被 build123d 上下文 shadow +_f = builtins.float + +from build123d import ( # noqa: E402 + Align, + Axis, + BuildPart, + BuildSketch, + Circle, + Cone, + Cylinder, + Edge, + Face, + Location, + Locations, + Mode, + Plane, + Polygon, + Sphere, + Vector, + Wire, + export_step, + extrude, + import_step, + revolve, +) + + +# Keep this in sync with the execution branches in run_engine_plan. The +# agent-facing schema and its parity test prevent unsupported names reaching +# this low-level dispatcher. +SUPPORTED_ATOMIC_IDS = frozenset({ + "extrude_add_blind", + "extrude_add_two_sided", + "extrude_cut_blind", + "revolve_add", + "revolve_cut", + "hole_blind", + "hole_countersink", + "hole_counterbore", + "sphere_add", + "reference_plane", + "reference_axis", +}) + + +def _load(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _plane_from_workplane(wp: dict[str, Any]) -> Plane: + o = wp.get("origin_mm") or [0, 0, 0] + x = wp.get("x_dir") or [1, 0, 0] + n = wp.get("normal") or [0, 0, 1] + return Plane( + origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), + x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])), + z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])), + ) + + +def _axis_from_params(axis: dict[str, Any]) -> Axis: + o = axis.get("origin_mm") or [0, 0, 0] + d = axis.get("direction") or [1, 0, 0] + return Axis( + origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), + direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])), + ) + + +def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]: + entities = sketch.get("entities") or [] + line_loop = [ + i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction") + ] + if not line_loop: + raise ValueError(f"sketch {sketch.get('id')}: no profile lines") + pts: list[tuple[float, float]] = [] + for i in line_loop: + e = entities[i] + s = (_f(e["start"][0]), _f(e["start"][1])) + en = (_f(e["end"][0]), _f(e["end"][1])) + if not pts: + pts.append(s) + if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4: + if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4: + s, en = en, s + else: + pts.append(s) + pts.append(en) + if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4: + pts.append(pts[0]) + return pts + + +def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector: + """Return a point on the intended directed arc for ``make_three_point_arc``. + + Legacy contour data has no sweep direction and retains its prior shortest + arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a + major arc or a clockwise arc cannot be silently inverted by the adapter. + """ + v1 = p1 - center + v2 = p2 - center + if v1.length < 1e-9 or v2.length < 1e-9: + return (p1 + p2) / 2 + n = Vector(*(edge.get("normal") or [0, 0, 1])) + if n.length < 1e-9: + n = v1.cross(v2) + if n.length < 1e-9: + n = Vector(0, 0, 1) + n = n.normalized() + v1n = v1.normalized() * radius + if "clockwise" not in edge: + bisector = v1n + v2.normalized() * radius + if bisector.length < 1e-9: + bisector = n.cross(v1n) + return center + bisector.normalized() * radius + sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2)) + if bool(edge["clockwise"]): + if sweep >= 0: + sweep -= math.tau + elif sweep <= 0: + sweep += math.tau + half = sweep / 2 + midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half) + return center + midpoint_vector + + +def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face: + b123_edges: list[Edge] = [] + for e in edges_mm: + p1 = Vector(*e["start_mm"]) + p2 = Vector(*e["end_mm"]) + if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: + center = Vector(*e["center_mm"]) + r = _f(e["radius_mm"]) + v1 = p1 - center + v2 = p2 - center + if v1.length < 1e-9 or v2.length < 1e-9: + b123_edges.append(Edge.make_line(p1, p2)) + continue + mid = _arc_midpoint(e, p1, p2, center, r) + try: + b123_edges.append(Edge.make_three_point_arc(p1, mid, p2)) + except Exception: + b123_edges.append(Edge.make_line(p1, p2)) + else: + b123_edges.append(Edge.make_line(p1, p2)) + face = Face(Wire(b123_edges)) + if desired_normal is not None: + dn = Vector(*desired_normal) + if dn.length > 1e-9: + fn = face.normal_at() + if fn.dot(dn) < 0: + # 重建反转的 Wire:边顺序反转 + 每条边起止点交换 + # 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed) + rev_edges: list[Edge] = [] + for e in reversed(edges_mm): + p1 = Vector(*e["end_mm"]) + p2 = Vector(*e["start_mm"]) + if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: + center = Vector(*e["center_mm"]) + r = _f(e["radius_mm"]) + v1 = p1 - center + v2 = p2 - center + if v1.length < 1e-9 or v2.length < 1e-9: + rev_edges.append(Edge.make_line(p1, p2)) + continue + mid = _arc_midpoint(e, p1, p2, center, r) + try: + rev_edges.append(Edge.make_three_point_arc(p1, mid, p2)) + except Exception: + rev_edges.append(Edge.make_line(p1, p2)) + else: + rev_edges.append(Edge.make_line(p1, p2)) + face = Face(Wire(rev_edges)) + return face + + +def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float: + dist = abs(_f(params["distance_mm"])) + if prefer_sign == "plus": + return dist + if prefer_sign == "minus": + return -dist + return -dist if bool(params.get("reverse")) else dist + + +def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None: + """Build circular islands and holes from containment parity. + + A circle contained by one larger circle is a hole; a circle contained by + two larger circles is an island again. This preserves annular profiles + without storing the heavy tessellated sketch regions from the SW export. + """ + ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True) + tolerance = 1e-6 + for index, circle in enumerate(ordered): + center = circle["center"] + radius = _f(circle["radius_mm"]) + containing = 0 + for outer in ordered[:index]: + outer_center = outer["center"] + outer_radius = _f(outer["radius_mm"]) + distance = math.hypot( + _f(center[0]) - _f(outer_center[0]), + _f(center[1]) - _f(outer_center[1]), + ) + if distance + radius <= outer_radius + tolerance: + containing += 1 + mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT + with Locations((_f(center[0]), _f(center[1]))): + Circle(radius, mode=mode) + + +def run_engine_plan( + pack: dict[str, Any], + out_step: Path, + *, + cut_sign: str = "from_params", +) -> dict[str, Any]: + log: list[str] = [] + + compiler_context = pack.get("compiler_context") + if isinstance(compiler_context, dict): + # 回退路径:使用本包 translator(不依赖外部 backend.src) + try: + from .translator import generate_build123d_code, get_part_name + except ImportError: + from translator import generate_build123d_code, get_part_name + + context = dict(compiler_context) + context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem) + out_step.parent.mkdir(parents=True, exist_ok=True) + completed = subprocess.run( + [sys.executable, "-c", generate_build123d_code(context)], + cwd=out_step.parent, + capture_output=True, + text=True, + timeout=180, + ) + if completed.returncode != 0: + raise RuntimeError( + f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}" + ) + generated_name = get_part_name({"part_name": context["metadata"]["part_name"]}) + generated = out_step.parent / f"{generated_name}.step" + if generated != out_step and generated.exists(): + generated.replace(out_step) + if not out_step.exists(): + raise RuntimeError(f"exact compiler did not generate {out_step}") + solid = import_step(str(out_step)) + bb = solid.bounding_box() + return { + "out_step": str(out_step), + "volume_mm3": _f(solid.volume), + "bbox_mm": { + "min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z], + }, + "engine": "translator_fallback", + } + + with BuildPart() as part: + for step in pack.get("steps") or []: + atomic = step["atomic_id"] + params = step["params"] + sketch = step.get("sketch") + sid = step.get("step_id") + + if atomic == "reference_plane": + # Context features deliberately produce no solid. They remain + # executable plan steps so their dependencies are preserved and + # can be registered by the session-based runtime. + plane = _plane_from_workplane(params.get("plane") or {}) + log.append( + f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}" + ) + + elif atomic == "reference_axis": + axis = _axis_from_params(params.get("axis") or {}) + log.append( + f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}" + ) + + elif atomic == "sphere_add": + radius = _f(params.get("radius_mm") or 0) + center = params.get("center_mm") or [0, 0, 0] + if radius <= 0 or len(center) != 3: + raise ValueError(f"{sid}: sphere_add requires a positive radius_mm and center_mm") + with Locations((_f(center[0]), _f(center[1]), _f(center[2]))): + Sphere(radius, mode=Mode.ADD) + log.append(f"{sid}: sphere_add radius={radius}") + + elif atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): + if sketch is None: + raise ValueError(f"{sid}: missing sketch") + plane = _plane_from_workplane(sketch.get("workplane") or {}) + mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD + edges = sketch.get("contour_edges_mm") or [] + regions = sketch.get("contour_regions_mm") or [] + sign = cut_sign if "cut" in atomic else "from_params" + + circles = [ + e + for e in (sketch.get("entities") or []) + if e.get("type") == "circle" and not e.get("construction") + ] + lines = [ + e + for e in (sketch.get("entities") or []) + if e.get("type") == "line" and not e.get("construction") + ] + + # 多区域轮廓(外环 + 孔):由 shape generator 展开 + if regions: + faces = [] + normal = (sketch.get("workplane") or {}).get("normal") + for reg in regions: + outer_edges = reg.get("outer") or [] + if len(outer_edges) < 2: + continue + face = _face_from_contour_edges(outer_edges, desired_normal=normal) + for hole_edges in reg.get("holes") or []: + if len(hole_edges) < 2: + continue + hole = _face_from_contour_edges(hole_edges, desired_normal=normal) + face = face.cut(hole) + faces.append(face) + if not faces: + raise ValueError(f"{sid}: contour_regions_mm produced no faces") + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + for face in faces: + extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + for face in faces: + extrude(to_extrude=face, amount=amt, mode=mode) + log.append(f"{sid}: {atomic} regions={len(faces)}") + continue + + # 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除 + prefer_circles = bool(circles) and atomic.startswith("extrude_cut") + + if prefer_circles: + with BuildSketch(plane): + for e in circles: + with Locations((_f(e["center"][0]), _f(e["center"][1]))): + Circle(_f(e["radius_mm"])) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} circle-only n={len(circles)}") + elif len(edges) >= 2: + face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) + log.append(f"{sid}: extrude_two_sided both={d} contour") + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(to_extrude=face, amount=amt, mode=mode) + log.append(f"{sid}: {atomic} amount={amt} contour") + elif circles and not lines: + # 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。 + with BuildSketch(plane): + _build_nested_circle_profiles(circles) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} circle-only n={len(circles)}") + else: + with BuildSketch(plane): + pts = _ordered_profile_points(sketch) + poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts + Polygon(*poly) + for e in circles: + with Locations((_f(e["center"][0]), _f(e["center"][1]))): + Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT) + if atomic == "extrude_add_two_sided": + d = abs(_f(params["distance_mm"])) + extrude(amount=d, both=True, mode=Mode.ADD) + log.append(f"{sid}: extrude_two_sided both={d} poly") + else: + amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) + extrude(amount=amt, mode=mode) + log.append(f"{sid}: {atomic} amount={amt} poly") + + elif atomic in ("revolve_add", "revolve_cut"): + if sketch is None: + raise ValueError(f"{sid}: missing sketch") + plane = _plane_from_workplane(sketch.get("workplane") or {}) + axis = _axis_from_params(params.get("axis") or {}) + angle = _f(params.get("angle_deg") or 360) + mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD + edges = sketch.get("contour_edges_mm") or [] + if len(edges) >= 2: + face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) + revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode) + else: + with BuildSketch(plane): + pts = _ordered_profile_points(sketch) + poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts + Polygon(*poly) + revolve(axis=axis, revolution_arc=angle, mode=mode) + log.append(f"{sid}: {atomic} angle={angle}") + + elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"): + dia = _f(params.get("diameter_mm") or 0) + depth = _f(params.get("depth_mm") or 0) + positions = params.get("positions") or [] + if sketch is not None: + plane = _plane_from_workplane(sketch.get("workplane") or {}) + else: + plane = Plane.XY + host_face = params.get("host_face") or {} + frame = host_face.get("frame") or {} + frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple())) + frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple())) + frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple())) + normal = plane.z_dir.normalized() + bb = part.part.bounding_box() + part_center = Vector( + (bb.min.X + bb.max.X) / 2, + (bb.min.Y + bb.max.Y) / 2, + (bb.min.Z + bb.max.Z) / 2, + ) + inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal + for pos in positions: + mm = pos.get("mm") or [0, 0, 0] + start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1]) + cs_dia = _f(params.get("countersink_diameter_mm") or 0) + cs_angle = _f(params.get("countersink_angle_rad") or 0) + cb_dia = _f(params.get("counterbore_diameter_mm") or 0) + cb_depth = _f(params.get("counterbore_depth_mm") or 0) + cs_depth = ( + ((cs_dia - dia) / 2) / math.tan(cs_angle / 2) + if cs_dia > dia and cs_angle > 0 + else 0 + ) + base_offset = cs_depth + (cb_depth if cb_dia > dia else 0) + main_depth = max(0.001, abs(depth) - base_offset) + main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward)) + tools = [ + Cylinder( + radius=dia / 2, + height=main_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(main_place) + ] + if cb_dia > dia and cb_depth > 0: + tools.append( + Cylinder( + radius=cb_dia / 2, + height=cb_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(Location(Plane(origin=start, z_dir=inward))) + ) + if cs_depth > 0: + tools.append( + Cone( + bottom_radius=cs_dia / 2, + top_radius=dia / 2, + height=cs_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move(Location(Plane(origin=start, z_dir=inward))) + ) + drill_angle = _f(params.get("drill_angle_rad") or 0) + if drill_angle > 0: + tip_depth = (dia / 2) / math.tan(drill_angle / 2) + tools.append( + Cone( + bottom_radius=dia / 2, + top_radius=0, + height=tip_depth, + align=(Align.CENTER, Align.CENTER, Align.MIN), + mode=Mode.PRIVATE, + ).move( + Location( + Plane(origin=start + inward * abs(depth), z_dir=inward) + ) + ) + ) + for tool in tools: + part.part = part.part.cut(tool) + log.append(f"{sid}: {atomic} npos={len(positions)}") + + else: + raise ValueError(f"unsupported atomic_id: {atomic}") + + solid = part.part + + out_step.parent.mkdir(parents=True, exist_ok=True) + export_step(solid, str(out_step)) + bb = solid.bounding_box() + return { + "out_step": str(out_step), + "volume_mm3": _f(solid.volume), + "bbox_mm": { + "min": [bb.min.X, bb.min.Y, bb.min.Z], + "max": [bb.max.X, bb.max.Y, bb.max.Z], + }, + "log": log, + "cut_sign": cut_sign, + } + + +def main() -> None: + import argparse + + ap = argparse.ArgumentParser() + ap.add_argument("--pack", type=Path, required=True) + ap.add_argument("--out-step", type=Path, required=True) + ap.add_argument("--report", type=Path, default=None) + ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"]) + args = ap.parse_args() + info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign) + if args.report: + args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8") + print( + json.dumps( + {k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info}, + ensure_ascii=False, + indent=2, + ) + ) + for line in info.get("log") or []: + print(line) + + +if __name__ == "__main__": + main() diff --git a/backend/engine/cdsl_engine/llm_compiler.py b/backend/engine/cdsl_engine/llm_compiler.py index 0ec2559b..d421c881 100644 --- a/backend/engine/cdsl_engine/llm_compiler.py +++ b/backend/engine/cdsl_engine/llm_compiler.py @@ -1,287 +1,12 @@ -"""通用编译器:瘦 CDSL → build_pack;线性阵列在此展开为重复步骤。""" +"""Compatibility shim: the implementation moved to ``legacy.llm_compiler``. + +``compile_cdsl`` expands a thin CDSL document into a ``build_pack`` plan. +It predates the session-based runtime and is frozen; new capability belongs +in ``executors/`` and the schema contracts. +""" from __future__ import annotations -import json -from copy import deepcopy -from pathlib import Path -from typing import Any +from .legacy.llm_compiler import REQUIRED, compile_cdsl, main -try: - from .sketch_solver import resolve_all_sketches -except ImportError: - from sketch_solver import resolve_all_sketches - - -REQUIRED = { - "revolve_add": ["angle_deg", "axis"], - "revolve_cut": ["angle_deg", "axis"], - "extrude_add_blind": ["distance_mm"], - "extrude_add_two_sided": ["distance_mm"], - "extrude_cut_blind": ["distance_mm"], - "hole_blind": ["diameter_mm", "depth_mm"], - "hole_countersink": ["diameter_mm", "depth_mm"], - "hole_counterbore": ["diameter_mm", "depth_mm"], - "sphere_add": ["radius_mm", "center_mm"], -} - - -def _load(path: Path) -> dict[str, Any]: - return json.loads(path.read_text(encoding="utf-8")) - - -def _offset_sketch(sketch: dict[str, Any] | None, dx: float, dy: float, dz: float) -> dict[str, Any] | None: - if sketch is None: - return None - s = deepcopy(sketch) - wp = s.get("workplane") or {} - o = list(wp.get("origin_mm") or [0, 0, 0]) - wp["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] - s["workplane"] = wp - edges = [] - for e in s.get("contour_edges_mm") or []: - ne = deepcopy(e) - for key in ("start_mm", "end_mm", "center_mm"): - if key in ne: - p = ne[key] - ne[key] = [p[0] + dx, p[1] + dy, p[2] + dz] - edges.append(ne) - if edges: - s["contour_edges_mm"] = edges - # 2D entities: shift in plane if offset has in-plane components only — skip for world offset patterns - return s - - -def _offset_params_positions(params: dict[str, Any], dx: float, dy: float, dz: float) -> dict[str, Any]: - p = deepcopy(params) - if "positions" in p: - for pos in p["positions"]: - mm = pos.get("mm") - if mm: - pos["mm"] = [mm[0] + dx, mm[1] + dy, mm[2] + dz] - if "axis" in p and isinstance(p["axis"], dict): - o = list(p["axis"].get("origin_mm") or [0, 0, 0]) - p["axis"]["origin_mm"] = [o[0] + dx, o[1] + dy, o[2] + dz] - return p - - -def compile_cdsl( - cdsl: dict[str, Any], - atoms_catalog: dict[str, Any] | None = None, - techniques_catalog: dict[str, Any] | None = None, -) -> dict[str, Any]: - allowed = set() - if atoms_catalog: - allowed = {a["atomic_id"] for a in atoms_catalog.get("atoms") or []} - techniques = { - item["technique_id"]: item - for item in (techniques_catalog or {}).get("techniques") or [] - } - - sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} - - # 参数化轮廓求解:将 profile 字段展开为精确的 entities + contour_edges_mm - cdsl = resolve_all_sketches(cdsl) - sketches = {s["id"]: s for s in (cdsl.get("geometry") or {}).get("sketches") or []} - steps: list[dict[str, Any]] = [] - seen_ids: set[str] = set() - # feature_id -> list of emitted step dicts (for pattern source) - emitted: dict[str, list[dict[str, Any]]] = {} - - def emit(feature: dict[str, Any], params: dict[str, Any], sketch: dict[str, Any] | None, step_id: str) -> dict[str, Any]: - atomic = feature["atomic_id"] - if allowed and atomic not in allowed: - raise ValueError(f"{step_id}: atomic_id {atomic!r} is not admitted by catalog") - for dep in feature.get("depends_on") or []: - if dep not in seen_ids and not any(dep in emitted): - # dependency may be ok if earlier - if dep not in seen_ids: - raise ValueError(f"{step_id}: depends_on {dep} not yet defined") - step = { - "step_id": step_id, - "atomic_id": atomic, - "depends_on": list(feature.get("depends_on") or []), - "params": params, - "sketch": sketch, - "source_name": feature.get("name"), - } - steps.append(step) - seen_ids.add(step_id) - return step - - for feat in cdsl.get("features") or []: - fid = feat["id"] - atomic = feat.get("atomic_id") - technique_id = feat.get("technique_id") - if technique_id: - technique = techniques.get(technique_id) - if technique is None: - raise ValueError(f"{fid}: technique_id {technique_id!r} is not admitted by catalog") - groups = feat.get("params") or {} - expanded: list[dict[str, Any]] = [] - previous_step_id: str | None = None - for index, internal in enumerate(technique.get("internal_steps") or [], start=1): - group_name = internal.get("params_from") - group = deepcopy(groups.get(group_name) or {}) - if not isinstance(group, dict): - raise ValueError(f"{fid}: parameter group {group_name!r} must be an object") - params = deepcopy(group.get("params") if isinstance(group.get("params"), dict) else group) - sketch_id = group.get("sketch_id") or params.pop("sketch_id", None) - sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None - internal_atomic = internal.get("atomic_id") - if not internal_atomic: - raise ValueError(f"{fid}: technique {technique_id!r} has an invalid internal step") - for key in REQUIRED.get(internal_atomic, []): - if params.get(key) is None: - raise ValueError( - f"{fid}: technique {technique_id!r} group {group_name!r} missing {key}" - ) - internal_feature = { - "atomic_id": internal_atomic, - "depends_on": [previous_step_id] if previous_step_id else list(feat.get("depends_on") or []), - "name": f"{feat.get('name') or technique_id}:{group_name or index}", - } - step_id = f"{fid}.t{index}" - expanded.append(emit(internal_feature, params, sketch, step_id)) - previous_step_id = step_id - if len(expanded) < 2: - raise ValueError(f"{fid}: technique {technique_id!r} must expand to at least two steps") - emitted[fid] = expanded - seen_ids.add(fid) - continue - if not atomic: - raise ValueError(f"{fid}: missing atomic_id") - - if atomic == "pattern_linear": - params = feat.get("params") or {} - src_ids = params.get("source_feature_ids") or [] - c1 = int(params.get("pattern_count_1") or 1) - c2 = int(params.get("pattern_count_2") or 1) - s1 = float(params.get("spacing_1_mm") or 0) - s2 = float(params.get("spacing_2_mm") or 0) - d1 = params.get("direction_1") or [1, 0, 0] - d2 = params.get("direction_2") or [0, 1, 0] - if params.get("direction_1_reverse"): - d1 = [-d1[0], -d1[1], -d1[2]] - if params.get("direction_2_reverse"): - d2 = [-d2[0], -d2[1], -d2[2]] - - src_steps: list[dict[str, Any]] = [] - for sid in src_ids: - src_steps.extend(emitted.get(sid) or []) - if not src_steps: - # 无源则跳过并记录 - steps.append( - { - "step_id": fid, - "atomic_id": "noop_pattern", - "depends_on": list(feat.get("depends_on") or []), - "params": params, - "sketch": None, - "note": "pattern source steps missing", - } - ) - seen_ids.add(fid) - continue - - clone_steps = [] - k = 0 - for i in range(c1): - for j in range(c2): - if i == 0 and j == 0: - continue - dx = d1[0] * s1 * i + d2[0] * s2 * j - dy = d1[1] * s1 * i + d2[1] * s2 * j - dz = d1[2] * s1 * i + d2[2] * s2 * j - for src in src_steps: - k += 1 - clone_id = f"{fid}.p{k}" - fake_feat = { - "atomic_id": src["atomic_id"], - "depends_on": [steps[-1]["step_id"]] if steps else [], - "name": f"{src.get('source_name')}_pattern", - } - st = emit( - fake_feat, - _offset_params_positions(src["params"], dx, dy, dz), - _offset_sketch(src.get("sketch"), dx, dy, dz), - clone_id, - ) - clone_steps.append(st) - emitted[fid] = clone_steps - seen_ids.add(fid) - continue - - params = deepcopy(feat.get("params") or {}) - sketch_id = feat.get("sketch_id") or params.get("sketch_id") - sketch = deepcopy(sketches[sketch_id]) if sketch_id and sketch_id in sketches else None - if sketch_id: - params["sketch_id"] = sketch_id - - # Auto-derive revolve axis origin - if "revolve" in atomic and sketch and "axis" in params: - ax = params.get("axis") or {} - # 优先级: from_workplane_origin > from_contour_vertex > origin_mm 裸坐标 - wp = sketch.get("workplane") or {} - wp_origin = wp.get("origin_mm") or [0.0, 0.0, 0.0] - - if ax.get("from_workplane_origin") and "origin_mm" not in ax: - params["axis"] = deepcopy(params["axis"]) - params["axis"]["origin_mm"] = list(wp_origin) - elif "origin_mm" not in ax: - ce = sketch.get("contour_edges_mm") or [] - if ce: - idx = int(ax.get("from_contour_vertex", 0)) - vertex = ce[idx % len(ce)]["start_mm"] - params["axis"] = deepcopy(params["axis"]) - params["axis"]["origin_mm"] = list(vertex) - - for key in REQUIRED.get(atomic, []): - if key == "axis" and "axis" not in params: - raise ValueError(f"{fid}: missing axis") - if key not in ("axis",) and params.get(key) is None and key != "sketch_id": - # positions can be empty temporarily - if key in params: - continue - if key in ("diameter_mm", "depth_mm", "distance_mm", "angle_deg") and params.get(key) is None: - raise ValueError(f"{fid}: missing {key}") - - st = emit(feat, params, sketch, fid) - emitted[fid] = [st] - - # filter noop - steps = [s for s in steps if s.get("atomic_id") != "noop_pattern"] - - return { - "schema": "cad.engine_plan.v1", - "part_id": cdsl.get("part_id"), - "unit": "mm", - "steps": steps, - "compiler_context": deepcopy(cdsl.get("compiler_context")), - "meta": { - "from_cdsl_schema": cdsl.get("schema"), - "compiler": "cad-heard.llm_compiler.v1", - "n_steps": len(steps), - }, - } - - -def main() -> None: - import argparse - - ap = argparse.ArgumentParser() - ap.add_argument("--cdsl", type=Path, required=True) - ap.add_argument("--catalog", type=Path, default=None) - ap.add_argument("--techniques", type=Path, default=None) - ap.add_argument("--out", type=Path, required=True) - args = ap.parse_args() - catalog = _load(args.catalog) if args.catalog else None - techniques = _load(args.techniques) if args.techniques else None - pack = compile_cdsl(_load(args.cdsl), catalog, techniques) - args.out.parent.mkdir(parents=True, exist_ok=True) - args.out.write_text(json.dumps(pack, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"wrote {args.out} steps={len(pack['steps'])}") - - -if __name__ == "__main__": - main() +__all__ = ["REQUIRED", "compile_cdsl", "main"] diff --git a/backend/engine/cdsl_engine/llm_engine.py b/backend/engine/cdsl_engine/llm_engine.py index bc913eeb..0b4d8652 100644 --- a/backend/engine/cdsl_engine/llm_engine.py +++ b/backend/engine/cdsl_engine/llm_engine.py @@ -1,555 +1,12 @@ -"""build123d 绘图引擎:执行 build_pack → STEP。""" +"""Compatibility shim: the implementation moved to ``legacy.llm_engine``. + +``run_engine_plan`` executes a legacy ``build_pack`` through build123d. +It predates the session-based runtime and is frozen; new capability belongs +in ``executors/`` and the schema contracts. +""" from __future__ import annotations -import builtins -import json -import math -import subprocess -import sys -from pathlib import Path -from typing import Any +from .legacy.llm_engine import SUPPORTED_ATOMIC_IDS, main, run_engine_plan -# 保留内置 float,防止被 build123d 上下文 shadow -_f = builtins.float - -from build123d import ( # noqa: E402 - Align, - Axis, - BuildPart, - BuildSketch, - Circle, - Cone, - Cylinder, - Edge, - Face, - Location, - Locations, - Mode, - Plane, - Polygon, - Sphere, - Vector, - Wire, - export_step, - extrude, - import_step, - revolve, -) - - -# Keep this in sync with the execution branches in run_engine_plan. The -# agent-facing schema and its parity test prevent unsupported names reaching -# this low-level dispatcher. -SUPPORTED_ATOMIC_IDS = frozenset({ - "extrude_add_blind", - "extrude_add_two_sided", - "extrude_cut_blind", - "revolve_add", - "revolve_cut", - "hole_blind", - "hole_countersink", - "hole_counterbore", - "sphere_add", - "reference_plane", - "reference_axis", -}) - - -def _load(path: Path) -> dict[str, Any]: - return json.loads(path.read_text(encoding="utf-8")) - - -def _plane_from_workplane(wp: dict[str, Any]) -> Plane: - o = wp.get("origin_mm") or [0, 0, 0] - x = wp.get("x_dir") or [1, 0, 0] - n = wp.get("normal") or [0, 0, 1] - return Plane( - origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), - x_dir=Vector(_f(x[0]), _f(x[1]), _f(x[2])), - z_dir=Vector(_f(n[0]), _f(n[1]), _f(n[2])), - ) - - -def _axis_from_params(axis: dict[str, Any]) -> Axis: - o = axis.get("origin_mm") or [0, 0, 0] - d = axis.get("direction") or [1, 0, 0] - return Axis( - origin=Vector(_f(o[0]), _f(o[1]), _f(o[2])), - direction=Vector(_f(d[0]), _f(d[1]), _f(d[2])), - ) - - -def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]: - entities = sketch.get("entities") or [] - line_loop = [ - i for i, e in enumerate(entities) if e["type"] == "line" and not e.get("construction") - ] - if not line_loop: - raise ValueError(f"sketch {sketch.get('id')}: no profile lines") - pts: list[tuple[float, float]] = [] - for i in line_loop: - e = entities[i] - s = (_f(e["start"][0]), _f(e["start"][1])) - en = (_f(e["end"][0]), _f(e["end"][1])) - if not pts: - pts.append(s) - if abs(pts[-1][0] - s[0]) + abs(pts[-1][1] - s[1]) > 1e-4: - if abs(pts[-1][0] - en[0]) + abs(pts[-1][1] - en[1]) <= 1e-4: - s, en = en, s - else: - pts.append(s) - pts.append(en) - if abs(pts[0][0] - pts[-1][0]) + abs(pts[0][1] - pts[-1][1]) > 1e-4: - pts.append(pts[0]) - return pts - - -def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector: - """Return a point on the intended directed arc for ``make_three_point_arc``. - - Legacy contour data has no sweep direction and retains its prior shortest - arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a - major arc or a clockwise arc cannot be silently inverted by the adapter. - """ - v1 = p1 - center - v2 = p2 - center - if v1.length < 1e-9 or v2.length < 1e-9: - return (p1 + p2) / 2 - n = Vector(*(edge.get("normal") or [0, 0, 1])) - if n.length < 1e-9: - n = v1.cross(v2) - if n.length < 1e-9: - n = Vector(0, 0, 1) - n = n.normalized() - v1n = v1.normalized() * radius - if "clockwise" not in edge: - bisector = v1n + v2.normalized() * radius - if bisector.length < 1e-9: - bisector = n.cross(v1n) - return center + bisector.normalized() * radius - sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2)) - if bool(edge["clockwise"]): - if sweep >= 0: - sweep -= math.tau - elif sweep <= 0: - sweep += math.tau - half = sweep / 2 - midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half) - return center + midpoint_vector - - -def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face: - b123_edges: list[Edge] = [] - for e in edges_mm: - p1 = Vector(*e["start_mm"]) - p2 = Vector(*e["end_mm"]) - if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: - center = Vector(*e["center_mm"]) - r = _f(e["radius_mm"]) - v1 = p1 - center - v2 = p2 - center - if v1.length < 1e-9 or v2.length < 1e-9: - b123_edges.append(Edge.make_line(p1, p2)) - continue - mid = _arc_midpoint(e, p1, p2, center, r) - try: - b123_edges.append(Edge.make_three_point_arc(p1, mid, p2)) - except Exception: - b123_edges.append(Edge.make_line(p1, p2)) - else: - b123_edges.append(Edge.make_line(p1, p2)) - face = Face(Wire(b123_edges)) - if desired_normal is not None: - dn = Vector(*desired_normal) - if dn.length > 1e-9: - fn = face.normal_at() - if fn.dot(dn) < 0: - # 重建反转的 Wire:边顺序反转 + 每条边起止点交换 - # 这样法向自然翻转,但每条边的几何方向不变(不同于 Face.Reversed) - rev_edges: list[Edge] = [] - for e in reversed(edges_mm): - p1 = Vector(*e["end_mm"]) - p2 = Vector(*e["start_mm"]) - if e.get("type") == "arc" and e.get("center_mm") and e.get("radius_mm") is not None: - center = Vector(*e["center_mm"]) - r = _f(e["radius_mm"]) - v1 = p1 - center - v2 = p2 - center - if v1.length < 1e-9 or v2.length < 1e-9: - rev_edges.append(Edge.make_line(p1, p2)) - continue - mid = _arc_midpoint(e, p1, p2, center, r) - try: - rev_edges.append(Edge.make_three_point_arc(p1, mid, p2)) - except Exception: - rev_edges.append(Edge.make_line(p1, p2)) - else: - rev_edges.append(Edge.make_line(p1, p2)) - face = Face(Wire(rev_edges)) - return face - - -def _amount(params: dict[str, Any], *, prefer_sign: str | None = None) -> float: - dist = abs(_f(params["distance_mm"])) - if prefer_sign == "plus": - return dist - if prefer_sign == "minus": - return -dist - return -dist if bool(params.get("reverse")) else dist - - -def _build_nested_circle_profiles(circles: list[dict[str, Any]]) -> None: - """Build circular islands and holes from containment parity. - - A circle contained by one larger circle is a hole; a circle contained by - two larger circles is an island again. This preserves annular profiles - without storing the heavy tessellated sketch regions from the SW export. - """ - ordered = sorted(circles, key=lambda item: _f(item["radius_mm"]), reverse=True) - tolerance = 1e-6 - for index, circle in enumerate(ordered): - center = circle["center"] - radius = _f(circle["radius_mm"]) - containing = 0 - for outer in ordered[:index]: - outer_center = outer["center"] - outer_radius = _f(outer["radius_mm"]) - distance = math.hypot( - _f(center[0]) - _f(outer_center[0]), - _f(center[1]) - _f(outer_center[1]), - ) - if distance + radius <= outer_radius + tolerance: - containing += 1 - mode = Mode.ADD if containing % 2 == 0 else Mode.SUBTRACT - with Locations((_f(center[0]), _f(center[1]))): - Circle(radius, mode=mode) - - -def run_engine_plan( - pack: dict[str, Any], - out_step: Path, - *, - cut_sign: str = "from_params", -) -> dict[str, Any]: - log: list[str] = [] - - compiler_context = pack.get("compiler_context") - if isinstance(compiler_context, dict): - # 回退路径:使用本包 translator(不依赖外部 backend.src) - try: - from .translator import generate_build123d_code, get_part_name - except ImportError: - from translator import generate_build123d_code, get_part_name - - context = dict(compiler_context) - context.setdefault("metadata", {})["part_name"] = str(pack.get("part_id") or out_step.stem) - out_step.parent.mkdir(parents=True, exist_ok=True) - completed = subprocess.run( - [sys.executable, "-c", generate_build123d_code(context)], - cwd=out_step.parent, - capture_output=True, - text=True, - timeout=180, - ) - if completed.returncode != 0: - raise RuntimeError( - f"exact compiler execution failed\nSTDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}" - ) - generated_name = get_part_name({"part_name": context["metadata"]["part_name"]}) - generated = out_step.parent / f"{generated_name}.step" - if generated != out_step and generated.exists(): - generated.replace(out_step) - if not out_step.exists(): - raise RuntimeError(f"exact compiler did not generate {out_step}") - solid = import_step(str(out_step)) - bb = solid.bounding_box() - return { - "out_step": str(out_step), - "volume_mm3": _f(solid.volume), - "bbox_mm": { - "min": [bb.min.X, bb.min.Y, bb.min.Z], - "max": [bb.max.X, bb.max.Y, bb.max.Z], - }, - "engine": "translator_fallback", - } - - with BuildPart() as part: - for step in pack.get("steps") or []: - atomic = step["atomic_id"] - params = step["params"] - sketch = step.get("sketch") - sid = step.get("step_id") - - if atomic == "reference_plane": - # Context features deliberately produce no solid. They remain - # executable plan steps so their dependencies are preserved and - # can be registered by the session-based runtime. - plane = _plane_from_workplane(params.get("plane") or {}) - log.append( - f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}" - ) - - elif atomic == "reference_axis": - axis = _axis_from_params(params.get("axis") or {}) - log.append( - f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}" - ) - - elif atomic == "sphere_add": - radius = _f(params.get("radius_mm") or 0) - center = params.get("center_mm") or [0, 0, 0] - if radius <= 0 or len(center) != 3: - raise ValueError(f"{sid}: sphere_add requires a positive radius_mm and center_mm") - with Locations((_f(center[0]), _f(center[1]), _f(center[2]))): - Sphere(radius, mode=Mode.ADD) - log.append(f"{sid}: sphere_add radius={radius}") - - elif atomic in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): - if sketch is None: - raise ValueError(f"{sid}: missing sketch") - plane = _plane_from_workplane(sketch.get("workplane") or {}) - mode = Mode.SUBTRACT if "cut" in atomic else Mode.ADD - edges = sketch.get("contour_edges_mm") or [] - regions = sketch.get("contour_regions_mm") or [] - sign = cut_sign if "cut" in atomic else "from_params" - - circles = [ - e - for e in (sketch.get("entities") or []) - if e.get("type") == "circle" and not e.get("construction") - ] - lines = [ - e - for e in (sketch.get("entities") or []) - if e.get("type") == "line" and not e.get("construction") - ] - - # 多区域轮廓(外环 + 孔):由 shape generator 展开 - if regions: - faces = [] - normal = (sketch.get("workplane") or {}).get("normal") - for reg in regions: - outer_edges = reg.get("outer") or [] - if len(outer_edges) < 2: - continue - face = _face_from_contour_edges(outer_edges, desired_normal=normal) - for hole_edges in reg.get("holes") or []: - if len(hole_edges) < 2: - continue - hole = _face_from_contour_edges(hole_edges, desired_normal=normal) - face = face.cut(hole) - faces.append(face) - if not faces: - raise ValueError(f"{sid}: contour_regions_mm produced no faces") - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - for face in faces: - extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - for face in faces: - extrude(to_extrude=face, amount=amt, mode=mode) - log.append(f"{sid}: {atomic} regions={len(faces)}") - continue - - # 切除:草图常含面外框线+圆孔;优先圆孔,避免误用外框整面切除 - prefer_circles = bool(circles) and atomic.startswith("extrude_cut") - - if prefer_circles: - with BuildSketch(plane): - for e in circles: - with Locations((_f(e["center"][0]), _f(e["center"][1]))): - Circle(_f(e["radius_mm"])) - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - extrude(amount=d, both=True, mode=Mode.ADD) - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - extrude(amount=amt, mode=mode) - log.append(f"{sid}: {atomic} circle-only n={len(circles)}") - elif len(edges) >= 2: - face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - extrude(to_extrude=face, amount=d, both=True, mode=Mode.ADD) - log.append(f"{sid}: extrude_two_sided both={d} contour") - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - extrude(to_extrude=face, amount=amt, mode=mode) - log.append(f"{sid}: {atomic} amount={amt} contour") - elif circles and not lines: - # 纯圆轮廓:用包含层级区分实体、内孔和孔中岛。 - with BuildSketch(plane): - _build_nested_circle_profiles(circles) - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - extrude(amount=d, both=True, mode=Mode.ADD) - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - extrude(amount=amt, mode=mode) - log.append(f"{sid}: {atomic} circle-only n={len(circles)}") - else: - with BuildSketch(plane): - pts = _ordered_profile_points(sketch) - poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts - Polygon(*poly) - for e in circles: - with Locations((_f(e["center"][0]), _f(e["center"][1]))): - Circle(_f(e["radius_mm"]), mode=Mode.SUBTRACT) - if atomic == "extrude_add_two_sided": - d = abs(_f(params["distance_mm"])) - extrude(amount=d, both=True, mode=Mode.ADD) - log.append(f"{sid}: extrude_two_sided both={d} poly") - else: - amt = _amount(params, prefer_sign=None if sign == "from_params" else sign) - extrude(amount=amt, mode=mode) - log.append(f"{sid}: {atomic} amount={amt} poly") - - elif atomic in ("revolve_add", "revolve_cut"): - if sketch is None: - raise ValueError(f"{sid}: missing sketch") - plane = _plane_from_workplane(sketch.get("workplane") or {}) - axis = _axis_from_params(params.get("axis") or {}) - angle = _f(params.get("angle_deg") or 360) - mode = Mode.SUBTRACT if atomic == "revolve_cut" else Mode.ADD - edges = sketch.get("contour_edges_mm") or [] - if len(edges) >= 2: - face = _face_from_contour_edges(edges, desired_normal=sketch.get("workplane", {}).get("normal")) - revolve(profiles=face, axis=axis, revolution_arc=angle, mode=mode) - else: - with BuildSketch(plane): - pts = _ordered_profile_points(sketch) - poly = pts[:-1] if len(pts) >= 2 and pts[0] == pts[-1] else pts - Polygon(*poly) - revolve(axis=axis, revolution_arc=angle, mode=mode) - log.append(f"{sid}: {atomic} angle={angle}") - - elif atomic in ("hole_blind", "hole_countersink", "hole_counterbore"): - dia = _f(params.get("diameter_mm") or 0) - depth = _f(params.get("depth_mm") or 0) - positions = params.get("positions") or [] - if sketch is not None: - plane = _plane_from_workplane(sketch.get("workplane") or {}) - else: - plane = Plane.XY - host_face = params.get("host_face") or {} - frame = host_face.get("frame") or {} - frame_origin = Vector(*(frame.get("origin_mm") or plane.origin.to_tuple())) - frame_x = Vector(*(frame.get("x_dir") or plane.x_dir.to_tuple())) - frame_y = Vector(*(frame.get("y_dir") or plane.y_dir.to_tuple())) - normal = plane.z_dir.normalized() - bb = part.part.bounding_box() - part_center = Vector( - (bb.min.X + bb.max.X) / 2, - (bb.min.Y + bb.max.Y) / 2, - (bb.min.Z + bb.max.Z) / 2, - ) - inward = normal if (part_center - frame_origin).dot(normal) >= 0 else -normal - for pos in positions: - mm = pos.get("mm") or [0, 0, 0] - start = frame_origin + frame_x * _f(mm[0]) + frame_y * _f(mm[1]) - cs_dia = _f(params.get("countersink_diameter_mm") or 0) - cs_angle = _f(params.get("countersink_angle_rad") or 0) - cb_dia = _f(params.get("counterbore_diameter_mm") or 0) - cb_depth = _f(params.get("counterbore_depth_mm") or 0) - cs_depth = ( - ((cs_dia - dia) / 2) / math.tan(cs_angle / 2) - if cs_dia > dia and cs_angle > 0 - else 0 - ) - base_offset = cs_depth + (cb_depth if cb_dia > dia else 0) - main_depth = max(0.001, abs(depth) - base_offset) - main_place = Location(Plane(origin=start + inward * base_offset, z_dir=inward)) - tools = [ - Cylinder( - radius=dia / 2, - height=main_depth, - align=(Align.CENTER, Align.CENTER, Align.MIN), - mode=Mode.PRIVATE, - ).move(main_place) - ] - if cb_dia > dia and cb_depth > 0: - tools.append( - Cylinder( - radius=cb_dia / 2, - height=cb_depth, - align=(Align.CENTER, Align.CENTER, Align.MIN), - mode=Mode.PRIVATE, - ).move(Location(Plane(origin=start, z_dir=inward))) - ) - if cs_depth > 0: - tools.append( - Cone( - bottom_radius=cs_dia / 2, - top_radius=dia / 2, - height=cs_depth, - align=(Align.CENTER, Align.CENTER, Align.MIN), - mode=Mode.PRIVATE, - ).move(Location(Plane(origin=start, z_dir=inward))) - ) - drill_angle = _f(params.get("drill_angle_rad") or 0) - if drill_angle > 0: - tip_depth = (dia / 2) / math.tan(drill_angle / 2) - tools.append( - Cone( - bottom_radius=dia / 2, - top_radius=0, - height=tip_depth, - align=(Align.CENTER, Align.CENTER, Align.MIN), - mode=Mode.PRIVATE, - ).move( - Location( - Plane(origin=start + inward * abs(depth), z_dir=inward) - ) - ) - ) - for tool in tools: - part.part = part.part.cut(tool) - log.append(f"{sid}: {atomic} npos={len(positions)}") - - else: - raise ValueError(f"unsupported atomic_id: {atomic}") - - solid = part.part - - out_step.parent.mkdir(parents=True, exist_ok=True) - export_step(solid, str(out_step)) - bb = solid.bounding_box() - return { - "out_step": str(out_step), - "volume_mm3": _f(solid.volume), - "bbox_mm": { - "min": [bb.min.X, bb.min.Y, bb.min.Z], - "max": [bb.max.X, bb.max.Y, bb.max.Z], - }, - "log": log, - "cut_sign": cut_sign, - } - - -def main() -> None: - import argparse - - ap = argparse.ArgumentParser() - ap.add_argument("--pack", type=Path, required=True) - ap.add_argument("--out-step", type=Path, required=True) - ap.add_argument("--report", type=Path, default=None) - ap.add_argument("--cut-sign", default="from_params", choices=["from_params", "plus", "minus"]) - args = ap.parse_args() - info = run_engine_plan(_load(args.pack), args.out_step, cut_sign=args.cut_sign) - if args.report: - args.report.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8") - print( - json.dumps( - {k: info[k] for k in ("out_step", "volume_mm3", "bbox_mm", "cut_sign", "engine") if k in info}, - ensure_ascii=False, - indent=2, - ) - ) - for line in info.get("log") or []: - print(line) - - -if __name__ == "__main__": - main() +__all__ = ["SUPPORTED_ATOMIC_IDS", "main", "run_engine_plan"] diff --git a/backend/engine/cdsl_engine/rebuild.py b/backend/engine/cdsl_engine/rebuild.py index 5d3815a1..78101205 100644 --- a/backend/engine/cdsl_engine/rebuild.py +++ b/backend/engine/cdsl_engine/rebuild.py @@ -1,7 +1,7 @@ """ CDSL → STEP 重建管道 ==================== -优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only) +优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only) 回退: CDSL + compiler_context → translator """ @@ -15,41 +15,43 @@ from pathlib import Path from typing import Any try: - from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles - from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from .llm_compiler import compile_cdsl from .llm_engine import run_engine_plan from .translator import generate_build123d_code, normalize_to_ir + from .legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized except ImportError: # 允许直接 python rebuild.py - from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles - from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches + from cdsl_importer.legacy_profile_adapter import lower_legacy_profiles + from sketch_solver import SHAPE_GENERATORS, resolve_all_sketches from llm_compiler import compile_cdsl from llm_engine import run_engine_plan from translator import generate_build123d_code, normalize_to_ir + from legacy.exact_rebuild import _apply_geometric_compensations, _run_exact, _run_parameterized -def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, - force_exact: bool = False) -> dict[str, Any]: +def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = None, gold_step: Path | None = None, + force_exact: bool = False) -> dict[str, Any]: """主重建入口。 优先:纯 CDSL 参数化路径(sketch_solver → llm_compiler → llm_engine),不依赖 compiler_context。 回退:CDSL + compiler_context 的 translator 路径。 """ - # Compatibility entry point only: new CDSL-only runtime calls do not use - # macro profiles and therefore never invoke this adapter. - cdsl = lower_legacy_profiles(cdsl) - sketches = cdsl.get("geometry", {}).get("sketches", []) - # Sketchless parameterized features (for example sphere_add) are fully - # executable by the CDSL-only runtime. ``all([])`` deliberately keeps - # that path available rather than forcing an unavailable legacy fallback. - all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches) + # Compatibility entry point only: new CDSL-only runtime calls do not use + # macro profiles and therefore never invoke this adapter. + cdsl = lower_legacy_profiles(cdsl) + sketches = cdsl.get("geometry", {}).get("sketches", []) + # Sketchless parameterized features (for example sphere_add) are fully + # executable by the CDSL-only runtime. ``all([])`` deliberately keeps + # that path available rather than forcing an unavailable legacy fallback. + all_drawable = all(_sketch_is_cdsl_drawable(s) for s in sketches) - cdsl_only_error: Exception | None = None - if all_drawable and not force_exact: - try: - return run_cdsl_only(cdsl, out_step, gold_step=gold_step) - except Exception as e: - cdsl_only_error = e + cdsl_only_error: Exception | None = None + if all_drawable and not force_exact: + try: + return run_cdsl_only(cdsl, out_step, gold_step=gold_step) + except Exception as e: + cdsl_only_error = e # 加载 compiler_context(后备路径) ctx = None @@ -71,13 +73,13 @@ def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = No "references": ir.get("references", []), "validation_hints": ir.get("validation_hints", {}), } - if ctx is None and not (cdsl.get("compiler_context")): - if cdsl_only_error is not None: - raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error - raise RuntimeError( - "CDSL-only rebuild is unavailable: every sketch must use a supported " - "self-contained profile." - ) + if ctx is None and not (cdsl.get("compiler_context")): + if cdsl_only_error is not None: + raise RuntimeError(f"CDSL-only rebuild failed: {cdsl_only_error}") from cdsl_only_error + raise RuntimeError( + "CDSL-only rebuild is unavailable: every sketch must use a supported " + "self-contained profile." + ) if ctx is not None: cdsl["compiler_context"] = ctx @@ -112,24 +114,24 @@ def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool: return ptype in SHAPE_GENERATORS -def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: - """Pure semantic CDSL path with no compiler_context fallback.""" - t0 = time.time() - try: - from .runtime import rebuild_cdsl - except ImportError: - from runtime import rebuild_cdsl - slim = {key: value for key, value in cdsl.items() if key != "compiler_context"} - result = rebuild_cdsl(slim, out_step, strict=True) - result["engine"] = "cdsl_only" - result["elapsed_s"] = round(time.time() - t0, 1) - result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)") +def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: + """Pure semantic CDSL path with no compiler_context fallback.""" + t0 = time.time() + try: + from .runtime import rebuild_cdsl + except ImportError: + from runtime import rebuild_cdsl + slim = {key: value for key, value in cdsl.items() if key != "compiler_context"} + result = rebuild_cdsl(slim, out_step, strict=True) + result["engine"] = "cdsl_only" + result["elapsed_s"] = round(time.time() - t0, 1) + result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)") if gold_step and gold_step.exists(): result["gold_step"] = str(gold_step) return result -def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]: +def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]: pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"}) pack.pop("compiler_context", None) return pack @@ -139,216 +141,6 @@ def run_engine(pack: dict[str, Any], out_step: Path) -> dict[str, Any]: return run_engine_plan(pack, out_step) -def _run_parameterized(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: - """参数化路径: CDSL语义结构 + compiler_context精确数据 → translator生成代码 → 执行 - - 采用双层IR架构: - Learning IR (CDSL) 提供参数化形状、特征结构 - Execution IR (compiler_context) 提供精确坐标 - translator 提供经过充分测试的代码生成 - """ - - import subprocess - import tempfile, os - - t0 = time.time() - - # 1. 获取 compiler_context (Execution IR: 精确坐标) - compiler_context = cdsl.get("compiler_context") or {} - if not compiler_context: - # 从外部文件加载 - ctx_file = out_step.parent / "{}.compiler_context.json".format(cdsl.get("part_id", "")) - if ctx_file.exists(): - import json as _json - with open(ctx_file, "r", encoding="utf-8") as _f: - compiler_context = _json.load(_f) - if not compiler_context: - raise RuntimeError("CDSL缺少 compiler_context,无法重建") - - part_name = str(cdsl.get("part_id") or out_step.stem) - context = dict(compiler_context) - context.setdefault("metadata", {})["part_name"] = part_name - - # 2. 将 compiler_context 的精确实体注入 CDSL 草图 (供 sketch_solver 使用) - # 015133: CDSL (Learning IR) 不含坐标,坐标来自 Execution IR - ctx_sketches_map = {s["id"]: s for s in context.get("sketches", [])} - cdsl_sketches = cdsl.get("geometry", {}).get("sketches", []) - for sk in cdsl_sketches: - ctx_sk = ctx_sketches_map.get(sk["id"]) - if ctx_sk: - # 注入 entities/contour 供 polygon/complex_arc_shape 生成器使用 - if not sk.get("entities"): - sk["entities"] = ctx_sk.get("entities", []) - if not sk.get("contour_edges_mm"): - sk["contour_edges_mm"] = ctx_sk.get("contour_edges_mm", []) - - # 3. 解析 CDSL 的参数化草图 (现在有 entities 可用) - cdsl_resolved = resolve_all_sketches(cdsl) - - # 4. 将 CDSL 解析后的 profile/profile_from 注入 compiler_context - # translator 使用 compiler_context 的精确 entities + CDSL 的 profile 分类 - cdsl_resolved_map = {s["id"]: s for s in cdsl_resolved.get("geometry", {}).get("sketches", [])} - ctx_sketches = list(context.get("sketches", [])) - updated_count = 0 - for i, ctx_sk in enumerate(ctx_sketches): - sk_id = ctx_sk.get("id", "") - cdsl_sk = cdsl_resolved_map.get(sk_id) - if cdsl_sk and cdsl_sk.get("profile"): - ctx_sketches[i] = {**ctx_sk, "profile": cdsl_sk["profile"]} - updated_count += 1 - if cdsl_sk and cdsl_sk.get("profile_from"): - ctx_sketches[i] = {**ctx_sk, "profile_from": cdsl_sk["profile_from"]} - updated_count += 1 - context["sketches"] = ctx_sketches - - # 4. 使用 compiler_context 的原始 operations(保持 translator 兼容性) - - # 5. 读取 gold volume - gold_volume_mm3 = None - if gold_step and gold_step.exists(): - try: - from build123d import import_step - gold_solid = import_step(str(gold_step)) - gold_volume_mm3 = float(gold_solid.volume) - except Exception: - pass - - # 6. 用 translator 生成并执行 - code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) - - # 6b. 应用几何补偿 (SW导出缺失的特征) - part_id = str(cdsl.get("part_id") or "") - code = _apply_geometric_compensations(code, part_id) - - out_step.parent.mkdir(parents=True, exist_ok=True) - - with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as tf: - tf.write(code) - script_path = tf.name - - try: - r = subprocess.run( - ["python", script_path], - capture_output=True, text=True, encoding="utf-8", timeout=120, - env={**os.environ, "PYTHONIOENCODING": "utf-8"}, - ) - if r.returncode != 0: - raise RuntimeError(f"Build script failed:\n{r.stderr}") - finally: - try: - os.unlink(script_path) - except Exception: - pass - - # 7. 读取重建结果 - out_step.parent.mkdir(parents=True, exist_ok=True) - built_step = Path(part_name + ".step") - if not built_step.exists(): - built_step = Path.cwd() / (part_name + ".step") - if built_step.exists(): - import shutil - shutil.copy2(str(built_step), str(out_step)) - built_step.unlink() - else: - raise RuntimeError(f"No STEP output found: {part_name}.step") - - from build123d import import_step - rebuilt = import_step(str(out_step)) - bbox = rebuilt.bounding_box() - bbox_mm = { - "min": [bbox.min.X, bbox.min.Y, bbox.min.Z], - "max": [bbox.max.X, bbox.max.Y, bbox.max.Z], - } - - elapsed = time.time() - t0 - return { - "out_step": str(out_step), - "volume_mm3": float(rebuilt.volume), - "bbox_mm": bbox_mm, - "log": [f"param: CDSL-informed translator rebuild, {updated_count} sketches updated from CDSL"], - "engine": "parameterized", - "elapsed_s": round(elapsed, 1), - } - - -def _run_exact(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]: - """精确路径: generate_build123d_code (后备)""" - - import subprocess - - compiler_context = cdsl.get("compiler_context") or {} - part_name = str(cdsl.get("part_id") or out_step.stem) - context = dict(compiler_context) - context.setdefault("metadata", {})["part_name"] = part_name - - # Read gold volume if available, for chamfer/candidate scoring - gold_volume_mm3 = None - if gold_step and gold_step.exists(): - try: - from build123d import import_step - gold_solid = import_step(str(gold_step)) - gold_volume_mm3 = float(gold_solid.volume) - except Exception: - pass - - out_step.parent.mkdir(parents=True, exist_ok=True) - - # Apply geometric compensations FIRST (may return full replacement code) - part_id = str(cdsl.get("part_id") or "") - compensation_code = _apply_geometric_compensations("", part_id) - - if compensation_code and "build123d" in compensation_code and "__main__" in compensation_code: - # 完整替换代码 (跳过generate_build123d_code) - code = compensation_code - else: - code = generate_build123d_code(context, gold_volume_mm3=gold_volume_mm3) - code = _apply_geometric_compensations(code, part_id) - - t0 = time.time() - script_path = out_step.parent / "_tmp" / f"build_{part_name}_{int(time.time())}.py" - script_path.parent.mkdir(exist_ok=True) - script_path.write_text(code, encoding="utf-8") - - completed = subprocess.run( - [sys.executable, str(script_path)], - cwd=out_step.parent, - capture_output=True, - text=True, - timeout=600, - ) - - if completed.returncode != 0: - raise RuntimeError( - f"Exact compiler FAILED (rc={completed.returncode})\n" - f"STDOUT:\n{completed.stdout[-2000:]}\n" - f"STDERR:\n{completed.stderr[-3000:]}" - ) - # Print any warnings from safe_subtract - for line in completed.stdout.split('\n'): - if 'SUBTRACT' in line or 'UNION' in line: - print(f" {line.strip()}") - - from build123d import import_step - # 生成的 build 脚本将 STEP 写到 CWD 下的 "{part_name}.step" - # 移到 out_step 位置以供后续对比 - actual_step = out_step.parent / f"{part_name}.step" - if actual_step.exists(): - import shutil - shutil.copy2(str(actual_step), str(out_step)) - solid = import_step(str(out_step)) - bb = solid.bounding_box() - elapsed = time.time() - t0 - - return { - "out_step": str(out_step), - "volume_mm3": float(solid.volume), - "bbox_mm": {"min": [bb.min.X, bb.min.Y, bb.min.Z], - "max": [bb.max.X, bb.max.Y, bb.max.Z]}, - "engine": "exact", - "elapsed_s": round(elapsed, 1), - } - - def compare_with_gold(gold_step: Path, rebuilt_step: Path) -> dict[str, Any]: from build123d import import_step import math, random, time @@ -493,27 +285,6 @@ def _surface_deviation(gold, rebuilt, n_points: int = 500) -> dict[str, Any]: # (下面的不再需要,新逻辑已在_surface_deviation中实现) -# ═══════════════════════════════════════════════════════════════ -# Geometric compensations(项目特例;拷贝到其他项目时可删) -# ═══════════════════════════════════════════════════════════════ - -def _apply_geometric_compensations(code: str, part_id: str) -> str: - """为SW导出中缺失的特征添加几何补偿切操作""" - if part_id == "113246": - if "export_step(result, " not in code: - return code - comp = ( - " # === COMPENSATION: 侧槽 (SW缺失特征) ===\n" - " with BuildSketch(Plane(origin=(-70.0, -13.0, 10.0), " - "x_dir=(0.0, 1.0, 0.0), z_dir=(1.0, 0.0, 0.0))) as comp_sk:\n" - " Rectangle(10.0, 3.0, align=(Align.MIN, Align.MIN))\n" - " comp_cutter = extrude(comp_sk.sketch, amount=10.0)\n" - " result = safe_subtract(result, comp_cutter)\n" - ) - code = code.replace("export_step(result, ", comp + " export_step(result, ") - return code - - # =========================================================================== # CLI(便携:显式路径,无项目目录假设) # =========================================================================== -- 2.52.0 From 6fa4501f16783c01111aeab8ebb35ff819046310 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 14:34:14 +0800 Subject: [PATCH 07/10] docs(cdsl_engine): document package layout and the add-atomic-operation workflow Phase 8: README now maps every module of the split package, documents the executor registration + schema-contract workflow for new atomic operations, and restates the schema maintenance rule for executors/. --- backend/engine/cdsl_engine/README.md | 61 ++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 12 deletions(-) diff --git a/backend/engine/cdsl_engine/README.md b/backend/engine/cdsl_engine/README.md index fe1789aa..9980ef70 100644 --- a/backend/engine/cdsl_engine/README.md +++ b/backend/engine/cdsl_engine/README.md @@ -4,17 +4,53 @@ This package rebuilds `cad.cdsl.llm.v1` models through the CDSL-only path: `semantic validation -> capability analysis -> sketch resolution -> session runtime -> STEP` -`runtime.py` owns the executor registry, an `ExecutionSession`, and the -feature/topology lifecycle. `build123d_adapter.py` is the only layer that -creates or mutates B-rep objects. `runtime_types.py` owns runtime-neutral -feature, context, selector, and topology contracts. `llm_compiler.py` and -`llm_engine.py` remain available for legacy engine-plan compatibility but are -not used by `run_cdsl_only`. +## Package layout + +| Module | Responsibility | +|---|---| +| `specs.py` | Vector math, plane/axis helpers, parametric feature specs (no kernel deps) | +| `topology.py` | Diagnostics, planning contracts, `TopologyRegistry`, selector resolution | +| `capabilities.py` | `CapabilityAnalyzer`: preflight blockers before any geometry runs | +| `sketch_solver.py` | Profile expansion (circle / polygon / analytic contours) | +| `session.py` | `ExecutionSession` and the kernel-facing `GeometryAdapter` protocol | +| `runtime_base.py` | Shared error types and the `ExtentVector` value | +| `extents.py` | End-condition planning (blind / through / up-to-surface / ...) | +| `pattern_transform.py` | Translate/mirror/rotate parameter algebra for pattern replay | +| `registry.py` | `EXECUTORS`, the `atomic_executor` decorator, and `execute_node` dispatch | +| `executors/` | One module per executor family; importing the package registers all | +| `build123d_adapter.py` | The only layer that creates or mutates B-rep objects | +| `topology.py` / `runtime_types.py` | Historical re-export shim (`runtime_types`) | +| `translator/` | Frozen SolidWorks-exact code generation (`ir` / `codegen` / `runtime_lib`) | +| `legacy/` | Frozen legacy engine paths (`llm_compiler`, `llm_engine`, exact rebuilds) | +| `rebuild.py` | Legacy three-way facade plus `compare_with_gold` acceptance | + +`runtime.py` keeps the `analyze_cdsl` / `rebuild_cdsl` entry points and +re-exports the historical names. `llm_compiler.py` and `llm_engine.py` are +compatibility shims for the frozen `legacy/` implementations and are not used +by `run_cdsl_only`. + +## Adding an atomic operation + +1. Add the operation contract to `profile_schema.json` (`operation_contracts`), + including its `runtime_capability` flags — this is the single source of + truth for preflight classification. +2. Add a decorated executor function in exactly one `executors/.py` + module (`@atomic_executor("...")`); the shared registry never changes. +3. Add the atomic id to `ALL_ATOMIC_IDS` in `registry.py` (registration fails + fast on unknown or duplicate ids, and `executors/__init__` fails if any + declared id has no registered executor). +4. Update `cdsl_schema.json` in the same change. +5. Extend `backend/tests/test_profile_schema.py` fixtures if the contract + shape changed. + +Multiple people can add different operations in parallel without touching a +shared registry file: the only shared edits are the two schema documents. Supported profiles are defined by `SHAPE_GENERATORS` in `sketch_solver.py`. -Supported feature atomic operations are defined by `EXECUTORS` in `runtime.py`. -Their human-readable contract is in `profile_schema.json`; the complete, -machine-enforced CDSL object contract is in `cdsl_schema.json`. +Supported feature atomic operations are defined by `EXECUTORS` (populated from +`executors/` at import time). Their human-readable contract is in +`profile_schema.json`; the complete, machine-enforced CDSL object contract is +in `cdsl_schema.json`. The Studio only accepts self-contained profile data and requires successful `engine=cdsl_only` output. It never uses the legacy translator fallback or `compiler_context`. @@ -24,10 +60,11 @@ The Studio only accepts self-contained profile data and requires successful `profile_schema.json` and `cdsl_schema.json` together are the source of truth for the engine contract exposed to the CAD Agent and the backend validator. Any addition, removal, rename, or parameter-contract change in -`sketch_solver.py`, `runtime.py`, or the build adapter must update both files +`sketch_solver.py`, `executors/`, or the build adapter must update both files in the same change. -`backend/tests/test_profile_schema.py` fails when the registered profiles or -supported atomic operations diverge from the document. +`backend/tests/test_profile_schema.py` fails when the registered profiles, +supported atomic operations, or `runtime_capability` flags diverge from the +document. ## Batch baseline -- 2.52.0 From cf9cd0366a7e09b50f221b129e6f554d74c1ec99 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 16:07:25 +0800 Subject: [PATCH 08/10] refactor(cdsl_engine): extract topology evidence export from the adapter Phase 6 of the decoupling refactor (behavior-preserving move): - topology_export.py: body_geometry / surface_geometry / topology_records (~290 lines) moved verbatim out of build123d_adapter.py as module-level functions; includes the circle_center_mm/radius_mm concentric-circle evidence added by the lk_dev integration - build123d_adapter.py: the three methods are now one-line forwards, so kernel construction and topology evidence export live in separate files and can evolve independently No protocol change (GeometryAdapter untouched). Verified by: - golden comparison: full-corpus analyze (2881 docs) + 12 complete rebuilds -- zero field differences vs f79c287 - full test suite: 435 tests, failure set identical to baseline --- .../engine/cdsl_engine/build123d_adapter.py | 296 +---------------- backend/engine/cdsl_engine/topology_export.py | 311 ++++++++++++++++++ 2 files changed, 322 insertions(+), 285 deletions(-) create mode 100644 backend/engine/cdsl_engine/topology_export.py diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 9fce3d51..c633da3a 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -33,6 +33,11 @@ from .runtime_types import ( TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3, canonical_plane_signature, ) +from .topology_export import ( + body_geometry as _body_geometry_impl, + surface_geometry as _surface_geometry_impl, + topology_records as _topology_records_impl, +) def _vector(value: list[float] | tuple[float, float, float]) -> Vector: @@ -1926,294 +1931,15 @@ class Build123dGeometryAdapter: @staticmethod def body_geometry(body: Any) -> dict[str, Any]: - # 汇总主体基本几何信息:包围盒与体积。 - bbox = body.bounding_box() - # A feature history can contain several body IDs while still ending in - # one connected solid (for example, a base extrusion followed by hole - # cuts). Count the current OCC result, never feature history entries. - solids = list(body.solids()) if hasattr(body, "solids") else [body] - return { - "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], - "volume_mm3": float(body.volume), - "solid_count": len(solids), - } + # 汇总主体基本几何信息:包围盒与体积(实现见 topology_export)。 + return _body_geometry_impl(body) @staticmethod def surface_geometry(surface: Any) -> dict[str, Any]: - # 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。 - bbox = surface.bounding_box() - return { - "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], - "area_mm2": float(surface.area), - "face_count": len(surface.faces()), - } + # 曲面结果的独立拓扑摘要(实现见 topology_export)。 + return _surface_geometry_impl(surface) @staticmethod def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: - # 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。 - records: list[TopologyRecord] = [] - faces = list(body.faces()) - edges = list(body.edges()) - vertices = list(body.vertices()) - - def index_for(shape: Any, candidates: list[Any]) -> int | None: - """Map a subshape returned by a face/edge back to body topology.""" - # 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。 - for index, candidate in enumerate(candidates): - if shape.is_same(candidate): - return index - return None - - # 1. 建立邻接索引:每条边关联的面集合(edge_faces)。 - edge_faces: list[set[int]] = [set() for _edge in edges] - for face_index, face in enumerate(faces): - for edge in face.edges(): - edge_index = index_for(edge, edges) - if edge_index is not None: - edge_faces[edge_index].add(face_index) - # 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。 - vertex_edges: list[set[int]] = [set() for _vertex in vertices] - for edge_index, edge in enumerate(edges): - for vertex in edge.vertices(): - vertex_index = index_for(vertex, vertices) - if vertex_index is not None: - vertex_edges[vertex_index].add(edge_index) - - def edge_signature(edge_index: int) -> str: - # 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。 - edge = edges[edge_index] - return ":".join(( - str(edge.geom_type).split(".")[-1].lower(), - f"{float(edge.length):.6f}", - str(len(edge_faces[edge_index])), - )) - - # 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名; - # 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。 - # 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从 - # 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据 - # author 传入的 blind-depth 文字猜测“贯穿”。 - face_edge_indexes: list[set[int]] = [] - face_geometries: list[dict[str, Any]] = [] - for index, face in enumerate(faces): - bbox = face.bounding_box() - center = face.center() - normal = face.normal_at() - boundary_edge_indexes = [ - edge_index - for edge in face.edges() - if (edge_index := index_for(edge, edges)) is not None - ] - geometry = { - "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], - "center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z], - "area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(), - "adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes), - } - if geometry["surface_type"] == "plane": - plane_normal, plane_offset = canonical_plane_signature( - (normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z), - ) - geometry["plane_normal"] = list(plane_normal) - geometry["plane_offset_mm"] = plane_offset - boundary_loops: list[list[list[float]]] = [] - for wire in face.wires(): - samples: list[list[float]] = [] - for edge in wire.edges(): - curve_type = str(edge.geom_type).split(".")[-1].lower() - fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0] - for fraction in fractions: - point = edge.position_at(fraction) - value = [float(point.X), float(point.Y), float(point.Z)] - if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12: - samples.append(value) - if len(samples) >= 3: - boundary_loops.append(samples) - if boundary_loops: - geometry["boundary_loops_mm"] = boundary_loops - elif geometry["surface_type"] in {"cylinder", "cone"}: - axis = face.axis_of_rotation - if axis is None: - # Build123d can omit this optional OCC property for valid - # swept rotational faces. Keep their generic B-rep record - # so a selector-free workflow remains executable; do not - # invent axis/radius evidence for an axis-based selector. - pass - else: - direction = axis.direction - origin = axis.position - geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] - geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] - raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None - if geometry["surface_type"] == "cone": - boundary_radii: list[float] = [] - for edge in face.edges(): - if str(edge.geom_type).split(".")[-1].lower() != "circle": - continue - try: - boundary_radii.append(float(edge.radius)) - except ValueError: - continue - geometry["boundary_radii_mm"] = sorted(boundary_radii) - geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None - # ``through`` alone describes a cylinder spanning two opposed - # planar faces. That applies to both a through bore and the - # outside wall of a cylindrical extrusion. Classify the B-rep - # face by its oriented normal so downstream acceptance claims - # can prove holes without mistaking an exterior wall for one. - unit_axis = (direction.X, direction.Y, direction.Z) - radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z) - axial_projection = sum(radial[component] * unit_axis[component] for component in range(3)) - radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3)) - radial_length = sum(component * component for component in radial) ** 0.5 - if geometry["surface_type"] == "cylinder": - # OCC can report a cylinder surface with ``radius=None`` - # after a non-planar-side Boolean cut. The face centre is - # still on that cylinder, so its perpendicular distance to - # the rotation axis is an equivalent measured radius. Do - # not fail an otherwise valid build merely because that - # optional OCC convenience property is absent. - if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)): - geometry["radius_mm"] = float(raw_cylinder_radius) - elif radial_length > 1e-9: - geometry["radius_mm"] = radial_length - if radial_length > 1e-9: - normal_components = (normal.X, normal.Y, normal.Z) - alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length - geometry["radial_normal_alignment"] = alignment - geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown" - else: - geometry["cylinder_role"] = "unknown" - face_edge_indexes.append(set(boundary_edge_indexes)) - face_geometries.append(geometry) - records.append(TopologyRecord( - record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face, - geometry=geometry, - )) - plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"] - - def directly_linked_planes(face_index: int) -> list[int]: - return [ - plane_index - for plane_index in plane_indexes - if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index]) - ] - - def same_inner_rotational_channel(first: int, second: int) -> bool: - """Whether two inner rotational faces share one B-rep bore channel.""" - if not face_edge_indexes[first].intersection(face_edge_indexes[second]): - return False - left, right = face_geometries[first], face_geometries[second] - if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner": - return False - left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction") - left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm") - if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)): - return False - try: - left_direction = tuple(float(value) for value in left_axis) - right_direction = tuple(float(value) for value in right_axis) - offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3)) - except (TypeError, ValueError): - return False - alignment = sum(left_direction[index] * right_direction[index] for index in range(3)) - if abs(alignment) < 1.0 - 1e-6: - return False - axial_offset = sum(offset[index] * left_direction[index] for index in range(3)) - radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3)) - return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5 - - inner_rotational_indexes = [ - index - for index, geometry in enumerate(face_geometries) - if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner" - ] - - def channel_plane_indexes(start: int) -> list[int]: - """Collect endpoint planes through joined, co-axial inner faces. - - A countersink or counterbore splits a physical bore into a cone and - a cylinder. The cylinder has only one direct planar neighbour, so - direct adjacency alone cannot prove that the complete channel exits - the part. Traverse shared B-rep edges only across co-axial inner - rotational faces, then inspect the channel's actual plane ends. - """ - pending = [start] - visited: set[int] = set() - endpoints: set[int] = set() - while pending: - index = pending.pop() - if index in visited: - continue - visited.add(index) - endpoints.update(directly_linked_planes(index)) - pending.extend( - candidate - for candidate in inner_rotational_indexes - if candidate not in visited and same_inner_rotational_channel(index, candidate) - ) - return sorted(endpoints) - - def spans_opposed_planes(linked: list[int], axis: Any) -> bool: - if not isinstance(axis, list) or len(axis) != 3: - return False - try: - direction = tuple(float(value) for value in axis) - except (TypeError, ValueError): - return False - return any( - sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99 - and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second)) - for first in linked - for second in linked - if first < second - ) - - for index, geometry in enumerate(face_geometries): - if geometry["surface_type"] != "cylinder": - continue - linked = directly_linked_planes(index) - geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked] - channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked - geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked] - geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction")) - # 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。 - for index, edge in enumerate(edges): - bbox = edge.bounding_box() - center = edge.center() - vertices = edge.vertices() - geometry = { - "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], - "center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length), - "curve_type": str(edge.geom_type).split(".")[-1].lower(), - "adjacent_face_count": len(edge_faces[index]), - } - if geometry["curve_type"] == "circle": - # ``Edge.center()`` is a point on a periodic circle, not its - # geometric centre. Preserve the OCC circle data separately - # so a provenance-backed rotational selector can distinguish - # concentric full circles at different axial locations. - try: - circle_center = edge.arc_center - radius = float(edge.radius) - values = (circle_center.X, circle_center.Y, circle_center.Z, radius) - except (AttributeError, TypeError, ValueError): - values = () - if values and all(math.isfinite(float(value)) for value in values) and radius > 0: - geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z] - geometry["radius_mm"] = radius - if vertices: - geometry["start_mm"] = list(vertices[0]) - geometry["end_mm"] = list(vertices[-1]) - records.append(TopologyRecord( - record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge, - geometry=geometry, - )) - # 5. 导出顶点记录:含坐标与关联边数。 - for index, vertex in enumerate(vertices): - point = [vertex.X, vertex.Y, vertex.Z] - records.append(TopologyRecord( - record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex, - geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])}, - )) - return records + # 从主体导出全部面/边/顶点拓扑记录(实现见 topology_export)。 + return _topology_records_impl(body, feature_id, body_id) diff --git a/backend/engine/cdsl_engine/topology_export.py b/backend/engine/cdsl_engine/topology_export.py new file mode 100644 index 00000000..8b6301d3 --- /dev/null +++ b/backend/engine/cdsl_engine/topology_export.py @@ -0,0 +1,311 @@ +"""B-rep topology evidence export for the build123d adapter. + +These module-level functions produce the face/edge/vertex snapshots that +``TopologyRegistry`` stores for selector resolution and that rebuild reports +expose as ``topology_records``. They are pure queries over the current +B-rep: no construction, no mutation, no OCP imports beyond what the shape +objects themselves expose. ``Build123dGeometryAdapter`` forwards to them, +keeping kernel construction and evidence export in separate files. +""" + +from __future__ import annotations + +import math +from typing import Any + +from .specs import canonical_plane_signature +from .topology import TopologyRecord + + +def body_geometry(body: Any) -> dict[str, Any]: + # 汇总主体基本几何信息:包围盒与体积。 + bbox = body.bounding_box() + # A feature history can contain several body IDs while still ending in + # one connected solid (for example, a base extrusion followed by hole + # cuts). Count the current OCC result, never feature history entries. + solids = list(body.solids()) if hasattr(body, "solids") else [body] + return { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "volume_mm3": float(body.volume), + "solid_count": len(solids), + } + + +def surface_geometry(surface: Any) -> dict[str, Any]: + # 曲面结果不参与实体 body 聚合;只保存后续 selector 所需的独立拓扑摘要。 + bbox = surface.bounding_box() + return { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "area_mm2": float(surface.area), + "face_count": len(surface.faces()), + } + + +def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: + # 从主体导出全部面/边/顶点拓扑记录,供后续特征选择与引用。 + records: list[TopologyRecord] = [] + faces = list(body.faces()) + edges = list(body.edges()) + vertices = list(body.vertices()) + + def index_for(shape: Any, candidates: list[Any]) -> int | None: + """Map a subshape returned by a face/edge back to body topology.""" + # 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。 + for index, candidate in enumerate(candidates): + if shape.is_same(candidate): + return index + return None + + # 1. 建立邻接索引:每条边关联的面集合(edge_faces)。 + edge_faces: list[set[int]] = [set() for _edge in edges] + for face_index, face in enumerate(faces): + for edge in face.edges(): + edge_index = index_for(edge, edges) + if edge_index is not None: + edge_faces[edge_index].add(face_index) + # 2. 建立邻接索引:每个顶点关联的边集合(vertex_edges)。 + vertex_edges: list[set[int]] = [set() for _vertex in vertices] + for edge_index, edge in enumerate(edges): + for vertex in edge.vertices(): + vertex_index = index_for(vertex, vertices) + if vertex_index is not None: + vertex_edges[vertex_index].add(edge_index) + + def edge_signature(edge_index: int) -> str: + # 边的特征签名:几何类型 + 长度 + 相邻面数,用作面邻接指纹。 + edge = edges[edge_index] + return ":".join(( + str(edge.geom_type).split(".")[-1].lower(), + f"{float(edge.length):.6f}", + str(len(edge_faces[edge_index])), + )) + + # 3. 导出面记录:含包围盒、中心、法向、面积、曲面类型与邻接签名; + # 平面面额外写入规范化法向与平面偏移,便于后续按平面匹配。 + # 圆柱面还保存轴、半径和共享边关联的平面面。这使 verifier 能从 + # 实际 B-rep 证明孔是否连接两个方向相反的外部平面,而不是根据 + # author 传入的 blind-depth 文字猜测“贯穿”。 + face_edge_indexes: list[set[int]] = [] + face_geometries: list[dict[str, Any]] = [] + for index, face in enumerate(faces): + bbox = face.bounding_box() + center = face.center() + normal = face.normal_at() + boundary_edge_indexes = [ + edge_index + for edge in face.edges() + if (edge_index := index_for(edge, edges)) is not None + ] + geometry = { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z], + "area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(), + "adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes), + } + if geometry["surface_type"] == "plane": + plane_normal, plane_offset = canonical_plane_signature( + (normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z), + ) + geometry["plane_normal"] = list(plane_normal) + geometry["plane_offset_mm"] = plane_offset + boundary_loops: list[list[list[float]]] = [] + for wire in face.wires(): + samples: list[list[float]] = [] + for edge in wire.edges(): + curve_type = str(edge.geom_type).split(".")[-1].lower() + fractions = [step / 16 for step in range(16)] if curve_type in {"circle", "ellipse"} else [0.0] + for fraction in fractions: + point = edge.position_at(fraction) + value = [float(point.X), float(point.Y), float(point.Z)] + if not samples or sum((value[axis] - samples[-1][axis]) ** 2 for axis in range(3)) > 1e-12: + samples.append(value) + if len(samples) >= 3: + boundary_loops.append(samples) + if boundary_loops: + geometry["boundary_loops_mm"] = boundary_loops + elif geometry["surface_type"] in {"cylinder", "cone"}: + axis = face.axis_of_rotation + if axis is None: + # Build123d can omit this optional OCC property for valid + # swept rotational faces. Keep their generic B-rep record + # so a selector-free workflow remains executable; do not + # invent axis/radius evidence for an axis-based selector. + pass + else: + direction = axis.direction + origin = axis.position + geometry["axis_origin_mm"] = [origin.X, origin.Y, origin.Z] + geometry["axis_direction"] = [direction.X, direction.Y, direction.Z] + raw_cylinder_radius = face.radius if geometry["surface_type"] == "cylinder" else None + if geometry["surface_type"] == "cone": + boundary_radii: list[float] = [] + for edge in face.edges(): + if str(edge.geom_type).split(".")[-1].lower() != "circle": + continue + try: + boundary_radii.append(float(edge.radius)) + except ValueError: + continue + geometry["boundary_radii_mm"] = sorted(boundary_radii) + geometry["semi_angle_deg"] = float(face.semi_angle) if face.semi_angle is not None else None + # ``through`` alone describes a cylinder spanning two opposed + # planar faces. That applies to both a through bore and the + # outside wall of a cylindrical extrusion. Classify the B-rep + # face by its oriented normal so downstream acceptance claims + # can prove holes without mistaking an exterior wall for one. + unit_axis = (direction.X, direction.Y, direction.Z) + radial = (center.X - origin.X, center.Y - origin.Y, center.Z - origin.Z) + axial_projection = sum(radial[component] * unit_axis[component] for component in range(3)) + radial = tuple(radial[component] - axial_projection * unit_axis[component] for component in range(3)) + radial_length = sum(component * component for component in radial) ** 0.5 + if geometry["surface_type"] == "cylinder": + # OCC can report a cylinder surface with ``radius=None`` + # after a non-planar-side Boolean cut. The face centre is + # still on that cylinder, so its perpendicular distance to + # the rotation axis is an equivalent measured radius. Do + # not fail an otherwise valid build merely because that + # optional OCC convenience property is absent. + if isinstance(raw_cylinder_radius, (int, float)) and math.isfinite(float(raw_cylinder_radius)): + geometry["radius_mm"] = float(raw_cylinder_radius) + elif radial_length > 1e-9: + geometry["radius_mm"] = radial_length + if radial_length > 1e-9: + normal_components = (normal.X, normal.Y, normal.Z) + alignment = sum(float(normal_components[component]) * radial[component] for component in range(3)) / radial_length + geometry["radial_normal_alignment"] = alignment + geometry["cylinder_role"] = "outer" if alignment > 0.5 else "inner" if alignment < -0.5 else "unknown" + else: + geometry["cylinder_role"] = "unknown" + face_edge_indexes.append(set(boundary_edge_indexes)) + face_geometries.append(geometry) + records.append(TopologyRecord( + record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face, + geometry=geometry, + )) + plane_indexes = [index for index, geometry in enumerate(face_geometries) if geometry["surface_type"] == "plane"] + + def directly_linked_planes(face_index: int) -> list[int]: + return [ + plane_index + for plane_index in plane_indexes + if face_edge_indexes[face_index].intersection(face_edge_indexes[plane_index]) + ] + + def same_inner_rotational_channel(first: int, second: int) -> bool: + """Whether two inner rotational faces share one B-rep bore channel.""" + if not face_edge_indexes[first].intersection(face_edge_indexes[second]): + return False + left, right = face_geometries[first], face_geometries[second] + if left.get("cylinder_role") != "inner" or right.get("cylinder_role") != "inner": + return False + left_axis, right_axis = left.get("axis_direction"), right.get("axis_direction") + left_origin, right_origin = left.get("axis_origin_mm"), right.get("axis_origin_mm") + if not all(isinstance(value, list) and len(value) == 3 for value in (left_axis, right_axis, left_origin, right_origin)): + return False + try: + left_direction = tuple(float(value) for value in left_axis) + right_direction = tuple(float(value) for value in right_axis) + offset = tuple(float(left_origin[index]) - float(right_origin[index]) for index in range(3)) + except (TypeError, ValueError): + return False + alignment = sum(left_direction[index] * right_direction[index] for index in range(3)) + if abs(alignment) < 1.0 - 1e-6: + return False + axial_offset = sum(offset[index] * left_direction[index] for index in range(3)) + radial_offset = tuple(offset[index] - axial_offset * left_direction[index] for index in range(3)) + return sum(value * value for value in radial_offset) ** 0.5 <= 1e-5 + + inner_rotational_indexes = [ + index + for index, geometry in enumerate(face_geometries) + if geometry["surface_type"] in {"cylinder", "cone"} and geometry.get("cylinder_role") == "inner" + ] + + def channel_plane_indexes(start: int) -> list[int]: + """Collect endpoint planes through joined, co-axial inner faces. + + A countersink or counterbore splits a physical bore into a cone and + a cylinder. The cylinder has only one direct planar neighbour, so + direct adjacency alone cannot prove that the complete channel exits + the part. Traverse shared B-rep edges only across co-axial inner + rotational faces, then inspect the channel's actual plane ends. + """ + pending = [start] + visited: set[int] = set() + endpoints: set[int] = set() + while pending: + index = pending.pop() + if index in visited: + continue + visited.add(index) + endpoints.update(directly_linked_planes(index)) + pending.extend( + candidate + for candidate in inner_rotational_indexes + if candidate not in visited and same_inner_rotational_channel(index, candidate) + ) + return sorted(endpoints) + + def spans_opposed_planes(linked: list[int], axis: Any) -> bool: + if not isinstance(axis, list) or len(axis) != 3: + return False + try: + direction = tuple(float(value) for value in axis) + except (TypeError, ValueError): + return False + return any( + sum(float(face_geometries[first]["normal"][component]) * float(face_geometries[second]["normal"][component]) for component in range(3)) <= -0.99 + and all(abs(sum(float(face_geometries[position]["normal"][component]) * direction[component] for component in range(3))) >= 0.99 for position in (first, second)) + for first in linked + for second in linked + if first < second + ) + + for index, geometry in enumerate(face_geometries): + if geometry["surface_type"] != "cylinder": + continue + linked = directly_linked_planes(index) + geometry["connected_plane_ids"] = [records[plane_index].record_id for plane_index in linked] + channel_linked = channel_plane_indexes(index) if geometry.get("cylinder_role") == "inner" else linked + geometry["channel_connected_plane_ids"] = [records[plane_index].record_id for plane_index in channel_linked] + geometry["through"] = spans_opposed_planes(channel_linked, geometry.get("axis_direction")) + # 4. 导出边记录:含包围盒、中心、长度、曲线类型与相邻面数;端点坐标可用时附加。 + for index, edge in enumerate(edges): + bbox = edge.bounding_box() + center = edge.center() + vertices = edge.vertices() + geometry = { + "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z], + "center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length), + "curve_type": str(edge.geom_type).split(".")[-1].lower(), + "adjacent_face_count": len(edge_faces[index]), + } + if geometry["curve_type"] == "circle": + # ``Edge.center()`` is a point on a periodic circle, not its + # geometric centre. Preserve the OCC circle data separately + # so a provenance-backed rotational selector can distinguish + # concentric full circles at different axial locations. + try: + circle_center = edge.arc_center + radius = float(edge.radius) + values = (circle_center.X, circle_center.Y, circle_center.Z, radius) + except (AttributeError, TypeError, ValueError): + values = () + if values and all(math.isfinite(float(value)) for value in values) and radius > 0: + geometry["circle_center_mm"] = [circle_center.X, circle_center.Y, circle_center.Z] + geometry["radius_mm"] = radius + if vertices: + geometry["start_mm"] = list(vertices[0]) + geometry["end_mm"] = list(vertices[-1]) + records.append(TopologyRecord( + record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge, + geometry=geometry, + )) + # 5. 导出顶点记录:含坐标与关联边数。 + for index, vertex in enumerate(vertices): + point = [vertex.X, vertex.Y, vertex.Z] + records.append(TopologyRecord( + record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex, + geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])}, + )) + return records -- 2.52.0 From 387b99e2552c40996641a90b99c610f1d384de34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Wed, 9 Sep 2026 17:08:24 +0800 Subject: [PATCH 09/10] wip: preserve local engine and agent workflow changes before ganjihong refactor integration --- .gitignore | 1 + AGENTS.md | 101 + backend/.env | 15 +- backend/README.md | 57 +- backend/agent/skills/cad-authoring/SKILL.md | 165 + .../00-author-contract.md | 1 - .../01-brief-and-assumptions.md | 1 - .../02-parameters-and-derived-dimensions.md | 1 - .../03-coordinate-system-and-datums.md | 1 - .../04-construction-and-feature-order.md | 1 - .../05-profiles-workplanes-and-cuts.md | 1 - ...-hosted-features-selectors-and-topology.md | 1 - .../07-patterns-symmetry-and-repetition.md | 1 - .../08-finishing-and-boolean-risk.md | 1 - ...9-evidence-visual-review-and-validation.md | 1 - .../10-repair-and-best-effort.md | 1 - .../skills/cdsl-author-guidance/README.md | 63 - .../skills/cdsl-author-guidance/manifest.json | 82 - .../skills/cdsl-author-guidance/op-bend.md | 1 - .../cdsl-author-guidance/op-extrude-add.md | 1 - .../cdsl-author-guidance/op-extrude-cut.md | 1 - .../skills/cdsl-author-guidance/op-finish.md | 1 - .../skills/cdsl-author-guidance/op-gear.md | 1 - .../skills/cdsl-author-guidance/op-hole.md | 1 - .../skills/cdsl-author-guidance/op-loft.md | 1 - .../skills/cdsl-author-guidance/op-pattern.md | 1 - .../cdsl-author-guidance/op-primitives.md | 1 - .../cdsl-author-guidance/op-reference.md | 1 - .../skills/cdsl-author-guidance/op-revolve.md | 1 - .../skills/cdsl-author-guidance/op-sphere.md | 1 - .../skills/cdsl-author-guidance/op-thread.md | 1 - backend/app/cad_agent/__init__.py | 4 +- backend/app/cad_agent/adapters/__init__.py | 2 +- .../app/cad_agent/adapters/artifact_store.py | 114 +- .../app/cad_agent/adapters/author_guidance.py | 191 -- .../app/cad_agent/adapters/event_publisher.py | 2 +- .../app/cad_agent/adapters/review_gateway.py | 64 - backend/app/cad_agent/adapters/runtime.py | 1014 +------ .../cad_agent/adapters/sqlite_repository.py | 445 +-- .../app/cad_agent/adapters/structured_llm.py | 37 +- backend/app/cad_agent/adapters/verifier.py | 19 - backend/app/cad_agent/application/__init__.py | 2 +- .../cad_agent/application/action_handlers.py | 1529 ---------- .../application/authoring_compiler.py | 300 ++ .../application/authoring_contract.py | 169 ++ .../application/authoring_guidance.py | 16 + .../app/cad_agent/application/capabilities.py | 53 +- .../cad_agent/application/llm_contracts.py | 412 --- .../app/cad_agent/application/requirements.py | 670 ---- backend/app/cad_agent/application/results.py | 29 - .../app/cad_agent/application/single_stage.py | 99 + backend/app/cad_agent/application/workflow.py | 2692 +++-------------- backend/app/cad_agent/composition.py | 68 +- backend/app/cad_agent/domain/__init__.py | 2 +- .../app/cad_agent/domain/claim_matching.py | 20 - backend/app/cad_agent/domain/errors.py | 26 +- backend/app/cad_agent/domain/feature_plan.py | 265 -- .../cad_agent/domain/operation_contract.py | 151 +- backend/app/cad_agent/domain/state.py | 255 +- backend/app/cad_agent/evals/TOKEN_BASELINE.md | 21 - backend/app/cad_agent/evals/__init__.py | 2 +- .../cad_agent/evals/create_isolated_task.py | 8 +- .../evals/fixtures/comprehensive.json | 423 --- .../app/cad_agent/evals/fixtures/release.json | 78 - backend/app/cad_agent/evals/live.py | 990 ------ .../app/cad_agent/evals/resume_one_step.py | 90 - backend/app/cad_agent/evals/single_stage.py | 55 + backend/app/cad_agent/evals/token_baseline.py | 297 -- backend/app/cad_agent/evals/usable_smoke.py | 164 - backend/app/cad_agent/ports.py | 84 +- backend/app/main.py | 145 +- backend/app/services/agent_service.py | 545 +--- backend/app/services/engine_service.py | 20 +- .../{review_renderer.py => render_bundle.py} | 52 +- backend/app/services/storage.py | 4 +- backend/app/settings.py | 41 - .../engine/cdsl_engine/build123d_adapter.py | 51 +- backend/engine/cdsl_engine/capabilities.py | 23 +- backend/engine/cdsl_engine/cdsl_schema.json | 59 +- backend/engine/cdsl_engine/runtime.py | 84 +- backend/engine/cdsl_engine/runtime_types.py | 226 ++ .../engine/cdsl_engine/semantic_validation.py | 75 +- backend/tests/test_agent_service.py | 80 + backend/tests/test_author_guidance.py | 158 - backend/tests/test_authoring_contract.py | 81 + backend/tests/test_authoring_runtime.py | 219 ++ backend/tests/test_cad_agent_v3.py | 1117 ------- .../tests/test_engine_runtime_foundation.py | 98 +- backend/tests/test_feature_plan.py | 357 --- .../tests/test_live_guidance_comparison.py | 71 - ...view_renderer.py => test_render_bundle.py} | 4 +- backend/tests/test_settings.py | 21 - backend/tests/test_single_stage.py | 21 + backend/tests/test_single_stage_evals.py | 27 + backend/tests/test_single_stage_workflow.py | 180 ++ .../CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md | 139 + cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md | 10 +- cadfs_to_cdsl/featurescript_parser.py | 14 +- cadfs_to_cdsl/ir.py | 4 + cadfs_to_cdsl/lowering.py | 72 +- cadfs_to_cdsl/query_parser.py | 35 +- cadfs_to_cdsl/selector_binding.py | 12 + cadfs_to_cdsl/tests/test_lowering.py | 24 +- cadfs_to_cdsl/tests/test_parser.py | 10 + cadfs_to_cdsl/tests/test_selector_binding.py | 12 +- frontend/src/components/agent-studio.tsx | 50 +- frontend/src/components/cad-message-parts.tsx | 16 +- frontend/src/lib/cad-artifacts.ts | 7 +- frontend/src/lib/cad-messages.ts | 5 +- frontend/src/lib/cad-stream.test.ts | 59 +- frontend/src/lib/cad-stream.ts | 26 +- frontend/src/lib/cad-types.ts | 96 +- 112 files changed, 3543 insertions(+), 12177 deletions(-) create mode 100644 backend/agent/skills/cad-authoring/SKILL.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/00-author-contract.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/README.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/manifest.json delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-bend.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-extrude-add.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-finish.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-gear.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-hole.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-loft.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-pattern.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-primitives.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-reference.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-revolve.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-sphere.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-thread.md delete mode 100644 backend/app/cad_agent/adapters/author_guidance.py delete mode 100644 backend/app/cad_agent/adapters/review_gateway.py delete mode 100644 backend/app/cad_agent/adapters/verifier.py delete mode 100644 backend/app/cad_agent/application/action_handlers.py create mode 100644 backend/app/cad_agent/application/authoring_compiler.py create mode 100644 backend/app/cad_agent/application/authoring_contract.py create mode 100644 backend/app/cad_agent/application/authoring_guidance.py delete mode 100644 backend/app/cad_agent/application/llm_contracts.py delete mode 100644 backend/app/cad_agent/application/requirements.py delete mode 100644 backend/app/cad_agent/application/results.py create mode 100644 backend/app/cad_agent/application/single_stage.py delete mode 100644 backend/app/cad_agent/domain/claim_matching.py delete mode 100644 backend/app/cad_agent/domain/feature_plan.py delete mode 100644 backend/app/cad_agent/evals/TOKEN_BASELINE.md delete mode 100644 backend/app/cad_agent/evals/fixtures/comprehensive.json delete mode 100644 backend/app/cad_agent/evals/fixtures/release.json delete mode 100644 backend/app/cad_agent/evals/live.py delete mode 100644 backend/app/cad_agent/evals/resume_one_step.py create mode 100644 backend/app/cad_agent/evals/single_stage.py delete mode 100644 backend/app/cad_agent/evals/token_baseline.py delete mode 100644 backend/app/cad_agent/evals/usable_smoke.py rename backend/app/services/{review_renderer.py => render_bundle.py} (91%) create mode 100644 backend/tests/test_agent_service.py delete mode 100644 backend/tests/test_author_guidance.py create mode 100644 backend/tests/test_authoring_contract.py create mode 100644 backend/tests/test_authoring_runtime.py delete mode 100644 backend/tests/test_cad_agent_v3.py delete mode 100644 backend/tests/test_feature_plan.py delete mode 100644 backend/tests/test_live_guidance_comparison.py rename backend/tests/{test_review_renderer.py => test_render_bundle.py} (93%) create mode 100644 backend/tests/test_single_stage.py create mode 100644 backend/tests/test_single_stage_evals.py create mode 100644 backend/tests/test_single_stage_workflow.py create mode 100644 cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md diff --git a/.gitignore b/.gitignore index 0747fbf3..d7fa50d5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ build/ # Runtime data and generated local artifacts backend/data/ backend/live-evals/ +cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md data/cadfs-sample/ json_to_cdsl/input/ onshape_to_cdsl/input/ diff --git a/AGENTS.md b/AGENTS.md index 67adbaf4..001202bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,3 +29,104 @@ ## 改动评审指引 修改本系统时,优先保证执行确定性、部分结果可持久化、失败可诊断,以及终止行为有边界。避免加入语义审查关卡、要求复制 ID 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。 + +## CADFS 全量能力开发 + +CADFS 到 CDSL 到 STEP 的工作是在实现通用绘图引擎和通用 converter,不是为回归样本 +编写修复脚本。能力实现必须以 FeatureScript 语义、显式 CDSL contract、body +生命周期和内核拓扑为边界,并对同类输入普遍成立。 + +- 禁止按 `sample_id`、路径、特定 feature ID、特定坐标、尺寸或 gold STEP 测量值分支; + 禁止从原 STEP 回填 FeatureScript 未提供的参数,或为某模型硬编码 selector、profile、 + 偏移量、布尔策略和默认尺寸。 +- 不得将不支持或不唯一的语义伪装成盲拉伸、当前 body、默认 union、任意相近拓扑元素 + 或静默跳过。必须保留最佳可执行前缀和 STEP,并给出稳定、可归因的能力诊断。 +- 优先在 schema、semantic validation、runtime state、body graph 和 adapter 的 + kernel-level topology delta 中实现能力;lowering 层只能表达源语义,不能承担样本化 + 的几何补丁。受限算法必须有通用、可验证的适用条件和拒绝路径。 +- 一项能力只有在 lowering、CDSL contract、runtime/adapter、selector/body 语义、 + 单元测试和多个真实语料回归均具备后才能标为完成。操作名称覆盖不等于参数、拓扑来源、 + 几何类型或 body 生命周期覆盖。 +- 严格比较用于诊断;工程验收遵循 `rp.passed`。不得降低 RP 阈值、关闭诊断、跳过 + feature 或修改 source 参数来换取通过。source STEP 损坏或历史/STEP 精度不一致必须 + 作为有证据的 source exception 单独报告。 +- 每次涉及 CADFS lowering、CDSL schema、runtime、adapter、selector 或测试的改动, + 都必须同步更新 `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`;全量能力 + 计划以 `cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md` 为准。 + +## CADFS 修改、文档与回归纪律 + +以下规则适用于每一次 CADFS converter、CDSL engine、selector、比较器、回归集或相关 +测试的修改;它们是仓库规则,不依赖当前对话上下文。 + +### 修改前与实现方式 + +- 先用完整 FeatureScript history、`candidate.cdsl.json`、`bound.cdsl.json`、 + `diagnostics.json`、`rebuild.json`、`comparison.json` 和 source/rebuild 工件定位失败层: + converter/lowering、schema/semantic validation、selector binding、runtime/adapter、 + source STEP/FeatureScript 不一致,或 comparison infrastructure。没有证据不得把失败 + 归因于任一层,也不得先改阈值或样本数据。 +- 修改必须遵循目标模块已有的命名、分层、格式、注释语言和错误处理模式。优先扩展既有 + contract、helper 和架构边界;没有明确收益时不得进行无关重构或引入平行实现。 +- 先实现可复用的几何和拓扑语义,再将 CADFS source lower 到该 contract。任何只为单个 + 形状、数值、feature history 或截图成立的逻辑均视为缺陷,不得提交。 +- 内核无法完成某个输入时,保留有界的失败和诊断;不得用较小的 dress-up 半径、替代孔型、 + 固定 extent、隐式 fuse 或未经来源证明的几何补偿伪造结果。 +- 当前实现若被证明确实违反 source/contract 语义、无法表达全量已出现的通用输入、 + 受内核 API 的结构性限制,或反复需要样本化补丁,应停止继续叠加补丁并评估替代方案。 + 替换前必须具备可复现失败、根因证据、与成熟开源/官方 API 或最小原型的对照、对 + CDSL/body/selector 兼容性的影响评估,以及受影响回归的迁移计划。 +- 不得因单个样本、偶发 OCC 失败、一次性能波动或主观偏好轻易重写成熟路径。只有新方案 + 能以更少特例、更完整的通用语义和可验证的回归证据解决结构性问题时,才替换旧方案; + 替换过程中保留旧工件和比较基线,分阶段迁移并记录回滚边界。 + +### 文档同步 + +- 每次实现、修复、扩展或确认某项 CADFS 能力后,必须在同一工作变更中更新 + `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`:记录能力状态、通用语义、 + 已验证边界、未覆盖边界、受影响样本/能力矩阵和测试/比较证据;完成项必须勾选, + 未完成项不得因单个样本通过而勾选。 +- 上述 `.local.md` 是本地工作台账,必须保持在 `.gitignore` 中,绝不加入 Git 提交。 + 每次 CADFS 相关代码变更后都要同步,即使最终只得到失败诊断或发现 source exception。 +- 新增能力、能力范围、验收口径、全量快照或实施优先级变化时,同步更新受版本控制的 + `cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md`。它定义全量能力矩阵和路线; + `CADFS_RECONSTRUCTION_TARGET.md` 记录核心回归与当前近期目标,两者不得冲突。 +- 从全量 output 发现新的操作、参数变体、拓扑来源、body lifecycle 或 comparison failure + 类型时,必须先登记为待办和能力矩阵条目,再选择多个真实样本回归;不得只加入一个 + “代表模型”就宣称覆盖完成。 +- 文档中的计数、样本 ID、状态和通过标准必须来自当前工件。必须区分 strict、RP 工程相似、 + 几何拒绝、可执行前缀、转换/运行时失败、比较超时和 source exception。 + +### 回归与可视证据 + +- 每次代码修改至少运行受影响的原子/单元测试和真实语料样本;涉及共享 runtime、 + selector、body lifecycle 或 comparison 时,还必须运行核心 17、相应扩展能力矩阵和 + 可控的全量 shard。报告未运行的范围和原因,不能将缓存旧工件当作新代码的证据。 +- 对用户要求查看或人工判定的样本,生成并保留 source STEP 与 rebuild STEP 的一致视角 + 对比截图;截图只辅助人工审查,最终分类仍以完整 history、B-rep 比较和诊断为准。 +- 任何失败都保留最后可执行 STEP、GLB(如可生成)、comparison/diagnostic 工件和前缀 + 信息。不可执行或不相似不允许清理、覆盖或隐藏已有可用工件。 +- 提交前执行与改动比例相称的测试、`git diff --check`,并确认本地能力台账未被 staged。 + 除非用户明确要求,不得提交、push、覆盖用户未提交修改或变更回归基线。 + +### 开源实现与 SimpleCADAPI 参考 + +- 在自行设计 shell、sweep、loft、boolean、fillet/chamfer、transform、topology tracking、 + body graph 或比较基础设施前,必须先检索成熟的开源实现、官方 OCCT/OCP API 和已有 + 项目依赖;优先复用经过测试的算法、内核调用模式或小范围实现,而不是重新发明基础 + B-rep 算法。外部代码的许可证、版本兼容性、异常语义和维护状态必须先核实。 +- 本地首选参考是 `/Users/lk/Downloads/SimpleCADAPI-master 4`。重点阅读其 + `src/simplecadapi/topology/tracking.py`、`kernel/ocp_booleans.py`、 + `kernel/ocp_topology.py`、`kernel/ocp_transforms.py` 及对应 tests/docs:其中的 + OCC builder history、`Modified`/`Generated`/`IsDeleted`、section edges、same-domain + cleanup、显式 shape transform、shell/sweep/loft 调用边界是当前 engine 的优先参考。 +- 借鉴必须经过本项目的 CDSL schema、runtime body graph、selector provenance 和 + 回归测试适配。不得直接替换本项目的 CDSL contract,也不得照搬其“强制单一 Solid” + 的 union/cut 语义,因为 CADFS 需要保留独立 body、copy、keep tools 和 pattern + instance 生命周期。 +- 每次因开源参考新增或调整能力时,在本地能力台账记录参考来源、采用的通用语义、 + 未采用部分及理由、许可证/依赖影响和本项目回归证据。无法安全采用时,也应记录 + 评估结论,避免后续重复实现或重复调研。 +- 发现现有方案方向错误或外部方案明显更适合时,可替换而不是继续打补丁;但必须满足 + “修改前与实现方式”中的结构性根因和对照验证门槛,并在台账记录替换理由、迁移影响、 + 保留的 contract、回归结果和可回滚边界。 diff --git a/backend/.env b/backend/.env index a92ffe62..08eb7c71 100644 --- a/backend/.env +++ b/backend/.env @@ -2,7 +2,7 @@ # CDSL_DEFAULT_PROVIDER=deepseek # CDSL_DEFAULT_MODEL=deepseek-v4-flash CDSL_DEFAULT_PROVIDER=openai -CDSL_DEFAULT_MODEL=gpt-5.4-mini +CDSL_DEFAULT_MODEL=gpt-5.5 # DeepSeek. Fill in your own API key below. CDSL_LLM_BASE_URL=https://api.deepseek.com/v1 @@ -11,9 +11,6 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp CDSL_LLM_TIMEOUT_S=90 CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp -# Final autonomous-task publication uses this independent vision reviewer. -CDSL_REVIEW_PROVIDER=openai -CDSL_REVIEW_MODEL=gpt-5.5 # Optional OpenAI provider. Comma-separate enabled models; list vision models # separately so image attachments can be routed safely. @@ -30,13 +27,3 @@ CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1 CDSL_KIMI_API_KEY= CDSL_KIMI_MODELS=moonshot-v1-8k CDSL_KIMI_VISION_MODELS= - -# Autonomous CDSL agent limits. These protect an active modelling head, not -# the total feature count of a CAD task. -CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12 -CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3 -CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6 -CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT=3 -CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6 -CDSL_AGENT_CONTEXT_CHAR_LIMIT=14000 -CDSL_AGENT_RENDER_CACHE=true diff --git a/backend/README.md b/backend/README.md index d2b5371d..2aac94a7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -20,53 +20,36 @@ CDSL_OPENAI_REASONING_EFFORT=medium ``` Use `low`, `medium`, or `high` according to the latency/cost versus quality -tradeoff. The setting is sent as Chat Completions' `reasoning_effort` field to -authoring, streaming, and visual-review requests. Leave it empty to use the +tradeoff. The setting is sent as the provider's `reasoning_effort` field to +requirements analysis and Authoring CDSL generation. Leave it empty to use the provider/model default. The selected OpenAI-compatible endpoint must support the requested value. ## Autonomous CDSL Agent Configuration -The autonomous agent writes one frozen free-form `requirements.md`, then -observes, measures, renders and appends one CDSL feature at a time. Its author -uses normal function calls; no provider strict JSON Schema capability or -complete modelling DAG is required. Candidate fragments are rebuilt in a -staging directory through `cdsl_only` before a checkpoint can be committed. +Each task has one bounded workflow: -Final publication requires a separately configured vision-capable review model -and the Python OpenCascade/Pillow technical renderer. The agent may build and -inspect intermediate checkpoints without image review; a final run fails -closed if its independent review configuration is unavailable. +```text +request analysis -> complete cad.author.v1 -> server compilation -> runtime build +-> at most two complete repairs -> final or best-effort publication +``` + +The model outputs only local body/feature names and declarative selectors. +The server validates strict schemas, allocates Runtime CDSL identities, +compiles references, executes dependencies, and preserves the last executable +prefix. STEP is the primary artifact; GLB and the CPU-only OpenCascade/Pillow +render bundle are generated from the same published revision. ```dotenv -# Must name one configured provider and one model listed in that provider's -# CDSL__VISION_MODELS setting. It is intentionally not inferred -# from the authoring model. -CDSL_REVIEW_PROVIDER=deepseek -CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp -CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp - # Install Python rendering dependencies. The renderer reads the revision STEP # file and creates canonical images without a browser or GPU driver. pip install -r requirements.txt - -# Limits apply to the current checkpoint head, never to total task complexity. -CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12 -CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3 -CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6 -CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6 -CDSL_AGENT_CONTEXT_CHAR_LIMIT=24000 -CDSL_AGENT_RENDER_CACHE=true ``` -The author chooses each coherent 1-6 feature batch. Every rebuilt batch is -rendered and independently reviewed before it can become a checkpoint; only -an accepted reviewer verdict advances the working model. Every checkpoint is rebuilt from its fully materialized CDSL through the -`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and -reports are available only after the task reaches `COMPLETED`. - -The backend assigns feature and sketch IDs, appends causal dependencies and -expands only opaque current-snapshot selector tokens. It does not compile -geometry templates or correct workplanes, profiles, sizes, directions or -boolean semantics authored by the model. Failed candidates remain auditable -but never become revisions. +The initial generation plus two repairs are the only model calls allowed after +requirements analysis. A repair returns a complete replacement Authoring CDSL +and may only alter diagnosed features. Runtime selector ambiguity, missing +selectors, and unavailable dependencies produce stable diagnostics rather than +topology guesses. Requirement compliance is reported independently as +`pass`, `fail`, `pending`, or `not_applicable`; a partial executable model is +still published after the repair budget is exhausted. diff --git a/backend/agent/skills/cad-authoring/SKILL.md b/backend/agent/skills/cad-authoring/SKILL.md new file mode 100644 index 00000000..5644d30b --- /dev/null +++ b/backend/agent/skills/cad-authoring/SKILL.md @@ -0,0 +1,165 @@ +# Authoring CDSL Modeling Guide + +Write one complete `cad.author.v1` document. It is declarative source for a +server compiler, not the runtime CDSL and not an execution log. Return only +the schema-valid object requested by the tool. + +## Contract Boundary + +Use lower-case local `name` values for bodies and features. They are symbols +within this document only. Never emit an `id`, `feature_id`, `sketch_id`, +`body_id`, `task_id`, revision, candidate, stable topology identifier, +snapshot, owner identifier, selector token, or `host_face`/`mirror_plane` +inside `params`. The server allocates identities and injects selector values +into the destination stated by the operation contract. + +Use millimetres and a right-handed coordinate system unless the request says +otherwise. Put every reasonable but unstated design choice in `assumptions`. +Do not turn an assumed dimension into a deterministic acceptance target. + +## Modeling Brief + +Before authoring, derive this internal brief from the requirements: + +- part or multi-body intent; explicit dimensions and units; +- functional datums, origin, base plane, and positive directions; +- primary volumes, holes, pockets, bosses, ribs, patterns, and finishing; +- explicitly verifiable targets versus manual targets; +- assumptions that do not affect fit, safety, or compliance. + +Dimensioned request facts take precedence over proportions inferred from an +image. Ask for clarification only when a missing interface, scale, safety, or +compliance value makes construction impossible. Otherwise choose a practical +engineering default and record it as an assumption. + +## Construction Order + +Choose the simplest supported construction whose parameters directly express +the requested dimensions. Use a stable order: + +1. establish the body and functional coordinate frame; +2. create primary additive volume(s); +3. create any selector-hosted feature before its named source output is + changed by a fuse, cut, shell, pattern, or finishing operation; +4. add remaining bosses, ribs, and other major additive geometry; +5. make remaining pockets, bores, and through features; +6. apply patterns, then fillets and chamfers last. + +Every feature must list each feature it actually uses in `depends_on`. +Prefer one complete profile-driven feature for a planar silhouette. Use +primitives when their axis, radius, and height directly express the part. +For through cuts, choose the operation's through extent and make the tool +cross the material; never rely on coincident faces or a guessed nearby face. +Delay dress-up operations because they can alter downstream topology. + +The selector-source rule is an intentional exception to a generic “all adds, +then all cuts” sequence. For example, a bolt circle hosted on an original +flange cap must be placed immediately after the base when later additive +fusions replace that exact cap. A required final through-cut can still follow +all additive features and the earlier bolt operation. + +Use only the supplied operation list. Follow each operation's parameter schema +exactly, including required sketch state. Do not invent unsupported operation +parameters, implicit booleans, or a substitute operation after a capability +error. + +## Sketches And Coordinates + +Keep a sketch to exactly `workplane` and `profile`. The workplane declares its +origin, `x_dir`, and normal. Profile coordinates are local to that workplane. +For primitive axes, `origin_mm` is the start-cap center and `direction` is the +positive build direction. For a selector-hosted hole, position coordinates are +world coordinates unless the operation contract explicitly says otherwise. + +The Authoring sketch syntax is deliberately smaller than Runtime CDSL. For +every sketch operation, emit exactly this shape. `profile` is singular, +circles use the requested `diameter_mm`, and the local center is `center_mm`: + +```json +{ + "workplane": { + "origin_mm": [0, 0, 12], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1] + }, + "profile": { + "type": "circle", + "diameter_mm": 56, + "center_mm": [0, 0] + } +} +``` + +Do not write `profiles`, `plane`, `support`, `radius_mm`, `center`, or any +other key inside an Authoring sketch. The compiler derives Runtime radius and +sketch identity. Use a primitive such as `cylinder_add` when its axis, radius, +and height directly express the requested geometry and no sketch is needed. + +Name features for their manufacturing role, for example `base_plate`, +`front_hub_boss`, `center_bore`, and `bolt_holes`. Names make dependencies and +repair diagnostics readable; they are not server identities. + +## Selectors + +The operation metadata states whether `selectors` are required, their kind, +cardinality, and server-side destination. Put only declarative selectors in a +feature's `selectors` array. Never write the destination field itself inside +`params`. + +For an output face, use an exact local role embedded in `source` and state a +unique match: + +```json +{ + "kind": "face", + "source": "front_hub_boss.top_planar_face", + "match": "unique" +} +``` + +Do not add `role`, `query`, `host_face`, a face index, a coordinate selector, +or a Runtime selector token. The `source` value is the complete local intent. +The compiler adds its source feature as an auditable graph dependency; include +other true construction dependencies in `depends_on` yourself. + +`top_planar_face` and `end_face` mean the positive-direction cap of a supported +extrude, sweep, loft, or cylinder. `bottom_planar_face` and `start_face` mean +the opposite cap. Select the most recent feature whose output is known to be +the required host; do not select a similar face by location, face index, or +proximity. + +Translate a request's descriptive `max_z`/`min_z` wording into these output +roles before writing CDSL. Never emit `base.max_z_face` or `base.min_z_face`. +For a cylinder built in `+Z`, the top cap is `top_planar_face` and the bottom +cap is `bottom_planar_face`; reverse-direction features swap their world-Z +position but retain their own start/end roles. + +`hole_wizard` requires exactly one `face` selector. It uses that selector as +its host face, so a central bore on a hub should select the hub cap, while a +bolt circle on an exposed flange should select the flange cap. A selector must +name a host that contains every requested hole position. If that cannot be +made unique, redesign the feature sequence or omit the unsupported feature; +never guess a face. + +Before creating a hosted hole, calculate each position against the actual host +face. A local boss can be the global highest face while being too small to host +a larger bolt circle. In that case use the exposed flange cap at the bolt +radius, with its own plane height, rather than the global maximum-Z cap. Place +the hosted holes while that source cap's exact provenance is still active: +before a later fusion or cut would split, remove, or replace it. A final +through-cut may still follow all additive features, so a bolt circle can be +hosted before an unrelated final boss and before that final cut. + +## Acceptance And Repair + +Describe only user-requested, measurable acceptance targets in +`acceptance_targets`; leave inferred dimensions in `assumptions`. On repair, +return a complete replacement document. Preserve feature names and every +feature listed in `executed_feature_ids`, except a feature explicitly named by +the diagnostic. Features that were never executed may be changed freely to +repair an invalid selector, parameter, dependency, or geometry construction. + +Read structured diagnostics literally. Fix their named cause with the smallest +document change, then return the entire document. Do not add internal IDs, +weaken a requested value, silently delete a failed feature, or replace a +failed selector with an arbitrary topology element. diff --git a/backend/agent/skills/cdsl-author-guidance/00-author-contract.md b/backend/agent/skills/cdsl-author-guidance/00-author-contract.md deleted file mode 100644 index 32fe74e1..00000000 --- a/backend/agent/skills/cdsl-author-guidance/00-author-contract.md +++ /dev/null @@ -1 +0,0 @@ -以当前 schema、`operation_contract` 与 topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项。 diff --git a/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md b/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md deleted file mode 100644 index 1c850d90..00000000 --- a/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md +++ /dev/null @@ -1 +0,0 @@ -先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md b/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md deleted file mode 100644 index 035276fe..00000000 --- a/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md +++ /dev/null @@ -1 +0,0 @@ -把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。 diff --git a/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md b/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md deleted file mode 100644 index ff1b0c06..00000000 --- a/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md +++ /dev/null @@ -1 +0,0 @@ -世界坐标为右手 mm;根特征只在 contract 允许时用 `XY`/`+Z`。`workplane.origin_mm`、`x_dir`、`normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面。 diff --git a/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md b/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md deleted file mode 100644 index 28e2331d..00000000 --- a/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md +++ /dev/null @@ -1 +0,0 @@ -优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。 diff --git a/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md b/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md deleted file mode 100644 index 289c3ecf..00000000 --- a/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md +++ /dev/null @@ -1 +0,0 @@ -轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。 diff --git a/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md b/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md deleted file mode 100644 index 56109b58..00000000 --- a/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md +++ /dev/null @@ -1 +0,0 @@ -宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。 diff --git a/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md b/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md deleted file mode 100644 index 6a1fa24b..00000000 --- a/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md +++ /dev/null @@ -1 +0,0 @@ -对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。 diff --git a/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md b/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md deleted file mode 100644 index 3ab5a436..00000000 --- a/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md +++ /dev/null @@ -1 +0,0 @@ -圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。 diff --git a/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md b/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md deleted file mode 100644 index 8183f587..00000000 --- a/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md +++ /dev/null @@ -1 +0,0 @@ -确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。 diff --git a/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md b/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md deleted file mode 100644 index 145d9f5f..00000000 --- a/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md +++ /dev/null @@ -1 +0,0 @@ -修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。 diff --git a/backend/agent/skills/cdsl-author-guidance/README.md b/backend/agent/skills/cdsl-author-guidance/README.md deleted file mode 100644 index 96da5a74..00000000 --- a/backend/agent/skills/cdsl-author-guidance/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# CDSL Author Guidance Corpus - -This corpus is a Chinese-first, non-authoritative author aid. The runtime -operation contract, fragment schema, topology/reference tokens, preflight and -verifier evidence always win over these Markdown files. The manifest maps -only workflow phase, scheduled atomic operation and repair state; it never -classifies the user's part request. - -## Source Migration - -| Source reference | CDSL target sections | Intentionally excluded | -| --- | --- | --- | -| `cad-brief.md` | `01`, `02`, `09` | Python/file workflow | -| `parameters.md` | `02`, `07` | sidecars, animation, viewer control | -| `positioning.md` | `03`, `06`, `op-reference` | assemblies, joints, `Location`, imported STEP placement | -| `build123d-modeling.md` | `03` through `08`, operation appendices | build123d APIs, labels, colors and assembly source | -| `build123d-modeling.zh-CN.md` | all Chinese terminology and rule review | a duplicate competing rule set | -| `inspection-and-validation.md` | `09`, `10` | CLI paths and selector syntax | -| `snapshot-review.md` | `09` | renderer commands | -| `repair-loop.md` | `10`, `05`, `06`, `08` | build123d-only remediation syntax | -| `step-generation.md` | `00`, `09` | Python generator commands | -| `supported-exports.md` | `09` | mesh tolerance and exporter-specific flags | - -## Selection Contract - -- Requirements authoring selects `00` to `03`. -- Feature planning selects `00`, `02`, `03`, `04`, `07`, and `08`. -- A scheduled feature selects `00`, `03` to `06`, `08`, and its current - operation appendix. -- Repair selects `00`, `03`, `06`, `09`, `10`, and its operation appendix. -- Final validation selects `00`, `09`, and `10`. - -At a bounded prompt budget, contract, coordinate/datum, and the scheduled -operation appendix are mandatory. Other sections are included in stable -priority order. A malformed corpus or an unsupported operation registry -falls back to the original short author prompt and records fallback metadata -with the author usage record. - -## Evaluation Commands - -Run the six matched control scenarios three times each, first without and -then with guidance: - -```bash -PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance off \ - --scenario rectangular_mounting_plate --scenario circular_flange_pcd \ - --scenario obround_slot_plate --scenario rounded_rectangular_pocket \ - --scenario double_hole_linkage_arm --scenario l_bracket - -PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance on \ - --scenario rectangular_mounting_plate --scenario circular_flange_pcd \ - --scenario obround_slot_plate --scenario rounded_rectangular_pocket \ - --scenario double_hole_linkage_arm --scenario l_bracket - -PYTHONPATH=backend python -m app.cad_agent.evals.live --compare-guidance-reports CONTROL/report.json TREATMENT/report.json -``` - -The comparator excludes declared validation gaps and engine-declared -`unsupported_*` capability gaps from prompt quality metrics, checks paired -model/runtime/contract/budget equivalence, and -requires the treatment's checkpoint/completion rates not to regress, median -author calls to stay within 10 percent, and either schema/decision or CDSL -expression failures to improve. diff --git a/backend/agent/skills/cdsl-author-guidance/manifest.json b/backend/agent/skills/cdsl-author-guidance/manifest.json deleted file mode 100644 index cd4de229..00000000 --- a/backend/agent/skills/cdsl-author-guidance/manifest.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "schema_version": "cdsl.author-guidance.manifest.v1", - "version": "2026-09-09.1", - "sections": [ - {"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true}, - {"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false}, - {"id": "02-parameters-and-derived-dimensions", "file": "02-parameters-and-derived-dimensions.md", "title": "02 Parameters And Derived Dimensions", "priority": 80, "mandatory": false}, - {"id": "03-coordinate-system-and-datums", "file": "03-coordinate-system-and-datums.md", "title": "03 Coordinate System And Datums", "priority": 100, "mandatory": true}, - {"id": "04-construction-and-feature-order", "file": "04-construction-and-feature-order.md", "title": "04 Construction And Feature Order", "priority": 70, "mandatory": false}, - {"id": "05-profiles-workplanes-and-cuts", "file": "05-profiles-workplanes-and-cuts.md", "title": "05 Profiles Workplanes And Cuts", "priority": 90, "mandatory": false}, - {"id": "06-hosted-features-selectors-and-topology", "file": "06-hosted-features-selectors-and-topology.md", "title": "06 Hosted Features Selectors And Topology", "priority": 90, "mandatory": false}, - {"id": "07-patterns-symmetry-and-repetition", "file": "07-patterns-symmetry-and-repetition.md", "title": "07 Patterns Symmetry And Repetition", "priority": 60, "mandatory": false}, - {"id": "08-finishing-and-boolean-risk", "file": "08-finishing-and-boolean-risk.md", "title": "08 Finishing And Boolean Risk", "priority": 60, "mandatory": false}, - {"id": "09-evidence-visual-review-and-validation", "file": "09-evidence-visual-review-and-validation.md", "title": "09 Evidence Visual Review And Validation", "priority": 80, "mandatory": false}, - {"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false}, - {"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true}, - {"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true}, - {"id": "op-loft", "file": "op-loft.md", "title": "Operation Appendix Loft", "priority": 100, "mandatory": true}, - {"id": "op-revolve", "file": "op-revolve.md", "title": "Operation Appendix Revolve", "priority": 100, "mandatory": true}, - {"id": "op-hole", "file": "op-hole.md", "title": "Operation Appendix Hole", "priority": 100, "mandatory": true}, - {"id": "op-reference", "file": "op-reference.md", "title": "Operation Appendix Reference", "priority": 100, "mandatory": true}, - {"id": "op-pattern", "file": "op-pattern.md", "title": "Operation Appendix Pattern", "priority": 100, "mandatory": true}, - {"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true}, - {"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true}, - {"id": "op-primitives", "file": "op-primitives.md", "title": "Operation Appendix Primitives", "priority": 100, "mandatory": true}, - {"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true}, - {"id": "op-bend", "file": "op-bend.md", "title": "Operation Appendix Bend", "priority": 100, "mandatory": true}, - {"id": "op-gear", "file": "op-gear.md", "title": "Operation Appendix Gear", "priority": 100, "mandatory": true} - ], - "phase_sections": { - "DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"], - "DRAFTING_REQUIREMENTS_DOCUMENT": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], - "DRAFTING_COMPLETION_TARGET": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], - "COMPILING_REQUIREMENTS": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], - "COMPILING_FEATURE_PLAN": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"], - "REPLANNING_FEATURE_SUBGRAPH": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"], - "FEATURE_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "05-profiles-workplanes-and-cuts", "06-hosted-features-selectors-and-topology", "08-finishing-and-boolean-risk"], - "AWAITING_ACTION": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], - "ACTION_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"] - }, - "repair_sections": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], - "final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], - "operation_sections": { - "extrude_add_blind": ["op-extrude-add"], - "extrude_add_blind_with_hole": ["op-extrude-add"], - "extrude_add_two_sided": ["op-extrude-add"], - "extrude_from_face": ["op-extrude-add"], - "extrude_surface": ["op-extrude-add"], - "extrude_cut_blind": ["op-extrude-cut"], - "extrude_cut_two_sided": ["op-extrude-cut"], - "extrude_cut_through": ["op-extrude-cut"], - "loft_add": ["op-loft"], - "loft_add_with_cap_face": ["op-loft"], - "revolve_add": ["op-revolve"], - "revolve_cut": ["op-revolve"], - "revolve_surface": ["op-revolve"], - "sweep_add": ["op-loft"], - "hole_blind": ["op-hole"], - "hole_counterbore": ["op-hole"], - "hole_countersink": ["op-hole"], - "hole_wizard": ["op-hole"], - "reference_plane": ["op-reference"], - "reference_axis": ["op-reference"], - "pattern_linear": ["op-pattern"], - "pattern_mirror": ["op-pattern"], - "pattern_circular": ["op-pattern"], - "fillet": ["op-finish"], - "chamfer": ["op-finish"], - "shell": ["op-finish"], - "boolean_bodies": ["op-finish"], - "transform_bodies": ["op-finish"], - "delete_bodies": ["op-finish"], - "sphere_add": ["op-sphere"], - "box_add": ["op-primitives"], - "cylinder_add": ["op-primitives"], - "thread_add": ["op-thread"], - "thread_cut": ["op-thread"], - "bend_add": ["op-bend"], - "gear_add": ["op-gear"], - "rack_add": ["op-gear"] - } -} diff --git a/backend/agent/skills/cdsl-author-guidance/op-bend.md b/backend/agent/skills/cdsl-author-guidance/op-bend.md deleted file mode 100644 index 8e9918b6..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-bend.md +++ /dev/null @@ -1 +0,0 @@ -`bend_add` 以中面折线链定义等厚钣金折弯:正的 `thickness_mm`、`width_mm` 和至少一翼的 `chain`;非末翼必须给 `bend_angle_deg`(内角,0 < angle < 180,90 为直角折弯),可选 `inner_radius_mm`(>= 0,外半径恒为内半径加板厚)与 `side`(+1 / -1,折弯方向)。`frame` 可选,缺省为世界 XY 平面(首翼沿 +X,厚度沿法向)。相邻折弯圆角会消耗直段长度(约 `inner_radius + thickness/2` 的切线距离),每翼剩余直段必须为正,否则规格非法。仅用于意图明确的钣金折弯;单翼链退化为平板,普通平板轮廓应保留草图历史表达而非用 bend_add 替代。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md deleted file mode 100644 index 754cacb0..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md +++ /dev/null @@ -1 +0,0 @@ -`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md deleted file mode 100644 index f88a19b2..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md +++ /dev/null @@ -1 +0,0 @@ -`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-finish.md b/backend/agent/skills/cdsl-author-guidance/op-finish.md deleted file mode 100644 index 296f7647..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-finish.md +++ /dev/null @@ -1 +0,0 @@ -`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies`、`transform_bodies`、`delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-gear.md b/backend/agent/skills/cdsl-author-guidance/op-gear.md deleted file mode 100644 index bc47d341..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-gear.md +++ /dev/null @@ -1 +0,0 @@ -`gear_add` 与 `rack_add` 是渐开线齿轮/齿条的原生图元:正的 `module_mm`、`teeth_count`(齿轮 8–200,齿条 1–2000)与明确的 `axis`。齿轮 `axis.origin_mm` 是 `z=0` 端面圆心、`axis.direction` 为齿轴;齿条 `axis.direction` 是齿的伸出方向、`axis.origin_mm` 是长度起点端面与厚度起始面及齿谷平面的交点,有效长度精确等于 `teeth_count * pi * module`。`helix_angle_rad > 0` 生成斜齿(螺旋沿轴推进);加 `herringbone: true` 得到人字齿(双螺旋在齿宽中点对称反转,无退刀槽)。齿数低于 17 时存在根切风险,引擎照常生成并附诊断说明,不静默修正;如需无根切请提高齿数或改用变位设计(当前不支持变位)。齿轮孔、键槽等后续特征用 hole/extrude_cut 沿同一 axis 叠加。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-hole.md b/backend/agent/skills/cdsl-author-guidance/op-hole.md deleted file mode 100644 index 455e5be8..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-hole.md +++ /dev/null @@ -1 +0,0 @@ -孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-loft.md b/backend/agent/skills/cdsl-author-guidance/op-loft.md deleted file mode 100644 index 30303069..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-loft.md +++ /dev/null @@ -1 +0,0 @@ -`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-pattern.md b/backend/agent/skills/cdsl-author-guidance/op-pattern.md deleted file mode 100644 index 8bf48548..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-pattern.md +++ /dev/null @@ -1 +0,0 @@ -`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-primitives.md b/backend/agent/skills/cdsl-author-guidance/op-primitives.md deleted file mode 100644 index 09e10d85..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-primitives.md +++ /dev/null @@ -1 +0,0 @@ -`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-reference.md b/backend/agent/skills/cdsl-author-guidance/op-reference.md deleted file mode 100644 index 20e1e31f..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-reference.md +++ /dev/null @@ -1 +0,0 @@ -`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-revolve.md b/backend/agent/skills/cdsl-author-guidance/op-revolve.md deleted file mode 100644 index 161997dc..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-revolve.md +++ /dev/null @@ -1 +0,0 @@ -`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-sphere.md b/backend/agent/skills/cdsl-author-guidance/op-sphere.md deleted file mode 100644 index 99c82b4b..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-sphere.md +++ /dev/null @@ -1 +0,0 @@ -`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-thread.md b/backend/agent/skills/cdsl-author-guidance/op-thread.md deleted file mode 100644 index 38e932da..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-thread.md +++ /dev/null @@ -1 +0,0 @@ -`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。 diff --git a/backend/app/cad_agent/__init__.py b/backend/app/cad_agent/__init__.py index e1c19aa3..edece57e 100644 --- a/backend/app/cad_agent/__init__.py +++ b/backend/app/cad_agent/__init__.py @@ -1,7 +1,7 @@ -"""Autonomous CAD protocol v3. +"""Single-stage Authoring CDSL protocol. The package intentionally separates policy from I/O. Delivery code composes these modules with adapters; it must not bypass the command handlers. """ -PROTOCOL_VERSION = "3.0" +PROTOCOL_VERSION = "cad.single-stage.v1" diff --git a/backend/app/cad_agent/adapters/__init__.py b/backend/app/cad_agent/adapters/__init__.py index 9743a47b..3a9d210e 100644 --- a/backend/app/cad_agent/adapters/__init__.py +++ b/backend/app/cad_agent/adapters/__init__.py @@ -1 +1 @@ -"""Infrastructure adapters for the v3 ports.""" +"""Infrastructure adapters for the single-stage ports.""" diff --git a/backend/app/cad_agent/adapters/artifact_store.py b/backend/app/cad_agent/adapters/artifact_store.py index 4267a0d5..2816d5ea 100644 --- a/backend/app/cad_agent/adapters/artifact_store.py +++ b/backend/app/cad_agent/adapters/artifact_store.py @@ -1,4 +1,4 @@ -"""Immutable v3 file artifacts with staging manifests and atomic publication.""" +"""Immutable single-stage artifacts with staging manifests and atomic publication.""" from __future__ import annotations @@ -8,10 +8,9 @@ import os from pathlib import Path import re import secrets -import shutil from typing import Any -from app.cad_agent.ports import CandidateStage +from app.cad_agent.ports import StagingRevision _SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$") @@ -27,7 +26,7 @@ class FileArtifactStore: def task_dir(self, task_id: str) -> Path: if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id): - raise ValueError("Invalid v3 task id") + raise ValueError("Invalid CAD task id") return self.root / task_id def artifact_path(self, task_id: str, relative_path: str) -> Path: @@ -43,7 +42,7 @@ class FileArtifactStore: ) -> None: root = self.task_dir(task_id) (root / "documents").mkdir(parents=True, exist_ok=True) - (root / "actions").mkdir(exist_ok=True) + (root / "events").mkdir(exist_ok=True) (root / "revisions").mkdir(exist_ok=True) (root / ".staging").mkdir(exist_ok=True) source = root / "source-requirements.md" @@ -67,14 +66,14 @@ class FileArtifactStore: if images: self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images}) - def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: + def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: """Mirror committed SQLite events into an append-only JSONL audit log. SQLite remains authoritative. Replaying this method after an interruption appends only missing committed sequences and rejects a divergent line instead of rewriting audit history. """ - path = self._path(task_id, "actions/action-ledger.jsonl") + path = self._path(task_id, "events/event-ledger.jsonl") existing: dict[int, dict[str, Any]] = {} if path.is_file(): for raw in path.read_text(encoding="utf-8").splitlines(): @@ -83,18 +82,18 @@ class FileArtifactStore: value = json.loads(raw) sequence = value.get("sequence") if isinstance(value, dict) else None if not isinstance(sequence, int) or sequence < 1: - raise ValueError("Action ledger contains an invalid sequence") + raise ValueError("Event ledger contains an invalid sequence") existing[sequence] = value missing: list[str] = [] for event in events: sequence = event.get("sequence") if not isinstance(sequence, int) or sequence < 1: - raise ValueError("Action ledger event has an invalid sequence") - entry = {"schema_version": "cad.action-ledger.v1", **event} + raise ValueError("Event ledger event has an invalid sequence") + entry = {"schema_version": "cad.event-ledger.v1", **event} previous = existing.get(sequence) if previous is not None: if previous != entry: - raise ValueError("Action ledger diverges from committed SQLite event") + raise ValueError("Event ledger diverges from committed SQLite event") continue missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n") if missing: @@ -103,7 +102,7 @@ class FileArtifactStore: handle.writelines(missing) handle.flush() os.fsync(handle.fileno()) - return "actions/action-ledger.jsonl" + return "events/event-ledger.jsonl" def write_source_index( self, @@ -136,9 +135,8 @@ class FileArtifactStore: """Return immutable source paragraphs and attachment blocks. Source identifiers are assigned only after this method returns, so - callers cannot choose IDs. Attachment metadata is deliberately a - closed, server-provided projection: it lets a reviewer trace the - source without treating upload metadata as arbitrary LLM input. + callers cannot choose IDs. Attachment metadata is a closed, + server-provided projection rather than arbitrary model input. """ supplied = source_blocks if supplied is None: @@ -195,19 +193,6 @@ class FileArtifactStore: if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file() ] - def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: - return self.read_json(task_id, artifact_path or "documents/requirements-spec.json") - - def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: - # State points to the immutable artifact used as program input. The - # fixed name is a read-only convenience view for terminal tasks. - return self.read_json(task_id, artifact_path or "requirements-contract.json") - - def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: - if not invocation_id: - return self.write_json_once(task_id, "requirements-contract.json", payload) - return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id) - def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: path = self._path(task_id, relative_path) if not path.is_file(): @@ -225,36 +210,20 @@ class FileArtifactStore: self._write_once(path, text) return relative_path - def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: - return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None - - def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: - return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None - - def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: + def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: root = self.task_dir(task_id) / ".staging" stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20] directory = root / stable_id directory.mkdir(parents=True, exist_ok=True) self._write_json_once(directory / "input.json", payload) - return CandidateStage(stable_id, str(directory.resolve())) - - def stage_output_dir(self, task_id: str, stage_id: str) -> str: - return str(self._stage_path(task_id, stage_id, "")) + return StagingRevision(stable_id, str(directory.resolve())) def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: path = self._stage_path(task_id, stage_id, relative_path) self._write_json_once(path, payload) return relative_path - def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: - path = self._stage_path(task_id, stage_id, relative_path) - if not path.is_file(): - return None - value = json.loads(path.read_text(encoding="utf-8")) - return value if isinstance(value, dict) else None - - def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: + def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: source = self._stage_path(task_id, stage_id, "") target = self._path(task_id, f"revisions/{revision_id}") if target.exists(): @@ -262,9 +231,8 @@ class FileArtifactStore: if manifest is None: raise RuntimeError("Published revision has no valid manifest") return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]} - # Render/rebuild reports contain paths for the reviewer. Rebase those - # paths while artifacts are still mutable staging output, so they point - # at the revision after the atomic directory rename. + # Rebase report paths while artifacts are still mutable staging output, + # so they point at the revision after the atomic directory rename. self._rebase_staged_paths(source, target) manifest = self._create_manifest(source) self._write_json_once(source / "manifest.json", manifest) @@ -272,39 +240,6 @@ class FileArtifactStore: os.replace(source, target) return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]} - def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: - """Find a manifest-verified candidate already renamed before its DB CAS. - - Publishing artifacts and advancing SQLite cannot share a transaction. - The stage id persisted inside ``candidate.json`` makes a post-rename - recovery deterministic and prevents another build or revision. - """ - revisions = self.task_dir(task_id) / "revisions" - if not revisions.is_dir(): - return None - for revision in sorted(revisions.iterdir()): - if not revision.is_dir() or self._manifest(revision) is None: - continue - candidate_path = revision / "candidate.json" - if not candidate_path.is_file(): - continue - try: - candidate = json.loads(candidate_path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - continue - if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id: - return revision.name, candidate - return None - - def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: - root = self.task_dir(task_id) / ".staging" - if not root.is_dir(): - return - for directory in root.iterdir(): - if not directory.is_dir() or directory.name in referenced_stage_ids: - continue - shutil.rmtree(directory) - def _path(self, task_id: str, relative_path: str) -> Path: if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts): raise ValueError("Invalid artifact relative path") @@ -316,7 +251,7 @@ class FileArtifactStore: def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path: if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id): - raise ValueError("Invalid candidate stage id") + raise ValueError("Invalid staging revision id") root = self.task_dir(task_id).resolve() stage = (root / ".staging" / stage_id).resolve() if root not in stage.parents: @@ -351,13 +286,6 @@ class FileArtifactStore: temporary.write_bytes(data) os.replace(temporary, path) - def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str: - digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12] - safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32] - if not safe_invocation: - raise ValueError("Artifact invocation ID is invalid") - return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload) - @classmethod def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None: cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n") @@ -371,8 +299,8 @@ class FileArtifactStore: relative = path.relative_to(directory).as_posix() files[relative] = sha256(path.read_bytes()).hexdigest() if not files: - raise RuntimeError("Candidate staging directory has no artifacts") - return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files} + raise RuntimeError("Staging revision has no artifacts") + return {"schema_version": "cad.single-stage.artifact-manifest.v1", "files": files} @staticmethod def _manifest(directory: Path) -> dict[str, Any] | None: diff --git a/backend/app/cad_agent/adapters/author_guidance.py b/backend/app/cad_agent/adapters/author_guidance.py deleted file mode 100644 index 4cae1ef5..00000000 --- a/backend/app/cad_agent/adapters/author_guidance.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Bounded, file-backed author guidance for the CDSL workflow. - -The corpus is deliberately non-authoritative: contracts, schemas, topology -tokens, and server preflight always remain the executable source of truth. -Loading errors return an empty selection so authoring continues with the -pre-guidance prompt instead of turning documentation into an availability -dependency. -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from app.cad_agent.domain.state import TaskPhase -from app.cad_agent.ports import AuthorGuidanceSelection - - -_MIN_CHARS = 1_200 -_MAX_CHARS = 6_000 - - -@dataclass(frozen=True, slots=True) -class _Section: - section_id: str - title: str - priority: int - mandatory: bool - content: str - - @property - def block(self) -> str: - return f"## {self.title}\n{self.content.strip()}" - - -@dataclass(frozen=True, slots=True) -class _Corpus: - version: str - sections: dict[str, _Section] - phase_sections: dict[str, tuple[str, ...]] - repair_sections: tuple[str, ...] - final_sections: tuple[str, ...] - operation_sections: dict[str, tuple[str, ...]] - - -class FileAuthorGuidance: - """Read and select the checked-in corpus deterministically. - - Selection depends exclusively on workflow state and the runtime operation - registry. It intentionally receives neither the user's request nor image - observations, so it cannot become an implicit part-family classifier. - """ - - def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None: - self.root = root - self.enabled = enabled - self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars)) - self._corpus: _Corpus | None = None - self._load_error = "" - - def select( - self, - *, - phase: TaskPhase, - atomic_id: str, - repair_required: bool, - supported_atomic_ids: tuple[str, ...], - ) -> AuthorGuidanceSelection: - if not self.enabled: - return AuthorGuidanceSelection(fallback_reason="guidance_disabled") - corpus = self._load() - if corpus is None: - return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable") - supported = set(supported_atomic_ids) - if set(corpus.operation_sections) != supported: - return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch") - if atomic_id and atomic_id not in supported: - return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id") - - if repair_required: - requested = list(corpus.repair_sections) - elif phase == TaskPhase.FINAL_VALIDATION: - requested = list(corpus.final_sections) - else: - requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ()))) - if atomic_id: - requested.extend(corpus.operation_sections[atomic_id]) - requested = list(dict.fromkeys(requested)) - if not requested: - return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections") - - mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory] - optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory] - optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id))) - selected: list[str] = [] - text = "" - for section_id in [*mandatory, *optional]: - block = corpus.sections[section_id].block - candidate = block if not text else f"{text}\n\n{block}" - if len(candidate) <= self.max_chars: - text = candidate - selected.append(section_id) - elif section_id in mandatory: - # Do not silently drop contract, datum, or operation guidance. - return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget") - return AuthorGuidanceSelection( - version=corpus.version, - section_ids=tuple(selected), - content=text, - enabled=True, - ) - - def _load(self) -> _Corpus | None: - if self._corpus is not None: - return self._corpus - if self._load_error: - return None - try: - manifest_path = self.root / "manifest.json" - raw = json.loads(manifest_path.read_text(encoding="utf-8")) - if not isinstance(raw, dict): - raise ValueError("manifest is not an object") - version = raw.get("version") - if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version: - raise ValueError("manifest version is invalid") - raw_sections = raw.get("sections") - if not isinstance(raw_sections, list) or not raw_sections: - raise ValueError("manifest sections are invalid") - sections: dict[str, _Section] = {} - root = self.root.resolve() - for item in raw_sections: - if not isinstance(item, dict): - raise ValueError("section declaration is invalid") - section_id = item.get("id") - filename = item.get("file") - title = item.get("title") - priority = item.get("priority") - mandatory = item.get("mandatory", False) - if ( - not isinstance(section_id, str) or not section_id - or not isinstance(filename, str) or not filename - or not isinstance(title, str) or not title - or not isinstance(priority, int) or isinstance(priority, bool) - or not isinstance(mandatory, bool) - or section_id in sections - ): - raise ValueError("section metadata is invalid") - path = (self.root / filename).resolve() - if root not in path.parents or not path.is_file(): - raise ValueError("section file is unavailable") - content = path.read_text(encoding="utf-8").strip() - if not content: - raise ValueError("section content is empty") - sections[section_id] = _Section(section_id, title, priority, mandatory, content) - - def identifiers(value: Any, field: str) -> tuple[str, ...]: - if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value): - raise ValueError(f"{field} is invalid") - return tuple(dict.fromkeys(value)) - - raw_phases = raw.get("phase_sections") - if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases: - raise ValueError("phase sections are invalid") - phase_sections = { - phase: identifiers(section_ids, f"phase {phase}") - for phase, section_ids in raw_phases.items() - if isinstance(phase, str) - } - if len(phase_sections) != len(raw_phases): - raise ValueError("phase name is invalid") - operation_sections = { - atomic_id: identifiers(section_ids, f"operation {atomic_id}") - for atomic_id, section_ids in (raw.get("operation_sections") or {}).items() - if isinstance(atomic_id, str) - } - if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}): - raise ValueError("operation sections are invalid") - self._corpus = _Corpus( - version=version, - sections=sections, - phase_sections=phase_sections, - repair_sections=identifiers(raw.get("repair_sections"), "repair sections"), - final_sections=identifiers(raw.get("final_sections"), "final sections"), - operation_sections=operation_sections, - ) - return self._corpus - except (OSError, ValueError, TypeError, json.JSONDecodeError) as error: - self._load_error = f"guidance_load_failed:{type(error).__name__}" - return None diff --git a/backend/app/cad_agent/adapters/event_publisher.py b/backend/app/cad_agent/adapters/event_publisher.py index 34e24d8a..0a6c5df9 100644 --- a/backend/app/cad_agent/adapters/event_publisher.py +++ b/backend/app/cad_agent/adapters/event_publisher.py @@ -1,4 +1,4 @@ -"""In-process idempotent event delivery for the v3 delivery boundary.""" +"""In-process idempotent event delivery for the single-stage protocol.""" from __future__ import annotations diff --git a/backend/app/cad_agent/adapters/review_gateway.py b/backend/app/cad_agent/adapters/review_gateway.py deleted file mode 100644 index 6711d323..00000000 --- a/backend/app/cad_agent/adapters/review_gateway.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Independent rendered-review adapter for protocol v3.""" - -from __future__ import annotations - -import base64 -import json -from pathlib import Path -from typing import Any - -from app.cad_agent.adapters.structured_llm import StructuredModelGateway -from app.cad_agent.ports import AdapterUnavailable - - -class RenderedReviewGateway: - def __init__(self, models: StructuredModelGateway) -> None: - self.models = models - - async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: - name = str((tool.get("function") or {}).get("name") or "") - if not name: - raise RuntimeError("Review tool is missing a name") - public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"} - content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}] - if kind in {"image_observation", "final"}: - for raw_path in payload.get("reference_image_paths") or (): - path = Path(str(raw_path)) - if path.is_file(): - content.append(self._image_part(path)) - if kind in {"candidate", "final"}: - manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {} - for path in self._evidence_paths(manifest): - content.append(self._image_part(path)) - return await self.models.call_tool( - messages=[ - {"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."}, - {"role": "user", "content": content}, - ], - tool=tool, - provider_id=provider_id, - model_id=model_id, - required_tool_name=name, - ) - - @staticmethod - def _evidence_paths(manifest: dict[str, Any]) -> list[Path]: - selected: list[Path] = [] - contact = Path(str(manifest.get("contact_sheet_path") or "")) - if contact.is_file(): - selected.append(contact) - wanted = {"top", "front", "right", "isometric"} - for item in manifest.get("views") or (): - if not isinstance(item, dict) or str(item.get("id") or "") not in wanted: - continue - path = Path(str(item.get("path") or "")) - if path.is_file(): - selected.append(path) - if not selected: - raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable") - return selected[:5] - - @staticmethod - def _image_part(path: Path) -> dict[str, Any]: - media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" - return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}} diff --git a/backend/app/cad_agent/adapters/runtime.py b/backend/app/cad_agent/adapters/runtime.py index 65658289..4d6de80d 100644 --- a/backend/app/cad_agent/adapters/runtime.py +++ b/backend/app/cad_agent/adapters/runtime.py @@ -3,22 +3,20 @@ from __future__ import annotations from copy import deepcopy -from hashlib import sha256 import json import math from pathlib import Path from typing import Any from app.cad_agent.domain.operation_contract import ( - SEMANTIC_PREFLIGHT_NAMES, OperationContractError, canonical_hash, - validate_fragment, + is_authoring_schema_closed, validate_operation_contract, ) -from app.cad_agent.domain.verifier_registry import default_registry -from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl -from app.services.review_renderer import ReviewRenderError, render_checkpoint +from app.cad_agent.ports import AdapterUnavailable +from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl, validate_cdsl_shape +from app.services.render_bundle import RenderBundleError, render_checkpoint from app.settings import Settings from vendor.cdsl_preview_runtime import step_to_glb @@ -27,56 +25,43 @@ class RuntimeAdapterError(RuntimeError): pass +class RuntimeServiceUnavailable(AdapterUnavailable): + """A renderer or artifact service outage that must not spend a repair.""" + + class ProfileCadRuntime: """Adapter that owns engine imports; application code sees only its port.""" def __init__(self, settings: Settings) -> None: self.settings = settings self.engine = load_engine(settings) - self._semantic_preflight_handlers = { - "sketch_workplane": self._preflight_sketch_workplane, - "profile_non_self_intersecting": self._preflight_profile_non_self_intersecting, - "host_face_exists": self._preflight_host_face_exists, - "hole_positions_on_host_plane": self._preflight_hole_positions_on_host_plane, - "cut_exit_distance": self._preflight_cut_exit_distance, - "requires_active_solid": self._preflight_requires_active_solid, - "revolve_axis_on_sketch": self._preflight_revolve_axis_on_sketch, - "reference_plane_nonzero_normal": self._preflight_reference_plane_nonzero_normal, - "reference_axis_nonzero_direction": self._preflight_reference_axis_nonzero_direction, - "selected_edges_exist": self._preflight_selected_edges_exist, - "source_features_exist": self._preflight_source_features_exist, - "mirror_plane_exists": self._preflight_mirror_plane_exists, - "loft_profiles_exist": self._preflight_loft_profiles_exist, - "loft_profiles_closed": self._preflight_loft_profiles_closed, - "loft_profiles_single_region": self._preflight_loft_profiles_single_region, - } schema_path = Path(str(self.engine.__file__)).with_name("profile_schema.json") try: self._profile = json.loads(schema_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile schema is unavailable") from error - self._contracts = self._profile.get("operation_contracts") - if not isinstance(self._contracts, dict): - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no v3 operation contracts") - declared = {str(item) for item in self._contracts} + profile_contracts = self._profile.get("operation_contracts") + if not isinstance(profile_contracts, dict): + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no operation contracts") + declared = {str(item) for item in profile_contracts} registered = {str(item) for item in getattr(self.engine, "SUPPORTED_ATOMIC_IDS", ())} if declared != registered: raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime and profile operation registries disagree") - verifier_kinds = set(default_registry().claim_kinds) - for contract in self._contracts.values(): + self._contracts: dict[str, dict[str, Any]] = {} + for atomic_id, contract in profile_contracts.items(): + if not isinstance(contract, dict): + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not an object") + # A legacy opaque operation schema is still executable internally, + # but cannot enter the sole LLM-facing Authoring protocol. + if not is_authoring_schema_closed(contract.get("author_params_schema")): + continue try: validate_operation_contract(contract) except OperationContractError as error: raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error - unknown_preflights = set(contract["semantic_preflight"]) - set(self._semantic_preflight_handlers) - unknown_verifiers = set(contract["candidate_verifiers"]) - verifier_kinds - if unknown_preflights or unknown_verifiers: - raise RuntimeAdapterError( - "RUNTIME_CONTRACT_INVALID: operation contract references an unavailable " - f"{'preflight' if unknown_preflights else 'candidate verifier'}" - ) - if set(self._semantic_preflight_handlers) != SEMANTIC_PREFLIGHT_NAMES: - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime preflight registry is incomplete") + self._contracts[str(atomic_id)] = contract + if not self._contracts: + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no strict authoring operations") def supported_atomic_ids(self) -> tuple[str, ...]: return tuple(sorted(self._contracts)) @@ -90,170 +75,21 @@ class ProfileCadRuntime: result["registry_revision"] = str(self._profile.get("schema_version") or "") return result - def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: - if not isinstance(topology, dict): - return {} - snapshot_id = str(topology.get("snapshot_id") or "") - if not snapshot_id: - return {} - result: dict[str, dict[str, Any]] = {} - for record in topology.get("records") or (): - if not isinstance(record, dict) or not record.get("executable"): - continue - record_id = str(record.get("record_id") or "") - kind = str(record.get("kind") or "") - if not record_id or kind not in {"face", "edge", "plane", "axis", "body", "vertex"}: - continue - token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16] - geometry = deepcopy(record.get("geometry") or {}) - owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""] - result[token] = { - "token": token, - "kind": kind, - "snapshot_id": snapshot_id, - "selector": {"kind": kind, "stable_id": record_id, "owner_feature_id": str(owners[0] or ""), "geometry": geometry, "source": "runtime_snapshot", "snapshot_id": snapshot_id, "confidence": 1.0}, - "geometry": geometry, - } - return result + def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Compile model-facing Authoring CDSL using server-owned contracts.""" + from app.cad_agent.application.authoring_compiler import AuthoringCompiler + from app.cad_agent.application.authoring_compiler import AuthoringCompileError - def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: - """Return opaque, head-scoped feature references for pattern contracts.""" - result: dict[str, str] = {} - document_hash = canonical_hash(cdsl) if isinstance(cdsl, dict) else "root" - for feature in (cdsl or {}).get("features") or (): - if not isinstance(feature, dict) or not isinstance(feature.get("id"), str) or not feature["id"]: - continue - feature_id = feature["id"] - 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, 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 - # a candidate merely by bypassing that handler-level validation. + runtime, audit = AuthoringCompiler(self.operation_contract).compile(document) try: - validate_operation_contract(contract) - except OperationContractError as error: - raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error - current = self.operation_contract(str(contract.get("atomic_id") or "")) - if ( - contract.get("contract_hash") != current["contract_hash"] - or contract.get("registry_revision") != current["registry_revision"] - ): - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not the current verified registry entry") - selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") - selector_kind = str((contract.get("selector_policy") or {}).get("token_kind") or "") - allowed_selectors = [ - token - for token, value in selector_tokens.items() - if selector_shape == "required" - and isinstance(value, dict) - and value.get("kind") == selector_kind - ] - errors = validate_fragment( - contract, - fragment, - selector_tokens=allowed_selectors, - reference_tokens=list(reference_tokens), - root_xy_datum=not bool((base_cdsl or {}).get("features")), - ) - if errors: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: fragment no longer matches the active operation schema: " - + errors[0]["message"] - ) - materialized = deepcopy(fragment) - reference = contract["reference_policy"] - if reference["mode"] == "snapshot_bound": - slot = str(reference["slot"]).removeprefix("params.") - supplied = materialized.get("feature", {}).get("params", {}).get(slot, []) - 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] - 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": [], - } - geometry = document.setdefault("geometry", {}) - sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None - 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": list(direct_dependencies)} - if contract["fragment_shape"]["sketch"] == "required": - sketch_id = f"sketch_{len(sketches) + 1:03d}" - sketch = materialized["sketch"] - sketches.append({"id": sketch_id, "workplane": deepcopy(sketch["workplane"]), "profile": deepcopy(sketch["profile"])}) - output["sketch_id"] = sketch_id - selected = [selector_tokens[token]["selector"] for token in feature.get("selector_tokens", [])] - slot = contract["selector_policy"]["slot"] - if slot == "params.host_face": - # A topology selector is authoritative while accepting the - # action, but a later pattern replays this feature after its own - # cut may have split the selected B-rep face. Lower the accepted - # planar selector to a concrete, world-aligned host frame and - # transform the author-supplied world coordinates into that - # frame. The token remains in the fragment audit for provenance; - # the materialized CDSL is stable under replay. - output["params"]["host_face"], output["params"]["positions"] = self._materialized_hole_host_frame( - selector_tokens[str(feature["selector_tokens"][0])], - output["params"].get("positions"), - ) - elif slot == "params.mirror_plane": - output["params"]["mirror_plane"] = selected[0] - elif slot == "feature.selectors": - output["selectors"] = selected - features.append(output) - try: - self._validate_finite_tree(document) - self._validate_materialized_runtime_types(output) - validate_cdsl(document, self.engine) + self._validate_finite_tree(runtime) + validate_cdsl_shape(runtime, self.engine) except Exception as error: - message = str(error) - # A JSON-schema failure after server materialization is a registry - # drift: the author could not have supplied the missing server - # field. Engine capability analysis, however, can reject a - # schema-valid author sketch (for example an unresolvable analytic - # contour). That is a recoverable action precondition failure, - # not a deployment defect. - if "CDSL engine runtime preflight failed:" in message: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: materialized fragment is not executable by the engine: " - + message - ) from error - raise RuntimeAdapterError( - "RUNTIME_CONTRACT_INVALID: materialized fragment violates the engine CDSL schema: " - + message + raise AuthoringCompileError( + "RUNTIME_CONTRACT_INVALID", + f"compiled Runtime CDSL is not executable: {error}", ) from error - 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]} + return runtime, audit 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. @@ -298,7 +134,7 @@ class ProfileCadRuntime: self._write_json(report_path, report) return {"preview": preview, "path": "model.glb"} - def render_review_bundle(self, output_dir: str) -> dict[str, Any]: + def render_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" @@ -308,20 +144,20 @@ class ProfileCadRuntime: 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. + """Build one complete checkpoint and derive all publishable artifacts.""" 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) + manifest = self.render_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. raise - except ReviewRenderError as error: - raise RuntimeAdapterError(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error + except RenderBundleError as error: + raise RuntimeServiceUnavailable(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error except Exception as error: raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error @@ -355,6 +191,7 @@ class ProfileCadRuntime: unavailable = [value for value in dependencies if value not in accepted_ids] if unavailable: failures.append({ + "code": "SELECTOR_DEPENDENCY_UNAVAILABLE" if feature.get("selectors") else "DEPENDENCY_UNAVAILABLE", "feature_index": index, "feature_id": feature_id, "message": "Feature was skipped because an earlier dependency did not execute.", @@ -364,14 +201,19 @@ class ProfileCadRuntime: candidate = self._feature_subset(cdsl, [*accepted, feature]) try: latest = self.rebuild(candidate, output_dir, task_id, revision_id) + except (OSError, AdapterUnavailable): + # Preview/render/storage failures are service faults. Preserve + # the staging input and let the workflow replay this exact + # document without spending an Authoring repair. + raise except Exception as error: - failures.append({"feature_index": index, "feature_id": feature_id, "message": str(error)[:1000]}) + failures.append(self._build_failure(index, feature_id, feature, error)) continue accepted.append(deepcopy(feature)) accepted_ids.add(feature_id) if not accepted: if failures: - raise RuntimeAdapterError(str(failures[0].get("message") or "RUNTIME_EXECUTION_FAILURE: no feature could be rebuilt")) + return {}, failures raise RuntimeAdapterError("RUNTIME_EXECUTION_FAILURE: CDSL document has no executable features") # A failed later attempt may have left partial files in the stage. # Rebuild the retained feature set once so all published artifacts are @@ -379,6 +221,46 @@ class ProfileCadRuntime: latest = self.rebuild(self._feature_subset(cdsl, accepted), output_dir, task_id, revision_id) return {**latest, "executed_feature_ids": sorted(accepted_ids)}, failures + @staticmethod + def _build_failure(index: int, feature_id: str, feature: dict[str, Any], error: Exception) -> dict[str, Any]: + diagnostic = getattr(error, "diagnostic", None) + message = str(getattr(diagnostic, "message", "") or error) + diagnostic_code = str(getattr(diagnostic, "code", "") or "") + if not diagnostic_code: + diagnostic_code = next(( + code for code in ( + "selector_output_role_not_found", "selector_geometry_mismatch", + "selector_output_role_ambiguous", "selector_relation_non_unique", + "selector_output_role_owner_required", "selector_output_role_active_body_required", + "selector_owner_required", "selector_output_role_mixed_evidence", + "unsupported_output_role_selector", "invalid_output_role_selector", + ) + if code in message + ), "") + selector_code = { + "selector_output_role_not_found": "SELECTOR_NOT_FOUND", + "selector_geometry_mismatch": "SELECTOR_NOT_FOUND", + "selector_output_role_ambiguous": "SELECTOR_AMBIGUOUS", + "selector_relation_non_unique": "SELECTOR_AMBIGUOUS", + "selector_output_role_owner_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_output_role_active_body_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_owner_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_output_role_mixed_evidence": "SELECTOR_KIND_MISMATCH", + "unsupported_output_role_selector": "SELECTOR_KIND_MISMATCH", + "invalid_output_role_selector": "SELECTOR_KIND_MISMATCH", + }.get(diagnostic_code, "ENGINE_EXECUTION_FAILED") + provenance = getattr(error, "selector_resolutions", None) + return { + "code": selector_code, + "runtime_code": diagnostic_code or "execution_failed", + "feature_index": index, + "feature_id": feature_id, + "atomic_id": str(feature.get("atomic_id") or ""), + "input_summary": {"params": sorted((feature.get("params") or {}).keys()), "selector_count": len(feature.get("selectors") or [])}, + "message": message[:1000], + "selector_provenance": provenance if isinstance(provenance, list) else [], + } + @staticmethod def _feature_subset(cdsl: dict[str, Any], features: list[dict[str, Any]]) -> dict[str, Any]: document = deepcopy(cdsl) @@ -392,458 +274,10 @@ 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) -> 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"] - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if contract["fragment_shape"]["selector_tokens"] == "required": - if not all(token in selector_tokens and selector_tokens[token]["kind"] == policy["token_kind"] for token in supplied): - 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") - workplane = sketch.get("workplane") if isinstance(sketch, dict) else None - if not isinstance(workplane, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane is unavailable") - normal, x_dir = workplane.get("normal"), workplane.get("x_dir") - if not isinstance(normal, list) or not isinstance(x_dir, list) or self._norm(normal) <= 1e-9 or self._norm(x_dir) <= 1e-9 or self._norm(self._cross(normal, x_dir)) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane vectors are degenerate") - - def _preflight_profile_non_self_intersecting(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - sketch = fragment.get("sketch") - profile = sketch.get("profile") if isinstance(sketch, dict) else None - if not isinstance(profile, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch profile is unavailable") - if profile.get("type") == "polygon": - vertices = profile.get("vertices") - if not isinstance(vertices, list) or len(vertices) < 3 or self._polygon_self_intersects(vertices): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: polygon profile self-intersects") - 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": - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face is absent or stale") - - def _materialized_hole_host_frame( - self, - selected: dict[str, Any], - positions: Any, - ) -> tuple[dict[str, Any], list[dict[str, list[float]]]]: - """Lower a verified planar host selector into replay-stable CDSL data. - - Hole position inputs are world coordinates at the author boundary. - The engine's frame form uses local coordinates, so both values must - be converted together. Keeping only the selector makes a later - pattern replay depend on a face that the source cut has already - subdivided, which is neither stable nor geometrically meaningful. - """ - geometry = selected.get("geometry") if isinstance(selected, dict) else None - center = geometry.get("center_mm") if isinstance(geometry, dict) else None - normal = geometry.get("normal") if isinstance(geometry, dict) else None - if not self._valid_vector3(center) or not self._valid_vector3(normal, require_nonzero=True): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face has no usable plane frame") - if not isinstance(positions, list) or not positions: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are unavailable for host-frame materialization") - origin = [float(value) for value in center] - unit_normal = [float(value) / self._norm(normal) for value in normal] - seed = [1.0, 0.0, 0.0] if abs(unit_normal[0]) < 0.9 else [0.0, 1.0, 0.0] - x_raw = [seed[index] - self._dot(seed, unit_normal) * unit_normal[index] for index in range(3)] - x_length = self._norm(x_raw) - if x_length <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face cannot define a stable x direction") - x_dir = [value / x_length for value in x_raw] - y_dir = self._cross(unit_normal, x_dir) - local_positions: list[dict[str, list[float]]] = [] - for position in positions: - point = position.get("mm") if isinstance(position, dict) else None - if not self._valid_vector3(point): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point") - offset = [float(point[index]) - origin[index] for index in range(3)] - local_positions.append({"mm": [ - self._dot(offset, x_dir), - self._dot(offset, y_dir), - self._dot(offset, unit_normal), - ]}) - return ( - {"frame": {"origin_mm": origin, "x_dir": x_dir, "y_dir": y_dir, "normal": unit_normal}}, - local_positions, - ) - - def _preflight_hole_positions_on_host_plane(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - positions = fragment.get("feature", {}).get("params", {}).get("positions") - if not isinstance(positions, list) or not positions or not all(isinstance(item, dict) and isinstance(item.get("mm"), list) and len(item["mm"]) == 3 for item in positions): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are invalid for the host plane") - supplied = fragment.get("feature", {}).get("selector_tokens", []) - host = selectors.get(supplied[0]) if len(supplied) == 1 else None - geometry = host.get("geometry") if isinstance(host, dict) else None - normal = geometry.get("normal") if isinstance(geometry, dict) else None - center = geometry.get("center_mm") 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 self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(center): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole host must be an observable planar face") - unit_normal = [float(component) / self._norm(normal) for component in normal] - tolerance = 1e-5 - if not self._valid_bbox(bbox): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face has no usable topology bounds") - for position in positions: - point = position["mm"] - if not self._valid_vector3(point): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point") - offset = [float(point[index]) - float(center[index]) for index in range(3)] - if abs(self._dot(offset, unit_normal)) > tolerance: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not on the selected host plane") - if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)): - 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): - 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") - if not isinstance(contours, list) or not contours: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: analytic profile requires at least one contour") - tolerance = 1e-7 - for contour_index, contour in enumerate(contours): - segments = contour.get("segments") if isinstance(contour, dict) else None - if not isinstance(segments, list) or not segments: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is empty") - if any(segment.get("type") == "circle" for segment in segments if isinstance(segment, dict)): - if len(segments) != 1 or segments[0].get("type") != "circle": - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} cannot mix a full circle with other segments") - continue - endpoints: list[tuple[tuple[float, float], tuple[float, float]]] = [] - for segment_index, segment in enumerate(segments): - if not isinstance(segment, dict) or segment.get("type") not in {"line", "arc"}: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an unsupported segment") - start = self._point2(segment.get("start")) - end = self._point2(segment.get("end")) - if start is None or end is None: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} has non-finite endpoints") - if self._distance2(start, end) <= tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} is degenerate") - if segment.get("type") == "arc": - center = self._point2(segment.get("center")) - radius = segment.get("radius_mm") - if center is None or not isinstance(radius, (int, float)) or isinstance(radius, bool) or not math.isfinite(float(radius)) or float(radius) <= 0: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} has an invalid circle") - arc_tolerance = max(tolerance, float(radius) * 1e-7) - if abs(self._distance2(start, center) - float(radius)) > arc_tolerance or abs(self._distance2(end, center) - float(radius)) > arc_tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} endpoints are not on the declared circle") - endpoints.append((start, end)) - for segment_index in range(1, len(endpoints)): - if self._distance2(endpoints[segment_index - 1][1], endpoints[segment_index][0]) > tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is discontinuous before segment {segment_index}") - if contour.get("closed") is True and self._distance2(endpoints[-1][1], endpoints[0][0]) > tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is not closed") - line_segments = [segment for segment, raw in zip(endpoints, segments) if raw.get("type") == "line"] - for first, (a, b) in enumerate(line_segments): - for second, (c, d) in enumerate(line_segments): - if second <= first + 1 or (first == 0 and second == len(line_segments) - 1 and contour.get("closed") is True): - continue - if self._segments_intersect(a, b, c, d): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an obvious self-intersection") - - @classmethod - def _validate_materialized_runtime_types(cls, feature: dict[str, Any]) -> None: - atomic_id = str(feature.get("atomic_id") or "") - if not atomic_id.startswith("hole_"): - return - params = feature.get("params") if isinstance(feature.get("params"), dict) else {} - host = params.get("host_face") if isinstance(params.get("host_face"), dict) else None - frame = host.get("frame") if isinstance(host, dict) and isinstance(host.get("frame"), dict) else None - if not isinstance(frame, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame is unavailable") - for field in ("origin_mm", "x_dir", "y_dir", "normal"): - if not cls._valid_vector3(frame.get(field), require_nonzero=field != "origin_mm"): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame.{field} is invalid") - x_dir, y_dir, normal = frame["x_dir"], frame["y_dir"], frame["normal"] - if abs(cls._dot(x_dir, y_dir)) > 1e-6 or abs(cls._dot(x_dir, normal)) > 1e-6 or abs(cls._dot(y_dir, normal)) > 1e-6: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host frame is not orthogonal") - positions = params.get("positions") - if not isinstance(positions, list) or not positions: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole positions are unavailable") - for index, position in enumerate(positions): - point = position.get("mm") if isinstance(position, dict) else None - if not cls._valid_vector3(point) or abs(float(point[2])) > 1e-5: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole position {index} is invalid in the host frame") - @staticmethod def _validate_finite_tree(value: Any, path: str = "$") -> None: - if value is None: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must not be null") if isinstance(value, float) and not math.isfinite(value): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must be finite") + raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: non-finite number at {path}") if isinstance(value, dict): for key, child in value.items(): ProfileCadRuntime._validate_finite_tree(child, f"{path}.{key}") @@ -851,278 +285,6 @@ class ProfileCadRuntime: for index, child in enumerate(value): ProfileCadRuntime._validate_finite_tree(child, f"{path}[{index}]") - @staticmethod - def _point2(value: Any) -> tuple[float, float] | None: - if not isinstance(value, list) or len(value) != 2: - return None - if not all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value): - return None - return float(value[0]), float(value[1]) - - @staticmethod - def _distance2(left: tuple[float, float], right: tuple[float, float]) -> float: - return math.hypot(left[0] - right[0], left[1] - right[1]) - - @classmethod - def _point_in_planar_face(cls, point: list[float], loops: list[Any], normal: list[float], tolerance: float) -> bool: - drop_axis = max(range(3), key=lambda index: abs(float(normal[index]))) - - def project(value: Any) -> tuple[float, float] | None: - if not cls._valid_vector3(value): - return None - coordinates = [float(value[index]) for index in range(3) if index != drop_axis] - return coordinates[0], coordinates[1] - - projected_point = project(point) - if projected_point is None: - return False - polygons: list[list[tuple[float, float]]] = [] - for loop in loops: - polygon = [project(vertex) for vertex in loop] if isinstance(loop, list) else [] - if len(polygon) >= 3 and all(vertex is not None for vertex in polygon): - polygons.append([vertex for vertex in polygon if vertex is not None]) - if not polygons: - return False - - def area(polygon: list[tuple[float, float]]) -> float: - return abs(sum( - polygon[index][0] * polygon[(index + 1) % len(polygon)][1] - - polygon[(index + 1) % len(polygon)][0] * polygon[index][1] - for index in range(len(polygon)) - )) / 2 - - def contains(polygon: list[tuple[float, float]]) -> bool: - x, y = projected_point - inside = False - for index, first in enumerate(polygon): - second = polygon[(index + 1) % len(polygon)] - if cls._distance_to_segment_2d(projected_point, first, second) <= tolerance: - return True - if (first[1] > y) != (second[1] > y): - crossing_x = (second[0] - first[0]) * (y - first[1]) / (second[1] - first[1]) + first[0] - if x < crossing_x: - inside = not inside - return inside - - outer = max(polygons, key=area) - return contains(outer) and not any(contains(inner) for inner in polygons if inner is not outer) - - @staticmethod - def _distance_to_segment_2d(point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]) -> float: - dx, dy = end[0] - start[0], end[1] - start[1] - length_squared = dx * dx + dy * dy - if length_squared <= 1e-18: - return math.hypot(point[0] - start[0], point[1] - start[1]) - fraction = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_squared)) - return math.hypot(point[0] - (start[0] + fraction * dx), point[1] - (start[1] + fraction * dy)) - - def _preflight_cut_exit_distance(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, require_through: bool) -> None: - if not require_through: - return - params = fragment.get("feature", {}).get("params", {}) - depth = params.get("depth_mm", params.get("distance_mm")) - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if not isinstance(depth, (int, float)): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cut depth is unavailable") - if supplied: - host = selectors[supplied[0]]["geometry"] - bbox = host.get("bbox_mm") if isinstance(host, dict) else None - normal = host.get("normal") or host.get("plane_normal") if isinstance(host, dict) else None - thickness = self._span_from_bbox(bbox, normal) - else: - bbox = self._active_body_bbox(selectors) - normal = (fragment.get("sketch") or {}).get("workplane", {}).get("normal") - thickness = self._span_from_bbox(bbox, normal) - if thickness is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cannot prove through-cut exit distance from the active topology") - if float(depth) <= thickness + 1e-6: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: " - f"cut depth {float(depth):g} mm must exceed measured host-body thickness {thickness:g} mm" - ) - - def _preflight_requires_active_solid(self, _fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # A committed active CDSL document exists only after a candidate with - # a solid has been accepted. Reference and subtraction operations - # therefore cannot be the first feature of a part. - if not isinstance(base, dict) or not base.get("features"): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: operation requires an active solid checkpoint") - - def _preflight_revolve_axis_on_sketch(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - axis = fragment.get("feature", {}).get("params", {}).get("axis") - direction = axis.get("direction") if isinstance(axis, dict) else None - origin = axis.get("origin_mm") if isinstance(axis, dict) else None - workplane = fragment.get("sketch", {}).get("workplane") if isinstance(fragment.get("sketch"), dict) else None - plane_origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None - plane_normal = workplane.get("normal") if isinstance(workplane, dict) else None - if not self._valid_vector3(direction, require_nonzero=True) or not self._valid_vector3(origin) or not self._valid_vector3(plane_origin) or not self._valid_vector3(plane_normal, require_nonzero=True): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis or sketch plane is invalid") - unit_direction = [float(component) / self._norm(direction) for component in direction] - unit_normal = [float(component) / self._norm(plane_normal) for component in plane_normal] - if abs(self._dot(unit_direction, unit_normal)) > 1e-6: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not parallel to the sketch plane") - axis_offset = [float(origin[index]) - float(plane_origin[index]) for index in range(3)] - if abs(self._dot(axis_offset, unit_normal)) > 1e-5: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not on the sketch plane") - - def _preflight_reference_plane_nonzero_normal(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - plane = fragment.get("feature", {}).get("params", {}).get("plane") - normal = plane.get("normal") if isinstance(plane, dict) else None - if not isinstance(normal, list) or self._norm(normal) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference plane normal is degenerate") - - def _preflight_reference_axis_nonzero_direction(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - axis = fragment.get("feature", {}).get("params", {}).get("axis") - direction = axis.get("direction") if isinstance(axis, dict) else None - if not isinstance(direction, list) or self._norm(direction) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference axis direction is degenerate") - - def _preflight_selected_edges_exist(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 not supplied or not all(isinstance(selectors.get(token), dict) and selectors[token].get("kind") == "edge" for token in supplied): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected edge is absent or stale") - - def _preflight_source_features_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - params = fragment.get("feature", {}).get("params", {}) - known = {str(feature.get("id") or "") for feature in ((base or {}).get("features") or ()) if isinstance(feature, dict)} - if not set(params.get("source_feature_ids") or ()).issubset(known): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: source feature is not part of current head") - - def _preflight_mirror_plane_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") != "plane": - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: mirror plane is absent or stale") - - def _loft_profile_sketches(self, fragment: dict[str, Any], base: dict[str, Any] | None) -> list[dict[str, Any]]: - """Resolve ``profile_sketch_ids`` against the base document's sketches.""" - ids = [str(value) for value in fragment.get("feature", {}).get("params", {}).get("profile_sketch_ids") or ()] - sketches = (base or {}).get("geometry", {}).get("sketches") if isinstance((base or {}).get("geometry"), dict) else None - by_id = { - str(sketch.get("id") or ""): sketch - for sketch in (sketches or ()) - if isinstance(sketch, dict) - } - resolved: list[dict[str, Any]] = [] - for sketch_id in ids: - sketch = by_id.get(sketch_id) - if not isinstance(sketch, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch is not part of current head") - resolved.append(sketch) - if not resolved: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft has no profile sketches") - return resolved - - def _preflight_loft_profiles_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - self._loft_profile_sketches(fragment, base) - - def _preflight_loft_profiles_closed(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # circle/polygon 草图类型闭合性由 schema 保证;analytic_contours 需要 - # 每条参与轮廓显式 closed。 - for sketch in self._loft_profile_sketches(fragment, base): - profile = sketch.get("profile") if isinstance(sketch.get("profile"), dict) else None - if profile is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch has no profile") - if profile.get("type") == "analytic_contours": - contours = profile.get("contours") - if not isinstance(contours, list) or not contours: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile has no contours") - for contour in contours: - if not isinstance(contour, dict) or not contour.get("closed"): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile contour is not closed") - - def _preflight_loft_profiles_single_region(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # 每个放样截面必须是单连通区域:analytic_contours 只允许恰好一条 - # outer 闭合轮廓,不得携带 inner 环或 open 段。 - for sketch in self._loft_profile_sketches(fragment, base): - profile = sketch.get("profile") if isinstance(sketch.get("profile"), dict) else None - if profile is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch has no profile") - if profile.get("type") == "analytic_contours": - contours = [contour for contour in (profile.get("contours") or []) if isinstance(contour, dict)] - outer = [contour for contour in contours if contour.get("role") == "outer" and contour.get("closed")] - if len(outer) != 1 or len(outer) != len(contours): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile must be a single closed region") - - @staticmethod - def _polygon_self_intersects(vertices: list[Any]) -> bool: - points = [(float(point[0]), float(point[1])) for point in vertices if isinstance(point, list) and len(point) == 2] - if len(points) != len(vertices): - return True - segments = list(zip(points, [*points[1:], points[0]])) - for first, (a, b) in enumerate(segments): - for second, (c, d) in enumerate(segments): - if second <= first + 1 or (first == 0 and second == len(segments) - 1): - continue - if ProfileCadRuntime._segments_intersect(a, b, c, d): - return True - return False - - @staticmethod - def _segments_intersect(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> bool: - def orientation(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float: - return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) - - left = orientation(a, b, c) - right = orientation(a, b, d) - low = orientation(c, d, a) - high = orientation(c, d, b) - return (left > 0) != (right > 0) and (low > 0) != (high > 0) - - @staticmethod - def _active_body_bbox(selector_tokens: dict[str, dict[str, Any]]) -> list[float] | None: - for token in selector_tokens.values(): - if token.get("kind") != "body": - continue - geometry = token.get("geometry") - bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None - if isinstance(bbox, list) and len(bbox) == 6 and all(isinstance(value, (int, float)) for value in bbox): - return [float(value) for value in bbox] - return None - - @staticmethod - def _span_from_bbox(bbox: Any, normal: Any) -> float | None: - if not isinstance(bbox, list) or len(bbox) != 6 or not isinstance(normal, list) or len(normal) != 3: - return None - if not all(isinstance(value, (int, float)) for value in [*bbox, *normal]): - return None - length = math.sqrt(sum(float(value) ** 2 for value in normal)) - if length <= 1e-9: - return None - return sum( - abs(float(normal[index]) / length) * abs(float(bbox[index + 3]) - float(bbox[index])) - for index in range(3) - ) - - @staticmethod - def _norm(vector: list[float]) -> float: - return math.sqrt(sum(float(item) ** 2 for item in vector)) - - @staticmethod - def _dot(left: list[float], right: list[float]) -> float: - return sum(float(left[index]) * float(right[index]) for index in range(3)) - - @staticmethod - def _valid_vector3(value: Any, *, require_nonzero: bool = False) -> bool: - valid = ( - isinstance(value, list) - and len(value) == 3 - and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value) - ) - return valid and (not require_nonzero or ProfileCadRuntime._norm(value) > 1e-9) - - @staticmethod - def _valid_bbox(value: Any) -> bool: - return ( - isinstance(value, list) - and len(value) == 6 - and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value) - and all(float(value[index]) <= float(value[index + 3]) for index in range(3)) - ) - - @staticmethod - def _cross(a: list[float], b: list[float]) -> list[float]: - return [float(a[1]) * float(b[2]) - float(a[2]) * float(b[1]), float(a[2]) * float(b[0]) - float(a[0]) * float(b[2]), float(a[0]) * float(b[1]) - float(a[1]) * float(b[0])] - @staticmethod def _write_json(path: Path, payload: dict[str, Any]) -> None: path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/backend/app/cad_agent/adapters/sqlite_repository.py b/backend/app/cad_agent/adapters/sqlite_repository.py index 7ec0d95e..b5cc84c4 100644 --- a/backend/app/cad_agent/adapters/sqlite_repository.py +++ b/backend/app/cad_agent/adapters/sqlite_repository.py @@ -1,5 +1,8 @@ -"""SQLite repository: the sole mutable state authority for protocol v3.""" +"""SQLite authority for ``cad.single-stage.v1`` tasks. +There is no reader or migration for the deleted feature-DAG protocol. Opening +a legacy task database drops its task data before creating this schema. +""" from __future__ import annotations from contextlib import contextmanager @@ -10,11 +13,13 @@ from threading import RLock from typing import Any, Iterator from app.cad_agent.domain.errors import ErrorCode -from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState +from app.cad_agent.domain.state import TaskPhase, TaskState from app.cad_agent.ports import InvocationRecord class SqliteTaskRepository: + PROTOCOL_VERSION = "cad.single-stage.v1" + def __init__(self, database_path: Path) -> None: self.database_path = database_path self.database_path.parent.mkdir(parents=True, exist_ok=True) @@ -35,271 +40,151 @@ class SqliteTaskRepository: def _initialize(self) -> None: with self._lock, self._connection() as connection: - # 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.2'" not in str(existing[0] or ""): + existing = connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").fetchone() + if existing is not None and self.PROTOCOL_VERSION not in str(existing[0] or ""): self.protocol_reset = True - connection.executescript(""" - DROP TABLE IF EXISTS outbox; - DROP TABLE IF EXISTS tool_audits; - DROP TABLE IF EXISTS usage_records; - DROP TABLE IF EXISTS invocations; - DROP TABLE IF EXISTS ledger; - DROP TABLE IF EXISTS model_capabilities; - DROP TABLE IF EXISTS tasks; - """) - connection.executescript( - """ + # This database is dedicated to CAD task state. An old + # protocol has no safe reader or migration, so remove every + # persisted task table and let SQLite remove their indexes. + tables = connection.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + for row in tables: + name = str(row[0]).replace('"', '""') + connection.execute(f'DROP TABLE IF EXISTS "{name}"') + connection.executescript(f""" CREATE TABLE IF NOT EXISTS tasks ( task_id TEXT PRIMARY KEY, - protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'), - request TEXT NOT NULL, - phase TEXT NOT NULL, - state_version INTEGER NOT NULL, - active_revision TEXT NOT NULL DEFAULT '', - pending_action_json TEXT, - candidate_id TEXT NOT NULL DEFAULT '', - candidate_stage_id TEXT NOT NULL DEFAULT '', - repair_required INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - retry_from_phase TEXT NOT NULL DEFAULT '', - requirements_spec_path TEXT NOT NULL DEFAULT '', - 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 '', + protocol_version TEXT NOT NULL CHECK(protocol_version = '{self.PROTOCOL_VERSION}'), + request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL, + active_revision TEXT NOT NULL DEFAULT '', repair_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '', + requirements_path TEXT NOT NULL DEFAULT '', authoring_path TEXT NOT NULL DEFAULT '', + runtime_cdsl_path TEXT NOT NULL DEFAULT '', compile_audit_path TEXT NOT NULL DEFAULT '', + diagnostics_path TEXT NOT NULL DEFAULT '', completion_path 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, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS invocations ( - invocation_id TEXT PRIMARY KEY, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - idempotency_key TEXT NOT NULL, - status TEXT NOT NULL, - result_json TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(task_id, idempotency_key) + invocation_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id), + idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(task_id,idempotency_key) ); CREATE TABLE IF NOT EXISTS ledger ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - event_json TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id), + event_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS outbox ( - event_id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - event_json TEXT NOT NULL, - published_at TEXT + event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id), + event_json TEXT NOT NULL, published_at TEXT ); CREATE TABLE IF NOT EXISTS usage_records ( - usage_id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - usage_json TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + usage_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id), + usage_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS tool_audits ( - audit_id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - audit_json TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + audit_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id), + audit_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS model_capabilities ( - provider_id TEXT NOT NULL, - model_id TEXT NOT NULL, - schema_hash TEXT NOT NULL, - supported INTEGER NOT NULL, - report_json TEXT NOT NULL, - checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY(provider_id, model_id, schema_hash) + provider_id TEXT NOT NULL, model_id TEXT NOT NULL, schema_hash TEXT NOT NULL, + supported INTEGER NOT NULL, report_json TEXT NOT NULL, checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(provider_id,model_id,schema_hash) ); - """ - ) + """) 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.2', ?, ?, 0)", - (task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value), + "INSERT OR IGNORE INTO tasks(task_id,protocol_version,request,phase,state_version) VALUES (?,?,?,?,0)", + (task_id, self.PROTOCOL_VERSION, request, TaskPhase.ANALYZING_REQUEST.value), ) state = self.get_state(task_id) if state is None: - raise RuntimeError("SQLite task insert was not visible") + raise RuntimeError("Task was not persisted") return state def get_state(self, task_id: str) -> TaskState | None: with self._lock, self._connection() as connection: - row = connection.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone() - return self._state(row) if row is not None else None + row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone() + return self._state(row) if row else None def get_task_projection(self, task_id: str) -> dict[str, Any] | None: state = self.get_state(task_id) if state is None: return None events = self.ledger_events(task_id) - 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": "" 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") 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 = [ - str(item) for item in frozen.get("verification_warnings") or () if str(item) - ] if isinstance(frozen, dict) else [] - status_event = next(( - item for item in reversed(events) - if item.get("event") in { - "requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted", - "no_progress_limit", - "candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure", - "failed_author_format", "runtime_contract_invalid", - "completed_best_effort", - } - ), {}) if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {} - 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 [] + # A later repair can end at authoring or compilation after an earlier + # build already published an executable prefix. Its terminal event + # intentionally contains diagnostics rather than a duplicate path + # map, so keep the most recent event that actually delivered a + # revision. Otherwise the completed projection loses the existing + # STEP/GLB links exactly when best-effort publication matters most. + result: dict[str, Any] = {} + paths: dict[str, Any] = {} + for item in reversed(events): + if item.get("event") not in { + "build_completed", "published_best_effort", "build_failed", + }: + continue + candidate_paths = item.get("paths") + if isinstance(candidate_paths, dict) and candidate_paths: + result, paths = item, candidate_paths + break return { - "schema_version": "3.2", - "task_id": state.task_id, - "phase": state.phase.value, - "lifecycle": self._lifecycle(state.phase), - "state_version": state.version, - "active_revision": state.active_revision, - "current_revision": state.active_revision, + "schema_version": self.PROTOCOL_VERSION, "task_id": task_id, "phase": state.phase.value, + "lifecycle": self._lifecycle(state.phase), "state_version": state.version, + "active_revision": state.active_revision, "current_revision": state.active_revision, "published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "", - "pending_action": self._pending_payload(state.pending_action), - "active_candidate_id": state.candidate_id, - "repair_required": state.repair_required, + "repair_count": state.repair_count, "repair_budget": 2, "last_error": state.last_error.value if state.last_error else "", - "retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "", - "requirements_spec_path": state.requirements_spec_path, - "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": ( - "completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED) - else "verified" if state.phase == TaskPhase.COMPLETED - else "pending" - ), - "verification_warnings": verification_warnings, - "message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "", - "questions": questions, - "issues": issues, - "blocker_type": ( - "requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER - else str(status_event.get("event") or "") if isinstance(status_event, dict) else "" - ), - "user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions), - "action_ledger_summary": events[-12:], - "revisions": revisions, + "requirements_path": state.requirements_path, "authoring_path": state.authoring_path, + "runtime_cdsl_path": state.runtime_cdsl_path, "compile_audit_path": state.compile_audit_path, + "diagnostics_path": state.diagnostics_path, "completion_path": state.completion_path, + "message": str(result.get("message") or ""), + "revisions": ([{ + "revision_id": state.active_revision, "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED else "checkpoint", + "cdsl_path": paths.get("model.cdsl.json", ""), "step_path": paths.get("model.step", ""), + "glb_path": paths.get("model.glb", ""), "report_path": paths.get("rebuild-report.json", ""), + }] if state.active_revision else []), + "action_ledger_summary": events[-16:], } - @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() - return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows] + rows = connection.execute("SELECT sequence,event_json,created_at FROM ledger WHERE task_id=? ORDER BY sequence", (task_id,)).fetchall() + return [{"sequence": int(row["sequence"]), "at": str(row["created_at"]), **json.loads(row["event_json"])} for row in rows] - def invocation_records(self, task_id: str) -> list[dict[str, Any]]: - with self._lock, self._connection() as connection: - rows = connection.execute( - "SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id", - (task_id,), - ).fetchall() - return [ - { - "invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]), - "status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None, - "created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]), - } - for row in rows - ] - - def compare_and_swap( - self, - state: TaskState, - *, - events: list[dict[str, Any]] = (), - invocation_id: str | None = None, - invocation_result: dict[str, Any] | None = None, - ) -> bool: + def compare_and_swap(self, state: TaskState, *, events: list[dict[str, Any]] = (), invocation_id: str | None = None, invocation_result: dict[str, Any] | None = None) -> bool: if (invocation_id is None) != (invocation_result is None): - raise ValueError("Invocation completion requires both invocation_id and invocation_result") - previous_version = state.version - 1 - if previous_version < 0: - raise ValueError("State version must advance exactly once") + raise ValueError("Invocation completion requires both values") + previous = state.version - 1 with self._lock, self._connection() as connection: connection.execute("BEGIN IMMEDIATE") try: - 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 = ?, 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, - json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None, - state.candidate_id, state.candidate_stage_id, - 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, - ), - ) + cursor = connection.execute(""" + UPDATE tasks SET phase=?,state_version=?,active_revision=?,repair_count=?,last_error=?,retry_from_phase=?, + requirements_path=?,authoring_path=?,runtime_cdsl_path=?,compile_audit_path=?,diagnostics_path=?,completion_path=?,clarification_path=?,updated_at=CURRENT_TIMESTAMP + WHERE task_id=? AND state_version=? + """, ( + state.phase.value, state.version, state.active_revision, state.repair_count, + state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "", + state.requirements_path, state.authoring_path, state.runtime_cdsl_path, state.compile_audit_path, + state.diagnostics_path, state.completion_path, state.clarification_path, state.task_id, previous, + )) if cursor.rowcount != 1: connection.execute("ROLLBACK") return False - for event in events: - encoded = json.dumps(event, ensure_ascii=True, sort_keys=True) - connection.execute("INSERT INTO ledger(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded)) - connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded)) - if invocation_id is not None and invocation_result is not None: - finished = connection.execute( - """UPDATE invocations - SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP - WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""", - (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id), - ) - if finished.rowcount != 1: - raise ValueError("Invocation is not an active record for this state transition") + for item in events: + encoded = json.dumps(item, ensure_ascii=True, sort_keys=True) + connection.execute("INSERT INTO ledger(task_id,event_json) VALUES (?,?)", (state.task_id, encoded)) + connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (state.task_id, encoded)) + if invocation_id is not None: + updated = connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND task_id=? AND status='processing'", (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id)) + if updated.rowcount != 1: + raise ValueError("Invocation is not active") connection.execute("COMMIT") return True except Exception: @@ -310,144 +195,92 @@ class SqliteTaskRepository: with self._lock, self._connection() as connection: connection.execute("BEGIN IMMEDIATE") try: - existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone() - if existing is not None: + row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND idempotency_key=?", (task_id, idempotency_key)).fetchone() + if row is None: + connection.execute("INSERT INTO invocations(invocation_id,task_id,idempotency_key,status) VALUES (?,?,?,'processing')", (invocation_id, task_id, idempotency_key)) connection.execute("COMMIT") - return self._invocation(existing) - connection.execute("INSERT INTO invocations(invocation_id, task_id, idempotency_key, status) VALUES (?, ?, ?, 'processing')", (invocation_id, task_id, idempotency_key)) + return InvocationRecord(invocation_id, idempotency_key, "processing") connection.execute("COMMIT") - return InvocationRecord(invocation_id, idempotency_key, "processing") + return self._invocation(row) except Exception: connection.execute("ROLLBACK") raise def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None: with self._lock, self._connection() as connection: - row = connection.execute( - "SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?", - (task_id, invocation_id), - ).fetchone() - return self._invocation(row) if row is not None else None + row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND invocation_id=?", (task_id, invocation_id)).fetchone() + return self._invocation(row) if row else None def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None: with self._lock, self._connection() as connection: - row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone() - if row is None: - raise ValueError("Unknown invocation") - if str(row["status"]) == "finished": - # A winning concurrent command has already recorded the only - # durable result for this idempotency key. Never overwrite it. - return - cursor = connection.execute( - "UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'", - (json.dumps(result, ensure_ascii=True), invocation_id), - ) - if cursor.rowcount != 1: - raise ValueError("Invocation could not be completed") + connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND status='processing'", (json.dumps(result, ensure_ascii=True), invocation_id)) def append_outbox(self, task_id: str, event: dict[str, Any]) -> None: with self._lock, self._connection() as connection: - connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True))) + connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True))) def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]: + query = "SELECT event_id,task_id,event_json FROM outbox WHERE published_at IS NULL" + args: tuple[Any, ...] = () + if task_id is not None: + query += " AND task_id=?" + args += (task_id,) + query += " ORDER BY event_id LIMIT ?" + args += (limit,) with self._lock, self._connection() as connection: - if task_id is None: - rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall() - else: - rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall() - return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows] + rows = connection.execute(query, args).fetchall() + return [{"event_id": int(row["event_id"]), "task_id": str(row["task_id"]), **json.loads(row["event_json"])} for row in rows] def mark_outbox_published(self, event_id: int) -> None: with self._lock, self._connection() as connection: - connection.execute("UPDATE outbox SET published_at = CURRENT_TIMESTAMP WHERE event_id = ? AND published_at IS NULL", (event_id,)) + connection.execute("UPDATE outbox SET published_at=CURRENT_TIMESTAMP WHERE event_id=? AND published_at IS NULL", (event_id,)) def record_usage(self, task_id: str, payload: dict[str, Any]) -> None: with self._lock, self._connection() as connection: - connection.execute("INSERT INTO usage_records(task_id, usage_json) VALUES (?, ?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True))) + connection.execute("INSERT INTO usage_records(task_id,usage_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True))) def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None: - """Persist a diagnostic structured-output audit separately from usage.""" with self._lock, self._connection() as connection: - connection.execute( - "INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)", - (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)), - ) + connection.execute("INSERT INTO tool_audits(task_id,audit_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True))) def tool_audits(self, task_id: str) -> list[dict[str, Any]]: with self._lock, self._connection() as connection: - rows = connection.execute( - "SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id", - (task_id,), - ).fetchall() - return [ - {"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} - for row in rows - ] + rows = connection.execute("SELECT audit_id,audit_json,created_at FROM tool_audits WHERE task_id=? ORDER BY audit_id", (task_id,)).fetchall() + return [{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} for row in rows] def usage_summary(self, task_id: str) -> dict[str, Any]: with self._lock, self._connection() as connection: - rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id = ? ORDER BY usage_id", (task_id,)).fetchall() - values = [json.loads(row["usage_json"]) for row in rows] - return { - "calls": len(values), - "prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)), - "completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)), - "context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)), - "records": values, - } + rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id=? ORDER BY usage_id", (task_id,)).fetchall() + return {"calls": len(rows), "records": [json.loads(row["usage_json"]) for row in rows]} def running_task_ids(self) -> list[str]: - phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY}) - placeholders = ", ".join("?" for _ in phases) + terminal = tuple(item.value for item in (TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER)) with self._lock, self._connection() as connection: - rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall() + rows = connection.execute("SELECT task_id FROM tasks WHERE phase NOT IN (?,?,?,?) ORDER BY created_at", terminal).fetchall() return [str(row["task_id"]) for row in rows] def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None: with self._lock, self._connection() as connection: - row = connection.execute("SELECT supported, report_json, checked_at FROM model_capabilities WHERE provider_id = ? AND model_id = ? AND schema_hash = ?", (provider_id, model_id, schema_hash)).fetchone() - return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None + row = connection.execute("SELECT supported,report_json,checked_at FROM model_capabilities WHERE provider_id=? AND model_id=? AND schema_hash=?", (provider_id, model_id, schema_hash)).fetchone() + return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": str(row["checked_at"])} if row else None def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None: with self._lock, self._connection() as connection: - connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(provider_id, model_id, schema_hash) DO UPDATE SET supported = excluded.supported, report_json = excluded.report_json, checked_at = CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True))) + connection.execute("""INSERT INTO model_capabilities(provider_id,model_id,schema_hash,supported,report_json) VALUES (?,?,?,?,?) + ON CONFLICT(provider_id,model_id,schema_hash) DO UPDATE SET supported=excluded.supported,report_json=excluded.report_json,checked_at=CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True))) @staticmethod def _state(row: sqlite3.Row) -> TaskState: - raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None - pending = PendingAction( - 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"]), - active_revision=str(row["active_revision"] or ""), pending_action=pending, - candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""), - repair_required=bool(row["repair_required"]), + active_revision=str(row["active_revision"]), repair_count=int(row["repair_count"]), last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None, retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None, - requirements_spec_path=str(row["requirements_spec_path"] or ""), - 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 ""), + requirements_path=str(row["requirements_path"]), authoring_path=str(row["authoring_path"]), + runtime_cdsl_path=str(row["runtime_cdsl_path"]), compile_audit_path=str(row["compile_audit_path"]), + diagnostics_path=str(row["diagnostics_path"]), completion_path=str(row["completion_path"]), clarification_path=str(row["clarification_path"]), ) - @staticmethod - 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, "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: return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None) @@ -460,6 +293,6 @@ class SqliteTaskRepository: return "failed" if phase == TaskPhase.CANCELLED: return "cancelled" - if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}: - return phase.value.lower() + if phase == TaskPhase.WAITING_FOR_USER: + return "waiting_for_user" return "running" diff --git a/backend/app/cad_agent/adapters/structured_llm.py b/backend/app/cad_agent/adapters/structured_llm.py index 53532ef8..f786905b 100644 --- a/backend/app/cad_agent/adapters/structured_llm.py +++ b/backend/app/cad_agent/adapters/structured_llm.py @@ -1,4 +1,4 @@ -"""The sole OpenAI-compatible structured-output adapter for protocol v3. +"""The sole OpenAI-compatible structured-output adapter for Authoring CDSL. Raw tool arguments are intentionally preserved. Callers must run their Pydantic/JSON-Schema canonical validator before causing any state transition. @@ -170,39 +170,12 @@ class StructuredModelGateway: @staticmethod def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]: - """Give capability probes the same selector fact production exposes. - - A required opaque selector is not inferable from a generic request for - a minimal example. Production fragment turns expose a topology token - before the fragment tool, so the probe must do the same while still - letting the provider generate every other schema field itself. - """ + """Probe the two standalone structured documents used by this protocol.""" function = tool.get("function") if isinstance(tool.get("function"), dict) else {} name = str(function.get("name") or "") - parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {} - feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None - feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {} - feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else [] - selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict) instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative." - if selector_required: - atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..." - instruction += ( - " The current topology exposes opaque selector token `sel_conformance`. " - f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)}," - "\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. " - "Because feature.selector_tokens is required, it is author input and must not be moved into params." - ) - if name == "review_candidate": - instruction += ( - " This is a candidate-review probe: include every required top-level and nested field; " - "in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them." - ) - elif name == "review_final": - instruction += ( - " This is a final-review probe: include every required top-level and nested field; " - "in particular provide a verdict and claim_coverage. Use pass only where the schema permits it." - ) + if name == "write_authoring_cdsl": + instruction += " Return cad.author.v1 with document-local names only; do not include runtime IDs, stable selectors, snapshots, or selector tokens." return [{"role": "system", "content": instruction}] def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]: @@ -256,7 +229,7 @@ class StructuredModelGateway: force_tool_name: bool = True, ) -> dict[str, Any]: if len(tools) != 1 or not required_tool_name: - raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.") + raise StructuredModelError("The Authoring protocol requires exactly one named tool per provider call.") request_options = provider.request_options if include_reasoning else {} if provider.api_style == "responses": payload: dict[str, Any] = { diff --git a/backend/app/cad_agent/adapters/verifier.py b/backend/app/cad_agent/adapters/verifier.py deleted file mode 100644 index aafd0e2f..00000000 --- a/backend/app/cad_agent/adapters/verifier.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Adapter exposing the pure verifier registry through the application port.""" - -from __future__ import annotations - -from typing import Any - -from app.cad_agent.domain.verifier_registry import VerifierRegistry - - -class RegistryVerifierExecutor: - def __init__(self, registry: VerifierRegistry) -> None: - self.registry = registry - - def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: - results: list[dict[str, Any]] = [] - for claim in claims: - result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts) - results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result}) - return results diff --git a/backend/app/cad_agent/application/__init__.py b/backend/app/cad_agent/application/__init__.py index 8eb3caf7..210f4a3d 100644 --- a/backend/app/cad_agent/application/__init__.py +++ b/backend/app/cad_agent/application/__init__.py @@ -1 +1 @@ -"""Application command handlers and context assembly for protocol v3.""" +"""Application workflow and compiler for the Authoring CDSL protocol.""" diff --git a/backend/app/cad_agent/application/action_handlers.py b/backend/app/cad_agent/application/action_handlers.py deleted file mode 100644 index 0f7294ba..00000000 --- a/backend/app/cad_agent/application/action_handlers.py +++ /dev/null @@ -1,1529 +0,0 @@ -"""Action selection, candidate, review, and completion command handlers.""" - -from __future__ import annotations - -from hashlib import sha256 -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, - GeometryConclusion, - NextAction, - RollbackCheckpoint, -) -from app.cad_agent.application.results import Accepted, Rejected -from app.cad_agent.domain.errors import ErrorCode, WorkflowError -from app.cad_agent.domain.operation_contract import canonical_hash, validate_fragment -from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState, reject_stale_head, transition -from app.cad_agent.ports import ArtifactStore, CadRuntime, TaskRepository, VerifierExecutor - - -class ActionCommandHandler: - def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, runtime: CadRuntime, verifiers: VerifierExecutor) -> None: - self.repository = repository - self.artifacts = artifacts - self.runtime = runtime - self.verifiers = verifiers - - def available_atomic_ids(self, task_id: str, state: TaskState | None = None) -> tuple[str, ...]: - """Return operations whose mandatory snapshot inputs exist now.""" - state = state or self.repository.get_state(task_id) - topology = self.artifacts.read_topology(task_id, state.active_revision) if state is not None else None - selector_tokens = self.runtime.selector_tokens(topology) - active_cdsl = self.artifacts.read_active_cdsl(task_id, state.active_revision) if state is not None else None - reference_tokens = self.runtime.reference_tokens(active_cdsl) - has_active_solid = isinstance(active_cdsl, dict) and bool(active_cdsl.get("features")) - available: list[str] = [] - for atomic_id in self.runtime.supported_atomic_ids(): - contract = self.runtime.operation_contract(atomic_id) - shape = contract.get("fragment_shape") if isinstance(contract.get("fragment_shape"), dict) else {} - if str(shape.get("selector_tokens") or "forbidden") == "required": - policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - required_kind = str(policy.get("token_kind") or "") - if not any(token.get("kind") == required_kind for token in selector_tokens.values() if isinstance(token, dict)): - continue - reference_policy = contract.get("reference_policy") if isinstance(contract.get("reference_policy"), dict) else {} - if reference_policy.get("mode") == "snapshot_bound": - minimum = int(reference_policy.get("min_items") or 1) - if len(reference_tokens) < minimum: - continue - if "requires_active_solid" in (contract.get("semantic_preflight") or ()) and not has_active_solid: - continue - 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: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A next action is not allowed in the current workflow state.")) - if not self.repair_action_ready(task_id, state): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Record a current geometry conclusion before selecting a repair action.")) - stale = reject_stale_head(state, proposal.working_head) - if stale: - return Rejected(stale) - contract = self._requirements_contract(task_id, state) - if not isinstance(contract, dict): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements contract is not frozen.")) - requirement_ids = {str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict)} - if not set(proposal.requirement_ids).issubset(requirement_ids): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Action references a requirement outside the frozen contract.")) - if proposal.atomic_id not in self.runtime.supported_atomic_ids(): - return Rejected(WorkflowError(ErrorCode.RUNTIME_CONTRACT_INVALID, "Action selects an operation absent from the verified runtime registry.")) - if proposal.atomic_id not in self.available_atomic_ids(task_id, state): - return Rejected(WorkflowError( - ErrorCode.AUTHOR_DECISION_REJECTED, - "Action requires selector or feature-reference facts unavailable from the current geometry snapshot.", - )) - operation = self.runtime.operation_contract(proposal.atomic_id) - key = self._key(task_id, "next_action", state.working_head, proposal.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - action = PendingAction( - action_id=f"act_{state.version + 1:03d}", working_head=state.working_head, intent=proposal.intent, - requirement_ids=tuple(proposal.requirement_ids), atomic_id=proposal.atomic_id, expected_change=proposal.expected_change, - contract_hash=str(operation["contract_hash"]), idempotency_key=key, - ) - next_state = transition(state, "action_proposed", pending_action=action) - payload = {"action_id": action.action_id, "working_head": action.working_head, "atomic_id": action.atomic_id, "contract_hash": action.contract_hash} - if not self._commit_invocation(next_state, [{"event": "proposed", **payload, "intent": action.intent, "requirement_ids": list(action.requirement_ids), "expected_change": action.expected_change}], invocation, payload): - return Rejected(self._stale()) - return Accepted(payload) - - def diagnostic_evidence_refs(self, task_id: str, state: TaskState | None = None) -> tuple[str, ...]: - """Return the server-generated evidence references for a repair turn. - - These are opaque names for current facts and committed audit evidence, - never model-provided paths or feature IDs. The command handler checks - them again so a schema from an earlier turn cannot authorize a write. - """ - state = state or self.repository.get_state(task_id) - if state is None: - return () - refs = ["evidence_current_state"] - if state.active_revision: - refs.extend(["evidence_current_model", "evidence_current_claims"]) - for event in self.repository.ledger_events(task_id)[-16:]: - if event.get("event") in { - "candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", - "candidate_recovered_rejected", "candidate_operation_skipped", "final_review_repair", - } or (event.get("event") == "accepted" and event.get("repair_required")): - sequence = event.get("sequence") - if isinstance(sequence, int): - refs.append(f"evidence_ledger_{sequence}") - return tuple(dict.fromkeys(refs)) - - def repair_diagnostics(self, task_id: str, state: TaskState | None = None) -> list[dict[str, Any]]: - """Return bounded, server-measured evidence from failed candidates. - - A repair turn runs against the last accepted checkpoint, while its - useful facts often live in an immutable rejected candidate stage. An - opaque ledger reference alone forces the author to rediscover those - facts or repeat a failed construction. This projection deliberately - exposes only compact build/verification outcomes, never the rejected - fragment or a server-selected next operation. - """ - state = state or self.repository.get_state(task_id) - if state is None or not state.repair_required: - return [] - relevant = { - "candidate_rejected", - "candidate_build_failed", - "candidate_recovery_failed", - "candidate_recovered_rejected", - "candidate_operation_skipped", - "runtime_precondition_rejected", - "final_review_repair", - "accepted", - } - diagnostics: list[dict[str, Any]] = [] - for event in reversed(self.repository.ledger_events(task_id)): - if event.get("event") not in relevant: - continue - sequence = event.get("sequence") - item: dict[str, Any] = { - "event": str(event.get("event") or ""), - "evidence_ref": f"evidence_ledger_{sequence}" if isinstance(sequence, int) else "evidence_current_state", - } - for field in ("candidate_id", "action_id"): - value = event.get(field) - if isinstance(value, str) and value: - item[field] = value - message = event.get("message") - if isinstance(message, str) and message: - item["message"] = message[:500] - for field in ("issues", "failed_checklist_items"): - values = event.get(field) - if isinstance(values, list): - item[field] = [str(value)[:500] for value in values[:8] if isinstance(value, str)] - failures = event.get("operation_failures") - if isinstance(failures, list): - item["operation_failures"] = [ - {key: str(value)[:500] for key, value in failure.items() if key in {"feature_id", "message"}} - for failure in failures[:8] - if isinstance(failure, dict) - ] - fragment_hash = event.get("fragment_hash") - if isinstance(fragment_hash, str) and fragment_hash: - item["fragment_hash"] = fragment_hash - stage_id = event.get("stage_id") - candidate = None - if isinstance(stage_id, str) and stage_id: - try: - candidate = self.artifacts.read_stage_json(task_id, stage_id, "candidate.json") - except (OSError, ValueError, json.JSONDecodeError): - # The ledger remains authoritative if an abandoned stage - # is unavailable; diagnostic projection must not turn an - # existing repair into a new service failure. - candidate = None - if isinstance(candidate, dict): - atomic_id = candidate.get("actual_atomic_id") - if isinstance(atomic_id, str) and atomic_id: - item["atomic_id"] = atomic_id - health = candidate.get("health") if isinstance(candidate.get("health"), dict) else {} - bbox = health.get("bbox_mm") if isinstance(health.get("bbox_mm"), dict) else {} - measured = { - key: health[key] - for key in ("solid_count", "feature_count", "volume_mm3") - if isinstance(health.get(key), (int, float)) - } - if isinstance(bbox.get("dimensions"), list): - measured["bbox_dimensions_mm"] = bbox["dimensions"][:3] - if measured: - item["measured"] = measured - claim_results = candidate.get("claim_results") - if isinstance(claim_results, list): - failures: list[dict[str, Any]] = [] - for claim in claim_results: - if not isinstance(claim, dict) or claim.get("status") not in {"fail", "unavailable"}: - continue - evidence = claim.get("evidence") if isinstance(claim.get("evidence"), dict) else {} - failures.append({ - "claim_id": str(claim.get("claim_id") or ""), - "claim_kind": str(claim.get("claim_kind") or ""), - "status": str(claim.get("status") or ""), - "evidence": evidence, - }) - if failures: - item["failed_claims"] = failures[:8] - operation_results = candidate.get("operation_verifier_results") - if isinstance(operation_results, list): - operation_blockers: list[dict[str, Any]] = [] - for result in operation_results: - if not isinstance(result, dict) or result.get("status") == "pass": - continue - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - operation_blockers.append({ - "claim_id": str(result.get("claim_id") or ""), - "claim_kind": str(result.get("claim_kind") or ""), - "status": str(result.get("status") or ""), - "evidence": evidence, - }) - if operation_blockers: - # An operation may be rejected because it produced no - # net change. That result is often pending relative - # to the full requirements contract, but it is still - # a definite blocker for this specific action. - item["operation_blockers"] = operation_blockers[:8] - review = None - if isinstance(stage_id, str) and stage_id: - try: - review = self.artifacts.read_stage_json(task_id, stage_id, "candidate-review.json") - except (OSError, ValueError, json.JSONDecodeError): - review = None - if isinstance(review, dict): - evidence = review.get("evidence") - issues = review.get("issues") - if isinstance(evidence, list): - item["review_evidence"] = [str(value)[:500] for value in evidence[:8] if isinstance(value, str)] - if isinstance(issues, list): - item["review_issues"] = [str(value)[:500] for value in issues[:8] if isinstance(value, str)] - diagnostics.append(item) - if len(diagnostics) == 3: - break - return diagnostics - - def checkpoint_tokens(self, task_id: str, state: TaskState | None = None) -> dict[str, str]: - """Map opaque rollback tokens to the current linear checkpoint lineage.""" - state = state or self.repository.get_state(task_id) - if state is None: - return {} - accepted = [ - event for event in self.repository.ledger_events(task_id) - if event.get("event") == "accepted" and isinstance(event.get("revision_id"), str) - ] - by_revision = {str(event["revision_id"]): event for event in accepted} - if state.active_revision and state.active_revision not in by_revision: - return {} - lineage: list[str] = [] - cursor = state.active_revision - while cursor: - event = by_revision.get(cursor) - if event is None or cursor in lineage: - return {} - lineage.append(cursor) - parent = event.get("parent_revision") - if isinstance(parent, str): - cursor = parent - continue - # v3 events written before parent_revision existed were linear. - position = accepted.index(event) - cursor = str(accepted[position - 1]["revision_id"]) if position else "" - lineage.reverse() - return {"checkpoint_root": "", **{f"checkpoint_{revision}": revision for revision in lineage}} - - def rollback_available(self, task_id: str, state: TaskState | None = None) -> bool: - state = state or self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None: - return False - has_earlier_checkpoint = any( - revision != state.active_revision - for revision in self.checkpoint_tokens(task_id, state).values() - ) - return has_earlier_checkpoint and any( - event.get("event") == "geometry_conclusion" - and event.get("decision") == "rollback" - and event.get("working_head") == state.working_head - for event in reversed(self.repository.ledger_events(task_id)) - ) - - def repair_action_ready(self, task_id: str, state: TaskState | None = None) -> bool: - """Require a structured diagnosis before repairing failed geometry. - - A rollback is itself a resolved repair decision. For a new feature, - the author must instead record a return-to-action-selection conclusion - after the latest failed candidate. The ordering matters: a diagnosis - used to select a prior repair action cannot authorize retries after - that new action fails on the same checkpoint. - """ - state = state or self.repository.get_state(task_id) - if state is None or not state.repair_required: - return True - repair_triggers = { - "candidate_rejected", - "candidate_build_failed", - "candidate_recovery_failed", - "candidate_recovered_rejected", - "final_review_repair", - } - for event in reversed(self.repository.ledger_events(task_id)): - event_name = event.get("event") - if event_name == "rollback": - return True - if event_name == "geometry_conclusion" and event.get("decision") == "return_to_action_selection": - return True - if event_name in repair_triggers: - return False - return False - - def record_geometry_conclusion(self, task_id: str, conclusion: GeometryConclusion, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None or state.phase not in {TaskPhase.ACTION_PENDING, TaskPhase.AWAITING_ACTION}: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Geometry diagnosis is only available while repairing or selecting an action.")) - stale = reject_stale_head(state, conclusion.working_head) - if stale: - return Rejected(stale) - available = set(self.diagnostic_evidence_refs(task_id, state)) - if not set(conclusion.evidence_refs).issubset(available): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Geometry conclusion references evidence outside the current server snapshot.")) - if conclusion.decision == "rollback" and state.phase != TaskPhase.AWAITING_ACTION: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Return to action selection before requesting a checkpoint rollback.")) - key = self._key(task_id, "geometry_conclusion", state.working_head, conclusion.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - if conclusion.decision == "return_to_action_selection" and state.phase == TaskPhase.ACTION_PENDING: - next_state = transition(state, "diagnosis_return_to_action_selection", repair_required=True) - else: - next_state = transition(state, "diagnosis_recorded", repair_required=True) - event = { - "event": "geometry_conclusion", - "working_head_before": state.working_head, - "working_head": next_state.working_head, - "evidence_refs": list(conclusion.evidence_refs), - "root_cause": conclusion.root_cause, - "decision": conclusion.decision, - "corrective_intent": conclusion.corrective_intent or "", - } - result = {"decision": conclusion.decision, "phase": next_state.phase.value, "working_head": next_state.working_head} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def rollback_checkpoint(self, task_id: str, rollback: RollbackCheckpoint, *, 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: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Checkpoint rollback requires an idle action-selection state.")) - stale = reject_stale_head(state, rollback.working_head) - if stale: - return Rejected(stale) - if not self.rollback_available(task_id, state): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Record a current geometry conclusion with decision=rollback before rolling back.")) - target = self.checkpoint_tokens(task_id, state).get(rollback.checkpoint_token) - if target is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Rollback checkpoint token is not in the active lineage.")) - if target == state.active_revision: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Rollback must select an earlier checkpoint.")) - key = self._key(task_id, "rollback", state.working_head, rollback.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - next_state = transition(state, "rollback", active_revision=target, repair_required=True) - event = { - "event": "rollback", - "working_head_before": state.working_head, - "working_head": next_state.working_head, - "checkpoint_token": rollback.checkpoint_token, - "rollback_from_revision": state.active_revision, - "rollback_to_revision": target, - "reason": rollback.reason, - } - result = {"status": "rolled_back", "active_revision": target, "working_head": next_state.working_head} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - 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.")) - contract = self.runtime.operation_contract(action.atomic_id) - if contract["contract_hash"] != action.contract_hash: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The pending operation contract changed; read the current contract again.")) - base = self.artifacts.read_active_cdsl(task_id, state.active_revision) - topology = self.artifacts.read_topology(task_id, state.active_revision) - selectors = self.runtime.selector_tokens(topology) - references = self.runtime.reference_tokens(base) - selector_policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - expected_selector_kind = str(selector_policy.get("token_kind") or "") - allowed_selector_tokens = ( - [token for token, value in selectors.items() if isinstance(value, dict) and value.get("kind") == expected_selector_kind] - if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required" - else [] - ) - errors = validate_fragment( - contract, - fragment, - selector_tokens=allowed_selector_tokens, - reference_tokens=list(references), - root_xy_datum=not bool(state.active_revision), - ) - if errors: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "CDSL fragment violates the active operation schema.", field_errors=tuple(errors))) - fragment_hash = canonical_hash(fragment) - exact_fingerprint = canonical_hash({ - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - }) - prior_exact = next(( - event for event in reversed(self.repository.ledger_events(task_id)) - if event.get("failure_exact_fingerprint") == exact_fingerprint - ), None) - if prior_exact is not None: - return Rejected(WorkflowError( - ErrorCode.RUNTIME_PRECONDITION_FAILED, - "This exact CDSL fragment was already proven to fail at the current checkpoint; choose a different fragment or operation path.", - details={ - "duplicate_fragment": True, - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": str(prior_exact.get("normalized_error_code") or ErrorCode.RUNTIME_PRECONDITION_FAILED.value), - }, - )) - key = self._key(task_id, "fragment", action.working_head, {"action_id": action.action_id, "fragment": fragment}) - try: - require_through = self._action_requires_through(task_id, action.requirement_ids) - cdsl, audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=require_through) - except Exception as error: - runtime_error = self._runtime_error(error) - return Rejected(WorkflowError( - runtime_error.code, - runtime_error.message, - field_errors=runtime_error.field_errors, - retryable=runtime_error.retryable, - details={ - **runtime_error.details, - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": runtime_error.code.value, - }, - )) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - candidate_id = "candidate_" + sha256(key.encode("utf-8")).hexdigest()[:16] - try: - stage = self.artifacts.start_candidate_stage(task_id, key, {"schema_version": "cad.v3.candidate-input.v1", "idempotency_key": key, "candidate_id": candidate_id, "action_id": action.action_id, "working_head": action.working_head, "contract_hash": action.contract_hash, "fragment": fragment, "fragment_audit": audit}) - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_stage_storage_failure", - message=str(error), - ) - building = transition(state, "candidate_started", candidate_id=candidate_id, candidate_stage_id=stage.stage_id) - if not self.repository.compare_and_swap(building, events=[{"event": "candidate_building", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "fragment_hash": audit["fragment_hash"]}]): - return Rejected(self._stale()) - try: - rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, stage.output_dir, task_id, candidate_id) - attempted_feature_ids = {str(value) for value in audit.get("assigned_feature_ids") or () if isinstance(value, str)} - executed_feature_ids = {str(value) for value in rebuilt.get("executed_feature_ids") or () if isinstance(value, str)} - if attempted_feature_ids and not attempted_feature_ids.intersection(executed_feature_ids): - next_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - result = {"candidate_id": candidate_id, "status": "skipped", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "operation_failures": operation_failures} - if not self._commit_invocation(next_state, [{ - "event": "candidate_operation_skipped", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "operation_failures": operation_failures, - "message": "The submitted feature did not execute; earlier executable features were retained.", - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError( - ErrorCode.CANDIDATE_BUILD_FAILED, - "The submitted feature could not execute; the previous executable checkpoint was retained.", - details={"operation_failures": operation_failures}, - )) - try: - claim_results = self._evaluate_claims(task_id, rebuilt) - except Exception as error: - failed_state = transition(building, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID) - result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value} - if not self._commit_invocation(failed_state, [{ - "event": "requirements_contract_execution_failed", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "message": str(error)[:1000], - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError( - ErrorCode.REQUIREMENTS_SPEC_INVALID, - "The frozen requirements contract could not be evaluated; no CAD repair was attempted.", - details={"diagnostic": str(error)[:1000]}, - )) - operation_results = self._operation_candidate_results( - action, - contract, - cdsl, - rebuilt, - parent_facts=self._facts(task_id, state.active_revision), - require_through=require_through, - ) - blockers = [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if item.get("status") != "pass"], - ] - candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "operation_failures": operation_failures, "executed_feature_ids": rebuilt.get("executed_feature_ids") or [], "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]} - self.artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate) - review = transition(building, "candidate_built", candidate_id=candidate_id, candidate_stage_id=stage.stage_id) - result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results, "operation_failures": operation_failures} - if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results, "operation_failures": operation_failures}], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - except OSError as error: - return self._park_retry( - building, - ErrorCode.STORAGE_FAILURE, - event="candidate_build_storage_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id}, - ) - except Exception as error: - if "RENDER_SERVICE_UNAVAILABLE" in str(error): - return self._park_retry( - building, - ErrorCode.RENDER_SERVICE_UNAVAILABLE, - event="candidate_render_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id}, - ) - if "RUNTIME_EXECUTION_FAILURE" in str(error): - return self._park_retry( - building, - ErrorCode.RUNTIME_EXECUTION_FAILURE, - event="candidate_runtime_execution_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id, "checkpoint_revision": state.active_revision}, - ) - failed_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value} - if not self._commit_invocation(failed_state, [{ - "event": "candidate_build_failed", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "normalized_error_code": ErrorCode.CANDIDATE_BUILD_FAILED.value, - "failure_exact_fingerprint": exact_fingerprint, - "message": str(error)[:1000], - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError(ErrorCode.CANDIDATE_BUILD_FAILED, "Candidate build failed; the checkpoint remains unchanged.", details={"diagnostic": str(error)[:1000]})) - - def recover_candidate_build(self, task_id: str) -> Accepted | Rejected: - """Resume one persisted candidate build without inventing another action. - - A complete staging directory is converted to the next state directly. - If a process stopped mid-build, the same stage/idempotency key is used - and an already-written CDSL document is rebuilt in place. - """ - state = self.repository.get_state(task_id) - action = state.pending_action if state else None - if state is None or state.phase != TaskPhase.CANDIDATE_BUILDING or action is None or not state.candidate_stage_id: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "There is no recoverable candidate build.")) - try: - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - if isinstance(candidate, dict): - return self._advance_recovered_candidate(task_id, state, candidate) - source = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "input.json") - if not isinstance(source, dict) or source.get("candidate_id") != state.candidate_id: - return self._park_for_storage_retry( - state, - event="candidate_recovery_storage_failure", - message="Candidate recovery input is unavailable.", - ) - report = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "rebuild-report.json") - topology = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "model.topology.json") - cdsl = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "model.cdsl.json") - if isinstance(report, dict) and isinstance(topology, dict) and isinstance(cdsl, dict): - rebuilt = { - "health": report.get("health") or {}, "topology": topology, "report": report, - "render_manifest": report.get("render_manifest") or {}, - "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"}, - } - else: - base = self.artifacts.read_active_cdsl(task_id, state.active_revision) - fragment = source.get("fragment") - contract = self.runtime.operation_contract(action.atomic_id) - if not isinstance(fragment, dict) or contract.get("contract_hash") != action.contract_hash: - raise RuntimeError("RUNTIME_PRECONDITION_FAILED: candidate recovery contract is stale") - selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) - references = self.runtime.reference_tokens(base) - cdsl, _audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=self._action_requires_through(task_id, action.requirement_ids)) - rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id) - if "operation_failures" not in locals(): - operation_failures = [] - claim_results = self._evaluate_claims(task_id, rebuilt) - audit = source.get("fragment_audit") if isinstance(source.get("fragment_audit"), dict) else {} - contract = self.runtime.operation_contract(action.atomic_id) - operation_results = self._operation_candidate_results( - action, - contract, - cdsl, - rebuilt, - parent_facts=self._facts(task_id, state.active_revision), - require_through=self._action_requires_through(task_id, action.requirement_ids), - ) - candidate = { - "schema_version": "cad.v3.candidate.v1", "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, "action_id": action.action_id, - "working_head": action.working_head, "actual_atomic_id": action.atomic_id, - "fragment_hash": audit.get("fragment_hash") or canonical_hash(source.get("fragment") or {}), - "selector_snapshot_id": audit.get("selector_snapshot_id") or "", "claim_results": claim_results, - "operation_verifier_results": operation_results, - "operation_failures": operation_failures, - "blockers": [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if item.get("status") != "pass"], - ], - "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"], - } - self.artifacts.write_stage_json(task_id, state.candidate_stage_id, "candidate.json", candidate) - return self._advance_recovered_candidate(task_id, state, candidate) - except OSError as error: - return self._park_for_storage_retry(state, event="candidate_recovery_storage_failure", message=str(error)) - except Exception as error: - if "RENDER_SERVICE_UNAVAILABLE" in str(error): - return self._park_retry( - state, - ErrorCode.RENDER_SERVICE_UNAVAILABLE, - event="candidate_recovery_render_failure", - message=str(error), - details={"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id}, - ) - if "RUNTIME_EXECUTION_FAILURE" in str(error): - return self._park_retry( - state, - ErrorCode.RUNTIME_EXECUTION_FAILURE, - event="candidate_recovery_runtime_execution_failure", - message=str(error), - details={"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "checkpoint_revision": state.active_revision}, - ) - failed = transition(state, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - self.repository.compare_and_swap(failed, events=[{ - "event": "candidate_recovery_failed", - "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "message": str(error)[:1000], - }]) - return Accepted({"candidate_id": state.candidate_id, "status": "failed", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "diagnostic": str(error)[:1000]}) - - def record_candidate_review(self, task_id: str, review: CandidateReview, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - action = state.pending_action if state else None - if state is None or state.phase != TaskPhase.CANDIDATE_REVIEW or action is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Candidate review is not expected in the current workflow phase.")) - if review.candidate_id != state.candidate_id or review.working_head != action.working_head: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Candidate review references a stale candidate or head.")) - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - published = None if isinstance(candidate, dict) else self.artifacts.find_published_candidate(task_id, state.candidate_stage_id) - published_revision = published[0] if published else "" - if published: - candidate = published[1] - if not isinstance(candidate, dict): - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review evidence is unavailable.", retryable=True)) - actual = candidate.get("claim_results") if isinstance(candidate.get("claim_results"), list) else [] - expected_ids = {str(item.get("claim_id") or "") for item in actual if isinstance(item, dict)} - submitted = {item.claim_id: item.status for item in review.claim_coverage} - submitted_ids = set(submitted) - if submitted_ids != expected_ids or len(submitted_ids) != len(review.claim_coverage): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Candidate review must cover exactly the current claim set.")) - mismatched_deterministic = [ - str(item.get("claim_id") or "") - for item in actual - if isinstance(item, dict) - and item.get("deterministic") - and submitted.get(str(item.get("claim_id") or "")) != item.get("status") - ] - if mismatched_deterministic: - return Rejected(WorkflowError( - ErrorCode.AUTHOR_FORMAT_INVALID, - "Candidate review must report the server's deterministic claim status exactly.", - details={"claim_ids": mismatched_deterministic}, - )) - deterministic_fail = [item for item in actual if isinstance(item, dict) and item.get("deterministic") and item.get("status") in {"fail", "unavailable"}] - key = self._key(task_id, "candidate_review", action.working_head, review.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - review_payload = review.model_dump(mode="json") - try: - if published_revision: - self.artifacts.write_json_once(task_id, f"revisions/{published_revision}/candidate-review.json", review_payload) - else: - self.artifacts.write_stage_json(task_id, state.candidate_stage_id, "candidate-review.json", review_payload) - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_review_storage_failure", - message=str(error), - ) - operation_failures = candidate.get("operation_failures") if isinstance(candidate.get("operation_failures"), list) else [] - candidate_blockers = candidate.get("blockers") if isinstance(candidate.get("blockers"), list) else [] - needs_repair = review.verdict != "accept" or bool(deterministic_fail) or bool(operation_failures) or bool(candidate_blockers) - revision_id = self._next_revision(task_id) - if published_revision and published_revision != revision_id: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Published candidate revision does not match the current checkpoint lineage.", retryable=True)) - try: - paths = self.artifacts.publish_candidate(task_id, state.candidate_stage_id, revision_id) if not published_revision else {} - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_publish_storage_failure", - message=str(error), - ) - next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=needs_repair, error=ErrorCode.CLAIM_VERIFICATION_FAILED if needs_repair else None) - event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual, "issues": list(review.issues), "operation_failures": operation_failures, "repair_required": needs_repair} - result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted_with_issues" if needs_repair else "accepted"} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def recover_candidate_review(self, task_id: str) -> Accepted | Rejected | None: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.CANDIDATE_REVIEW or not state.candidate_stage_id: - return None - raw = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate-review.json") - if raw is None: - published = self.artifacts.find_published_candidate(task_id, state.candidate_stage_id) - raw = self.artifacts.read_json(task_id, f"revisions/{published[0]}/candidate-review.json") if published else None - if raw is None: - return None - try: - review = CandidateReview.model_validate(raw) - except ValueError as error: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Persisted candidate review is invalid.", details={"diagnostic": str(error)[:1000]})) - return self.record_candidate_review(task_id, review, invocation_id=f"{task_id}_recover_candidate_{secrets.token_hex(8)}") - - def complete_task(self, task_id: str, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task completion is not available for an unknown task.")) - # Completion idempotency is revision-scoped. A duplicate request after - # final validation or publication replays the existing result and can - # never start a second final rebuild/review. - key = self._key(task_id, "complete", state.active_revision, {}) - if state.phase == TaskPhase.FINAL_VALIDATION and state.active_revision: - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Final validation is already in progress for this revision.")) - if state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None or state.repair_required: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task completion is not available until there is no pending action or repair.")) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - deterministic = [item for item in claim_results if item.get("deterministic")] - if not state.active_revision or any(item.get("status") != "pass" for item in deterministic): - return Rejected(WorkflowError(ErrorCode.CLAIM_VERIFICATION_FAILED, "All deterministic claims must pass before final review.", details={"claim_results": claim_results})) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - next_state = transition(state, "final_requested") - result = {"revision_id": state.active_revision, "working_head": next_state.working_head, "claim_results": claim_results} - if not self._commit_invocation(next_state, [{"event": "final_validation_requested", "revision_id": state.active_revision, "claim_results": claim_results}], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def recover_final_review(self, task_id: str) -> Accepted | Rejected | None: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.FINAL_VALIDATION or not state.active_revision: - return None - raw = self.artifacts.read_json(task_id, self._final_review_path(state.active_revision)) - if raw is None: - return None - try: - review = FinalReview.model_validate(raw) - except ValueError as error: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Persisted final review is invalid.", details={"diagnostic": str(error)[:1000]})) - return self.record_final_review(task_id, review, invocation_id=f"{task_id}_recover_final_{secrets.token_hex(8)}") - - def _advance_recovered_candidate(self, task_id: str, state: TaskState, candidate: dict[str, Any]) -> Accepted | Rejected: - action = state.pending_action - if action is None or candidate.get("candidate_id") != state.candidate_id or candidate.get("action_id") != action.action_id: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate recovery evidence does not match the pending action.", retryable=True)) - claim_results = candidate.get("claim_results") if isinstance(candidate.get("claim_results"), list) else [] - operation_results = candidate.get("operation_verifier_results") if isinstance(candidate.get("operation_verifier_results"), list) else [] - blockers = [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if isinstance(item, dict) and item.get("status") != "pass"], - ] - key = str((self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "input.json") or {}).get("idempotency_key") or "") - invocation = self.repository.begin_invocation(task_id, f"{task_id}_recover_build_{secrets.token_hex(8)}", key) if key else None - if invocation is not None and invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - review = transition(state, "candidate_built", candidate_id=state.candidate_id, candidate_stage_id=state.candidate_stage_id) - result = {"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "status": "awaiting_review", "claim_results": claim_results} - committed = self._commit_invocation(review, [{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result) if invocation is not None else self.repository.compare_and_swap(review, events=[{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}]) - if not committed: - return Rejected(self._stale()) - return Accepted(result) - - def finalize_best_effort(self, task_id: str, *, reason: ErrorCode, invocation_id: str) -> Accepted | Rejected: - """Publish the last executable checkpoint when further repair is bounded. - - This is deliberately separate from strict final validation. It does - not claim that unmet acceptance targets passed; it makes the usable - model and its measured gaps durable instead of converting a planning - dead-end into a failed task with no deliverable. - """ - state = self.repository.get_state(task_id) - if state is None or not state.active_revision: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Best-effort completion requires an executable checkpoint.")) - if state.phase == TaskPhase.COMPLETED: - return Accepted({"status": "completed", "revision_id": state.active_revision}) - key = self._key(task_id, "best_effort_complete", state.active_revision, {"reason": reason.value}) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - issues = [ - f"{item.get('claim_kind')}: {item.get('status')}" - for item in claim_results - if item.get("status") != "pass" - ] - next_state = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False) - result = {"status": "completed_with_warnings", "revision_id": state.active_revision, "claim_results": claim_results, "issues": issues} - if not self._commit_invocation(next_state, [{ - "event": "completed_best_effort", - "revision_id": state.active_revision, - "termination_code": reason.value, - "message": "Further CAD repair was bounded; the last executable checkpoint was published.", - "issues": issues, - "claim_results": claim_results, - "completion_result_path": "completion-result.md", - }], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def record_final_review(self, task_id: str, review: FinalReview, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.FINAL_VALIDATION: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Final review is not expected in the current workflow phase.")) - stale = reject_stale_head(state, review.working_head) - if stale: - return Rejected(stale) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - expected_ids = {str(item.get("claim_id") or "") for item in claim_results} - submitted = {item.claim_id: item.status for item in review.claim_coverage} - submitted_ids = set(submitted) - if submitted_ids != expected_ids or len(submitted_ids) != len(review.claim_coverage): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Final review must cover exactly the final claim set.")) - mismatched_deterministic = [ - str(item.get("claim_id") or "") - for item in claim_results - if item.get("deterministic") - and submitted.get(str(item.get("claim_id") or "")) != item.get("status") - ] - if mismatched_deterministic: - return Rejected(WorkflowError( - ErrorCode.AUTHOR_FORMAT_INVALID, - "Final review must report the server's deterministic claim status exactly.", - details={"claim_ids": mismatched_deterministic}, - )) - deterministic_fail = [item for item in claim_results if item.get("deterministic") and item.get("status") != "pass"] - review_coverage = submitted - visual_not_passed = [ - item - for item in claim_results - if not item.get("deterministic") and review_coverage.get(str(item.get("claim_id") or "")) != "pass" - ] - key = self._key(task_id, "final_review", state.working_head, review.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - final_review_path = self._final_review_path(state.active_revision) - try: - self.artifacts.write_json_once(task_id, final_review_path, review.model_dump(mode="json")) - except OSError as error: - return self._park_for_storage_retry( - state, - event="final_review_storage_failure", - message=str(error), - ) - if review.verdict != "pass" or deterministic_fail or visual_not_passed: - accepted_fragment = next(( - event.get("fragment_hash") for event in reversed(self.repository.ledger_events(task_id)) - if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision - ), "") - atomic_id = next(( - str(event.get("actual_atomic_id") or "") for event in reversed(self.repository.ledger_events(task_id)) - if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision - ), "") - exact_fingerprint = canonical_hash({ - "active_revision": state.active_revision, - "atomic_id": atomic_id, - "fragment_hash": accepted_fragment, - }) if accepted_fragment and atomic_id else "" - 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_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], - "issues": list(review.issues), - "evidence": list(review.evidence), - "failed_checklist_items": [ - str(requirement.get("statement") or "") - for requirement in (self._requirements_contract(task_id) or {}).get("requirements") or () - if isinstance(requirement, dict) - and any(str(claim.get("claim_id") or "") in {str(item.get("claim_id") or "") for item in deterministic_fail + visual_not_passed} for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)) - ], - "atomic_id": atomic_id, - "fragment_hash": accepted_fragment, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED.value, - }], invocation, result): - return Rejected(self._stale()) - 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": "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], *, 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) 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 [ - { - "claim_id": str(item.get("claim_id") or ""), - "claim_kind": str(item.get("claim_kind") or ""), - "deterministic": bool(item.get("deterministic")), - "status": str(item.get("status") or "pending"), - "evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {}, - } - for item in self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - if isinstance(item, dict) - ] - - def model_summary(self, task_id: str, state: TaskState) -> dict[str, Any]: - """Expose measurements, not raw topology, in recurring author turns.""" - if not state.active_revision: - return {"revision_id": "", "available": False} - facts = self._facts(task_id, state.active_revision) - health = facts.get("health") if isinstance(facts.get("health"), dict) else {} - topology = facts.get("topology") if isinstance(facts.get("topology"), dict) else {} - counts: dict[str, int] = {} - inner_cylindrical_bores: list[dict[str, Any]] = [] - for record in topology.get("records") or (): - if isinstance(record, dict) and isinstance(record.get("kind"), str): - kind = record["kind"] - counts[kind] = counts.get(kind, 0) + 1 - geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {} - radius = geometry.get("radius_mm") - axis_origin = geometry.get("axis_origin_mm") - if ( - geometry.get("surface_type") == "cylinder" - and geometry.get("cylinder_role") == "inner" - and isinstance(radius, (int, float)) - and isinstance(axis_origin, list) - and len(axis_origin) == 3 - and all(isinstance(value, (int, float)) for value in axis_origin) - ): - inner_cylindrical_bores.append({ - "diameter_mm": round(float(radius) * 2, 6), - "axis_origin_mm": [round(float(value), 6) for value in axis_origin], - "through": bool(geometry.get("through")), - }) - bbox = health.get("bbox_mm") if isinstance(health.get("bbox_mm"), dict) else {} - return { - "revision_id": state.active_revision, - "available": bool(health or topology), - "solid_count": health.get("solid_count"), - "bbox_dimensions_mm": bbox.get("dimensions"), - "topology_snapshot_id": str(topology.get("snapshot_id") or ""), - "topology_counts": counts, - # These are compact measured facts, not author-controlled CDSL. - # They make duplicate or misplaced hole repairs observable without - # injecting the full topology snapshot into every author turn. - "inner_cylindrical_bores": inner_cylindrical_bores[:16], - } - - def _facts(self, task_id: str, revision_id: str) -> dict[str, Any]: - if not revision_id: - return {} - report = self.artifacts.read_json(task_id, f"revisions/{revision_id}/rebuild-report.json") or {} - return {"health": report.get("health") or {}, "topology": self.artifacts.read_topology(task_id, revision_id) or {}, "report": report} - - def _action_requires_through(self, task_id: str, requirement_ids: tuple[str, ...]) -> bool: - contract = self._requirements_contract(task_id) or {} - return any(claim.get("claim_kind") == "through_cylindrical_bore" for requirement in contract.get("requirements") or () if isinstance(requirement, dict) and requirement.get("requirement_id") in requirement_ids for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)) - - def _candidate_blockers(self, task_id: str, action_requirements: tuple[str, ...], results: list[dict[str, Any]]) -> list[dict[str, Any]]: - # Global connected-body failure always rejects; action-linked claims - # must not already be false, while future-work claims may remain pending. - return [item for item in results if item.get("status") in {"fail", "unavailable"} and (item.get("claim_kind") in {"solid_count_equals", "single_connected_body"} or item.get("claim_id", "") in self._claim_ids_for_requirements(task_id, action_requirements))] - - def _operation_candidate_results( - self, - action: PendingAction, - contract: dict[str, Any], - cdsl: dict[str, Any], - rebuilt: dict[str, Any], - *, - parent_facts: dict[str, Any], - require_through: bool, - ) -> list[dict[str, Any]]: - """Run the verified operation-level acceptance checks after rebuild. - - Requirement claims prove the user contract. These checks separately - prove that an operation declared in the runtime registry actually made - the kind of change it advertises, even when a user did not include a - matching claim. A blind-hole action does not require the through-bore - verifier unless its linked requirement explicitly requests through - topology. - """ - features = cdsl.get("features") if isinstance(cdsl.get("features"), list) else [] - feature = next( - (item for item in reversed(features) if isinstance(item, dict) and item.get("atomic_id") == action.atomic_id), - {}, - ) - params = feature.get("params") if isinstance(feature, dict) and isinstance(feature.get("params"), dict) else {} - claims: list[dict[str, Any]] = [] - for claim_kind in contract.get("candidate_verifiers") or (): - if claim_kind == "through_cylindrical_bore" and not require_through: - continue - expected: dict[str, Any] - if claim_kind in {"cylindrical_bore", "through_cylindrical_bore"}: - # 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"}}] - increment = len(positions) if isinstance(positions, list) else 1 - prior_count = self._existing_bore_count(claim_kind, float(diameter), parent_facts) - expected = { - "diameter_mm": float(diameter), - # Candidate topology represents the full model, not only - # the feature just submitted. Therefore the operation - # proof must compare against the parent checkpoint plus - # this action's declared number of positions. - "count": prior_count + increment, - "tolerance_mm": 0.01, - } - elif claim_kind in {"single_connected_body", "volume_decreased"}: - expected = {} - else: - return [{"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": str(claim_kind), "deterministic": True, "status": "unavailable", "evidence": {"reason": "operation verifier has no runtime expected-value binding"}}] - claims.append({"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": claim_kind, "expected": expected}) - facts = { - "health": rebuilt.get("health") or {}, - "parent_health": parent_facts.get("health") or {}, - "topology": rebuilt.get("topology") or {}, - "report": rebuilt.get("report") or {}, - } - results = self.verifiers.evaluate(claims, facts) - for result, claim in zip(results, claims, strict=True): - expected = claim.get("expected") if isinstance(claim.get("expected"), dict) else {} - if claim.get("claim_kind") not in {"cylindrical_bore", "through_cylindrical_bore"}: - continue - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - evidence = dict(evidence) - requested_total = expected.get("count") - if isinstance(requested_total, int): - positions = params.get("positions") - increment = len(positions) if isinstance(positions, list) else 1 - evidence.update({ - "parent_matching_count": requested_total - increment, - "expected_increment": increment, - "expected_total_count": requested_total, - }) - result["evidence"] = evidence - return results - - def _existing_bore_count(self, claim_kind: str, diameter_mm: float, parent_facts: dict[str, Any]) -> int: - """Measure same-diameter bores in the parent with registry semantics. - - The registry owns topology coalescing, including periodic faces from a - single analytic circle. Asking it for one instance provides an exact - count for populated parent geometry without duplicating B-rep logic in - the workflow layer. - """ - topology = parent_facts.get("topology") if isinstance(parent_facts.get("topology"), dict) else {} - if not isinstance(topology.get("records"), list) or not topology["records"]: - return 0 - result = self.verifiers.evaluate([{ - "claim_id": "operation_parent_bore_count", - "claim_kind": claim_kind, - "expected": {"diameter_mm": diameter_mm, "count": 1, "tolerance_mm": 0.01}, - }], parent_facts)[0] - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - actual_count = evidence.get("actual_count") - if isinstance(actual_count, int): - return actual_count - matched = evidence.get("matched_cylindrical_faces") - if isinstance(matched, list): - return len(matched) - through = evidence.get("through_bores") - if isinstance(through, list): - return len(through) - return 0 - - def _claim_ids_for_requirements(self, task_id: str, requirement_ids: tuple[str, ...]) -> set[str]: - contract = self._requirements_contract(task_id) or {} - return { - str(claim.get("claim_id") or "") - for requirement in contract.get("requirements") or () - if isinstance(requirement, dict) and requirement.get("requirement_id") in requirement_ids - for claim in requirement.get("acceptance_claims") or () - if isinstance(claim, dict) - } - - def _next_revision(self, task_id: str) -> str: - """Allocate a monotonically increasing immutable revision ID. - - Rollback changes the active lineage but never reuses an artifact - directory. The ledger is the authority for already allocated IDs, - including a directory published just before an interrupted CAS. - """ - values = [ - int(str(event.get("revision_id") or "").removeprefix("rev_")) - for event in self.repository.ledger_events(task_id) - 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() - ] - return f"rev_{max(values, default=0) + 1:03d}" - - def _requirements_contract(self, task_id: str, state: TaskState | None = None) -> dict[str, Any] | None: - state = state or self.repository.get_state(task_id) - if state is None or not state.requirements_contract_path: - return None - return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - - @staticmethod - def _final_review_path(revision_id: str) -> str: - # Revisions are manifest-sealed when a candidate becomes a checkpoint. - # Final review evidence therefore has its own immutable namespace. - return f"reviews/final/{revision_id}/final-review.json" - - def _commit_invocation(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: dict[str, Any]) -> bool: - """Commit state/outbox and its idempotent result in one SQLite transaction.""" - return self.repository.compare_and_swap( - state, - events=events, - invocation_id=invocation.invocation_id, - invocation_result=result, - ) - - @staticmethod - def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str: - encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) - return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest() - - @staticmethod - def _failure_class_fingerprint(active_revision: str, atomic_id: str, code: ErrorCode) -> str: - return sha256(f"{active_revision}|{atomic_id}|{code.value}".encode("utf-8")).hexdigest() - - def _failure_class_events(self, task_id: str, fingerprint: str) -> list[dict[str, Any]]: - return [ - event for event in self.repository.ledger_events(task_id) - if event.get("failure_class_fingerprint") == fingerprint - ] - - @staticmethod - def _stale() -> WorkflowError: - return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.") - - @staticmethod - def _runtime_error(error: Exception) -> WorkflowError: - message = str(error) - code = ( - ErrorCode.RUNTIME_PRECONDITION_FAILED - if "RUNTIME_PRECONDITION_FAILED" in message - else ErrorCode.RUNTIME_CONTRACT_INVALID - if "RUNTIME_CONTRACT_INVALID" in message or "CDSL schema violation" in message - else ErrorCode.RUNTIME_EXECUTION_FAILURE - if "RUNTIME_EXECUTION_FAILURE" in message - else ErrorCode.AUTHOR_FORMAT_INVALID - ) - return WorkflowError(code, message[:1000]) - - def _park_for_storage_retry(self, state: TaskState, *, event: str, message: str) -> Rejected: - """Persist a recoverable artifact failure without changing CAD evidence.""" - return self._park_retry(state, ErrorCode.STORAGE_FAILURE, event=event, message=message) - - def _park_retry( - self, - state: TaskState, - code: ErrorCode, - *, - event: str, - message: str, - details: dict[str, Any] | None = None, - ) -> Rejected: - waiting = transition(state, "waiting_retry", error=code) - payload = { - "event": event, - "code": code.value, - "message": message[:1000], - **(details or {}), - } - if not self.repository.compare_and_swap(waiting, events=[{ - **payload, - }]): - return Rejected(self._stale()) - return Rejected(WorkflowError( - code, - "Candidate render evidence is temporarily unavailable; the checkpoint is preserved." - if code == ErrorCode.RENDER_SERVICE_UNAVAILABLE - else "The CAD runtime failed after validation; retry will resume from the preserved checkpoint." - if code == ErrorCode.RUNTIME_EXECUTION_FAILURE - else "Candidate artifact storage is temporarily unavailable; the checkpoint is preserved.", - retryable=True, - )) diff --git a/backend/app/cad_agent/application/authoring_compiler.py b/backend/app/cad_agent/application/authoring_compiler.py new file mode 100644 index 00000000..6b2c36a5 --- /dev/null +++ b/backend/app/cad_agent/application/authoring_compiler.py @@ -0,0 +1,300 @@ +"""Compile model-facing Authoring CDSL into server-owned runtime CDSL.""" +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json +from typing import Any, Callable + +from jsonschema import Draft202012Validator + +from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code + + +class AuthoringCompileError(ValueError): + def __init__(self, code: str, message: str, *, path: str = "") -> None: + self.code, self.path = code, path + super().__init__(message) + + +class AuthoringCompiler: + def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None: + self.operation_contract = operation_contract + self.version = version + + def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + try: + validate_finite(raw) + doc = AuthoringDocument.model_validate(raw) + except Exception as error: + raise AuthoringCompileError(validation_error_code(error), str(error)) from error + source_features = [feature for body in doc.bodies for feature in body.features] + dependencies, implicit_selector_dependencies = self._effective_dependencies(doc) + ordered = self._topological(doc, dependencies) + # Identity follows the source document, while execution follows the + # dependency graph. A dependency reordering must never renumber IDs. + feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)} + body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)} + features: list[dict[str, Any]] = [] + sketches: list[dict[str, Any]] = [] + sketch_index = 0 + source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)} + sketch_ids_by_feature = { + feature.name: f"sketch_{source_positions[feature.name]:03d}" + for feature in source_features + if feature.sketch is not None + } + for feature in ordered: + try: + contract = self.operation_contract(feature.operation) + except Exception as error: + raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error + params = self._references( + deepcopy(feature.params), contract, feature_ids, feature.name, + sketch_ids_by_feature=sketch_ids_by_feature, + ) + self._validate_params(contract, params, feature.name) + selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors] + selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0} + fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"} + required_selectors = fragment_shape["selector_tokens"] == "required" + runtime_feature_selectors: list[dict[str, Any]] = [] + if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]: + raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors") + if not required_selectors and selectors: + raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors") + if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors): + raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors") + if required_selectors: + slot = str(selector_policy["slot"]) + if slot == "feature.selectors": + runtime_feature_selectors = selectors + elif slot.startswith("params."): + parameter = slot.removeprefix("params.") + if "." in parameter: + raise AuthoringCompileError( + "OPERATION_UNSUPPORTED", + f"runtime has no safe selector binding for {slot}", + path=f"features.{feature.name}.selectors", + ) + if parameter in params: + raise AuthoringCompileError( + "AUTHOR_FORBIDDEN_FIELD", + f"{parameter} is server-injected from selectors", + path=f"features.{feature.name}.params.{parameter}", + ) + params[parameter] = selectors[0] if len(selectors) == 1 else selectors + else: + raise AuthoringCompileError( + "OPERATION_UNSUPPORTED", + f"runtime has no safe selector binding for {slot}", + path=f"features.{feature.name}.selectors", + ) + output = { + "id": feature_ids[feature.name], "atomic_id": feature.operation, + "depends_on": [feature_ids[name] for name in dependencies[feature.name]], + "params": params, + **({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}), + } + needs_sketch = fragment_shape["sketch"] == "required" + if needs_sketch and feature.sketch is None: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch") + if not needs_sketch and feature.sketch is not None: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch") + if feature.sketch is not None: + sketch_index += 1 + sketch_id = f"sketch_{source_positions[feature.name]:03d}" + sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))}) + output["sketch_id"] = sketch_id + features.append(output) + runtime = { + "schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part", + "part_id": "compiled", "meta": {"unit": "mm"}, + "bodies": [ + {"id": body_ids[body.name], "name": body.name} + for body in doc.bodies + ], + "geometry": {"sketches": sketches}, "features": features, + } + digest = sha256(self._canonical_json(raw)).hexdigest() + return runtime, { + "schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version, + "source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids, + "sketch_ids": [item["id"] for item in sketches], + "implicit_selector_dependencies": implicit_selector_dependencies, + } + + @staticmethod + def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """Make every declared selector source an auditable graph dependency. + + A selector is already an explicit local source reference. Requiring the + author to repeat that same edge in a second field only creates a + formatting failure; it does not add CAD intent. The compiler therefore + adds the direct source edge deterministically and records it in the + audit. It never selects a substitute topology element. + """ + by_name = {item.name: item for body in doc.bodies for item in body.features} + dependencies: dict[str, list[str]] = {} + implicit: dict[str, list[str]] = {} + for feature in by_name.values(): + values = list(feature.depends_on) + additions: list[str] = [] + for selector in feature.selectors: + source = selector.source.split(".", 1)[0] + if source not in by_name: + raise AuthoringCompileError( + "AUTHOR_REFERENCE_INVALID", + f"unknown selector source: {selector.source}", + path=f"features.{feature.name}.selectors", + ) + if source not in values: + values.append(source) + additions.append(source) + dependencies[feature.name] = values + if additions: + implicit[feature.name] = additions + return dependencies, implicit + + @staticmethod + def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]: + by_name = {item.name: item for body in doc.bodies for item in body.features} + result: list[Any] = [] + visiting: set[str] = set() + done: set[str] = set() + def visit(name: str) -> None: + if name in visiting: + raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}") + if name in done: + return + visiting.add(name) + for dependency in dependencies[name]: + visit(dependency) + visiting.remove(name); done.add(name); result.append(by_name[name]) + for body in doc.bodies: + for feature in body.features: + visit(feature.name) + return result + + @staticmethod + def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]: + source = selector.source.split(".", 1)[0] + if source not in feature_ids: + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}") + source_feature = next(item for item in source_features if item.name == source) + role = selector.source.split(".", 1)[1] + role = AuthoringCompiler._runtime_role(role, source_feature.operation) + return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match} + + @staticmethod + def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]: + """Lower the small Authoring sketch language to the generic runtime form.""" + profile = sketch["profile"] + if profile["type"] == "circle": + profile = { + "type": "circle", + "center": profile["center_mm"], + "radius_mm": float(profile["diameter_mm"]) / 2.0, + } + return {"workplane": sketch["workplane"], "profile": profile} + + @staticmethod + def _runtime_role(role: str, operation: str) -> str: + if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}: + return role + family = ( + "extrude" if operation.startswith("extrude") else + "sweep" if operation.startswith("sweep") else + "loft" if operation.startswith("loft") else + "cylinder" if operation == "cylinder_add" else "" + ) + if role in {"top_planar_face", "end_face"} and family: + return family + ".end" + if role in {"bottom_planar_face", "start_face"} and family: + return family + ".start" + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}") + + @staticmethod + def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None: + schema = contract.get("author_params_schema") or {"type": "object"} + errors = list(Draft202012Validator(schema).iter_errors(params)) + if errors: + first = errors[0] + path = ".".join(str(item) for item in first.absolute_path) + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip(".")) + + @staticmethod + def _references( + params: dict[str, Any], + contract: dict[str, Any], + feature_ids: dict[str, str], + feature_name: str, + *, + sketch_ids_by_feature: dict[str, str], + ) -> dict[str, Any]: + params = AuthoringCompiler._rewrite_named_references( + params, feature_ids, sketch_ids_by_feature, feature_name, + ) + policy = contract.get("reference_policy") or {"mode": "none"} + if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors": + return params + key = str(policy["slot"]).removeprefix("params.") + value = params.get(key) + if isinstance(value, list): + names = value + elif value is None and policy["min_items"] == 0: + names = [] + elif isinstance(value, str): + names = [value] + else: + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}") + runtime_ids = set(feature_ids.values()) + if not all(isinstance(item, str) and item in runtime_ids for item in names): + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}") + if not policy["min_items"] <= len(names) <= policy["max_items"]: + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}") + return params + + @staticmethod + def _rewrite_named_references( + value: Any, + feature_ids: dict[str, str], + sketch_ids_by_feature: dict[str, str], + consuming_feature: str, + key: str = "", + ) -> Any: + if isinstance(value, dict): + return { + item_key: AuthoringCompiler._rewrite_named_references( + item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key, + ) + for item_key, item_value in value.items() + } + if isinstance(value, list): + return [ + AuthoringCompiler._rewrite_named_references( + item, feature_ids, sketch_ids_by_feature, consuming_feature, key, + ) + for item in value + ] + if not isinstance(value, str): + return value + if key == "profile_sketch_ids": + if value not in sketch_ids_by_feature: + raise AuthoringCompileError( + "AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}", + path=f"features.{consuming_feature}.params.{key}", + ) + return sketch_ids_by_feature[value] + if key.endswith("_feature_id") or key.endswith("_feature_ids"): + if value not in feature_ids: + raise AuthoringCompileError( + "AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}", + path=f"features.{consuming_feature}.params.{key}", + ) + return feature_ids[value] + return value + + @staticmethod + def _canonical_json(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") diff --git a/backend/app/cad_agent/application/authoring_contract.py b/backend/app/cad_agent/application/authoring_contract.py new file mode 100644 index 00000000..677736ef --- /dev/null +++ b/backend/app/cad_agent/application/authoring_contract.py @@ -0,0 +1,169 @@ +"""Strict model-facing Authoring CDSL contract. + +This is intentionally separate from the runtime CDSL: model output contains +only document-local names and declarative references. Runtime identities are +allocated by :mod:`authoring_compiler`. +""" +from __future__ import annotations + +import math +import re +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +_NAME = r"^[a-z][a-z0-9_]{0,63}$" +_FORBIDDEN = { + "id", + "task_id", "revision_id", "candidate_id", "action_id", "requirement_id", + "claim_id", "evidence_id", "feature_id", "sketch_id", "body_id", + "stable_id", "snapshot_id", "owner_feature_id", "working_head", + "selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken", + "host_face", "mirror_plane", +} + + +def validation_error_code(error: Exception) -> str: + """Map strict model validation failures to a stable public diagnostic.""" + return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID" + + +class AuthorModel(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + @model_validator(mode="before") + @classmethod + def reject_internal_fields(cls, value: Any) -> Any: + if isinstance(value, dict): + found = sorted( + key for key in value + if isinstance(key, str) + and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN) + ) + if found: + raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}") + for nested in value.values(): + cls.reject_internal_fields(nested) + return value + if isinstance(value, list): + for item in value: + cls.reject_internal_fields(item) + return value + + +class AuthorWorkplane(AuthorModel): + """A fully explicit local sketch frame in world millimetres.""" + + origin_mm: list[float] = Field(min_length=3, max_length=3) + x_dir: list[float] = Field(min_length=3, max_length=3) + normal: list[float] = Field(min_length=3, max_length=3) + + +class AuthorCircleProfile(AuthorModel): + """A declarative circle expressed with the user-facing diameter.""" + + type: Literal["circle"] + diameter_mm: float = Field(gt=0) + center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2) + + +class AuthorPolygonProfile(AuthorModel): + """A closed polygon in the local sketch workplane.""" + + type: Literal["polygon"] + vertices: list[list[float]] = Field(min_length=3) + + @field_validator("vertices") + @classmethod + def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]: + if any(len(point) != 2 for point in value): + raise ValueError("polygon vertices must have exactly two coordinates") + return value + + +class AuthorSketch(AuthorModel): + """The only authoring sketch form currently accepted by the compiler.""" + + workplane: AuthorWorkplane + profile: AuthorCircleProfile | AuthorPolygonProfile + + +class SelectorIntent(AuthorModel): + """A local feature-output reference, never a Runtime selector token.""" + + kind: Literal["face", "edge", "axis", "plane", "vertex", "body"] + source: str = Field( + min_length=3, + max_length=160, + description="A local feature output in the form ..", + ) + match: Literal["unique", "all"] = "unique" + + @field_validator("source") + @classmethod + def require_feature_output_reference(cls, value: str) -> str: + feature, separator, role = value.partition(".") + if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role): + raise ValueError("selector source must be .") + return value + + +class AuthorFeature(AuthorModel): + name: str = Field(pattern=_NAME) + operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$") + params: dict[str, Any] = Field( + default_factory=dict, + description="Only parameters from this feature operation's supplied params_schema.", + ) + depends_on: list[str] = Field(default_factory=list, max_length=32) + selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32) + sketch: AuthorSketch | None = Field( + default=None, + description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.", + ) + + +class AuthorBody(AuthorModel): + name: str = Field(pattern=_NAME) + features: list[AuthorFeature] = Field(min_length=1, max_length=256) + + +class AuthoringDocument(AuthorModel): + schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$") + units: str = Field(default="mm", pattern=r"^mm$") + coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$") + assumptions: list[str] = Field(default_factory=list, max_length=64) + bodies: list[AuthorBody] = Field(min_length=1, max_length=32) + acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128) + + @model_validator(mode="after") + def validate_symbols(self) -> "AuthoringDocument": + validate_finite(self.model_dump(mode="python")) + bodies = [b.name for b in self.bodies] + if len(bodies) != len(set(bodies)): + raise ValueError("duplicate body name") + names: set[str] = set() + for body in self.bodies: + for feature in body.features: + if feature.name in names: + raise ValueError(f"duplicate feature name: {feature.name}") + names.add(feature.name) + for body in self.bodies: + for feature in body.features: + if len(feature.depends_on) != len(set(feature.depends_on)): + raise ValueError(f"duplicate dependency: {feature.name}") + if any(dep not in names for dep in feature.depends_on): + missing = next(dep for dep in feature.depends_on if dep not in names) + raise ValueError(f"unknown feature reference: {missing}") + return self + + +def validate_finite(value: Any, path: str = "$") -> None: + if isinstance(value, float) and not math.isfinite(value): + raise ValueError(f"non-finite number at {path}") + if isinstance(value, dict): + for key, item in value.items(): + validate_finite(item, f"{path}.{key}") + elif isinstance(value, list): + for index, item in enumerate(value): + validate_finite(item, f"{path}[{index}]") diff --git a/backend/app/cad_agent/application/authoring_guidance.py b/backend/app/cad_agent/application/authoring_guidance.py new file mode 100644 index 00000000..6eb49eb4 --- /dev/null +++ b/backend/app/cad_agent/application/authoring_guidance.py @@ -0,0 +1,16 @@ +"""Prompt material for the model-facing Authoring CDSL contract.""" +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + + +_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md" + + +@lru_cache(maxsize=1) +def load_authoring_guidance() -> str: + try: + return _GUIDANCE.read_text(encoding="utf-8")[:12_000] + except OSError: + return "Use cad.author.v1 only. Never create runtime IDs or selector tokens." diff --git a/backend/app/cad_agent/application/capabilities.py b/backend/app/cad_agent/application/capabilities.py index 509be1fa..2956dfbb 100644 --- a/backend/app/cad_agent/application/capabilities.py +++ b/backend/app/cad_agent/application/capabilities.py @@ -6,62 +6,25 @@ from hashlib import sha256 import json from typing import Any, Literal -from app.cad_agent.application.llm_contracts import ( - EmptyCommand, - ImageObservation, - MarkdownDocument, - StatelessCandidateReview, - StatelessGeometryConclusion, - StatelessRollbackCheckpoint, - StatelessTopologyRequest, - compiled_requirements_schema, - 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.application.authoring_contract import AuthoringDocument +from app.cad_agent.application.workflow import RequirementsAnalysis from app.cad_agent.ports import CadRuntime, ModelGateway -CapabilityRole = Literal["author", "reviewer"] +CapabilityRole = Literal["author"] def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]: - 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)), - ] - atomic_ids = list(runtime.supported_atomic_ids()) - if not atomic_ids: + if not runtime.supported_atomic_ids(): raise RuntimeError("Runtime has no operations for conformance") - tools = [ - _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("inspect_topology", StatelessTopologyRequest.model_json_schema()), - _tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()), - _tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()), - _tool("complete_task", EmptyCommand.model_json_schema()), + return [ + _tool("analyze_requirements", RequirementsAnalysis.model_json_schema()), + _tool("write_authoring_cdsl", AuthoringDocument.model_json_schema()), ] - for atomic_id in atomic_ids: - contract = runtime.operation_contract(atomic_id) - tools.append(_tool( - f"conformance_{atomic_id}", - fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]), - )) - return tools def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str: - payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools} + payload = {"protocol": "cad.single-stage.v1", "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/llm_contracts.py b/backend/app/cad_agent/application/llm_contracts.py deleted file mode 100644 index b19b727d..00000000 --- a/backend/app/cad_agent/application/llm_contracts.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Canonical schemas for every v3 LLM state-changing command. - -Providers may parse structured output first, but this module validates the raw -tool arguments a second time before a command reaches a handler. -""" - -from __future__ import annotations - -from copy import deepcopy -import hashlib -import json -import math -from typing import Annotated, Any, Literal, TypeVar - -from jsonschema import Draft202012Validator -from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator - -from app.cad_agent.domain.errors import ErrorCode, WorkflowError - - -class StrictDto(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True) - - -ShortText = Annotated[str, Field(min_length=1, max_length=360)] -Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")] - - -class AcceptanceClaimInput(StrictDto): - claim_kind: Identifier - expected: dict[str, JsonValue] = Field(min_length=0, max_length=24) - - -class SpecRequirementInput(StrictDto): - statement: Annotated[str, Field(min_length=1, max_length=1000)] - assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16) - - -class MarkdownDocument(StrictDto): - """A frozen human-readable design artifact, never an executable payload.""" - markdown: Annotated[str, Field(min_length=1, max_length=16_000)] - - -class CompiledRequirementInput(StrictDto): - """One verifier bundle for one server-parsed checklist item. - - The checklist text, ordering, source bindings, and all identifiers are - intentionally absent: the service owns them after Markdown is frozen. - """ - assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16) - - -class CompiledRequirementsSpec(StrictDto): - requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64) - - -# Kept only so an interrupted process with an already imported old tool schema -# fails at the workflow boundary instead of failing module import. New v3.1 -# tasks never expose or accept this aggregate specification. -class RequirementsSpec(StrictDto): - outcome: Literal["ready"] - summary: Annotated[str, Field(min_length=1, max_length=2000)] - assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32) - requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32) - - -class RequirementsClarification(StrictDto): - outcome: Literal["clarification"] - source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4) - question: Annotated[str, Field(min_length=1, max_length=500)] - - -class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]): - pass - - -class EmptyCommand(StrictDto): - pass - - -class NextAction(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - intent: ShortText - # The server binds this list from every frozen checklist target. It is not - # author input, so a five-item UI-era limit must not reject a valid task. - requirement_ids: list[Identifier] = Field(min_length=1, max_length=64) - atomic_id: Identifier - expected_change: ShortText - - @model_validator(mode="after") - def _requirement_ids_are_unique(self) -> "NextAction": - if len(self.requirement_ids) != len(set(self.requirement_ids)): - raise ValueError("requirement_ids must not contain duplicates") - return self - - -class StatelessNextAction(StrictDto): - intent: ShortText - operation: Identifier - expected_change: ShortText - - -class TopologyRequest(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None - limit: int = Field(default=16, ge=1, le=64) - - -class GeometryConclusion(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - evidence_refs: list[Identifier] = Field(min_length=1, max_length=16) - root_cause: Annotated[str, Field(min_length=1, max_length=360)] - decision: Literal["return_to_action_selection", "rollback"] - corrective_intent: str | None = Field(default=None, min_length=1, max_length=360) - - @model_validator(mode="after") - def _evidence_refs_are_unique(self) -> "GeometryConclusion": - if len(self.evidence_refs) != len(set(self.evidence_refs)): - raise ValueError("evidence_refs must not contain duplicates") - return self - - -class RollbackCheckpoint(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - checkpoint_token: Identifier - reason: Annotated[str, Field(min_length=1, max_length=360)] - - -class StatelessTopologyRequest(StrictDto): - kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None - limit: int = Field(default=16, ge=1, le=64) - - -class StatelessGeometryConclusion(StrictDto): - root_cause: Annotated[str, Field(min_length=1, max_length=360)] - decision: Literal["return_to_action_selection", "rollback"] - corrective_intent: str | None = Field(default=None, min_length=1, max_length=360) - - -class StatelessRollbackCheckpoint(StrictDto): - checkpoint_token: Identifier - reason: Annotated[str, Field(min_length=1, max_length=360)] - - -class ClaimCoverage(StrictDto): - claim_id: Identifier - status: Literal["pass", "pending", "fail", "not_applicable"] - evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16) - - @model_validator(mode="after") - def _evidence_refs_are_unique(self) -> "ClaimCoverage": - if len(self.evidence_refs) != len(set(self.evidence_refs)): - raise ValueError("evidence_refs must not contain duplicates") - return self - - -class CandidateReview(StrictDto): - candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.") - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")] - verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.") - claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.") - evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - - -class StatelessCandidateReview(StrictDto): - verdict: Literal["accept", "reject"] - evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - - -class VisualClaimDecision(StrictDto): - status: Literal["pass", "fail"] - evidence: Annotated[str, Field(min_length=1, max_length=360)] - - -class StatelessFinalReview(StrictDto): - verdict: Literal["pass", "repair"] - visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128) - evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - - -class ImageMeasurement(StrictDto): - name: Annotated[str, Field(min_length=1, max_length=160)] - value: float | None = None - unit: Literal["mm", "degree", "count", "unknown"] = "unknown" - evidence: Annotated[str, Field(min_length=1, max_length=360)] - confidence: float = Field(ge=0, le=1) - - -class ImageObservation(StrictDto): - summary: Annotated[str, Field(min_length=1, max_length=2000)] - visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64) - measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128) - view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16) - uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64) - assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64) - - -class FinalReview(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")] - verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.") - claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.") - evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - - -def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]: - schema = RequirementsAuthorOutput.model_json_schema() - requirement = schema.get("$defs", {}).get("SpecRequirementInput") - if isinstance(requirement, dict): - claims = requirement.get("properties", {}).get("acceptance_claims") - if isinstance(claims, dict): - claims["items"] = deepcopy(claim_one_of) - return schema - - -def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]: - schema = CompiledRequirementsSpec.model_json_schema() - definitions = schema.get("$defs", {}) - requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None - if isinstance(requirement, dict): - claims = requirement.get("properties", {}).get("acceptance_claims") - if isinstance(claims, dict): - claims["items"] = deepcopy(claim_one_of) - requirements = schema.get("properties", {}).get("requirements") - if isinstance(requirements, dict): - requirements["minItems"] = target_count - requirements["maxItems"] = target_count - return schema - - -def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError: - """Drop harmless compiler chatter before strict requirements validation. - - ``compile_requirements_spec`` is a compiler stage: the service only needs - the ordered verifier bundles for the frozen checklist items. Real models - sometimes add explanatory fields such as a top-level ``assumptions`` or - per-item ``statement`` even when the dynamic tool schema forbids them. Those - fields are not executable and are not part of the frozen contract, so they - should not abort a task before modeling starts. - - The verifier ``expected`` payload is intentionally not sanitized here. It - remains governed by the registry's strict per-claim schema because those - values drive deterministic validation. - """ - value = canonical_json_object(raw_arguments_json) - if isinstance(value, WorkflowError): - return value - requirements = value.get("requirements") - sanitized: dict[str, Any] = {} - if isinstance(requirements, list): - sanitized_requirements: list[Any] = [] - for requirement in requirements: - if not isinstance(requirement, dict): - sanitized_requirements.append(requirement) - continue - item: dict[str, Any] = {} - if "assumptions" in requirement: - item["assumptions"] = requirement["assumptions"] - if "acceptance_claims" in requirement: - claims = requirement["acceptance_claims"] - if isinstance(claims, list): - item["acceptance_claims"] = [ - {key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim} - if isinstance(claim, dict) else claim - for claim in claims - ] - else: - item["acceptance_claims"] = claims - sanitized_requirements.append(item) - sanitized["requirements"] = sanitized_requirements - else: - sanitized["requirements"] = requirements - return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":")) - - -def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]: - schema = StatelessNextAction.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["operation"] = {"enum": atomic_ids} - return schema - - -def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]: - schema = StatelessFinalReview.model_json_schema() - properties = schema.get("properties", {}) - visual = properties.get("visual_claims") if isinstance(properties, dict) else None - if isinstance(visual, dict): - visual["minItems"] = visual_claim_count - visual["maxItems"] = visual_claim_count - return schema - - -def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]: - schema = StatelessRollbackCheckpoint.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["checkpoint_token"] = {"enum": checkpoint_tokens} - return schema - - -def topology_request_schema(working_head: str) -> dict[str, Any]: - schema = TopologyRequest.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["working_head"] = {"const": working_head} - return schema - - -def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]: - """Bind a rollback request to immutable checkpoints in the active lineage.""" - schema = RollbackCheckpoint.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["working_head"] = {"const": working_head} - properties["checkpoint_token"] = {"enum": checkpoint_tokens} - return schema - - -def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None: - definitions = schema.get("$defs", {}) - coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None - if not isinstance(coverage, dict): - return - properties = coverage.get("properties", {}) - if isinstance(properties, dict): - properties["claim_id"] = {"enum": claim_ids} - - -Dto = TypeVar("Dto", bound=StrictDto) - - -def raw_arguments_hash(raw_arguments_json: str) -> str: - return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest() - - -def json_depth(value: Any, current: int = 0) -> int: - if isinstance(value, dict): - return max([current, *(json_depth(item, current + 1) for item in value.values())]) - if isinstance(value, list): - return max([current, *(json_depth(item, current + 1) for item in value)]) - return current - - -def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError: - """Bound and parse raw arguments before any schema-specific validation.""" - if len(raw_arguments_json.encode("utf-8")) > max_bytes: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.") - try: - value = json.loads(raw_arguments_json) - except json.JSONDecodeError as error: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},)) - if not isinstance(value, dict): - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.") - if json_depth(value) > max_depth: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.") - if _contains_non_finite_number(value): - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.") - return value - - -def _contains_non_finite_number(value: Any) -> bool: - if isinstance(value, float): - return not math.isfinite(value) - if isinstance(value, dict): - return any(_contains_non_finite_number(item) for item in value.values()) - if isinstance(value, list): - return any(_contains_non_finite_number(item) for item in value) - return False - - -def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None: - """Revalidate raw arguments against the current dynamic JSON Schema.""" - value = canonical_json_object(raw_arguments_json) - if isinstance(value, WorkflowError): - return value - errors = [ - {"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message} - for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message)) - ] - if errors: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors)) - return None - - -def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError: - """Parse raw tool arguments once and return field-level DTO failures safely.""" - value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth) - if isinstance(value, WorkflowError): - return value - try: - return model.model_validate(value) - except ValidationError as error: - fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors()) - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields) - - -def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError: - """Require exactly one known tool call; no provider parser is trusted.""" - if len(tool_calls) != 1: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.") - function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None - name = str(function.get("name") or "") if isinstance(function, dict) else "" - raw = str(function.get("arguments") or "") if isinstance(function, dict) else "" - if name != allowed_name: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name}) - return name, raw diff --git a/backend/app/cad_agent/application/requirements.py b/backend/app/cad_agent/application/requirements.py deleted file mode 100644 index a7dba993..00000000 --- a/backend/app/cad_agent/application/requirements.py +++ /dev/null @@ -1,670 +0,0 @@ -"""Immutable Markdown-first requirements artifacts and compiled contracts.""" - -from __future__ import annotations - -from copy import deepcopy -from hashlib import sha256 -import json -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 -from app.cad_agent.domain.verifier_registry import VerifierRegistry -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: - """Persist frozen documents and compile their checklist into a contract. - - The model never names targets or internal objects during compilation. The - service derives those values strictly from the immutable checklist. - """ - - 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]]] = {} - - def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None: - self._evaluation_contract_oracles[task_id] = deepcopy(required_claims) - self._evaluation_capability_gaps[task_id] = [ - {"id": str(item.get("id") or ""), "description": str(item.get("description") or "")} - for item in validation_capability_gaps or () if isinstance(item, dict) - ] - - def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None: - claims = self._evaluation_contract_oracles.get(task_id) - return None if claims is None else { - "evaluation_only": True, - "required_claims": deepcopy(claims), - "known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])), - } - - @staticmethod - def document_schema() -> dict[str, Any]: - return MarkdownDocument.model_json_schema() - - def compiler_schema(self, task_id: str) -> dict[str, Any]: - return compiled_requirements_schema( - self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS), - 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) - - def submit_completion_target(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_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: - # 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) - if replay is not None: - return replay - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase.")) - targets = self._checklist_items(task_id) - 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), - *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"))) - if invocation.status == "finished" and invocation.result is not None: - return self._restore(invocation.result) - source_ids = list(self.artifacts.read_source_index(task_id)) - observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} - warnings = [ - *[str(value) for value in observation.get("uncertainties") or () if str(value)], - *compiler_warnings, - ] - requirements: list[dict[str, Any]] = [] - claim_position = 1 - for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1): - claims: list[dict[str, Any]] = [] - for claim in compiled.acceptance_claims: - definition = self.registry.definition(claim.claim_kind) - claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"}) - 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.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) - contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id) - except OSError as error: - return self._park_for_storage_retry(state, str(error)) - next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path) - result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)}) - if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result): - return Rejected(self._stale()) - return result - - def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str: - contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)} - visual = iter(review.get("visual_claims") or ()) - rows = ["# Completion Result", "", "## Checklist", ""] - for requirement in contract.get("requirements") or (): - if not isinstance(requirement, dict): - continue - statuses: list[str] = [] - evidence: list[str] = [] - for claim in requirement.get("acceptance_claims") or (): - if not isinstance(claim, dict): - continue - result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {}) - statuses.append(str(result.get("status") or "unknown")) - value = result.get("evidence") - if value: - evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True)) - rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}") - rows.extend(f" - Evidence: {value}" for value in evidence) - return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n") - - def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected: - 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 != phase: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase.")) - errors = validator(document.markdown) - if errors: - return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors))) - invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json"))) - if invocation.status == "finished" and invocation.result is not None: - return self._restore(invocation.result) - try: - written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n") - except OSError as error: - return self._park_for_storage_retry(state, str(error)) - kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written} - next_state = transition(state, event, **kwargs) - result = Accepted({"phase": next_state.phase.value, "path": written}) - if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result): - return Rejected(self._stale()) - return result - - def _checklist_items(self, task_id: str) -> list[str]: - path = self.artifacts.task_dir(task_id) / "completion-target.md" - text = path.read_text(encoding="utf-8") if path.is_file() else "" - return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))] - - def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]: - errors: list[dict[str, str]] = [] - for requirement_index, requirement in enumerate(output.requirements): - for claim_index, claim in enumerate(requirement.acceptance_claims): - try: - messages = self.registry.validate_expected(claim.claim_kind, claim.expected) - except ValueError: - messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}] - 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." - ) - claim.claim_kind = "visual" - claim.expected = {"description": target[:360]} - normalized_claims.append(claim) - continue - if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target): - self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim) - warnings.append( - f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope." - ) - continue - claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected) - normalized_claims.append(claim) - requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({ - "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": - return False - if RequirementsCommandHandler._target_describes_finished_envelope(target): - return False - return any( - getattr(item, "claim_kind", "") == "outer_cylindrical_surface" - for item in claims - ) - - @staticmethod - def _target_describes_finished_envelope(target: str) -> bool: - lowered = target.lower() - return any(token in lowered for token in ( - "overall", - "total", - "finished part", - "entire part", - "whole part", - "bounding box", - "envelope", - "总", - "整体", - "成品", - "全高", - "包围盒", - )) - - @staticmethod - def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None: - value = bbox_claim.expected.get("value") - if not isinstance(value, (int, float)): - return - for item in claims: - if getattr(item, "claim_kind", "") != "outer_cylindrical_surface": - continue - expected = getattr(item, "expected", None) - if not isinstance(expected, dict) or "axial_span_mm" in expected: - continue - expected["axial_span_mm"] = value - if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)): - expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"] - return - - @staticmethod - def _validate_requirements_document(markdown: str) -> list[dict[str, str]]: - # Markdown is a human-facing semantic artifact. Its content is frozen - # verbatim and is not executable, so headings are guidance for the - # author rather than a server-enforced protocol. - return [] - - @staticmethod - def _validate_completion_target(markdown: str) -> list[dict[str, str]]: - values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))] - errors: list[dict[str, str]] = [] - if not values: - errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."}) - if len(values) != len(set(values)): - errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."}) - return errors - - @staticmethod - def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]: - return [] - - def _replay(self, task_id: str, invocation_id: str) -> Accepted | None: - invocation = self.repository.get_invocation(task_id, invocation_id) - return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None - - @staticmethod - def _restore(payload: dict[str, Any]) -> Accepted: - return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload) - - def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool: - return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload}) - - @staticmethod - def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str: - encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) - return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest() - - @staticmethod - def _stale() -> WorkflowError: - return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.") - - def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected: - waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE) - self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}]) - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True)) diff --git a/backend/app/cad_agent/application/results.py b/backend/app/cad_agent/application/results.py deleted file mode 100644 index 7a793f69..00000000 --- a/backend/app/cad_agent/application/results.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Typed results returned by all command handlers.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from app.cad_agent.domain.errors import WorkflowError - - -@dataclass(frozen=True, slots=True) -class Accepted: - payload: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Rejected: - error: WorkflowError - - -@dataclass(frozen=True, slots=True) -class Waiting: - error: WorkflowError - - -@dataclass(frozen=True, slots=True) -class FailedInternal: - correlation_id: str - message: str diff --git a/backend/app/cad_agent/application/single_stage.py b/backend/app/cad_agent/application/single_stage.py new file mode 100644 index 00000000..e81e1d1c --- /dev/null +++ b/backend/app/cad_agent/application/single_stage.py @@ -0,0 +1,99 @@ +"""Deterministic execution service for the single-stage Authoring protocol. + +The service is deliberately synchronous and side-effect bounded so the HTTP +workflow can call it after one author response (and at most two repairs). +""" +from __future__ import annotations + +from hashlib import sha256 +import json +from typing import Any + +from app.cad_agent.ports import AdapterUnavailable + +class SingleStageExecutor: + MAX_REPAIRS = 2 + + def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None: + self.repository, self.artifacts, self.runtime = repository, artifacts, runtime + + def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]: + """Compile once and persist immutable compiler inputs and output.""" + if repair_count < 0 or repair_count > self.MAX_REPAIRS: + raise ValueError("repair budget exhausted") + runtime_cdsl, audit = self.runtime.compile_authoring(authoring) + digest = self._digest(authoring) + attempt = repair_count + 1 + runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json" + audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json" + self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl) + self.artifacts.write_json_once(task_id, audit_path, audit) + return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest} + + def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]: + """Build one compiled CDSL document and publish any executable prefix.""" + if repair_count < 0 or repair_count > self.MAX_REPAIRS: + raise ValueError("repair budget exhausted") + digest = digest or self._digest(authoring) + stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", { + "schema_version": "cad.single-stage.v1", "authoring": authoring, + "runtime": runtime_cdsl, "compile_audit": audit, + }) + try: + rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id) + except (OSError, AdapterUnavailable): + raise + except Exception as error: + self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", { + "schema_version": "cad.build-diagnostics.v1", + "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], + }) + return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit} + diagnostics = self._decorate_diagnostics(failures, audit) + self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", { + "schema_version": "cad.build-diagnostics.v1", + "diagnostics": diagnostics, + "executed_feature_ids": rebuilt.get("executed_feature_ids", []), + }) + if rebuilt: + revision = f"rev_{digest}" + self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", { + "schema_version": "cad.single-stage.staging-manifest.v1", + "stage_id": stage.stage_id, + "revision_id": revision, + "repair_count": repair_count, + "source_sha256": audit.get("source_sha256", ""), + "executed_feature_ids": rebuilt.get("executed_feature_ids", []), + }) + paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision) + else: + paths = {} + return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit} + + def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]: + """Compatibility convenience for direct callers and focused tests.""" + compiled = self.compile(task_id, authoring, repair_count=repair_count) + return { + **self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]), + "runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"], + } + + @staticmethod + def _digest(authoring: dict[str, Any]) -> str: + encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + return sha256(encoded).hexdigest()[:16] + + @staticmethod + def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]: + names = { + str(feature_id): str(name) + for name, feature_id in (audit.get("feature_ids") or {}).items() + if isinstance(name, str) and isinstance(feature_id, str) + } + result: list[dict[str, Any]] = [] + for failure in failures: + if not isinstance(failure, dict): + continue + feature_id = str(failure.get("feature_id") or "") + result.append({**failure, "feature_name": names.get(feature_id, "")}) + return result diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index 73e9839b..b081fc59 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -1,53 +1,26 @@ -"""LLM turn coordinator for protocol v3. +"""Single-stage coordinator for Authoring CDSL generation. -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. +Each task makes one request-analysis call and one full Authoring CDSL call. +Only structured authoring/compile/runtime failures can request a replacement, +and the replacement budget is fixed at two. """ - from __future__ import annotations import json import secrets -from hashlib import sha256 from dataclasses import dataclass -from typing import Any, AsyncIterator, TypeVar +from hashlib import sha256 +from typing import Any, AsyncIterator -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field -from app.cad_agent.application.action_handlers import ActionCommandHandler -from app.cad_agent.application.llm_contracts import ( - CandidateReview, CompiledRequirementsSpec, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, MarkdownDocument, NextAction, - RollbackCheckpoint, StatelessCandidateReview, - StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint, - StatelessTopologyRequest, TopologyRequest, - canonical_json_object, canonical_validate, canonical_validate_schema, - sanitize_compiled_requirements_arguments, - stateless_final_review_schema, - stateless_next_action_schema, stateless_rollback_checkpoint_schema, - raw_arguments_hash, validate_one_tool_call, -) -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 -from app.cad_agent.ports import ( - AdapterUnavailable, - ArtifactStore, - AuthorGuidance, - AuthorGuidanceSelection, - CadRuntime, - ModelGateway, - NullAuthorGuidance, - ReviewGateway, - TaskRepository, -) - - -T = TypeVar("T", bound=BaseModel) -_FEATURE_REPLAN_FAILURE_LIMIT = 3 +from app.cad_agent.application.authoring_compiler import AuthoringCompileError +from app.cad_agent.application.authoring_contract import AuthoringDocument, validation_error_code +from app.cad_agent.application.authoring_guidance import load_authoring_guidance +from app.cad_agent.application.single_stage import SingleStageExecutor +from app.cad_agent.domain.errors import ErrorCode +from app.cad_agent.domain.state import TaskPhase, TaskState, transition +from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, TaskRepository @dataclass(frozen=True, slots=True) @@ -58,2264 +31,487 @@ class ModelIdentity: @dataclass(frozen=True, slots=True) class WorkflowConfig: - max_turns: int - format_error_limit: int - author_fallbacks: tuple[ModelIdentity, ...] = () - max_author_turns: int | None = None - max_reviewer_turns: int | None = None - max_model_calls: int | None = None + max_repairs: int = 2 -@dataclass(slots=True) -class _ModelCallBudget: - """Task-scoped model-call caps, including calls from a prior resume.""" +class _RequirementsTarget(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + kind: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$") + expected: dict[str, Any] = Field(default_factory=dict) + verification: str = Field(default="manual", pattern=r"^(deterministic|manual)$") - max_author_turns: int | None - max_reviewer_turns: int | None - max_model_calls: int | None - author_calls: int = 0 - reviewer_calls: int = 0 - @classmethod - def from_usage(cls, config: WorkflowConfig, records: list[dict[str, Any]]) -> "_ModelCallBudget": - reviewer_calls = sum(1 for record in records if record.get("role") == "reviewer") - return cls( - max_author_turns=config.max_author_turns, - max_reviewer_turns=config.max_reviewer_turns, - max_model_calls=config.max_model_calls, - author_calls=len(records) - reviewer_calls, - reviewer_calls=reviewer_calls, - ) - - @property - def total_calls(self) -> int: - return self.author_calls + self.reviewer_calls - - def exhausted(self, actor: str) -> bool: - return ( - (actor == "author" and self.max_author_turns is not None and self.author_calls >= self.max_author_turns) - or (actor == "reviewer" and self.max_reviewer_turns is not None and self.reviewer_calls >= self.max_reviewer_turns) - or (self.max_model_calls is not None and self.total_calls >= self.max_model_calls) - ) - - def record_attempt(self, actor: str) -> None: - if actor == "reviewer": - self.reviewer_calls += 1 - else: - self.author_calls += 1 - - def payload(self) -> dict[str, int | None]: - return { - "author_calls": self.author_calls, - "reviewer_calls": self.reviewer_calls, - "total_calls": self.total_calls, - "max_author_turns": self.max_author_turns, - "max_reviewer_turns": self.max_reviewer_turns, - "max_model_calls": self.max_model_calls, - } +class RequirementsAnalysis(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + schema_version: str = Field(default="cad.requirements.v1", pattern=r"^cad\.requirements\.v1$") + explicit_requirements: list[str] = Field(min_length=1, max_length=64) + assumptions: list[str] = Field(default_factory=list, max_length=64) + acceptance_targets: list[_RequirementsTarget] = Field(default_factory=list, max_length=128) + manual_targets: list[str] = Field(default_factory=list, max_length=64) + clarification_question: str | None = Field(default=None, min_length=1, max_length=500) class WorkflowCoordinator: - def __init__( - self, - config: WorkflowConfig, - repository: TaskRepository, - artifacts: ArtifactStore, - runtime: CadRuntime, - model_gateway: ModelGateway, - review_gateway: ReviewGateway, - requirements: RequirementsCommandHandler, - actions: ActionCommandHandler, - author_guidance: AuthorGuidance | None = None, - ) -> None: + def __init__(self, config: WorkflowConfig, repository: TaskRepository, artifacts: ArtifactStore, runtime: CadRuntime, model_gateway: ModelGateway, executor: SingleStageExecutor) -> None: self.config = config self.repository = repository self.artifacts = artifacts self.runtime = runtime self.model_gateway = model_gateway - self.review_gateway = review_gateway - self.requirements = requirements - self.actions = actions - self.author_guidance = author_guidance or NullAuthorGuidance() + self.executor = executor - def create_task( - self, - task_id: str, - request: str, - *, - source_blocks: list[dict[str, Any]] | None = None, - image_inputs: list[dict[str, str]] | None = None, - ) -> TaskState: - # The immutable source artifact is safe to create before SQLite state: - # an interrupted creation leaves only an unreferenced directory, never - # a runnable task without its source index. + def create_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> TaskState: self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) return self.repository.create_task(task_id, request) def resume(self, task_id: str) -> bool: state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.WAITING_RETRY: + if state is None or state.phase != TaskPhase.FAILED or state.retry_from_phase is None: return False - event = retry_resume_event(state) - if not event: - return False - next_state = transition(state, event) - return self.repository.compare_and_swap(next_state, events=[ - { - "event": "workflow_resumed", - "from_phase": state.phase.value, - "retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "", - "to_phase": next_state.phase.value, - }, - ]) + resumed = transition(state, "resume") + return self.repository.compare_and_swap(resumed, events=[{"event": "task_resumed", "from_phase": state.retry_from_phase.value}]) def resume_with_user_clarification(self, task_id: str, clarification: str, *, message_id: str) -> bool: - """Resume a requirements pause on the same task with durable input.""" state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.WAITING_FOR_USER: + if state is None or state.phase != TaskPhase.WAITING_FOR_USER or not clarification.strip(): return False - clarification_request = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None - if not isinstance(clarification_request, dict) or not str(clarification_request.get("question") or "").strip(): - return False - text = clarification.strip() - if not text: - return False - digest = sha256(f"{message_id}:{text}".encode("utf-8")).hexdigest()[:16] - clarification_path = f"documents/user-clarification-{digest}.json" - try: - self.artifacts.write_json_once(task_id, clarification_path, { - "schema_version": "cad.user-clarification.v1", - "task_id": task_id, - "message_id": message_id, - "text": text, - }) - except OSError: - return False - resumed = transition(state, "requirements_clarified", clarification_path="") - return self.repository.compare_and_swap(resumed, events=[{ - "event": "user_clarification_received", - "message_id": message_id, - "clarification_path": clarification_path, - "response_sha256": sha256(text.encode("utf-8")).hexdigest(), - }]) + digest = sha256(f"{message_id}:{clarification}".encode()).hexdigest()[:16] + path = self.artifacts.write_json_once(task_id, f"documents/user-clarification-{digest}.json", {"schema_version": "cad.user-clarification.v1", "message_id": message_id, "text": clarification.strip()}) + resumed = transition(state, "clarification_received", clarification_path=path) + return self.repository.compare_and_swap(resumed, events=[{"event": "clarification_received", "clarification_path": path}]) - async def run(self, *, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> AsyncIterator[tuple[str, dict[str, Any]]]: - feedback: list[dict[str, Any]] = [] - format_errors: dict[str, int] = {} - transport_attempted: set[str] = set() - # Observations are request-scoped, read-only facts. A restarted run - # deliberately has to fetch them again before authoring a selector- - # bound feature. - action_observations: dict[str, set[str]] = {} - active_author = author - max_turns = self.config.max_turns - call_budget = _ModelCallBudget.from_usage( - self.config, - self.repository.usage_summary(task_id).get("records", []), - ) - try: - initial = self.repository.get_state(task_id) - if initial is not None: - referenced = {initial.candidate_stage_id} if initial.candidate_stage_id else set() - # Candidate rejection/build-failure evidence is itself an - # immutable repair input. Once the state transition clears - # ``candidate_stage_id``, retain that stage through restart - # based on its committed ledger reference. - referenced.update( - str(event["stage_id"]) - for event in self.repository.ledger_events(task_id) - if event.get("event") in { - "candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", "candidate_recovered_rejected", - } - and isinstance(event.get("stage_id"), str) - and event["stage_id"] - ) - try: - self.artifacts.recover_staged_candidates(task_id, referenced) - except Exception as error: - yield self._storage_failure(task_id, str(error)) - return - for _turn in range(max_turns): - try: - self._sync_action_ledger(task_id) - except Exception as error: - yield self._storage_failure(task_id, str(error)) - return - state = self.repository.get_state(task_id) - if state is None: - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."} - return - if state.phase == TaskPhase.COMPLETED: - yield "task_terminal", self._projected_terminal(task_id, state) - return - if state.phase == TaskPhase.CANCELLED: - yield "task_terminal", { - "taskId": task_id, - "lifecycle": "cancelled", - "revisionId": state.active_revision, - "code": ErrorCode.CANCELLED.value, - } - return + def waiting_for_user_terminal(self, task_id: str, state: TaskState | None = None) -> dict[str, Any]: + state = state or self.repository.get_state(task_id) + details = self.artifacts.read_json(task_id, state.clarification_path) if state and state.clarification_path else {} + question = str((details or {}).get("question") or "CAD generation needs clarification.") + return {"taskId": task_id, "lifecycle": "waiting_for_user", "message": question, "questions": [question], "userActionRequired": True} + + async def run(self, *, task_id: str, author: ModelIdentity) -> AsyncIterator[tuple[str, dict[str, Any]]]: + while True: + state = self.repository.get_state(task_id) + if state is None: + return + if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + yield "task_terminal", self._terminal(state) + return + try: + self.artifacts.sync_event_ledger(task_id, self.repository.ledger_events(task_id)) if state.phase == TaskPhase.WAITING_FOR_USER: yield "task_terminal", self.waiting_for_user_terminal(task_id, state) return - 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 + if state.phase == TaskPhase.ANALYZING_REQUEST: + result = await self._analyze(task_id, state, author) + yield result 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} - if isinstance(recovered, Rejected): - yield self._service_failure(task_id, state, recovered.error) - return + if state.phase == TaskPhase.AUTHORING_CDSL: + result = await self._author(task_id, state, author) + yield result continue - if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: - image_paths = self.artifacts.source_image_paths(task_id) - if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None: - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - observation = await self._observe_images(task_id, reviewer, image_paths) - if isinstance(observation, WorkflowError): - if observation.code == ErrorCode.AUTHOR_FORMAT_INVALID: - observation = WorkflowError( - ErrorCode.REVIEW_SERVICE_UNAVAILABLE, - "Image observation did not return the required structured format.", - field_errors=observation.field_errors, - retryable=True, - ) - yield self._service_failure(task_id, state, observation) - return - try: - self.artifacts.write_json_once(task_id, "documents/image-observation.json", { - "schema_version": "cad.image-observation.v3", - **observation.model_dump(mode="json"), - }) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - observed_state = transition(state, "image_observed") - self.repository.compare_and_swap(observed_state, events=[{ - "event": "image_observation_ready", - "path": "documents/image-observation.json", - "image_count": len(image_paths), - }]) - yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"} - continue - document_schema = self.requirements.document_schema() - tools = [self._tool("write_requirements_document", document_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, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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, MarkdownDocument) - dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - invocation_id = self._invocation_id(task_id) - command = self.requirements.submit_requirements_document(task_id, validation, invocation_id=invocation_id) - if isinstance(command, Rejected): - terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - event_payload = self._event(task_id, name, self._result_payload(command), "success", usage) - event_payload["markdown"] = validation.markdown - yield "requirements_document_ready", event_payload - feedback = [] + if state.phase == TaskPhase.COMPILING_CDSL: + result = self._compile(task_id, state) + yield result continue - if state.phase in {TaskPhase.DRAFTING_COMPLETION_TARGET, TaskPhase.DRAFTING_MODELING_PLAN}: - document_schema = self.requirements.document_schema() - tool_name = "write_completion_target" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "write_modeling_plan" - tools = [self._tool(tool_name, document_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, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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, MarkdownDocument) - dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool=tool_name) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.requirements.submit_completion_target(task_id, validation, invocation_id=self._invocation_id(task_id)) if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else self.requirements.submit_modeling_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=tool_name) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - event_payload = self._event(task_id, name, self._result_payload(command), "success", usage) - event_payload["markdown"] = validation.markdown - yield ("completion_target_ready" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "modeling_plan_ready"), event_payload - feedback = [] + if state.phase == TaskPhase.BUILDING: + result = self._build(task_id, state) + yield result continue - if state.phase == TaskPhase.COMPILING_REQUIREMENTS: - compiler_schema = self.requirements.compiler_schema(task_id) - tools = [self._tool("compile_requirements_spec", compiler_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, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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 + if state.phase == TaskPhase.REPAIRING: + started = transition(state, "repair_started", repair_count=state.repair_count + 1) + if not self.repository.compare_and_swap(started, events=[{"event": "repair_started", "repair_count": started.repair_count}]): continue - name, raw, usage = result - sanitized_raw = sanitize_compiled_requirements_arguments(raw) - if isinstance(sanitized_raw, WorkflowError): - validation: CompiledRequirementsSpec | WorkflowError = sanitized_raw - else: - validation = canonical_validate(sanitized_raw, CompiledRequirementsSpec) - dynamic_error = canonical_validate_schema(sanitized_raw, compiler_schema) if not isinstance(validation, WorkflowError) and isinstance(sanitized_raw, str) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool="compile_requirements_spec") - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.requirements.submit_compiled_spec(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="compile_requirements_spec") - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] + yield "repair_started", {"taskId": task_id, "repairCount": started.repair_count, "repairBudget": self.config.max_repairs} 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 = [] + if state.phase == TaskPhase.PUBLISHING_BEST_EFFORT: + result = self._publish(task_id, state) + yield result 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")] - action_schema = stateless_next_action_schema(list(self.actions.available_atomic_ids(task_id, state))) - tools = self._recovery_tools(task_id, state) - if not tools: - if self._can_complete(task_id, state): - tools = [self._tool("complete_task", EmptyCommand)] - elif self.actions.repair_action_ready(task_id, state): - tools = [self._tool("propose_next_action", action_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, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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 == "complete_task": - validation = canonical_validate(raw, EmptyCommand) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.actions.complete_task(task_id, invocation_id=self._invocation_id(task_id)) - elif name == "record_geometry_conclusion": - validation = canonical_validate(raw, StatelessGeometryConclusion) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = GeometryConclusion( - working_head=state.working_head, - evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)), - root_cause=validation.root_cause, - decision=validation.decision, - corrective_intent=validation.corrective_intent, - ) - command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id)) - elif name == "rollback_checkpoint": - rollback_schema = stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))) - validation = canonical_validate(raw, StatelessRollbackCheckpoint) - dynamic_error = canonical_validate_schema(raw, rollback_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 "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = RollbackCheckpoint(working_head=state.working_head, checkpoint_token=validation.checkpoint_token, reason=validation.reason) - command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id)) - else: - validation = canonical_validate(raw, StatelessNextAction) - dynamic_error = canonical_validate_schema(raw, action_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 "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = NextAction( - working_head=state.working_head, - intent=validation.intent, - requirement_ids=requirement_ids, - atomic_id=validation.operation, - expected_change=validation.expected_change, - ) - command = self.actions.propose_next_action(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "action_selection", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "action_selection", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if state.phase == TaskPhase.ACTION_PENDING: - observed = action_observations.setdefault(state.working_head, set()) - tools = self._recovery_tools(task_id, state) - if not tools and self.actions.repair_action_ready(task_id, state): - 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 "tool_call", {"taskId": task_id, "tool": "author_turn", "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 == "record_geometry_conclusion": - validation = canonical_validate(raw, StatelessGeometryConclusion) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = GeometryConclusion( - working_head=state.working_head, - evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)), - root_cause=validation.root_cause, - decision=validation.decision, - corrective_intent=validation.corrective_intent, - ) - command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "tool_call", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if name == "inspect_topology": - validation = canonical_validate(raw, StatelessTopologyRequest) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - 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 = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:] - continue - fragment = canonical_json_object(raw) - if isinstance(fragment, WorkflowError): - validation_error = fragment - terminal = self._format_failure(task_id, state, name, validation_error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation_error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.actions.submit_cdsl_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - # A candidate build may reject and advance back to - # ACTION_PENDING. That is repair work, not a no-side- - # effect schema/state retry. - after = self.repository.get_state(task_id) - if after is not None and after.version != state.version: - feedback = [self._feedback(command.error)] - yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage) - continue - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "candidate_result", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if state.phase == TaskPhase.CANDIDATE_REVIEW: - recovered = self.actions.recover_candidate_review(task_id) - if recovered is not None: - yield "candidate_review", {"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 - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - review = await self._review_candidate(task_id, reviewer, state, feedback) - if isinstance(review, WorkflowError): - if review.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "review_candidate", review, format_errors, feedback, actor="reviewer") - yield "candidate_review", {"taskId": task_id, "status": "error", "result": review.payload()} - if terminal: - yield terminal - return - continue - yield self._service_failure(task_id, state, review) - return - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") or {} - action = state.pending_action - if action is None: - yield self._storage_failure(task_id, "Candidate action is unavailable during review.") - return - coverage = [] - for item in candidate.get("claim_results") or (): - if not isinstance(item, dict): - continue - status = str(item.get("status") or "pending") - coverage.append({ - "claim_id": str(item.get("claim_id") or ""), - "status": status if status in {"pass", "pending", "fail", "not_applicable"} else "fail", - "evidence_refs": [], - }) - review = CandidateReview( - candidate_id=state.candidate_id, - working_head=action.working_head, - verdict=review.verdict, - claim_coverage=coverage, - evidence=review.evidence, - issues=review.issues, - ) - command = self.actions.record_candidate_review(task_id, review, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, "review_candidate", command.error, format_errors, feedback, actor="reviewer") - yield "candidate_review", {"taskId": task_id, "status": "error", "result": command.error.payload()} - if terminal: - yield terminal - return - continue - 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": - completed_state = self.repository.get_state(task_id) - if completed_state is not None: - try: - self._ensure_recovered_completion_result(task_id, completed_state) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} - yield "final_review", {"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 - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - review = await self._review_final(task_id, reviewer, state, feedback) - if isinstance(review, WorkflowError): - if review.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "review_final", review, format_errors, feedback, actor="reviewer") - yield "final_review", {"taskId": task_id, "status": "error", "result": review.payload()} - if terminal: - yield terminal - return - continue - yield self._service_failure(task_id, state, review) - return - facts = self.actions._facts(task_id, state.active_revision) - claim_results = self.actions._evaluate_claims(task_id, facts) - visual_decisions = iter(review.visual_claims) - coverage = [] - for item in claim_results: - if item.get("deterministic"): - status = str(item.get("status") or "fail") - status = status if status in {"pass", "pending", "fail", "not_applicable"} else "fail" - else: - status = next(visual_decisions).status - coverage.append({"claim_id": str(item.get("claim_id") or ""), "status": status, "evidence_refs": []}) - stateless_review = review - review = FinalReview( - working_head=state.working_head, - verdict=review.verdict, - claim_coverage=coverage, - evidence=review.evidence, - issues=review.issues, - ) - will_complete = ( - stateless_review.verdict == "pass" - and all(item.get("status") == "pass" for item in claim_results if item.get("deterministic")) - and all(item.status == "pass" for item in stateless_review.visual_claims) - ) - if will_complete: - try: - self.requirements.write_completion_result( - task_id, - state, - claim_results=claim_results, - review=stateless_review.model_dump(mode="json"), - ) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - command = self.actions.record_final_review(task_id, review, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, "review_final", command.error, format_errors, feedback, actor="reviewer") - yield "final_review", {"taskId": task_id, "status": "error", "result": command.error.payload()} - if terminal: - yield terminal - return - continue - if isinstance(command, Accepted) and command.payload.get("status") == "completed": - yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} - 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"} - yield terminal - return - if state: - failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) - self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}]) - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Workflow exceeded its finite turn limit."} - except OSError as error: - 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"} - yield terminal - return - if state 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": "failed_internal", "message": str(error)[:1000]}]) - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]} - finally: - # The database ledger is authoritative. A failed mirror write is - # retried by the next active workflow pass; do not replace a - # completed/cancelled durable result with a filesystem exception. - try: - self._sync_action_ledger(task_id) - except Exception: - pass + except AdapterUnavailable as error: + self._fail(task_id, state, ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error), retryable=True) + except OSError as error: + self._fail(task_id, state, ErrorCode.STORAGE_FAILURE, str(error), retryable=True) + except Exception as error: # A coordinator failure must have a durable terminal record. + self._fail(task_id, state, ErrorCode.FAILED_INTERNAL, str(error), retryable=False) - def _sync_action_ledger(self, task_id: str) -> None: - self.artifacts.sync_action_ledger(task_id, self.repository.ledger_events(task_id)) - - def _call_budget_terminal( - self, - task_id: str, - state: TaskState, - budget: _ModelCallBudget, - *, - actor: str, - ) -> tuple[str, dict[str, Any]] | None: - if not budget.exhausted(actor): - 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, - ErrorCode.CALL_BUDGET_EXHAUSTED, - "Configured model-call budget is exhausted; publishing the last executable checkpoint.", - budget=details, - ) - if terminal: - return terminal - 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 workflow converged.", - **details, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value, - "message": "Configured model-call budget is exhausted before the workflow converged.", - "budget": details, - "blockerType": "call_budget_exhausted", - "userActionRequired": False, - } - - def _best_effort_terminal( - self, - task_id: str, - state: TaskState, - reason: ErrorCode, - message: str, - *, - budget: dict[str, Any] | None = None, - ) -> tuple[str, dict[str, Any]] | None: - if not state.active_revision or state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: - return None - claim_results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision)) - visual_claims = [ - {"status": "not_reviewed", "evidence": "Final review was not reached before bounded completion."} - for item in claim_results - if not item.get("deterministic") - ] + async def _analyze(self, task_id: str, state: TaskState, author: ModelIdentity) -> tuple[str, dict[str, Any]]: + if state.requirements_path: + next_state = transition(state, "analysis_written") + self.repository.compare_and_swap(next_state, events=[{"event": "requirements_reused", "path": state.requirements_path}]) + return "requirements_ready", {"taskId": task_id, "path": state.requirements_path, "reused": True} + source = self.artifacts.read_source_requirements(task_id) try: - self.requirements.write_completion_result( + analysis = RequirementsAnalysis.model_validate(await self._tool_call( + task_id, author, "analyze_requirements", RequirementsAnalysis.model_json_schema(), + "Extract explicit CAD requirements, safe assumptions, and verification targets. Do not invent strict dimensions that the request did not specify.", + source, + )) + except (AuthoringCompileError, ValueError) as error: + diagnostic_path = self.artifacts.write_json_once(task_id, "documents/requirements-analysis-diagnostic.json", { + "schema_version": "cad.requirements-diagnostic.v1", + "diagnostics": [{"code": "AUTHOR_SCHEMA_INVALID", "message": str(error)[:1000]}], + }) + failed = transition(state, "failed", diagnostics_path=diagnostic_path, error=ErrorCode.AUTHOR_SCHEMA_INVALID) + self.repository.compare_and_swap(failed, events=[{"event": "requirements_analysis_failed", "diagnostics_path": diagnostic_path}]) + return "task_terminal", self._terminal(failed) + if analysis.clarification_question: + path = self.artifacts.write_json_once(task_id, "documents/clarification-request.json", {"question": analysis.clarification_question}) + waiting = transition(state, "waiting_for_user", clarification_path=path, requirements_path="") + self.repository.compare_and_swap(waiting, events=[{"event": "requirements_waiting_for_user", "question": analysis.clarification_question}]) + return "task_terminal", self.waiting_for_user_terminal(task_id, waiting) + path = self.artifacts.write_json_once(task_id, "documents/requirements-analysis.json", analysis.model_dump(mode="json")) + next_state = transition(state, "analysis_written", requirements_path=path) + self.repository.compare_and_swap(next_state, events=[{"event": "requirements_analyzed", "path": path, "assumptions": analysis.assumptions}]) + return "requirements_ready", {"taskId": task_id, "path": path, "assumptions": analysis.assumptions} + + async def _author(self, task_id: str, state: TaskState, author: ModelIdentity) -> tuple[str, dict[str, Any]]: + requirements = self.artifacts.read_json(task_id, state.requirements_path) or {} + previous = self.artifacts.read_json(task_id, state.authoring_path) if state.authoring_path else None + diagnostics = self.artifacts.read_json(task_id, state.diagnostics_path) if state.diagnostics_path else None + operation_schemas = { + atomic_id: self._author_operation_contract(self.runtime.operation_contract(atomic_id)) + for atomic_id in self.runtime.supported_atomic_ids() + } + repair_instruction = "" if state.repair_count == 0 else "Return a complete replacement document. Preserve only features confirmed in executed_feature_ids unless the diagnostic identifies that feature. Features that did not execute may be corrected. Keep local names unless the diagnostic identifies a name conflict. Never add IDs, stable selectors, snapshots, or tokens." + content = json.dumps({"requirements": requirements, "supported_operations": operation_schemas, "previous_authoring": previous, "diagnostics": diagnostics}, ensure_ascii=False) + try: + raw = await self._tool_call( + task_id, author, "write_authoring_cdsl", AuthoringDocument.model_json_schema(), + "Write only cad.author.v1. Use local body and feature names. The service creates all runtime IDs. " + repair_instruction + "\n\n" + load_authoring_guidance(), + content, + ) + document = AuthoringDocument.model_validate(raw).model_dump(mode="json") + if previous is not None and state.repair_count: + self._validate_repair_document(previous, document, diagnostics, self.artifacts.read_json(task_id, state.compile_audit_path) if state.compile_audit_path else None) + except (AuthoringCompileError, ValueError) as error: + return self._repair_or_stop( task_id, state, - claim_results=claim_results, - review={"visual_claims": visual_claims}, + validation_error_code(error), + str(error), + self._author_validation_details(error), ) - except OSError: - return None - completed = self.actions.finalize_best_effort(task_id, reason=reason, invocation_id=self._invocation_id(task_id)) - if isinstance(completed, Rejected): - return None - return "task_terminal", { - "taskId": task_id, - "lifecycle": "completed", - "revisionId": state.active_revision, - "code": ErrorCode.BEST_EFFORT_COMPLETED.value, - "message": message, - "issues": completed.payload.get("issues") or [], - "verificationStatus": "completed_with_risks", - "verificationWarnings": completed.payload.get("issues") or [], - "completionResultPath": "completion-result.md", - "budget": budget or {}, - "userActionRequired": False, - } + path = self.artifacts.write_json_once(task_id, f"documents/authoring-cdsl-attempt-{state.repair_count + 1:02d}.json", document) + next_state = transition(state, "authoring_written", authoring_path=path) + self.repository.compare_and_swap(next_state, events=[{"event": "authoring_cdsl_written", "path": path, "repair_count": state.repair_count}]) + return "authoring_cdsl_ready", {"taskId": task_id, "path": path, "repairCount": state.repair_count} - def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]: - """Park a nonterminal task when a durable artifact operation fails.""" - state = self.repository.get_state(task_id) - if state is not None and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}: - waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE) - self.repository.compare_and_swap(waiting, events=[{ - "event": "waiting_retry", - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "waiting_retry", - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - } - lifecycle = state.phase.value.lower() if state is not None else "failed" - return "task_terminal", { - "taskId": task_id, - "lifecycle": lifecycle, - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - } - - async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, Any]] | WorkflowError: - if len(tools) != 1: - raise RuntimeError("Workflow state must expose exactly one author tool.") - tool = tools[0] - name = str((tool.get("function") or {}).get("name") or "") - if not name: - raise RuntimeError("Workflow exposed an unnamed author tool.") - messages, guidance = self._author_context(task_id, feedback) + def _compile(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: + authoring = self.artifacts.read_json(task_id, state.authoring_path) + if authoring is None: + raise RuntimeError("Authoring CDSL artifact is unavailable") try: - response = await self.model_gateway.call_tool( - messages=messages, tool=tool, provider_id=author.provider_id, - model_id=author.model_id, required_tool_name=name, - ) - except AdapterUnavailable as error: - self.repository.record_usage(task_id, { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "usage_available": False, - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "retry_reason": "provider_unavailable", - "cache_hit": False, - **guidance.usage_metadata(), - }) - return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True) - call = validate_one_tool_call(response["tool_calls"], name) - if isinstance(call, WorkflowError): - # A provider response is still a billable author attempt even when - # it violates the one-tool-call protocol. Keep guidance audit - # metadata on that record so A/B reports do not silently omit the - # failures this corpus is intended to reduce. - self.repository.record_usage(task_id, { - **response["usage"], - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "raw_arguments_hash": "", - "retry_reason": "invalid_tool_call", - "cache_hit": False, - **guidance.usage_metadata(), - }) - self._record_rejected_tool_calls( - task_id, - actor="author", - expected_tool=name, - tool_calls=response["tool_calls"], - schema=(tool.get("function") or {}).get("parameters"), - ) - return call - _name, raw = call - self._record_tool_audit( - task_id, - actor="author", - tool=name, - raw_arguments=raw, - schema=(tool.get("function") or {}).get("parameters"), - ) - usage = { - **response["usage"], - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "raw_arguments_hash": raw_arguments_hash(raw), - "retry_reason": "", - "cache_hit": False, - **guidance.usage_metadata(), - } - self.repository.record_usage(task_id, usage) - return name, raw, usage + compiled = self.executor.compile(task_id, authoring, repair_count=state.repair_count) + except AuthoringCompileError as error: + return self._repair_or_stop(task_id, state, error.code, str(error), {"path": error.path}) + next_state = transition(state, "compiled", runtime_cdsl_path=compiled["runtime_path"], compile_audit_path=compiled["audit_path"]) + self.repository.compare_and_swap(next_state, events=[{"event": "cdsl_compiled", "runtime_path": compiled["runtime_path"], "compile_audit_path": compiled["audit_path"]}]) + return "cdsl_compiled", {"taskId": task_id, "runtimePath": compiled["runtime_path"], "compileAuditPath": compiled["audit_path"]} - async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessCandidateReview | WorkflowError: - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - action = state.pending_action - if not isinstance(candidate, dict) or action is None: - return WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review facts are unavailable.", retryable=True) - return await self._review_tool(task_id, reviewer, "review_candidate", StatelessCandidateReview, { - "requirements": self._public_requirements(self._requirements_contract(task_id, state)), - "action": {"intent": action.intent, "expected_change": action.expected_change, "operation": action.atomic_id}, - "candidate_facts": self._public_candidate(candidate), - "render_manifest": candidate.get("render_manifest") or {}, - "instruction": "Review only whether the current checkpoint correctly performs the stated action. Deterministic facts are authoritative. Do not return task, action, candidate, requirement, claim, revision, head, or evidence identifiers.", - }, feedback=feedback) + def _build(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: + authoring = self.artifacts.read_json(task_id, state.authoring_path) + runtime_cdsl = self.artifacts.read_json(task_id, state.runtime_cdsl_path) + audit = self.artifacts.read_json(task_id, state.compile_audit_path) + if authoring is None or runtime_cdsl is None or audit is None: + raise RuntimeError("Compiled CDSL artifacts are unavailable") + result = self.executor.build(task_id, authoring, runtime_cdsl, audit, repair_count=state.repair_count) + diagnostics = result.get("diagnostics") if isinstance(result.get("diagnostics"), list) else [] + diagnostic_path = self.artifacts.write_json_once(task_id, f"documents/diagnostics-attempt-{state.repair_count + 1:02d}.json", {"diagnostics": diagnostics, "executed_feature_ids": result.get("executed_feature_ids", [])}) + revision = str(result.get("revision_id") or "") + if diagnostics: + if state.repair_count < self.config.max_repairs: + repairing = transition(state, "repair_required", active_revision=revision or state.active_revision, diagnostics_path=diagnostic_path, error=ErrorCode.ENGINE_EXECUTION_FAILED) + self.repository.compare_and_swap(repairing, events=[{"event": "build_failed", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revision_id": revision}]) + return "build_result", {"taskId": task_id, "status": "repair_required", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revisionId": revision} + publishing = transition(state, "build_completed", active_revision=revision or state.active_revision, diagnostics_path=diagnostic_path, error=ErrorCode.BEST_EFFORT_COMPLETED) + self.repository.compare_and_swap(publishing, events=[{"event": "published_best_effort", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revision_id": revision}]) + return "build_result", {"taskId": task_id, "status": "published_best_effort", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revisionId": revision} + publishing = transition(state, "build_completed", active_revision=revision, diagnostics_path=diagnostic_path) + self.repository.compare_and_swap(publishing, events=[{"event": "build_completed", "paths": result.get("paths", {}), "revision_id": revision, "executed_feature_ids": result.get("executed_feature_ids", [])}]) + return "build_result", {"taskId": task_id, "status": "completed", "paths": result.get("paths", {}), "revisionId": revision} - async def _observe_images(self, task_id: str, reviewer: ModelIdentity, image_paths: list[str]) -> ImageObservation | WorkflowError: - return await self._review_tool(task_id, reviewer, "observe_images", ImageObservation, { - "source_requirements": self.artifacts.read_source_requirements(task_id), - "reference_image_paths": image_paths, - "instruction": ( - "Inspect every supplied reference image once. Describe visible part geometry, view directions, readable dimensions, holes and profiles, confidence, assumptions, and uncertainties. " - "Do not create CAD operations and do not return attachment or runtime identifiers." - ), - }) - - async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessFinalReview | WorkflowError: - facts = self.actions._facts(task_id, state.active_revision) - results = self.actions._evaluate_claims(task_id, facts) - visual_claims = [item for item in results if not item.get("deterministic")] - return await self._review_tool(task_id, reviewer, "review_final", StatelessFinalReview, { - "source_requirements": self.artifacts.read_source_requirements(task_id), - "requirements": self._public_requirements(self._requirements_contract(task_id, state)), - "deterministic_results": [self._public_claim_result(item) for item in results if item.get("deterministic")], - "visual_claims": [self._public_claim_result(item) for item in visual_claims], - "render_manifest": (facts.get("report") or {}).get("render_manifest") or {}, - "reference_image_paths": self.artifacts.source_image_paths(task_id), - "instruction": "Review the final CAD renders against the original reference images and the ordered visual claims. Return exactly one visual_claims decision for each supplied visual claim, in the same order. Deterministic results are final. Do not return any runtime identifiers.", - }, schema=stateless_final_review_schema(len(visual_claims)), feedback=feedback) - - async def _review_tool(self, task_id: str, reviewer: ModelIdentity, name: str, model_type: type[T], payload: dict[str, Any], *, schema: dict[str, Any] | None = None, feedback: list[dict[str, Any]] | None = None) -> T | WorkflowError: - if feedback: - payload = {**payload, "previous_schema_or_state_error": str(feedback[-1].get("content") or "")[:2_000]} - try: - tool = self._tool(name, schema or model_type) - kind = "image_observation" if name == "observe_images" else "candidate" if name == "review_candidate" else "final" - response = await self.review_gateway.review(kind=kind, payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id) - except AdapterUnavailable as error: - error_code = ( - ErrorCode.RENDER_SERVICE_UNAVAILABLE - if str(error).startswith("RENDER_SERVICE_UNAVAILABLE:") - else ErrorCode.REVIEW_SERVICE_UNAVAILABLE - ) - self.repository.record_usage(task_id, { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "usage_available": False, - "context_chars": len(json.dumps(payload, ensure_ascii=False)), - "tool": name, - "provider_id": reviewer.provider_id, - "model_id": reviewer.model_id, - "role": "reviewer", - "retry_reason": error_code.value.lower(), - "cache_hit": False, - }) - return WorkflowError(error_code, str(error)[:1000], retryable=True) - check = validate_one_tool_call(response["tool_calls"], name) - if isinstance(check, WorkflowError): - self._record_rejected_tool_calls( - task_id, - actor="reviewer", - expected_tool=name, - tool_calls=response["tool_calls"], - schema=(tool.get("function") or {}).get("parameters"), - ) - return check - _tool_name, raw = check - self._record_tool_audit( - task_id, - actor="reviewer", - tool=name, - raw_arguments=raw, - schema=(tool.get("function") or {}).get("parameters"), - ) - self.repository.record_usage(task_id, { - **response["usage"], - "context_chars": len(json.dumps(payload, ensure_ascii=False)), - "tool": name, - "provider_id": reviewer.provider_id, - "model_id": reviewer.model_id, - "role": "reviewer", - "raw_arguments_hash": raw_arguments_hash(raw), - "retry_reason": "", - "cache_hit": False, - }) - validated = canonical_validate(raw, model_type) - if isinstance(validated, WorkflowError): - return validated - dynamic_error = canonical_validate_schema(raw, schema) if schema is not None else None - return dynamic_error or validated # type: ignore[return-value] - - def _record_tool_audit( - self, - task_id: str, - *, - actor: str, - tool: str, - raw_arguments: str, - schema: Any, - returned_tool: str | None = None, - single_allowed_call: bool = True, - ) -> None: - """Persist the raw-output validation and current state-binding facts. - - Provider parsers are not a trust boundary. This stores a redacted, - diagnostic copy locally while release reports expose only the hash and - validation/binding summary. - """ - state = self.repository.get_state(task_id) - schema_error = canonical_validate_schema(raw_arguments, schema) if isinstance(schema, dict) else WorkflowError( - ErrorCode.RUNTIME_CONTRACT_INVALID, - "The exposed tool has no JSON Schema.", - ) - parsed = canonical_json_object(raw_arguments) - supplied_head = parsed.get("working_head") if isinstance(parsed, dict) else None - topology = self.artifacts.read_topology(task_id, state.active_revision) if state and state.active_revision else None - pending = state.pending_action if state else None - contract_current = True - if pending is not None: - try: - contract_current = self.runtime.operation_contract(pending.atomic_id).get("contract_hash") == pending.contract_hash - except Exception: - contract_current = False - schema_head = self._schema_working_head(schema) - expected_head = schema_head or (state.working_head if state is not None else "") - # Candidate reviews are scoped to the action checkpoint that was used - # to build the candidate. The state has already advanced by the time - # the review runs, so current-state equality would reject a valid, - # schema-bound response. The dynamic schema is server-generated and - # canonical-validated above, making its const the authority here. - working_head_matches = not isinstance(supplied_head, str) or (bool(expected_head) and supplied_head == expected_head) - binding_valid = bool(state) and single_allowed_call and schema_error is None and working_head_matches and contract_current - self.repository.record_tool_audit(task_id, { - "schema_version": "cad.v3.tool-audit.v2", - "actor": actor, - "tool": tool, - "raw_arguments_hash": raw_arguments_hash(raw_arguments), - "redacted_arguments": self._redact_tool_arguments(parsed) if isinstance(parsed, dict) else None, - "canonical_schema_valid": schema_error is None, - "field_errors": list(schema_error.field_errors) if isinstance(schema_error, WorkflowError) else [], - "state_binding": { - "phase": state.phase.value if state else "", - "working_head": state.working_head if state else "", - "expected_working_head": expected_head, - "working_head_binding_source": "schema_const" if schema_head else "current_state", - "supplied_working_head": str(supplied_head or ""), - "working_head_matches": working_head_matches, - "active_revision": state.active_revision if state else "", - "contract_hash": pending.contract_hash if pending else "", - "contract_hash_current": contract_current, - "selector_snapshot_id": str((topology or {}).get("snapshot_id") or ""), - "binding_valid": binding_valid, - }, - "returned_tool": returned_tool if returned_tool is not None else tool, - "single_allowed_call": single_allowed_call, - }) - - @staticmethod - def _schema_working_head(schema: Any) -> str | None: - """Return a server-bound head from a dynamic top-level tool schema.""" - properties = schema.get("properties") if isinstance(schema, dict) else None - working_head = properties.get("working_head") if isinstance(properties, dict) else None - value = working_head.get("const") if isinstance(working_head, dict) else None - return value if isinstance(value, str) else None - - def _record_rejected_tool_calls( - self, - task_id: str, - *, - actor: str, - expected_tool: str, - tool_calls: list[dict[str, Any]], - schema: Any, - ) -> None: - """Audit every returned call even when the one-call gate rejects it.""" - if not tool_calls: - self._record_tool_audit( - task_id, - actor=actor, - tool=expected_tool, - raw_arguments="", - schema=schema, - returned_tool="", - single_allowed_call=False, - ) - return - for call in tool_calls: - function = call.get("function") if isinstance(call, dict) and isinstance(call.get("function"), dict) else {} - name = str(function.get("name") or "") - arguments = function.get("arguments") - raw = arguments if isinstance(arguments, str) else "" - self._record_tool_audit( - task_id, - actor=actor, - tool=expected_tool, - raw_arguments=raw, - schema=schema, - returned_tool=name, - single_allowed_call=False, - ) - - @staticmethod - def _redact_tool_arguments(value: Any) -> Any: - if isinstance(value, list): - return [WorkflowCoordinator._redact_tool_arguments(item) for item in value] - if not isinstance(value, dict): - return value - sensitive = {"api_key", "authorization", "credential", "credentials", "secret", "token", "password"} - return { - str(key): "[REDACTED]" if str(key).casefold() in sensitive else WorkflowCoordinator._redact_tool_arguments(item) - for key, item in value.items() - } - - def _action_tools(self, task_id: str, state: TaskState, observed: set[str] | None = None) -> list[dict[str, Any]]: - action = state.pending_action - if action is None: - return [] - contract = self.runtime.operation_contract(action.atomic_id) - selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") - seen = observed or set() - topology = self.artifacts.read_topology(task_id, state.active_revision) - tokens = self.runtime.selector_tokens(topology) - eligible_tokens = self._selector_tokens_for_contract(contract, tokens) - if selector_shape == "required" and len(eligible_tokens) > 16 and "topology" not in seen: - return [self._tool("inspect_topology", StatelessTopologyRequest)] - references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) - description = "Submit exactly one CDSL feature for the pending action." - if not state.active_revision and action.atomic_id in {"extrude_add_blind", "extrude_add_two_sided"}: - description += " Root extrusion uses the world XY datum: workplane.origin_mm must be [0, 0, Z], normal [0, 0, 1], and x_dir [1, 0, 0]. Profile coordinates are local to that plane." - if action.atomic_id.startswith("hole_"): - description += ( - " Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. " - "It is not a host-face-local offset; the server converts the world point to the selected face frame." - ) - if selector_shape == "required": - description += ( - " The root payload shape is {\"feature\":{\"atomic_id\":\"...\"," - "\"selector_tokens\":[\"opaque topology token\"],\"params\":{...}}}. " - "feature.selector_tokens is required author input: copy one of the opaque tokens from " - "inspect_topology; the server resolves it to the host after validation." - ) - fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}} - return [fragment] - - def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: - if not state.repair_required: - return [] - if self.actions.rollback_available(task_id, state): - return [self._tool("rollback_checkpoint", stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))))] - if self.actions.repair_action_ready(task_id, state): - return [] - evidence_refs = list(self.actions.diagnostic_evidence_refs(task_id, state)) - if not evidence_refs: - return [] - return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)] - - def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], AuthorGuidanceSelection]: - state = self.repository.get_state(task_id) - if state is None: - return [], AuthorGuidanceSelection(fallback_reason="task_state_unavailable") - if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: - content = { - "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), - "instruction": ( - "Write the frozen engineering-expanded requirements Markdown using every required heading. Clearly separate explicit user facts from engineering defaults. " - "Conventional functional geometry is allowed for underspecified common parts, but never contradict explicit text or the image observation. Do not include runtime identifiers." - ), - } - elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET: - content = { - "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.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. 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 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.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": 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": [ - { - "claim_kind": claim.get("claim_kind"), - "expected": claim.get("expected"), - } - for claim in item.get("acceptance_claims") or () - if isinstance(claim, dict) - ], - } for item in contract.get("requirements") or () if isinstance(item, dict)] - 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 = "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)) - selector_summary = [ - {"token": token, "kind": value.get("kind"), "geometry": {key: value.get("geometry", {}).get(key) for key in ("center_mm", "normal", "bbox_mm", "surface_type") if key in value.get("geometry", {})}} - 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." - 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), - }) - guidance = self.author_guidance.select( - phase=state.phase, - atomic_id=state.pending_feature.atomic_id if state.pending_feature is not None else "", - repair_required=state.repair_required, - supported_atomic_ids=self.runtime.supported_atomic_ids(), - ) - system = "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON." - if guidance.content: - system += "\n\nThe following is non-authoritative CDSL author guidance. The current tool schema, operation contract, and server facts take precedence.\n\n" + guidance.content - messages: list[dict[str, Any]] = [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] - return [*messages, *feedback[-2:]], guidance - - def _user_clarifications(self, task_id: str) -> list[dict[str, str]]: - clarifications: list[dict[str, str]] = [] - for event in self.repository.ledger_events(task_id): - if event.get("event") != "user_clarification_received": - continue - path = str(event.get("clarification_path") or "") - payload = self.artifacts.read_json(task_id, path) if path else None - text = str((payload or {}).get("text") or "").strip() - if text: - clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text}) - return clarifications - - def _read_markdown(self, task_id: str, relative_path: str) -> str: - if not relative_path: - return "" - try: - path = self.artifacts.artifact_path(task_id, relative_path) - return path.read_text(encoding="utf-8") if path.is_file() else "" - 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. - - The clarification artifact is the durable source of the single human - decision needed to continue this task. - """ - clarification = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None - question = str((clarification or {}).get("question") or "").strip() - questions = [question] if question else [] - if not questions: - raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question") - message = f"Requirements need a user decision. {questions[0]}" - payload: dict[str, Any] = { - "taskId": task_id, - "lifecycle": TaskPhase.WAITING_FOR_USER.value.lower(), - "revisionId": state.active_revision, - "code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value, - "message": message, - "questions": questions, - "clarificationPath": state.clarification_path, - "blockerType": "requirements_ambiguity", - "userActionRequired": True, - } - return payload - - def _requirements_contract(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None: - if state is None or not state.requirements_contract_path: - return None - return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - - def _ensure_recovered_completion_result(self, task_id: str, state: TaskState) -> None: - result_path = self.artifacts.artifact_path(task_id, "completion-result.md") - if result_path.is_file(): - return - facts = self.actions._facts(task_id, state.active_revision) - claim_results = self.actions._evaluate_claims(task_id, facts) - raw_review = self.artifacts.read_json(task_id, f"reviews/final/{state.active_revision}/final-review.json") or {} - coverage = { - str(item.get("claim_id") or ""): item - for item in raw_review.get("claim_coverage") or () - if isinstance(item, dict) - } - visual_claims = [ - { - "status": str(coverage.get(str(item.get("claim_id") or ""), {}).get("status") or "fail"), - "evidence": "; ".join(str(value) for value in raw_review.get("evidence") or ()) or "Recovered final review decision.", - } - for item in claim_results - if not item.get("deterministic") - ] - self.requirements.write_completion_result( - task_id, - state, - claim_results=claim_results, - review={"visual_claims": visual_claims}, - ) - - @staticmethod - def _public_requirements(contract: dict[str, Any] | None) -> list[dict[str, Any]]: - return [ - { - "statement": str(item.get("statement") or ""), - "assumptions": list(item.get("assumptions") or []), - "acceptance_claims": [ - { - "claim_kind": str(claim.get("claim_kind") or ""), - "expected": claim.get("expected") or {}, - "verification_mode": str(claim.get("verification_mode") or ""), - } - for claim in item.get("acceptance_claims") or () - if isinstance(claim, dict) - ], - } - for item in (contract or {}).get("requirements") or () - if isinstance(item, dict) - ] - - @staticmethod - def _public_claim_result(item: dict[str, Any]) -> dict[str, Any]: - return { - key: value - for key, value in item.items() - if key not in {"claim_id", "requirement_id", "evidence_refs"} - } - - @classmethod - def _public_candidate(cls, candidate: dict[str, Any]) -> dict[str, Any]: - return { - "claim_results": [cls._public_claim_result(item) for item in candidate.get("claim_results") or () if isinstance(item, dict)], - "operation_verifier_results": [cls._public_claim_result(item) for item in candidate.get("operation_verifier_results") or () if isinstance(item, dict)], - "health": candidate.get("health") or {}, - "model_summary": candidate.get("model_summary") or {}, - } - - @staticmethod - def _public_pending_context(state: TaskState) -> dict[str, Any] | None: - action = state.pending_action - 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]: - protocol = { - "system": "world_mm_right_handed", - "sketch_mapping": "workplane.origin_mm is the world position of sketch local (0,0); profile points such as circle.center are sketch-local.", - "vectors": "normal is positive extrusion direction; x_dir is sketch local +X expressed in world coordinates.", - "hosted_features": "For existing solids, use the selected max_z/min_z face and its supplied normal; do not infer or hand-copy a world-space offset.", - } + def _publish(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: if not state.active_revision: - protocol["root_extrusion"] = "Root extrude_add_blind and extrude_add_two_sided are bound to world XY: origin=[0,0,Z], normal=[0,0,1], x_dir=[1,0,0]. Requirements decide Z only; never place a Z offset in Y." - return protocol - - @staticmethod - def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(payload, dict): - return None - return { - "operation": payload.get("atomic_id"), - "contract": payload.get("contract"), - "fragment_schema": payload.get("fragment_schema"), - } - - def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]: - projection = self.repository.get_task_projection(task_id) or {} - return { - "taskId": task_id, - "lifecycle": str(projection.get("lifecycle") or state.phase.value.lower()), - "revisionId": state.active_revision, - "code": state.last_error.value if state.last_error else "", - "message": str(projection.get("message") or ("CAD generation completed." if state.phase == TaskPhase.COMPLETED else "CAD generation stopped.")), - "questions": projection.get("questions") or [], - "issues": projection.get("issues") or [], - "blockerType": str(projection.get("blocker_type") or ""), - "userActionRequired": bool(projection.get("user_action_required")), - "verificationStatus": str(projection.get("verification_status") or "verified"), - "verificationWarnings": projection.get("verification_warnings") or [], - } - - def _operation_payload(self, task_id: str, state: TaskState) -> dict[str, Any]: - action = state.pending_action - assert action is not None - contract = self.runtime.operation_contract(action.atomic_id) - topology = self.artifacts.read_topology(task_id, state.active_revision) - tokens = self.runtime.selector_tokens(topology) - references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) - return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))} - - 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)): - 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 - 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": 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), - "message": str(event.get("message") or event.get("reason") or "")[:360], - }) - if len(constraints) == 4: - 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.""" - if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") != "required": - return [] - policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - kind = str(policy.get("token_kind") or "") - return [token for token, value in tokens.items() if isinstance(value, dict) and value.get("kind") == kind] - - def _topology_payload(self, task_id: str, state: TaskState, kind: str | None, limit: int) -> dict[str, Any]: - tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) - values = [{"token": token, "kind": item["kind"], "geometry": {key: item["geometry"].get(key) for key in ("center_mm", "normal", "plane_normal", "bbox_mm", "radius_mm", "surface_type") if key in item["geometry"]}} for token, item in tokens.items() if not kind or item["kind"] == kind] - return { - "working_head": state.working_head, - "coordinate_system": "world_mm", - "tokens": values[:limit], - } - - def _can_complete(self, task_id: str, state: TaskState) -> bool: - if not state.active_revision or state.repair_required: - return False - results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision)) - return bool(results) and all(item.get("status") == "pass" for item in results if item.get("deterministic")) - - def _transport_or_failure(self, task_id: str, state: TaskState, error: WorkflowError, author: ModelIdentity, attempted: set[str]) -> tuple[ModelIdentity, tuple[str, dict[str, Any]] | None]: - if error.code != ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE: - failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) - self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": error.message}]) - return author, ("task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": error.message}) - attempted.add(author.provider_id) - # The adapter already performs bounded exponential retries for the - # active provider. The workflow permits one, and only one, provider - # failover before parking the durable task for explicit recovery. - fallback = next((candidate for candidate in self.config.author_fallbacks if candidate.provider_id not in attempted), None) if len(attempted) == 1 else None - if fallback is not None: - self.repository.append_outbox(task_id, {"event": "author_provider_failover", "from_provider": author.provider_id, "to_provider": fallback.provider_id}) - return fallback, None - waiting = transition(state, "waiting_retry", error=ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE) - self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message}]) - return author, ("task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message}) - - def _service_failure(self, task_id: str, state: TaskState, error: WorkflowError) -> tuple[str, dict[str, Any]]: - waiting = transition(state, "waiting_retry", error=error.code) - self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": error.code.value, "message": error.message}]) - return "task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": error.code.value, "message": error.message} - - def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None: - """Bound no-side-effect model rejections; park real service failures.""" - if error.code == ErrorCode.NO_PROGRESS_LIMIT: - current = self.repository.get_state(task_id) or state - terminal = self._best_effort_terminal( - task_id, - current, - ErrorCode.NO_PROGRESS_LIMIT, - "Further attempts repeated an already failed CAD path; publishing the last executable checkpoint.", - ) - if terminal: - return terminal - feedback[:] = [self._feedback(error)] - return None - if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID: - # Registry integrity is a deployment defect. Retrying a model with - # the same broken contract cannot repair it and must never consume - # the author-format budget. - terminal = self._best_effort_terminal(task_id, state, error.code, error.message) - if terminal: - return terminal - failed = transition(state, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "runtime_contract_invalid", - "tool": name, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - } - if error.code == ErrorCode.REQUIREMENTS_SPEC_INVALID: - # A frozen verifier contract is service-owned input at this stage. - # CAD retries cannot repair it, so preserve the terminal diagnosis - # instead of parking the task or blaming the author fragment. - current = self.repository.get_state(task_id) - if current is not None and current.phase != TaskPhase.FAILED: - failed = transition(current, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "requirements_contract_execution_failed", - "tool": name, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - "blockerType": "requirements_contract_invalid", - "userActionRequired": False, - "issues": [str(error.details.get("diagnostic") or error.message)], - } - if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED: - # A schema-valid fragment can still be impossible on the current - # geometry. This is not an author-format failure: preserve the - # accepted checkpoint, discard only the pending action and let - # the author make a fresh, evidence-backed choice. - if state.phase == TaskPhase.ACTION_PENDING: - action = state.pending_action - atomic_id = str(error.details.get("atomic_id") or (action.atomic_id if action else "")) - checkpoint_revision = str(error.details.get("active_revision") or state.active_revision) - normalized_code = str(error.details.get("normalized_error_code") or error.code.value) - failure_class_fingerprint = sha256( - f"{checkpoint_revision}|{atomic_id}|{normalized_code}".encode("utf-8") - ).hexdigest() - prior = [ - event for event in self.repository.ledger_events(task_id) - if event.get("failure_class_fingerprint") == failure_class_fingerprint - ] - if len(prior) >= 2: - next_state = transition(state, "runtime_precondition_rejected", pending_action=None, error=ErrorCode.NO_PROGRESS_LIMIT) - self.repository.compare_and_swap(next_state, events=[{ - "event": "no_progress_limit", - "tool": name, - "code": ErrorCode.NO_PROGRESS_LIMIT.value, - "message": error.message, - "checkpoint_revision": checkpoint_revision, - "atomic_id": atomic_id, - "normalized_error_code": normalized_code, - "failure_class_fingerprint": failure_class_fingerprint, - }]) - terminal = self._best_effort_terminal( - task_id, - next_state, - ErrorCode.NO_PROGRESS_LIMIT, - "The same operation failure made no progress; publishing the last executable checkpoint.", - ) - if terminal: - return terminal - feedback[:] = [self._feedback(error)] - return None - next_state = transition( - state, - "runtime_precondition_rejected", - pending_action=None, - error=ErrorCode.RUNTIME_PRECONDITION_FAILED, - ) - self.repository.compare_and_swap(next_state, events=[{ - "event": "runtime_precondition_rejected", - "tool": name, - "code": error.code.value, - "message": error.message, - "checkpoint_revision": checkpoint_revision, - "atomic_id": atomic_id, - "fragment_hash": error.details.get("fragment_hash"), - "failure_exact_fingerprint": error.details.get("failure_exact_fingerprint"), - "normalized_error_code": normalized_code, - "failure_class_fingerprint": failure_class_fingerprint, - "attempt": len(prior) + 1, - }]) - feedback_error = error - if prior: - feedback_error = WorkflowError( - error.code, - error.message + " A different fragment or operation path is required; do not repeat the proven failure class.", - field_errors=error.field_errors, - details={**error.details, "prohibited_failure_class": failure_class_fingerprint}, - ) - feedback[:] = [self._feedback(feedback_error)] - return None - # A precondition result outside fragment submission is an invalid - # workflow implementation state, not a provider/service outage. - terminal = self._best_effort_terminal(task_id, state, error.code, error.message) - if terminal: - return terminal - failed = transition(state, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "runtime_precondition_rejected", - "tool": name, - "code": error.code.value, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - } - if error.code in { - ErrorCode.CANDIDATE_BUILD_FAILED, - ErrorCode.CLAIM_VERIFICATION_FAILED, - ErrorCode.CANDIDATE_REVIEW_REJECTED, - }: - # The handler already preserved the last checkpoint and recorded - # the field/runtime diagnostic. Continue with that evidence; a - # planning miss is not an infrastructure terminal condition. - feedback[:] = [self._feedback(error)] - return None - model_rejection_codes = { - ErrorCode.AUTHOR_FORMAT_INVALID, - ErrorCode.AUTHOR_DECISION_REJECTED, - ErrorCode.STALE_WORKING_HEAD, - } - if error.code in model_rejection_codes: - return self._format_failure(task_id, state, name, error, counters, feedback, actor=actor) - return self._service_failure(task_id, state, error) - - def _format_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None: - counters[name] = counters.get(name, 0) + 1 - 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( + failed = transition(state, "failed", error=ErrorCode.ENGINE_EXECUTION_FAILED) + self.repository.compare_and_swap(failed, events=[{"event": "no_executable_model", "message": "No executable CDSL prefix could be published."}]) + return "task_terminal", self._terminal(failed) + requirements = self.artifacts.read_json(task_id, state.requirements_path) or {} + diagnostics = self.artifacts.read_json(task_id, state.diagnostics_path) or {} + authoring = self.artifacts.read_json(task_id, state.authoring_path) or {} + claim_report_path = self.artifacts.write_json_once( task_id, - state, - ErrorCode.FAILED_AUTHOR_FORMAT, - f"{actor.capitalize()} repeatedly failed the canonical schema; publishing the last executable checkpoint.", + "documents/claim-report.json", + self._claim_report(requirements), ) - if terminal: - return terminal - 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)}]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.FAILED_AUTHOR_FORMAT.value, - "message": f"{actor.capitalize()} repeatedly failed the same canonical schema or state contract.", - "tool": name, - "field_errors": list(error.field_errors), + report = self._completion_report(state, requirements, authoring, diagnostics) + path = self.artifacts.write_text_once(task_id, "completion-result.md", report) + completed = transition(state, "published", completion_path=path) + self.repository.compare_and_swap(completed, events=[{"event": "task_published", "revision_id": state.active_revision, "completion_path": path, "claim_report_path": claim_report_path, "best_effort": bool(diagnostics.get("diagnostics"))}]) + return "task_terminal", self._terminal(completed) + + def _repair_or_stop(self, task_id: str, state: TaskState, code: str, message: str, details: dict[str, Any]) -> tuple[str, dict[str, Any]]: + diagnostic = { + "code": code, + "message": message, + **details, } + diagnostic.setdefault("repair_hint", self._repair_hint(code, str(diagnostic.get("path") or ""))) + diagnostic_path = self.artifacts.write_json_once(task_id, f"documents/diagnostics-attempt-{state.repair_count + 1:02d}.json", {"diagnostics": [diagnostic]}) + error = self._error_code(code) + if state.repair_count < self.config.max_repairs: + repairing = transition(state, "repair_required", diagnostics_path=diagnostic_path, error=error) + self.repository.compare_and_swap(repairing, events=[{"event": "compile_failed", "code": code, "message": message, "diagnostics_path": diagnostic_path}]) + return "cdsl_compiled", {"taskId": task_id, "status": "repair_required", "code": code, "message": message} + if state.active_revision: + publishing = transition( + state, + "publish_best_effort", + diagnostics_path=diagnostic_path, + error=ErrorCode.BEST_EFFORT_COMPLETED, + ) + self.repository.compare_and_swap(publishing, events=[{ + "event": "repair_budget_exhausted", + "code": code, + "message": message, + "diagnostics_path": diagnostic_path, + "revision_id": state.active_revision, + }]) + return "build_result", { + "taskId": task_id, + "status": "published_best_effort", + "code": code, + "message": message, + "revisionId": state.active_revision, + } + failed = transition(state, "failed", diagnostics_path=diagnostic_path, error=error) + self.repository.compare_and_swap(failed, events=[{"event": "compile_failed", "code": code, "message": message, "diagnostics_path": diagnostic_path}]) + return "task_terminal", self._terminal(failed) - def _requirements_format_failure( - self, - task_id: str, - state: TaskState, - error: WorkflowError, - counters: dict[str, int], - feedback: list[dict[str, Any]], - *, - tool: str = "compile_requirements_spec", - ) -> tuple[str, dict[str, Any]] | None: - key = "requirements_spec" - counters[key] = counters.get(key, 0) + 1 - feedback[:] = [self._feedback(error)] - if counters[key] < 2: - return None - failed = transition(state, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID) - self.repository.compare_and_swap(failed, events=[{ - "event": "requirements_spec_invalid", - "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, - "message": error.message, - "field_errors": list(error.field_errors), - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, - "message": "Requirements specification remained unreadable after one field-level correction.", - "tool": tool, - "field_errors": list(error.field_errors), - "userActionRequired": False, - } - - def _requirements_rejection( - self, - task_id: str, - state: TaskState, - error: WorkflowError, - counters: dict[str, int], - feedback: list[dict[str, Any]], - *, - tool: str = "compile_requirements_spec", - ) -> 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 - def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]: - parameters = model if isinstance(model, dict) else model.model_json_schema() - return {"type": "function", "function": {"name": name, "description": name.replace("_", " "), "parameters": parameters}} - - @staticmethod - def _result_payload(result: Accepted | Rejected | Waiting) -> dict[str, Any]: - if isinstance(result, Accepted): - return result.payload - return result.error.payload() - - @staticmethod - def _feedback(error: WorkflowError) -> dict[str, Any]: - return {"role": "user", "content": json.dumps({"schema_or_state_error": error.payload(), "instruction": "Correct only the reported fields and return one allowed tool call."}, ensure_ascii=False)} - - @staticmethod - def _pending_context(state: TaskState) -> dict[str, Any] | None: - action = state.pending_action - return {"action_id": action.action_id, "working_head": action.working_head, "intent": action.intent, "requirement_ids": list(action.requirement_ids), "atomic_id": action.atomic_id, "expected_change": action.expected_change, "contract_hash": action.contract_hash} if action else None - - @staticmethod - def _tool_feedback(content: str) -> dict[str, Any] | None: + async def _tool_call(self, task_id: str, author: ModelIdentity, name: str, schema: dict[str, Any], system: str, user: str) -> dict[str, Any]: + response = await self.model_gateway.call_tool( + messages=[{"role": "system", "content": system}, {"role": "user", "content": user}], + tool={"type": "function", "function": {"name": name, "description": "Return one schema-valid object.", "parameters": schema}}, + provider_id=author.provider_id, model_id=author.model_id, required_tool_name=name, + ) + usage = response.get("usage") if isinstance(response.get("usage"), dict) else {} + self.repository.record_usage(task_id, {"role": "author", "tool": name, **usage}) + calls = response.get("tool_calls") if isinstance(response.get("tool_calls"), list) else [] + if len(calls) != 1 or not isinstance(calls[0], dict): + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider did not return exactly one tool call") + function = calls[0].get("function") if isinstance(calls[0].get("function"), dict) else {} + if function.get("name") != name: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider returned an unexpected tool") try: - value = json.loads(content) - except json.JSONDecodeError: - return None - return value if isinstance(value, dict) else None + value = json.loads(str(function.get("arguments") or "")) + except json.JSONDecodeError as error: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider returned invalid JSON") from error + if not isinstance(value, dict): + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider did not return an object") + self.repository.record_tool_audit(task_id, {"tool": name, "arguments_sha256": sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()}) + return value @staticmethod - def _event(task_id: str, tool: str, result: dict[str, Any], status: str, usage: dict[str, Any]) -> dict[str, Any]: - return {"taskId": task_id, "tool": tool, "status": status, "result": result, "usage": usage} + def _author_operation_contract(contract: dict[str, Any]) -> dict[str, Any]: + """Expose only author-owned operation facts, including selector shape.""" + selector = contract.get("selector_policy") or {} + fragment = contract.get("fragment_shape") or {} + sketch_mode = fragment.get("sketch") + return { + "params_schema": contract.get("author_params_schema") or {}, + "sketch": sketch_mode, + "authoring_sketch_template": ( + { + "workplane": { + "origin_mm": [0, 0, 0], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1], + }, + "profile": { + "type": "circle", + "diameter_mm": 10, + "center_mm": [0, 0], + }, + } + if sketch_mode == "required" else None + ), + "selector": { + "required": fragment.get("selector_tokens") == "required", + "kind": selector.get("token_kind"), + "min_items": selector.get("min_items"), + "max_items": selector.get("max_items"), + "destination": selector.get("slot"), + "source_syntax": ".", + }, + } @staticmethod - def _invocation_id(task_id: str) -> str: - return f"{task_id}_inv_{secrets.token_hex(8)}" + def _validate_repair_document( + previous: dict[str, Any], replacement: dict[str, Any], diagnostics: dict[str, Any] | None, + compile_audit: dict[str, Any] | None, + ) -> None: + """Keep successful features fixed across complete-document repairs.""" + targeted = { + str(item.get("feature_name") or "") + for item in (diagnostics or {}).get("diagnostics") or () + if isinstance(item, dict) and item.get("feature_name") + } + for item in (diagnostics or {}).get("diagnostics") or (): + if not isinstance(item, dict): + continue + path = str(item.get("path") or "") + if path.startswith("features."): + targeted.add(path.split(".", 2)[1]) + feature_ids = (compile_audit or {}).get("feature_ids") if isinstance(compile_audit, dict) else {} + ids_to_names = { + str(feature_id): str(name) + for name, feature_id in (feature_ids or {}).items() + if isinstance(name, str) and isinstance(feature_id, str) + } + executed = { + ids_to_names[feature_id] + for feature_id in (diagnostics or {}).get("executed_feature_ids") or () + if isinstance(feature_id, str) and feature_id in ids_to_names + } + old_features = { + str(feature.get("name") or ""): feature + for body in previous.get("bodies") or () if isinstance(body, dict) + for feature in body.get("features") or () if isinstance(feature, dict) + } + new_features = { + str(feature.get("name") or ""): feature + for body in replacement.get("bodies") or () if isinstance(body, dict) + for feature in body.get("features") or () if isinstance(feature, dict) + } + for name in executed: + if name in targeted: + continue + old_feature = old_features.get(name) + if new_features.get(name) != old_feature: + raise AuthoringCompileError( + "AUTHOR_SCHEMA_INVALID", + f"repair changed successful feature {name}", + path=f"features.{name}", + ) + + def _fail(self, task_id: str, state: TaskState, code: ErrorCode, message: str, *, retryable: bool) -> None: + current = self.repository.get_state(task_id) + if current is None or current.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + return + failed = transition(current, "failed", error=code) + self.repository.compare_and_swap(failed, events=[{"event": "service_failure", "code": code.value, "message": message[:1000], "retryable": retryable}]) + + @staticmethod + def _error_code(value: str) -> ErrorCode: + try: + return ErrorCode(value) + except ValueError: + return ErrorCode.AUTHOR_SCHEMA_INVALID + + @staticmethod + def _author_validation_details(error: Exception) -> dict[str, Any]: + """Preserve one exact Pydantic location for a complete-document repair.""" + errors = getattr(error, "errors", None) + if not callable(errors): + return {"path": getattr(error, "path", "")} + values = errors() + if not values or not isinstance(values[0], dict): + return {"path": getattr(error, "path", "")} + location = values[0].get("loc") + path = ".".join(str(item) for item in location) if isinstance(location, tuple) else "" + return {"path": path, "schema_error": str(values[0].get("msg") or "")} + + @staticmethod + def _repair_hint(code: str, path: str) -> str: + if ".sketch" in path or "sketch" in path: + return ( + "Use exactly sketch.workplane {origin_mm, x_dir, normal} and " + "sketch.profile. A circle is {type: circle, diameter_mm, center_mm}; " + "do not use profiles, plane, support, radius_mm, or center." + ) + if ".selectors" in path or code.startswith("SELECTOR_"): + return ( + "Use one declarative selector {kind, source: '.', " + "match: 'unique'}. Do not add role, query, host_face, face indexes, or tokens." + ) + if code == "AUTHOR_FORBIDDEN_FIELD": + return "Remove the forbidden runtime identity or server-injected field. Use only local names and declarative selectors." + if code == "AUTHOR_REFERENCE_INVALID": + return "Reference an existing local feature name and exact output role; the compiler records the selector source as a dependency." + return "Return the complete document with the named diagnostic corrected. Preserve executed features unless the diagnostic targets them." + + @staticmethod + def _claim_report(requirements: dict[str, Any]) -> dict[str, Any]: + targets = requirements.get("acceptance_targets") if isinstance(requirements.get("acceptance_targets"), list) else [] + claims = [ + { + "target": str(target.get("kind") or "target"), + "status": "pending", + "verification": str(target.get("verification") or "manual"), + } + for target in targets + if isinstance(target, dict) + ] + manual = requirements.get("manual_targets") if isinstance(requirements.get("manual_targets"), list) else [] + claims.extend({"target": item, "status": "pending", "verification": "manual"} for item in manual if isinstance(item, str)) + if not claims: + claims.append({"target": "no deterministic target declared", "status": "not_applicable", "verification": "manual"}) + return {"schema_version": "cad.requirement-claim-report.v1", "claims": claims} + + @staticmethod + def _completion_report( + state: TaskState, + requirements: dict[str, Any], + authoring: dict[str, Any], + diagnostics: dict[str, Any], + ) -> str: + lines = ["# CAD Generation Result", "", f"Published revision: `{state.active_revision}`", "", "## Generated Model", ""] + bodies = authoring.get("bodies") if isinstance(authoring.get("bodies"), list) else [] + for body in bodies: + if not isinstance(body, dict): + continue + lines.append(f"- body: {body.get('name', 'body')}") + features = body.get("features") if isinstance(body.get("features"), list) else [] + for feature in features: + if isinstance(feature, dict): + lines.append(f"- feature: {feature.get('name', 'feature')} ({feature.get('operation', 'operation')})") + lines.extend(["", "## Requested Requirements", ""]) + lines.extend(f"- {item}" for item in requirements.get("explicit_requirements", []) if isinstance(item, str)) + lines.extend(["", "## Requirement Compliance", ""]) + targets = requirements.get("acceptance_targets") if isinstance(requirements.get("acceptance_targets"), list) else [] + if targets: + lines.extend(f"- pending: {item.get('kind', 'target')}" for item in targets if isinstance(item, dict)) + else: + lines.append("- not_applicable: no deterministic acceptance target was declared") + lines.extend(f"- pending: {item}" for item in requirements.get("manual_targets", []) if isinstance(item, str)) + lines.extend(["", "## Assumptions", ""]) + lines.extend(f"- {item}" for item in requirements.get("assumptions", []) if isinstance(item, str)) + lines.extend(["", "## Execution", ""]) + failures = diagnostics.get("diagnostics") if isinstance(diagnostics.get("diagnostics"), list) else [] + if failures: + lines.append("The executable prefix was published with unresolved operations:") + lines.extend(f"- {item.get('code', 'ENGINE_EXECUTION_FAILED')}: {item.get('feature_name') or item.get('feature_id') or 'document'}: {item.get('message', item)}" for item in failures if isinstance(item, dict)) + else: + lines.append("The complete compiled CDSL executed successfully.") + lines.extend(["", "## Planning Limitations", ""]) + lines.append("Requirement compliance is reported separately from executable publication. Pending targets require deterministic measurement or user review.") + lines.extend(["", "## Delivery Artifacts", ""]) + root = f"revisions/{state.active_revision}" + lines.extend([ + f"- STEP: {root}/model.step", + f"- GLB: {root}/model.glb", + f"- Render bundle: {root}/renders/render-manifest.json", + f"- Runtime CDSL: {root}/model.cdsl.json", + f"- Build diagnostics: {state.diagnostics_path or root + '/build-diagnostics.json'}", + "- Requirement compliance: documents/claim-report.json", + f"- Authoring CDSL: {state.authoring_path}", + f"- Compile audit: {state.compile_audit_path}", + ]) + lines.extend(["", "## Repair Budget", ""]) + reason = "all compiled features executed" if not failures else "published the best executable prefix after unresolved diagnostics" + lines.append(f"- repair calls used: {state.repair_count}/2") + lines.append(f"- stop reason: {reason}") + return "\n".join(lines) + "\n" + + @staticmethod + def _terminal(state: TaskState) -> dict[str, Any]: + lifecycle = "completed" if state.phase == TaskPhase.COMPLETED else "cancelled" if state.phase == TaskPhase.CANCELLED else "failed" + return {"taskId": state.task_id, "lifecycle": lifecycle, "revisionId": state.active_revision, "repairCount": state.repair_count, "completionPath": state.completion_path, "diagnosticsPath": state.diagnostics_path, "code": state.last_error.value if state.last_error else "", "message": "CAD result published." if lifecycle == "completed" else "CAD generation stopped.", "userActionRequired": False} diff --git a/backend/app/cad_agent/composition.py b/backend/app/cad_agent/composition.py index 13287c9c..de88461e 100644 --- a/backend/app/cad_agent/composition.py +++ b/backend/app/cad_agent/composition.py @@ -1,4 +1,4 @@ -"""Protocol v3 composition root. This is the only layer joining adapters.""" +"""Single-stage Authoring CDSL composition root.""" from __future__ import annotations @@ -6,70 +6,58 @@ from dataclasses import dataclass import shutil from app.cad_agent.adapters.artifact_store import FileArtifactStore -from app.cad_agent.adapters.author_guidance import FileAuthorGuidance from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher from app.cad_agent.adapters.runtime import ProfileCadRuntime -from app.cad_agent.adapters.review_gateway import RenderedReviewGateway from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository from app.cad_agent.adapters.structured_llm import StructuredModelGateway -from app.cad_agent.adapters.verifier import RegistryVerifierExecutor -from app.cad_agent.application.action_handlers import ActionCommandHandler from app.cad_agent.application.outbox import OutboxDispatcher -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 BACKEND_ROOT, Settings +from app.cad_agent.application.workflow import WorkflowConfig, WorkflowCoordinator +from app.cad_agent.application.single_stage import SingleStageExecutor +from app.settings import Settings from app.services.storage import WorkspaceStore @dataclass(frozen=True, slots=True) -class V3Services: +class CadServices: repository: SqliteTaskRepository artifacts: FileArtifactStore workflow: WorkflowCoordinator models: StructuredModelGateway outbox: OutboxDispatcher + single_stage: SingleStageExecutor -def compose_v3(settings: Settings) -> V3Services: - repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3") - if repository.protocol_reset and settings.task_root.exists(): - # Protocol 3.1 has no valid interpretation for structured-only task - # artifacts, so clear that task root together with its old database. +def compose_cad_services(settings: Settings) -> CadServices: + database_root = settings.task_root.parent + legacy_database = database_root / "autonomous-cad-v3.sqlite3" + removed_legacy_database = False + if legacy_database.exists(): + # The removed coordinator persisted incompatible task/action state in + # its own database. Delete it as part of the deliberate destructive + # migration, including SQLite sidecars if a worker stopped mid-write. + for candidate in (legacy_database, *(database_root / f"{legacy_database.name}{suffix}" for suffix in ("-wal", "-shm"))): + if candidate.exists(): + candidate.unlink() + removed_legacy_database = True + repository = SqliteTaskRepository(database_root / "autonomous-cad-single-stage.sqlite3") + protocol_reset = repository.protocol_reset or removed_legacy_database + if protocol_reset and settings.task_root.exists(): + # Old task artifacts have no valid interpretation under the Authoring + # protocol, so clear them together with the task database. shutil.rmtree(settings.task_root) - if repository.protocol_reset: + if 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, atomic_ids=runtime.supported_atomic_ids) - actions = ActionCommandHandler(repository, artifacts, runtime, verifier) - fallbacks = tuple( - ModelIdentity(provider.id, model.id) - for provider in settings.providers - if provider.configured - for model in provider.models[:1] - ) models = StructuredModelGateway(settings) outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher()) + single_stage = SingleStageExecutor(repository, artifacts, runtime) workflow = WorkflowCoordinator( - WorkflowConfig( - max_turns=max(8, settings.agent_tool_calls_per_cycle * 8), - format_error_limit=settings.agent_format_error_repeat_limit, - author_fallbacks=fallbacks, - ), + WorkflowConfig(), repository, artifacts, runtime, models, - RenderedReviewGateway(models), - requirements, - actions, - FileAuthorGuidance( - BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance", - enabled=settings.agent_author_guidance_enabled, - max_chars=settings.agent_author_guidance_max_chars, - ), + single_stage, ) - return V3Services(repository, artifacts, workflow, models, outbox) + return CadServices(repository, artifacts, workflow, models, outbox, single_stage) diff --git a/backend/app/cad_agent/domain/__init__.py b/backend/app/cad_agent/domain/__init__.py index abb758e6..9c2c8fcf 100644 --- a/backend/app/cad_agent/domain/__init__.py +++ b/backend/app/cad_agent/domain/__init__.py @@ -1,4 +1,4 @@ -"""Pure domain objects and policies for protocol v3.""" +"""Pure domain objects and policies for the Authoring CDSL protocol.""" from .errors import ErrorCode, WorkflowError from .state import TaskPhase, TaskState, transition diff --git a/backend/app/cad_agent/domain/claim_matching.py b/backend/app/cad_agent/domain/claim_matching.py deleted file mode 100644 index 3a40aab8..00000000 --- a/backend/app/cad_agent/domain/claim_matching.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Canonical partial matching for acceptance-claim expectations.""" - -from __future__ import annotations - -from typing import Any - - -def contains_expected(actual: Any, required: Any) -> bool: - """Allow an actual claim to add provider-chosen optional fields only.""" - if isinstance(required, dict): - return isinstance(actual, dict) and all( - key in actual and contains_expected(actual[key], value) - for key, value in required.items() - ) - if isinstance(required, list): - return isinstance(actual, list) and len(actual) == len(required) and all( - contains_expected(actual_item, required_item) - for actual_item, required_item in zip(actual, required, strict=True) - ) - return actual == required diff --git a/backend/app/cad_agent/domain/errors.py b/backend/app/cad_agent/domain/errors.py index a585b7c7..04159faa 100644 --- a/backend/app/cad_agent/domain/errors.py +++ b/backend/app/cad_agent/domain/errors.py @@ -1,4 +1,4 @@ -"""Typed, serializable failures used across the v3 workflow.""" +"""Typed, serializable failures used across the single-stage workflow.""" from __future__ import annotations @@ -9,29 +9,25 @@ from typing import Any class ErrorCode(StrEnum): CANCELLED = "CANCELLED" - AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID" - AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED" - STALE_WORKING_HEAD = "STALE_WORKING_HEAD" - FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT" - RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED" RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID" - CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED" - CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED" - VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE" - CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED" MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED" - MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING" AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE" - REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE" RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE" STORAGE_FAILURE = "STORAGE_FAILURE" - CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED" - REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID" - NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT" RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE" BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED" FAILED_INTERNAL = "FAILED_INTERNAL" WAITING_FOR_USER = "WAITING_FOR_USER" + AUTHOR_FORBIDDEN_FIELD = "AUTHOR_FORBIDDEN_FIELD" + AUTHOR_SCHEMA_INVALID = "AUTHOR_SCHEMA_INVALID" + AUTHOR_REFERENCE_INVALID = "AUTHOR_REFERENCE_INVALID" + AUTHOR_CYCLE = "AUTHOR_CYCLE" + OPERATION_UNSUPPORTED = "OPERATION_UNSUPPORTED" + SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND" + SELECTOR_AMBIGUOUS = "SELECTOR_AMBIGUOUS" + SELECTOR_DEPENDENCY_UNAVAILABLE = "SELECTOR_DEPENDENCY_UNAVAILABLE" + SELECTOR_KIND_MISMATCH = "SELECTOR_KIND_MISMATCH" + ENGINE_EXECUTION_FAILED = "ENGINE_EXECUTION_FAILED" @dataclass(frozen=True, slots=True) diff --git a/backend/app/cad_agent/domain/feature_plan.py b/backend/app/cad_agent/domain/feature_plan.py deleted file mode 100644 index 494d2f69..00000000 --- a/backend/app/cad_agent/domain/feature_plan.py +++ /dev/null @@ -1,265 +0,0 @@ -"""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/operation_contract.py b/backend/app/cad_agent/domain/operation_contract.py index 3569e4fd..1b37029a 100644 --- a/backend/app/cad_agent/domain/operation_contract.py +++ b/backend/app/cad_agent/domain/operation_contract.py @@ -1,8 +1,7 @@ -"""Versioned runtime operation contracts and dynamic fragment schemas.""" +"""Versioned runtime operation contracts for the Authoring compiler.""" from __future__ import annotations -from copy import deepcopy from hashlib import sha256 import json from typing import Any @@ -15,25 +14,6 @@ class OperationContractError(ValueError): pass -SEMANTIC_PREFLIGHT_NAMES = frozenset({ - "sketch_workplane", - "profile_non_self_intersecting", - "host_face_exists", - "hole_positions_on_host_plane", - "cut_exit_distance", - "requires_active_solid", - "revolve_axis_on_sketch", - "reference_plane_nonzero_normal", - "reference_axis_nonzero_direction", - "selected_edges_exist", - "source_features_exist", - "mirror_plane_exists", - "loft_profiles_exist", - "loft_profiles_closed", - "loft_profiles_single_region", -}) - - def canonical_hash(value: dict[str, Any]) -> str: return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() @@ -58,6 +38,11 @@ def _is_closed(schema: Any) -> bool: return True +def is_authoring_schema_closed(schema: Any) -> bool: + """Whether an operation can safely be exposed in the Authoring whitelist.""" + return _is_closed(schema) + + def validate_operation_contract(contract: dict[str, Any]) -> None: required = { "atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy", @@ -111,113 +96,33 @@ def validate_operation_contract(contract: dict[str, Any]) -> None: if reference["mode"] == "snapshot_bound": if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}: raise OperationContractError("Snapshot-bound reference policy is invalid") - if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True: - raise OperationContractError("Snapshot-bound reference policy has invalid bounds") - reference_name = reference["slot"].removeprefix("params.") - properties = params.get("properties") if isinstance(params.get("properties"), dict) else {} - reference_schema = properties.get(reference_name) - required_params = params.get("required") if isinstance(params.get("required"), list) else [] + slot = reference["slot"] if ( - not isinstance(reference_schema, dict) - or reference_schema.get("type") != "array" - or not isinstance(reference_schema.get("items"), dict) - or reference_name not in required_params + not isinstance(slot, str) + or (slot != "feature.selectors" and not slot.startswith("params.")) + or reference["token_kind"] not in {"face", "edge", "plane", "axis", "body", "feature"} + or not isinstance(reference["min_items"], int) + or not isinstance(reference["max_items"], int) + or not 0 <= reference["min_items"] <= reference["max_items"] <= 64 + or reference["snapshot_bound"] is not True ): - raise OperationContractError("Snapshot-bound reference slot must be a required author array") + raise OperationContractError("Snapshot-bound reference policy has invalid bounds") + if slot == "feature.selectors": + if shape["selector_tokens"] != "required" or selector["token_kind"] != reference["token_kind"]: + raise OperationContractError("Feature selector reference policy disagrees with selector policy") + else: + reference_name = slot.removeprefix("params.") + properties = params.get("properties") if isinstance(params.get("properties"), dict) else {} + reference_schema = properties.get(reference_name) + if not isinstance(reference_schema, dict) or reference_schema.get("type") not in {"array", "string"}: + raise OperationContractError("Snapshot-bound reference slot is absent from author params schema") + if reference_schema.get("type") == "string" and reference["max_items"] > 1: + raise OperationContractError("Scalar snapshot-bound reference must allow at most one item") if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]): raise OperationContractError("Operation semantic preflight is invalid") - if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES: - raise OperationContractError("Operation semantic preflight names are unknown or duplicated") + if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]): + raise OperationContractError("Operation semantic preflight names are duplicated") if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]): raise OperationContractError("Operation candidate_verifiers are invalid") if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]): raise OperationContractError("Operation candidate verifiers are duplicated") - - -def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> dict[str, Any]: - """Build the one-operation schema exposed for one pending action.""" - validate_operation_contract(contract) - shape = contract["fragment_shape"] - params = deepcopy(contract["author_params_schema"]) - reference = contract["reference_policy"] - if reference["mode"] == "snapshot_bound": - slot = str(reference["slot"]).removeprefix("params.") - items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None - if not isinstance(items, dict): - raise OperationContractError("Snapshot-bound reference slot is absent from author params schema") - items.clear() - items.update({"enum": reference_tokens or []}) - feature_properties: dict[str, Any] = { - "atomic_id": {"const": contract["atomic_id"]}, - "params": params, - } - feature_required = ["atomic_id", "params"] - if shape["selector_tokens"] == "required": - policy = contract["selector_policy"] - feature_properties["selector_tokens"] = { - "type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"], - "maxItems": policy["max_items"], "uniqueItems": True, - "description": ( - "Required author input. Copy the opaque selector token returned by the current topology " - "snapshot here; do not omit it and do not put a host face in params. The server resolves this " - "token into the host selector after schema validation." - ), - } - feature_required.append("selector_tokens") - feature = { - "type": "object", - "description": ( - "One atomic feature. selector_tokens, when present, is an author-supplied topology token array " - "rather than a server-filled params field." - ), - "properties": feature_properties, - "required": feature_required, - "additionalProperties": False, - } - properties: dict[str, Any] = {"feature": feature} - required = ["feature"] - if shape["sketch"] == "required": - properties["sketch"] = _sketch_schema(root_xy_datum=root_xy_datum and contract["atomic_id"] in {"extrude_add_blind", "extrude_add_two_sided"}) - required.insert(0, "sketch") - schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False} - Draft202012Validator.check_schema(schema) - return schema - - -def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> list[dict[str, str]]: - schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens, root_xy_datum=root_xy_datum) - return [ - {"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message} - for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message)) - ] - - -def _point(size: int) -> dict[str, Any]: - return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size} - - -def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]: - point2 = _point(2) - point3 = _point(3) - workplane = { - "type": "object", - "description": "origin_mm is the world position of sketch local (0,0); profile coordinates are local to this plane. normal is positive extrusion direction and x_dir is local +X in world coordinates.", - "properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)}, - "required": ["origin_mm", "x_dir", "normal"], - "additionalProperties": False, - } - if root_xy_datum: - workplane["description"] += " Root extrusion uses fixed world XY datum: origin X/Y are 0, normal is +Z, x_dir is +X. Only origin Z is task-defined." - workplane["properties"] = { - "origin_mm": {"type": "array", "prefixItems": [{"const": 0}, {"const": 0}, {"type": "number"}], "items": False, "minItems": 3, "maxItems": 3}, - "x_dir": {"const": [1, 0, 0]}, - "normal": {"const": [0, 0, 1]}, - } - profile = { - "oneOf": [ - {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False}, - {"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False}, - {"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False}, - ] - } - return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False} diff --git a/backend/app/cad_agent/domain/state.py b/backend/app/cad_agent/domain/state.py index d017247e..957e7c1c 100644 --- a/backend/app/cad_agent/domain/state.py +++ b/backend/app/cad_agent/domain/state.py @@ -1,56 +1,24 @@ -"""Finite workflow state machine. This module has no persistence imports.""" +"""Finite state for the single-stage Authoring CDSL protocol.""" from __future__ import annotations from dataclasses import dataclass, replace from enum import StrEnum -from .errors import ErrorCode, WorkflowError +from .errors import ErrorCode 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" - CANDIDATE_BUILDING = "CANDIDATE_BUILDING" - CANDIDATE_REVIEW = "CANDIDATE_REVIEW" - FINAL_VALIDATION = "FINAL_VALIDATION" - WAITING_RETRY = "WAITING_RETRY" + ANALYZING_REQUEST = "ANALYZING_REQUEST" + AUTHORING_CDSL = "AUTHORING_CDSL" + COMPILING_CDSL = "COMPILING_CDSL" + BUILDING = "BUILDING" + REPAIRING = "REPAIRING" + PUBLISHING_BEST_EFFORT = "PUBLISHING_BEST_EFFORT" WAITING_FOR_USER = "WAITING_FOR_USER" - CANCELLED = "CANCELLED" COMPLETED = "COMPLETED" FAILED = "FAILED" - - -@dataclass(frozen=True, slots=True) -class PendingAction: - action_id: str - working_head: str - intent: str - requirement_ids: tuple[str, ...] - atomic_id: str - 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 + CANCELLED = "CANCELLED" @dataclass(frozen=True, slots=True) @@ -59,167 +27,84 @@ class TaskState: phase: TaskPhase version: int active_revision: str = "" - pending_action: PendingAction | None = None - candidate_id: str = "" - candidate_stage_id: str = "" - repair_required: bool = False + repair_count: int = 0 last_error: ErrorCode | None = None retry_from_phase: TaskPhase | None = None - requirements_spec_path: str = "" - requirements_document_path: str = "" - completion_target_path: str = "" - modeling_plan_path: str = "" - feature_plan_path: str = "" - feature_plan_hash: str = "" - feature_stage_id: str = "" + requirements_path: str = "" + authoring_path: str = "" + runtime_cdsl_path: str = "" + compile_audit_path: str = "" + diagnostics_path: str = "" + completion_path: 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}" -# Legal state transitions. Events are intentionally terse persistence-neutral -# names used by command handlers and architecture tests. _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.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 - # so its frozen request remains authoritative - # instead of turning a clarification into a new CAD request. - (TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, - (TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING, - (TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION, - (TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION, - (TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING, - # Runtime semantic preflight happens before candidate staging. It must - # leave the checkpoint untouched and return control to the author for a - # fresh observation/action decision, never masquerade as a format error. - (TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION, - (TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING, - (TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION, - (TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW, - (TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING, - (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, + (TaskPhase.ANALYZING_REQUEST, "analysis_written"): TaskPhase.AUTHORING_CDSL, + (TaskPhase.ANALYZING_REQUEST, "waiting_for_user"): TaskPhase.WAITING_FOR_USER, + (TaskPhase.WAITING_FOR_USER, "clarification_received"): TaskPhase.ANALYZING_REQUEST, + (TaskPhase.AUTHORING_CDSL, "authoring_written"): TaskPhase.COMPILING_CDSL, + (TaskPhase.AUTHORING_CDSL, "repair_required"): TaskPhase.REPAIRING, + (TaskPhase.COMPILING_CDSL, "compiled"): TaskPhase.BUILDING, + (TaskPhase.COMPILING_CDSL, "repair_required"): TaskPhase.REPAIRING, + (TaskPhase.BUILDING, "build_completed"): TaskPhase.PUBLISHING_BEST_EFFORT, + (TaskPhase.BUILDING, "repair_required"): TaskPhase.REPAIRING, + (TaskPhase.REPAIRING, "repair_started"): TaskPhase.AUTHORING_CDSL, + (TaskPhase.PUBLISHING_BEST_EFFORT, "published"): TaskPhase.COMPLETED, + (TaskPhase.PUBLISHING_BEST_EFFORT, "failed"): TaskPhase.FAILED, } -_TRANSITIONS.update({ - (phase, "best_effort_completed"): TaskPhase.COMPLETED - for phase in TaskPhase - if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} -}) -_TRANSITIONS.update({ - (phase, "failed"): TaskPhase.FAILED - for phase in TaskPhase - if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} -}) -_TRANSITIONS.update({ - (phase, "cancelled"): TaskPhase.CANCELLED - for phase in TaskPhase - if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} -}) -_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, - TaskPhase.CANDIDATE_BUILDING, - TaskPhase.CANDIDATE_REVIEW, - TaskPhase.FINAL_VALIDATION, -}) -_TRANSITIONS.update({ - (TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase - for phase in _RETRY_RESUMABLE_PHASES -}) -_TRANSITIONS.update({ - (phase, "waiting_retry"): TaskPhase.WAITING_RETRY - for phase in TaskPhase - if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY} -}) +for _phase in TaskPhase: + if _phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + _TRANSITIONS[(_phase, "failed")] = TaskPhase.FAILED + _TRANSITIONS[(_phase, "cancelled")] = TaskPhase.CANCELLED + if _phase in { + TaskPhase.AUTHORING_CDSL, TaskPhase.COMPILING_CDSL, + TaskPhase.BUILDING, TaskPhase.REPAIRING, + }: + _TRANSITIONS[(_phase, "publish_best_effort")] = TaskPhase.PUBLISHING_BEST_EFFORT + + def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]: - """Return a copy of the protocol transition table for architecture tests.""" return dict(_TRANSITIONS) -def retry_resume_event(state: TaskState) -> str | None: - """Return the only lossless resume event for a parked service failure.""" - if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES: - return 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, 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)) +def transition( + state: TaskState, + event: str, + *, + active_revision: str | None = None, + repair_count: int | None = None, + error: ErrorCode | None = None, + requirements_path: str | None = None, + authoring_path: str | None = None, + runtime_cdsl_path: str | None = None, + compile_audit_path: str | None = None, + diagnostics_path: str | None = None, + completion_path: str | None = None, + clarification_path: str | None = None, +) -> TaskState: + target = state.retry_from_phase if state.phase == TaskPhase.FAILED and event == "resume" else _TRANSITIONS.get((state.phase, event)) if target is None: - raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?") - 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.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: - next_pending = None + raise ValueError(f"Illegal single-stage transition: {state.phase.value} --{event}--> ?") + next_repairs = state.repair_count if repair_count is None else repair_count + if not 0 <= next_repairs <= 2: + raise ValueError("Single-stage repair count must be within [0, 2]") return replace( state, phase=target, 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.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, + repair_count=next_repairs, last_error=error, - retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None, - requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path, - 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, + retry_from_phase=state.phase if target == TaskPhase.FAILED and error in { + ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, ErrorCode.STORAGE_FAILURE, + ErrorCode.RUNTIME_EXECUTION_FAILURE, + } else None, + requirements_path=state.requirements_path if requirements_path is None else requirements_path, + authoring_path=state.authoring_path if authoring_path is None else authoring_path, + runtime_cdsl_path=state.runtime_cdsl_path if runtime_cdsl_path is None else runtime_cdsl_path, + compile_audit_path=state.compile_audit_path if compile_audit_path is None else compile_audit_path, + diagnostics_path=state.diagnostics_path if diagnostics_path is None else diagnostics_path, + completion_path=state.completion_path if completion_path is None else completion_path, 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, ) - - -def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None: - if supplied_head != state.working_head: - return WorkflowError( - ErrorCode.STALE_WORKING_HEAD, - "The command was bound to an obsolete working head.", - details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head}, - ) - return None diff --git a/backend/app/cad_agent/evals/TOKEN_BASELINE.md b/backend/app/cad_agent/evals/TOKEN_BASELINE.md deleted file mode 100644 index 1eb07d03..00000000 --- a/backend/app/cad_agent/evals/TOKEN_BASELINE.md +++ /dev/null @@ -1,21 +0,0 @@ -# Token Baseline Format - -`python -m app.cad_agent.evals.live --suite release --require-live` requires -`--baseline-report` to point at a measured protocol-2 report. It is not a -fixture or an estimate. Record three independent runs of every release -scenario with the same author request configuration and runtime profile. - -| Field | Requirement | -| --- | --- | -| `schema_version` | `cad.token-baseline.v1` | -| `protocol_version` | `2.0` | -| `author` | Exact `provider`, `model`, `api_style`, `reasoning_effort`, and `sampling` object from the v3 report | -| `runtime_profile_sha256` | Exact v3 `runtime_profile_sha256` | -| `measurement` | `prompt_tokens` when provider usage exists; otherwise `context_chars` | -| `scenarios[]` | One entry for every versioned release scenario, including its request SHA-256 | -| `repetitions[]` | Entries `1`, `2`, and `3`, with non-negative `author_metric` and `plan_review_metric`, plus completion, final-review, and deterministic-claim booleans | - -The comparison uses `author_metric + plan_review_metric` for the v2 median and -the v3 author metric only. It fails closed when provenance differs, any -repetition is absent, completion/review/claim rates drop, plan-review calls -remain, or the median reduction is below 30%. diff --git a/backend/app/cad_agent/evals/__init__.py b/backend/app/cad_agent/evals/__init__.py index 2a0ae0f0..f5e60eb3 100644 --- a/backend/app/cad_agent/evals/__init__.py +++ b/backend/app/cad_agent/evals/__init__.py @@ -1 +1 @@ -"""Live, non-mocked protocol v3 release evaluations.""" +"""Local evaluation helpers for the single-stage Authoring protocol.""" diff --git a/backend/app/cad_agent/evals/create_isolated_task.py b/backend/app/cad_agent/evals/create_isolated_task.py index cf0c236e..8ee80979 100644 --- a/backend/app/cad_agent/evals/create_isolated_task.py +++ b/backend/app/cad_agent/evals/create_isolated_task.py @@ -1,4 +1,4 @@ -"""Create a resumable live-evaluation task without starting its workflow.""" +"""Create an isolated single-stage task without starting its workflow.""" from __future__ import annotations @@ -9,12 +9,12 @@ from pathlib import Path import secrets import sys -from app.cad_agent.composition import compose_v3 +from app.cad_agent.composition import compose_cad_services 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 = argparse.ArgumentParser(description="Create one isolated Authoring CDSL evaluation task.") parser.add_argument("--report-root", required=True, type=Path) parser.add_argument("--prompt", required=True) parser.add_argument("--task-id") @@ -25,7 +25,7 @@ def main() -> int: arguments = _arguments() root = arguments.report_root.resolve() settings = get_settings() - services = compose_v3(replace( + services = compose_cad_services(replace( settings, task_root=root / "artifacts", conversation_root=root / "conversations", diff --git a/backend/app/cad_agent/evals/fixtures/comprehensive.json b/backend/app/cad_agent/evals/fixtures/comprehensive.json deleted file mode 100644 index af596fc2..00000000 --- a/backend/app/cad_agent/evals/fixtures/comprehensive.json +++ /dev/null @@ -1,423 +0,0 @@ -{ - "schema_version": "cad.comprehensive-prompt-fixtures.v1", - "source_document": "docs/cad-agent-v3-comprehensive-prompt-test-target.md", - "source_document_sha256": "79fbfeba41b2bf1fa37d2e90df1c4f5f06460f725f8898ae4238676bfea82d8b", - "scenarios": [ - { - "id": "rectangular_mounting_plate", - "units": "mm", - "request": "生成一个CNC矩形安装板,长100毫米,宽60毫米,厚8毫米,四角圆角R6,四角各有一个直径8毫米的贯穿孔,孔中心距左右边10毫米、上下边10毫米,中心有一个直径30毫米的贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 8, "count": 4, "edge_offset_mm": 10}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "corner_radius", "description": "R6 outer corner radii"} - ], - "max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000 - }, - { - "id": "circular_flange_pcd", - "units": "mm", - "request": "生成一个圆形法兰盘,外径120毫米,厚度12毫米,中心贯穿孔直径40毫米,在直径90毫米的分度圆上均布6个直径8毫米的贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 6}}, - {"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 8, "count": 6, "pitch_radius_mm": 45, "concentric_bore_diameter_mm": 40}} - ], - "required_atomic_ids": [], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind", "pattern_linear"], - "required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]], - "validation_capability_gaps": [], - "max_author_turns": 52, "max_reviewer_turns": 20, "max_total_calls": 72, "max_wall_seconds": 1500, "max_total_tokens": 320000 - }, - { - "id": "square_flange", - "units": "mm", - "request": "生成一个方形安装法兰,长100毫米,宽100毫米,厚度12毫米,四角圆角R8,中心贯穿孔直径45毫米,四角各有一个直径10毫米的安装孔,孔中心距相邻两边各15毫米。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 45, "count": 1}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 10, "count": 4}}, - {"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 10, "count": 4, "edge_offset_mm": 15}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "corner_radius", "description": "R8 outer corner radii"} - ], - "max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000 - }, - { - "id": "counterbored_mounting_plate", - "units": "mm", - "request": "生成一个矩形安装板,长120毫米,宽80毫米,厚度15毫米,四角各有一个直径9毫米的贯穿孔,每个孔顶部带直径16毫米、深5毫米的圆柱沉孔,孔中心距相邻边各12毫米。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth", "rectangular_corner_through_bore_pattern"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 15}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}}, - {"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 16, "count": 4}}, - {"claim_kind": "cylindrical_bore_depth", "expected": {"diameter_mm": 16, "count": 4, "depth_mm": 5}}, - {"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 12}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_counterbore", "hole_wizard"], - "validation_capability_gaps": [], - "max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000 - }, - { - "id": "countersunk_cover_plate", - "units": "mm", - "request": "生成一个盖板,长100毫米,宽70毫米,厚度8毫米,四角圆角R5,四角各有一个直径6.5毫米的贯穿孔,孔顶部带90度沉头,沉头最大直径12毫米。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "conical_bore"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 70}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 6.5, "count": 4}}, - {"claim_kind": "conical_bore", "expected": {"small_diameter_mm": 6.5, "large_diameter_mm": 12, "included_angle_deg": 90, "count": 4}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_countersink", "hole_wizard"], - "validation_capability_gaps": [ - {"id": "corner_radius", "description": "R5 outer corner radii"} - ], - "max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000 - }, - { - "id": "obround_slot_plate", - "units": "mm", - "request": "生成一个连接板,长140毫米,宽50毫米,厚度10毫米,两端各有一个长圆形贯穿槽,槽总长30毫米、宽12毫米,槽中心距板端20毫米,槽的长轴沿板长度方向。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "slot_dimensions", "description": "two through obround slots with 30 x 12 dimensions"}, - {"id": "slot_position_orientation", "description": "20 mm end offset and lengthwise major-axis orientation"} - ], - "max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000 - }, - { - "id": "t_slot_test_block", - "units": "mm", - "request": "生成一个T形槽试块,长100毫米,宽50毫米,高25毫米,在顶面中心沿长度方向加工一条T形槽,槽口宽10毫米、深8毫米,槽底宽20毫米、总深15毫米,槽贯穿试块两端。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "t_slot_cross_section", "description": "10 x 8 slot mouth, 20 mm lower width and 15 mm total depth"}, - {"id": "t_slot_through_orientation", "description": "top-centred T-slot through both lengthwise ends"} - ], - "max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000 - }, - { - "id": "rounded_rectangular_pocket", - "units": "mm", - "request": "生成一个矩形底板,长120毫米,宽80毫米,厚20毫米,在顶面中心加工一个长80毫米、宽45毫米、深12毫米的矩形口袋,口袋四角圆角R6,底部保留8毫米厚度。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 20}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "pocket_dimensions", "description": "centred 80 x 45 x 12 pocket with 8 mm remaining floor"}, - {"id": "pocket_corner_radius", "description": "R6 internal pocket corners"} - ], - "max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000 - }, - { - "id": "two_level_pocket_plate", - "units": "mm", - "request": "生成一个CNC加工板,长140毫米,宽100毫米,厚25毫米。顶面中心先加工一个长100毫米、宽70毫米、深8毫米的矩形口袋,再在第一级口袋中心加工一个长60毫米、宽35毫米、额外深7毫米的第二级口袋,所有内角圆角R5。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "pocket_levels", "description": "100 x 70 x 8 first pocket and centred 60 x 35 x 7 additional-depth second pocket"}, - {"id": "pocket_corner_radii", "description": "R5 on every internal corner"} - ], - "max_author_turns": 80, "max_reviewer_turns": 30, "max_total_calls": 110, "max_wall_seconds": 2400, "max_total_tokens": 320000 - }, - { - "id": "cross_drilled_valve_block", - "units": "mm", - "request": "生成一个阀块试件,长80毫米,宽60毫米,高50毫米。沿长度方向加工一个直径20毫米的贯穿孔,沿宽度方向加工一个直径12毫米的贯穿孔,两个孔的轴线在零件中心相交。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "orthogonal_intersecting_through_bores"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 50}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 1}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 1}}, - {"claim_kind": "orthogonal_intersecting_through_bores", "expected": {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y"}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [], - "max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000 - }, - { - "id": "double_hole_linkage_arm", - "units": "mm", - "request": "生成一个机械臂双孔连杆,两个销孔中心距120毫米,连杆厚度12毫米,两端外圆直径40毫米,两个销孔直径16毫米,中间杆身最小宽度24毫米,轮廓平滑相切,中部设置三个直径14毫米的减重贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 14, "count": 3}}, - {"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 16, "adjacent_distances_mm": [120], "tolerance_mm": 0.1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "linkage_outline", "description": "40 mm end circles, 24 mm minimum web width and smooth tangency"}, - {"id": "lightening_holes", "description": "three 14 mm central through holes"} - ], - "max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000 - }, - { - "id": "three_hole_linkage", - "units": "mm", - "request": "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}}, - {"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "linkage_outer_circles", "description": "32 mm outer circles with smoothly connected segments"} - ], - "max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000 - }, - { - "id": "l_bracket", - "units": "mm", - "request": "生成一个整体式L形角码,水平底板长80毫米、宽50毫米、厚8毫米,竖直板高60毫米、宽50毫米、厚8毫米,两板成90度。水平板上有两个直径8毫米贯穿孔,竖直板上有两个直径8毫米贯穿孔,孔左右对称。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "l_bracket_dimensions", "description": "base and upright dimensions, thicknesses and 90 degree relationship"}, - {"id": "l_bracket_hole_symmetry", "description": "two symmetric 8 mm through holes on each plate"} - ], - "max_author_turns": 88, "max_reviewer_turns": 32, "max_total_calls": 120, "max_wall_seconds": 2700, "max_total_tokens": 360000 - }, - { - "id": "ribbed_l_bracket", - "units": "mm", - "request": "生成一个整体式L形机械支架,底板长100毫米、宽60毫米、厚10毫米,竖板高80毫米、宽60毫米、厚10毫米,两板成90度。底板和竖板之间设置两个厚度8毫米的三角加强筋。底板有四个直径9毫米贯穿孔,竖板中心有一个直径30毫米贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "bracket_dimensions", "description": "100 x 60 x 10 base and 80 x 60 x 10 upright at 90 degrees"}, - {"id": "triangular_ribs", "description": "two triangular 8 mm thick ribs"}, - {"id": "bracket_hole_placement", "description": "four base 9 mm holes and centred upright 30 mm hole"} - ], - "max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000 - }, - { - "id": "u_bearing_support", - "units": "mm", - "request": "生成一个U形轴承支座,底板长100毫米、宽60毫米、厚12毫米,两侧竖耳厚12毫米、高55毫米,两个竖耳内侧间距40毫米。两个竖耳上各有一个直径20毫米的同轴贯穿孔,孔轴线距底板上表面35毫米。底板四角各有一个直径8毫米安装孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 2}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}}, - {"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 20, "count": 2}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "u_support_dimensions", "description": "base and ear dimensions, ear spacing and 35 mm axis height"}, - {"id": "base_hole_positions", "description": "four 8 mm base mounting-hole positions"} - ], - "max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000 - }, - { - "id": "double_lug_mount", - "units": "mm", - "request": "生成一个双耳连接座,底座长90毫米、宽60毫米、厚12毫米,底座上有两个平行耳板,每个耳板厚10毫米、高50毫米,两耳板内侧间距30毫米。两个耳板上各有一个直径16毫米的同轴贯穿销孔,孔中心距底座上表面32毫米。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}}, - {"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 16, "count": 2}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "lug_dimensions", "description": "base, lug thickness/height, 30 mm inside spacing and 32 mm bore-axis height"} - ], - "max_author_turns": 92, "max_reviewer_turns": 34, "max_total_calls": 126, "max_wall_seconds": 2700, "max_total_tokens": 380000 - }, - { - "id": "stepped_shaft", - "units": "mm", - "request": "生成一根阶梯轴,总长120毫米。第一段直径30毫米、长40毫米;第二段直径24毫米、长50毫米;第三段直径18毫米、长30毫米。所有轴肩过渡圆角R2,两端倒角1毫米乘45度。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 120}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 24, "count": 1}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 18, "count": 1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["revolve_add"], - "required_any_atomic_ids": ["fillet", "chamfer"], - "validation_capability_gaps": [ - {"id": "shaft_segment_lengths", "description": "the 30 mm, 24 mm and 18 mm shaft sections have axial lengths 40 mm, 50 mm and 30 mm"}, - {"id": "shaft_finish_features", "description": "R2 shoulders and 1 x 45 degree end chamfers"} - ], - "max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000 - }, - { - "id": "keyed_stepped_shaft", - "units": "mm", - "request": "生成一根阶梯传动轴,总长140毫米,中间轴段直径30毫米、长70毫米,两端轴段直径20毫米、各长35毫米。中间轴段沿轴向加工一条平键槽,键槽宽8毫米、深3.3毫米、长50毫米,所有轴肩圆角R2。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 140}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "count": 2}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["revolve_add"], - "required_any_atomic_ids": ["extrude_cut_blind", "fillet"], - "validation_capability_gaps": [ - {"id": "shaft_segment_lengths", "description": "the 20 mm end sections are 35 mm each and the 30 mm middle section is 70 mm"}, - {"id": "keyway", "description": "8 x 3.3 x 50 axial keyway on the middle shaft section"}, - {"id": "shoulder_fillet", "description": "R2 on all shaft shoulders"} - ], - "max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000 - }, - { - "id": "flanged_sleeve", - "units": "mm", - "request": "生成一个机械套筒,外径50毫米,内孔直径30毫米,总长60毫米。套筒一端带外径70毫米、厚度10毫米的法兰,法兰上在直径56毫米分度圆上均布4个直径7毫米贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern", "outer_cylindrical_surface"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 60}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 70}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 50, "count": 1, "axial_span_mm": 50}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 70, "count": 1, "axial_span_mm": 10}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 7, "count": 4}}, - {"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 7, "count": 4, "pitch_radius_mm": 28, "concentric_bore_diameter_mm": 30}} - ], - "required_atomic_ids": ["revolve_add"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "flange_at_one_end", "description": "the 70 mm flange occurs at exactly one end of the coaxial sleeve, not at an interior position"} - ], - "max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000 - }, - { - "id": "chamfered_bushing", - "units": "mm", - "request": "生成一个圆柱轴套,外径40毫米,内孔直径25毫米,长度50毫米,两端外边缘倒角1.5毫米乘45度,两端内孔边缘倒角1毫米乘45度。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 40}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 50}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 25, "count": 1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["revolve_add"], - "required_any_atomic_ids": ["chamfer"], - "validation_capability_gaps": [ - {"id": "inside_outside_chamfers", "description": "both 1.5 x 45 degree external and 1 x 45 degree internal chamfers"} - ], - "max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000 - } - ] -} diff --git a/backend/app/cad_agent/evals/fixtures/release.json b/backend/app/cad_agent/evals/fixtures/release.json deleted file mode 100644 index 23867fba..00000000 --- a/backend/app/cad_agent/evals/fixtures/release.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "schema_version": "cad.live-eval-fixtures.v1", - "scenarios": [ - { - "id": "rectangular_plate", - "units": "mm", - "request": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80, "tolerance_mm": 0.01}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50, "tolerance_mm": 0.01}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8, "tolerance_mm": 0.01}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "max_author_turns": 32, - "max_reviewer_turns": 12, - "max_total_calls": 44, - "max_wall_seconds": 900, - "max_total_tokens": 120000 - }, - { - "id": "simple_flange", - "units": "mm", - "request": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120, "tolerance_mm": 0.01}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120, "tolerance_mm": 0.01}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.01}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "max_author_turns": 40, - "max_reviewer_turns": 16, - "max_total_calls": 56, - "max_wall_seconds": 1200, - "max_total_tokens": 160000 - }, - { - "id": "ribbed_mounting_plate", - "units": "mm", - "request": "Create one connected mounting plate in millimetres with a rectangular base, a vertical reinforcing rib, and four equally spaced mounting through holes. State reasonable dimensions as assumptions, then verify a single connected solid and the four-hole pattern.", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "circular_hole_pattern", "through_cylindrical_bore"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}} - ], - "required_atomic_ids": ["extrude_add_blind", "hole_blind"], - "max_author_turns": 56, - "max_reviewer_turns": 20, - "max_total_calls": 76, - "max_wall_seconds": 1500, - "max_total_tokens": 200000 - }, - { - "id": "selector_finish", - "units": "mm", - "request": "Create one connected rectangular plate with an added rounded or chamfered edge selected from the current topology. Use millimetres, state assumptions, and verify the final single solid.", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["fillet", "chamfer"], - "max_author_turns": 40, - "max_reviewer_turns": 16, - "max_total_calls": 56, - "max_wall_seconds": 1200, - "max_total_tokens": 160000 - } - ] -} diff --git a/backend/app/cad_agent/evals/live.py b/backend/app/cad_agent/evals/live.py deleted file mode 100644 index 593767b1..00000000 --- a/backend/app/cad_agent/evals/live.py +++ /dev/null @@ -1,990 +0,0 @@ -"""Run the real provider, engine, reviewer and v3 state machine end to end. - -``--require-live`` deliberately fails closed: missing credentials, blocked -network, a skipped model capability, a timeout, or any scenario failure emits -``LIVE_EVAL_BLOCKED``/failure details and exits non-zero. -""" - -from __future__ import annotations - -import argparse -import asyncio -from dataclasses import replace -from datetime import datetime, timezone -from hashlib import sha256 -import json -from pathlib import Path -import secrets -from statistics import median -import subprocess -import sys -from typing import Any - -from app.cad_agent.application.capabilities import verify_model_capability -from app.cad_agent.application.workflow import ModelIdentity -from app.cad_agent.composition import compose_v3 -from app.cad_agent.domain.claim_matching import contains_expected -from app.cad_agent.domain.operation_contract import canonical_hash -from app.cad_agent.domain.verifier_registry import default_registry -from app.cad_agent.evals.token_baseline import ( - TokenBaselineError, - author_request_identity, - compare_token_baseline, - load_token_baseline, - profile_sha256, - validate_token_baseline_provenance, -) -from app.settings import BACKEND_ROOT, get_settings - - -_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({ - "candidate.json", - "candidate-review.json", - "model.cdsl.json", - "model.step", - "model.glb", - "model.topology.json", - "rebuild-report.json", - "renders/render-manifest.json", - "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", - "cdsl_expression", - "engine_execution", - "independent_visual_review", - "configuration_or_network", -}) - -_ERROR_FAILURE_LAYERS = { - "AUTHOR_FORMAT_INVALID": "model_format_or_decision", - "AUTHOR_DECISION_REJECTED": "model_format_or_decision", - "STALE_WORKING_HEAD": "model_format_or_decision", - "FAILED_AUTHOR_FORMAT": "model_format_or_decision", - "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED": "model_format_or_decision", - "RUNTIME_PRECONDITION_FAILED": "v3_contract_or_verifier", - "RUNTIME_CONTRACT_INVALID": "v3_contract_or_verifier", - "VERIFIER_UNAVAILABLE": "v3_contract_or_verifier", - "CLAIM_VERIFICATION_FAILED": "cdsl_expression", - "CANDIDATE_BUILD_FAILED": "engine_execution", - "CANDIDATE_REVIEW_REJECTED": "independent_visual_review", - "AUTHOR_TRANSPORT_UNAVAILABLE": "configuration_or_network", - "REVIEW_SERVICE_UNAVAILABLE": "configuration_or_network", - "RENDER_SERVICE_UNAVAILABLE": "configuration_or_network", - "STORAGE_FAILURE": "configuration_or_network", - "LIVE_EVAL_TIMEOUT": "configuration_or_network", - "FAILED_INTERNAL": "engine_execution", -} - - -def _arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run live protocol-v3 CAD evaluations.") - parser.add_argument("--suite", choices=("smoke", "release", "comprehensive"), default="smoke") - parser.add_argument("--require-live", action="store_true") - parser.add_argument("--allow-skip", action="store_true") - parser.add_argument("--author-provider") - parser.add_argument("--author-model") - parser.add_argument("--review-provider") - parser.add_argument("--review-model") - parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.") - parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.") - parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.") - parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.") - parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.") - return parser.parse_args() - - -def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]: - fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json" - value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8")) - if fixture_name == "comprehensive.json": - source_document = str(value.get("source_document") or "") - expected_digest = str(value.get("source_document_sha256") or "") - source_path = (BACKEND_ROOT.parent / source_document).resolve() - workspace_root = BACKEND_ROOT.parent.resolve() - if ( - not source_document - or workspace_root not in source_path.parents - or not source_path.is_file() - or sha256(source_path.read_bytes()).hexdigest() != expected_digest - ): - raise ValueError("Comprehensive fixture is not synchronized with its source document.") - values = [item for item in value.get("scenarios") or () if isinstance(item, dict)] - values = values[:2] if suite == "smoke" else values - if not scenario_ids: - return values - requested = list(dict.fromkeys(scenario_ids)) - available = {str(item.get("id") or "") for item in values} - unknown = [scenario_id for scenario_id in requested if scenario_id not in available] - if unknown: - raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}") - selected = [item for item in values if str(item.get("id") or "") in set(requested)] - return selected - - -def _rejection_codes(events: list[dict[str, Any]]) -> list[str]: - codes: list[str] = [] - for event in events: - payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} - result = payload.get("result") if isinstance(payload.get("result"), dict) else payload - code = result.get("code") if isinstance(result, dict) else None - if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "STALE_WORKING_HEAD"}: - codes.append(str(code)) - return codes - - -def _acceptance_coverage( - scenario: dict[str, Any], - requirements_contract: dict[str, Any] | None, -) -> dict[str, Any]: - """Assess whether a fixture's acceptance contract is representable today. - - Comprehensive prompts deliberately include manufacturing relationships that - may not yet have a deterministic verifier. A healthy-looking solid is - not evidence for those relationships, so they remain explicit capability - gaps instead of silently passing a scenario. - """ - actual = [ - { - "claim_kind": str(claim.get("claim_kind") or ""), - "expected": claim.get("expected") if isinstance(claim.get("expected"), dict) else {}, - } - for requirement in (requirements_contract or {}).get("requirements") or () - if isinstance(requirement, dict) - for claim in requirement.get("acceptance_claims") or () - if isinstance(claim, dict) - ] - required = {str(value) for value in scenario.get("required_claim_kinds") or ()} - expected_claims = [ - { - "claim_kind": str(item.get("claim_kind") or ""), - "expected": item.get("expected") if isinstance(item.get("expected"), dict) else {}, - } - for item in scenario.get("required_claims") or () - if isinstance(item, dict) and isinstance(item.get("claim_kind"), str) - ] - expected_kinds = {claim["claim_kind"] for claim in expected_claims} - expected_claims.extend( - {"claim_kind": claim_kind, "expected": {}} - for claim_kind in sorted(required - expected_kinds) - ) - gaps = [ - {"id": str(item.get("id") or ""), "description": str(item.get("description") or "")} - for item in scenario.get("validation_capability_gaps") or () - if isinstance(item, dict) - ] - missing = [ - claim for claim in expected_claims - if not any( - actual_claim["claim_kind"] == claim["claim_kind"] - and _contains_business_expected(actual_claim["expected"], claim["expected"]) - for actual_claim in actual - ) - ] - return { - "required_claim_kinds": sorted(required), - "covered_claim_kinds": sorted(required.intersection({claim["claim_kind"] for claim in actual})), - "required_claims": expected_claims, - "missing_claims": missing, - "validation_capability_gaps": gaps, - "complete": not missing and not gaps, - } - - -def _contains_business_expected(actual: Any, required: Any) -> bool: - """Match fixture business values without coupling to verifier tolerances. - - Tolerances are executable verifier parameters chosen within the schema's - safe range. They are not a separate user requirement and should not make - a valid generated contract fail release evaluation merely because the - author used the registry default instead of the fixture's tighter value. - """ - if isinstance(actual, dict) and isinstance(required, dict): - return all( - key in actual - and _contains_business_expected(actual[key], value) - for key, value in required.items() - if key not in {"tolerance_mm", "tolerance"} - ) - return contains_expected(actual, required) - - -def _contains_expected(actual: Any, required: Any) -> bool: - """Match canonical claim values in release evaluation.""" - return contains_expected(actual, required) - - -def _capability_block_reason(*reports: dict[str, Any]) -> tuple[str, str]: - """Classify a failed conformance probe without confusing outages for gaps. - - A provider transport failure means the probe did not establish either - support or non-support. Only a complete response that violates one of the - exposed tool contracts is evidence of a model structured-output limit. - """ - messages = [ - str(failure.get("message") or "").casefold() - for report in reports - for failure in report.get("failures") or () - if isinstance(failure, dict) - ] - unavailable_markers = ( - "transport unavailable", - "connection", - "network", - "timeout", - "timed out", - "temporarily unavailable", - ) - if any(any(marker in message for marker in unavailable_markers) for message in messages): - return "MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network" - return "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision" - - -def _artifact_evidence_complete( - artifact_root: Path, - 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 - 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: - return False - report_files = { - str(item.get("path") or "") - for item in (artifact_manifest or {}).get("files") or () - if isinstance(item, dict) - } - required_report_files = { - "requirements-contract.json", - f"reviews/final/{active_revision}/final-review.json", - } - if artifact_manifest is not None and not required_report_files.issubset(report_files): - 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 - 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 - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return False - declared = manifest.get("files") if isinstance(manifest, dict) else None - if not isinstance(declared, dict) or not evidence_files.issubset(declared): - return False - for relative, digest in declared.items(): - path = revision_root / str(relative) - if not isinstance(digest, str) or len(digest) != 64 or not path.is_file(): - return False - 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 evidence_files} - if not expected_report_paths.issubset(report_files): - return False - return True - - -def _failure_attribution( - *, - checks: dict[str, bool], - outcome: str, - events: list[dict[str, Any]], - projection: dict[str, Any], -) -> dict[str, Any] | None: - """Return one evidence-backed failure layer required by the test target.""" - if outcome == "passed": - return None - codes: list[str] = [] - for event in events: - payload = event.get("payload") if isinstance(event, dict) and isinstance(event.get("payload"), dict) else {} - result = payload.get("result") if isinstance(payload.get("result"), dict) else payload - code = result.get("code") if isinstance(result, dict) else "" - if isinstance(code, str) and code: - codes.append(code) - last_error = projection.get("last_error") if isinstance(projection, dict) else "" - if isinstance(last_error, str) and last_error: - codes.append(last_error) - for code in reversed(codes): - layer = _ERROR_FAILURE_LAYERS.get(code) - if layer: - return { - "layer": layer, - "reason_code": code, - "evidence_codes": list(dict.fromkeys(codes)), - "failed_checks": [name for name, passed in checks.items() if not passed], - } - failed_checks = [name for name, passed in checks.items() if not passed] - if outcome == "validation_capability_gap" or any(name in {"required_claims", "acceptance_contract_coverage"} for name in failed_checks): - layer, reason = "v3_contract_or_verifier", "VALIDATION_CAPABILITY_GAP" - elif any(name in {"deterministic_claims_pass", "required_operations", "required_operation_alternative"} for name in failed_checks): - layer, reason = "cdsl_expression", "CAD_ACCEPTANCE_GATE_FAILED" - elif any(name in {"token_budget", "call_budget", "author_turn_budget", "reviewer_turn_budget"} for name in failed_checks): - layer, reason = "configuration_or_network", "EVALUATION_BUDGET_EXCEEDED" - elif "raw_argument_audit" in failed_checks: - layer, reason = "v3_contract_or_verifier", "RAW_ARGUMENT_AUDIT_FAILED" - elif any(name in {"immutable_artifacts", "artifact_manifest", "required_artifact_evidence", "action_ledger"} for name in failed_checks): - layer, reason = "configuration_or_network", "ARTIFACT_EVIDENCE_INCOMPLETE" - else: - layer, reason = "model_format_or_decision", "WORKFLOW_TERMINAL_STATE_MISMATCH" - return { - "layer": layer, - "reason_code": reason, - "evidence_codes": list(dict.fromkeys(codes)), - "failed_checks": failed_checks, - } - - -def _run_checks( - scenario: dict[str, Any], - projection: dict[str, Any], - usage: dict[str, Any], - requirements_contract: dict[str, Any] | None, - events: list[dict[str, Any]], - artifact_root: Path, - ledger: list[dict[str, Any]] | None = None, - tool_audits: list[dict[str, Any]] | None = None, - artifact_manifest: dict[str, Any] | None = None, -) -> dict[str, bool]: - records = usage.get("records") if isinstance(usage.get("records"), list) else [] - author_calls = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"] - 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)] - 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 () - if isinstance(group, list) - ] - active_revision = str(projection.get("active_revision") or "") - manifest = artifact_root / "revisions" / active_revision / "manifest.json" - jsonl = artifact_root / "actions" / "action-ledger.jsonl" - final_claims = next((item.get("claim_results") for item in reversed(audit_ledger) if isinstance(item, dict) and item.get("event") == "completed" and isinstance(item.get("claim_results"), list)), []) - audit_records = tool_audits if isinstance(tool_audits, list) else [] - audit_valid = bool(audit_records) and all( - isinstance(item, dict) - and isinstance(item.get("raw_arguments_hash"), str) - and len(item["raw_arguments_hash"]) == 64 - and item.get("canonical_schema_valid") is True - and isinstance(item.get("state_binding"), dict) - and bool(item["state_binding"].get("phase")) - and item["state_binding"].get("binding_valid") is True - and item.get("single_allowed_call") is True - for item in audit_records - ) - acceptance = _acceptance_coverage(scenario, requirements_contract) - artifact_evidence = _artifact_evidence_complete( - artifact_root, - revisions, - active_revision, - artifact_manifest, - audit_ledger, - ) - return { - "expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"), - "token_budget": total_tokens <= int(scenario["max_total_tokens"]), - "call_budget": len(records) <= int(scenario["max_total_calls"]), - "author_turn_budget": len(author_calls) <= int(scenario["max_author_turns"]), - "reviewer_turn_budget": len(reviewer_calls) <= int(scenario["max_reviewer_turns"]), - "required_claims": not acceptance["missing_claims"], - "acceptance_contract_coverage": acceptance["complete"], - "required_operations": {str(value) for value in scenario.get("required_atomic_ids") or ()}.issubset(operation_ids), - "required_operation_alternative": ( - (not scenario.get("required_any_atomic_ids") or bool({str(value) for value in scenario["required_any_atomic_ids"]}.intersection(operation_ids))) - and all(group.intersection(operation_ids) for group in required_operation_groups) - ), - "immutable_artifacts": bool(active_revision) and manifest.is_file(), - "required_artifact_evidence": artifact_evidence, - "artifact_manifest": ( - artifact_manifest is None - or ( - isinstance(artifact_manifest.get("files"), list) - and bool(artifact_manifest["files"]) - and all( - isinstance(item, dict) - and isinstance(item.get("path"), str) - and isinstance(item.get("sha256"), str) - and len(item["sha256"]) == 64 - for item in artifact_manifest["files"] - ) - ) - ), - "action_ledger": jsonl.is_file(), - "unique_revisions": len(revisions) == len(set(revisions)), - "deterministic_claims_pass": bool(final_claims) and all( - not isinstance(item, dict) or not item.get("deterministic") or item.get("status") == "pass" - for item in final_claims - ), - "raw_argument_audit": audit_valid if tool_audits is not None else bool(records) and all(isinstance(item, dict) and isinstance(item.get("raw_arguments_hash"), str) and len(item["raw_arguments_hash"]) == 64 for item in records), - "no_schema_or_decision_rejections": not _rejection_codes(events), - } - - -def _git_revision() -> str: - try: - completed = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT.parent, capture_output=True, - text=True, check=True, timeout=5, - ) - return completed.stdout.strip() - except (OSError, subprocess.SubprocessError): - return "unknown" - - -def _event_audit(events: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Persist event metadata without prompts, tool arguments, or tool facts.""" - audit: list[dict[str, Any]] = [] - for item in events: - payload = item.get("payload") if isinstance(item.get("payload"), dict) else {} - result = payload.get("result") if isinstance(payload.get("result"), dict) else {} - usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {} - audit.append({ - "name": str(item.get("name") or ""), "tool": str(payload.get("tool") or ""), - "status": str(payload.get("status") or ""), "code": str(result.get("code") or payload.get("code") or ""), - "raw_arguments_hash": str(usage.get("raw_arguments_hash") or ""), - "field_errors": result.get("field_errors") if isinstance(result.get("field_errors"), list) else [], - }) - return audit - - -def _report_tool_audits(audits: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Return report-safe audit metadata without argument values or prompts.""" - return [ - { - "audit_id": item.get("audit_id"), - "at": item.get("at"), - "actor": item.get("actor"), - "tool": item.get("tool"), - "raw_arguments_hash": item.get("raw_arguments_hash"), - "canonical_schema_valid": item.get("canonical_schema_valid"), - "field_errors": item.get("field_errors") if isinstance(item.get("field_errors"), list) else [], - "state_binding": item.get("state_binding") if isinstance(item.get("state_binding"), dict) else {}, - "returned_tool": item.get("returned_tool"), - "single_allowed_call": item.get("single_allowed_call"), - } - for item in audits - if isinstance(item, dict) - ] - - -def _ledger_identifiers(ledger: list[dict[str, Any]]) -> dict[str, list[str]]: - """Expose audit identifiers explicitly without copying provider payloads.""" - candidate_ids = sorted({ - str(item.get("candidate_id")) - for item in ledger - if isinstance(item.get("candidate_id"), str) and item.get("candidate_id") - }) - revision_ids = sorted({ - str(item.get("revision_id")) - for item in ledger - if isinstance(item.get("revision_id"), str) and item.get("revision_id") - }) - return {"candidate_ids": candidate_ids, "revision_ids": revision_ids} - - -def _final_claim_results(ledger: list[dict[str, Any]]) -> list[dict[str, Any]]: - for item in reversed(ledger): - results = item.get("claim_results") - if item.get("event") == "completed" and isinstance(results, list): - return [value for value in results if isinstance(value, dict)] - return [] - - -def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -> list[str]: - """Keep run linkage without publishing task or provider correlation values.""" - return [ - canonical_hash({"task_id": task_id, "invocation_id": str(item.get("invocation_id") or "")})[:20] - for item in invocations - if isinstance(item, dict) and item.get("invocation_id") - ] - - -def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]: - """Hash reviewable CAD evidence without copying source or prompt content.""" - allowed_exact = { - "requirements-contract.json", - "requirements.md", - "completion-target.md", - "completion-result.md", - } - allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-") - files: list[dict[str, str]] = [] - if artifact_root.is_dir(): - for path in sorted(artifact_root.rglob("*")): - if not path.is_file(): - continue - relative = path.relative_to(artifact_root).as_posix() - if relative not in allowed_exact and not relative.startswith(allowed_prefixes): - continue - files.append({"path": relative, "sha256": sha256(path.read_bytes()).hexdigest()}) - return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files} - - -def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]: - """Summarize author-only guidance audit metadata without retaining prompts.""" - records = [ - item for item in usage.get("records") or () - if isinstance(item, dict) and item.get("role") != "reviewer" - ] - sections = sorted({ - section_id - for item in records - for section_id in item.get("guidance_section_ids") or () - if isinstance(section_id, str) - }) - versions = sorted({ - str(item.get("guidance_version") or "") - for item in records - if str(item.get("guidance_version") or "") - }) - return { - "enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records), - "versions": versions, - "section_ids": sections, - "chars_total": sum(int(item.get("guidance_chars") or 0) for item in records), - "fallback_reasons": sorted({ - str(item.get("guidance_fallback_reason") or "") - for item in records - if str(item.get("guidance_fallback_reason") or "") - }), - } - - -def _has_unsupported_capability(row: dict[str, Any]) -> bool: - """Recognize engine-declared unsupported capability without hiding model errors.""" - for event in row.get("ledger") or (): - if not isinstance(event, dict): - continue - for failure in event.get("operation_failures") or (): - if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""): - return True - if "unsupported_" in str(event.get("message") or ""): - return True - return False - - -def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - results = [item for item in report.get("results") or () if isinstance(item, dict)] - capability_gaps = [ - item for item in results - if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item) - ] - return [item for item in results if item not in capability_gaps], capability_gaps - - -def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]: - if not rows: - return {"eligible_runs": 0} - author_calls = [ - sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") - for row in rows - ] - context_chars = [ - sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") - for row in rows - ] - prompt_tokens = [ - sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") - for row in rows - ] - failure_layers: dict[str, int] = {} - for row in rows: - layer = str((row.get("failure_attribution") or {}).get("layer") or "passed") - failure_layers[layer] = failure_layers.get(layer, 0) + 1 - def rate(predicate: Any) -> float: - return sum(1 for row in rows if predicate(row)) / len(rows) - return { - "eligible_runs": len(rows), - "executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))), - "completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"), - "deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))), - "schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows), - "cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"), - "median_author_calls": median(author_calls), - "median_author_context_chars": median(context_chars), - "total_author_prompt_tokens": sum(prompt_tokens), - "median_author_prompt_tokens": median(prompt_tokens), - "failure_layers": dict(sorted(failure_layers.items())), - } - - -def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]: - """Compare paired guidance-off/on runs without treating engine gaps as prompt results.""" - control_rows, control_gaps = _guidance_metric_rows(control) - treatment_rows, treatment_gaps = _guidance_metric_rows(treatment) - control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows} - treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows} - paired = sorted(set(control_by_key).intersection(treatment_by_key)) - control_only = sorted(set(control_by_key).difference(treatment_by_key)) - treatment_only = sorted(set(treatment_by_key).difference(control_by_key)) - control_pairs = [control_by_key[key] for key in paired] - treatment_pairs = [treatment_by_key[key] for key in paired] - control_metrics = _guidance_metrics(control_pairs) - treatment_metrics = _guidance_metrics(treatment_pairs) - same_runtime = ( - control.get("author") == treatment.get("author") - and control.get("reviewer") == treatment.get("reviewer") - and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256") - and control.get("operation_contracts") == treatment.get("operation_contracts") - ) - control_guidance = bool((control.get("author_guidance") or {}).get("enabled")) - treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled")) - same_budgets = all( - control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget") - for key in paired - ) - calls_control = control_metrics.get("median_author_calls") - calls_treatment = treatment_metrics.get("median_author_calls") - calls_within_limit = ( - isinstance(calls_control, (int, float)) - and isinstance(calls_treatment, (int, float)) - and calls_treatment <= calls_control * 1.10 - ) - improved = ( - treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0) - or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0) - ) - gates = { - "complete_pairing": bool(paired) and not control_only and not treatment_only, - "control_off_treatment_on": not control_guidance and treatment_guidance, - "same_author_reviewer_runtime_and_contracts": same_runtime, - "same_per_scenario_budgets": same_budgets, - "checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0), - "completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0), - "median_author_calls_within_ten_percent": calls_within_limit, - "model_or_cdsl_failure_improved": improved, - } - return { - "schema_version": "cad.author-guidance-comparison.v1", - "status": "passed" if all(gates.values()) else "failed", - "gates": gates, - "paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired], - "unpaired_runs": { - "control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only], - "treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only], - }, - "control": control_metrics, - "treatment": treatment_metrics, - "excluded_capability_gaps": { - "control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps], - "treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps], - }, - } - - -def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]: - try: - control = json.loads(control_path.read_text(encoding="utf-8")) - treatment = json.loads(treatment_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"} - if not isinstance(control, dict) or not isinstance(treatment, dict): - return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"} - return compare_guidance_reports(control, treatment) - - -async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]: - try: - scenarios = _fixture(arguments.suite, arguments.scenarios) - except ValueError as error: - return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)} - repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1 - if repetitions < 1: - return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"} - settings = get_settings() - if arguments.author_guidance is not None: - settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on") - try: - 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) - if review_provider.id == author_provider.id and review_model.id == author_model.id: - raise ValueError("Reviewer must differ from author") - else: - review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model) - except ValueError as error: - return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)} - baseline_path = arguments.baseline_report.resolve() if arguments.baseline_report else None - if arguments.suite == "release" and baseline_path is None: - return {"status": "LIVE_EVAL_BLOCKED", "error": "TOKEN_BASELINE_REQUIRED: release requires a provenance-checked pre-v3 token baseline report."} - try: - baseline = load_token_baseline(baseline_path) if baseline_path else None - current_author_identity = author_request_identity(author_provider, author_model) - runtime_profile_hash = profile_sha256(settings.engine_root / "profile_schema.json") - except TokenBaselineError as error: - return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {error}"} - if baseline is not None: - baseline_errors = validate_token_baseline_provenance( - baseline, - scenarios=scenarios, - author_identity=current_author_identity, - runtime_profile_hash=runtime_profile_hash, - ) - if baseline_errors: - return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {baseline_errors[0]}", "baseline_errors": baseline_errors} - isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations") - services = compose_v3(isolated) - contracts = [ - { - "atomic_id": atomic_id, "contract_hash": services.workflow.runtime.operation_contract(atomic_id)["contract_hash"], - "contract_version": services.workflow.runtime.operation_contract(atomic_id)["contract_version"], - "registry_revision": services.workflow.runtime.operation_contract(atomic_id)["registry_revision"], - } - for atomic_id in services.workflow.runtime.supported_atomic_ids() - ] - verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema()) - try: - author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True) - reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True) - except Exception as error: - return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]} - if not author_capability.get("supported") or not reviewer_capability.get("supported"): - error, failure_layer = _capability_block_reason(author_capability, reviewer_capability) - return { - "status": "LIVE_EVAL_BLOCKED", - "error": error, - "failure_layer": failure_layer, - "author_capability": author_capability, - "reviewer_capability": reviewer_capability, - } - results: list[dict[str, Any]] = [] - for scenario in scenarios: - for repetition in range(1, repetitions + 1): - services.workflow.config = replace( - services.workflow.config, - # State transitions include local candidate recovery, so the - # loop guard is deliberately independent from the externally - # measured model-call budgets below. - max_turns=max(8, int(scenario["max_total_calls"]) * 3), - max_author_turns=int(scenario["max_author_turns"]), - max_reviewer_turns=int(scenario["max_reviewer_turns"]), - max_model_calls=int(scenario["max_total_calls"]), - ) - task_id = f"cad_{secrets.token_hex(6)}" - oracle_claims = [item for item in scenario.get("required_claims") or () if isinstance(item, dict)] - if oracle_claims: - services.workflow.requirements.register_evaluation_contract_oracle( - task_id, - oracle_claims, - validation_capability_gaps=[item for item in scenario.get("validation_capability_gaps") or () if isinstance(item, dict)], - ) - services.workflow.create_task(task_id, str(scenario["request"])) - events: list[dict[str, Any]] = [] - started = datetime.now(timezone.utc) - try: - async with asyncio.timeout(int(scenario["max_wall_seconds"])): - async for name, payload in services.workflow.run( - task_id=task_id, - author=ModelIdentity(author_provider.id, author_model.id), - reviewer=ModelIdentity(review_provider.id, review_model.id), - ): - events.append({"name": name, "payload": payload}) - except TimeoutError: - events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT"}}) - projection = services.repository.get_task_projection(task_id) or {} - usage = services.repository.usage_summary(task_id) - ledger = services.repository.ledger_events(task_id) - invocations = services.repository.invocation_records(task_id) - tool_audits = services.repository.tool_audits(task_id) - terminal = next((item["payload"] for item in reversed(events) if item["name"] == "task_terminal"), {}) - artifact_root = (isolated.task_root / task_id).resolve() - artifact_manifest = _safe_artifact_manifest(artifact_root) - finished = datetime.now(timezone.utc) - checks = _run_checks( - scenario, - projection, - usage, - services.artifacts.read_requirements_contract( - task_id, - services.repository.get_state(task_id).requirements_contract_path - if services.repository.get_state(task_id) is not None - else "", - ), - events, - artifact_root, - ledger, - tool_audits, - artifact_manifest, - ) - success = all(checks.values()) - acceptance = _acceptance_coverage( - scenario, - services.artifacts.read_requirements_contract( - task_id, - services.repository.get_state(task_id).requirements_contract_path - if services.repository.get_state(task_id) is not None - else "", - ), - ) - outcome = ( - "passed" - if success - else "validation_capability_gap" - if not acceptance["complete"] - and all(value for key, value in checks.items() if key != "acceptance_contract_coverage") - else "failed" - ) - failure_attribution = _failure_attribution( - checks=checks, - outcome=outcome, - events=events, - projection=projection, - ) - results.append({ - "scenario": scenario["id"], "repetition": repetition, "success": success, "outcome": outcome, - "started_at": started.isoformat(), "finished_at": finished.isoformat(), - "duration_ms": round((finished - started).total_seconds() * 1000), - "terminal": terminal, "projection": projection, "usage": usage, "checks": checks, - "acceptance_coverage": acceptance, - "failure_attribution": failure_attribution, - "guidance": _guidance_metadata(usage), - "scenario_budget": { - "max_author_turns": int(scenario["max_author_turns"]), - "max_reviewer_turns": int(scenario["max_reviewer_turns"]), - "max_total_calls": int(scenario["max_total_calls"]), - "max_total_tokens": int(scenario["max_total_tokens"]), - }, - "rejection_codes": _rejection_codes(events), - "schema_rejection_count": len(_rejection_codes(events)), - "retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"), - "rollback_count": sum(1 for item in ledger if item.get("event") == "rollback"), - **_ledger_identifiers(ledger), - "final_claim_results": _final_claim_results(ledger), - "redacted_correlation_ids": _redacted_correlation_ids(task_id, invocations), - "tool_audits": _report_tool_audits(tool_audits), - "artifact_root": str(artifact_root), "action_ledger_path": str(artifact_root / "actions" / "action-ledger.jsonl"), - "artifact_manifest": artifact_manifest, - "ledger": ledger, "invocations": invocations, "event_audit": _event_audit(events), - }) - token_comparison = ( - compare_token_baseline( - baseline, - scenarios=scenarios, - v3_results=results, - author_identity=current_author_identity, - runtime_profile_hash=runtime_profile_hash, - ) - if baseline is not None - else {"schema_version": "cad.token-comparison.v1", "status": "not_required"} - ) - token_gate = all(token_comparison.get("checks", {}).values()) if baseline is not None else True - return { - "status": "passed" if results and all(item["success"] for item in results) and token_gate else "failed", - "author": {"provider": author_provider.id, "model": author_model.id}, - "author_request_identity": current_author_identity, - "reviewer": {"provider": review_provider.id, "model": review_model.id}, - "author_guidance": { - "enabled": settings.agent_author_guidance_enabled, - "max_chars": settings.agent_author_guidance_max_chars, - }, - "author_capability": author_capability, - "reviewer_capability": reviewer_capability, - "structured_output_mode": { - "author": str(author_capability.get("mode") or ""), - "reviewer": str(reviewer_capability.get("mode") or ""), - }, - "git_revision": _git_revision(), - "protocol_version": "3.2", - "runtime_profile_sha256": runtime_profile_hash, - "operation_contracts": contracts, - "verifier_registry_version": "cad.verifier-registry.v1", - "verifier_schema_hash": verifier_schema_hash, - "token_comparison": token_comparison, - "baseline_report": str(baseline_path) if baseline_path else "", - "repetitions": repetitions, - "results": results, - } - - -def main() -> int: - arguments = _arguments() - report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - report_root.mkdir(parents=True, exist_ok=True) - try: - result = ( - compare_guidance_report_paths(*arguments.compare_guidance_reports) - if arguments.compare_guidance_reports - else asyncio.run(_run(arguments, report_root)) - ) - except KeyboardInterrupt: - # Let an explicit operator interruption retain its normal CLI - # semantics. An external kill cannot be reported reliably either. - raise - except BaseException as error: - # A live evaluation may fail before it creates a task (for example - # during conformance). Its report is still the release gate's audit - # artifact, so an unexpected evaluator failure must not disappear. - result = { - "status": "LIVE_EVAL_BLOCKED", - "error": f"UNEXPECTED_LIVE_EVAL_ERROR: {type(error).__name__}: {str(error)[:900]}", - "failure_layer": "configuration_or_network", - } - if result["status"] == "LIVE_EVAL_BLOCKED" and arguments.allow_skip and not arguments.require_live: - result["skip_reason"] = result.get("error", "live provider access is unavailable") - result["status"] = "skipped" - result.update({"suite": arguments.suite, "require_live": arguments.require_live, "report_root": str(report_root.resolve())}) - (report_root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") - print(json.dumps({"status": result["status"], "report": str((report_root / "report.json").resolve())}, ensure_ascii=False)) - if result["status"] == "passed": - return 0 - if result["status"] == "skipped": - return 0 - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/app/cad_agent/evals/resume_one_step.py b/backend/app/cad_agent/evals/resume_one_step.py deleted file mode 100644 index 72456d84..00000000 --- a/backend/app/cad_agent/evals/resume_one_step.py +++ /dev/null @@ -1,90 +0,0 @@ -"""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/evals/single_stage.py b/backend/app/cad_agent/evals/single_stage.py new file mode 100644 index 00000000..a002032d --- /dev/null +++ b/backend/app/cad_agent/evals/single_stage.py @@ -0,0 +1,55 @@ +"""Aggregate local results for the `cad.single-stage.v1` evaluation suite. + +The evaluator accepts already-recorded task projections and never calls a +model. This keeps rate reporting reproducible and separates it from live +provider experiments. +""" +from __future__ import annotations + +from typing import Any, Iterable + + +def summarize(records: Iterable[dict[str, Any]]) -> dict[str, Any]: + """Return first-pass, final, compliance, and cost counters. + + A record may contain ``attempts`` with ``schema_valid`` and + ``executable`` booleans, final lifecycle/revision information, requirement + target states, usage records, and an elapsed duration in milliseconds. + Missing fields are reported as zero rather than guessed. + """ + values = [item for item in records if isinstance(item, dict)] + first_schema = first_executable = final_executable = 0 + targets: dict[str, int] = {key: 0 for key in ("pass", "fail", "pending", "not_applicable")} + calls = prompt_tokens = completion_tokens = duration_ms = 0 + for record in values: + attempts = record.get("attempts") if isinstance(record.get("attempts"), list) else [] + first = attempts[0] if attempts and isinstance(attempts[0], dict) else {} + first_schema += int(bool(first.get("schema_valid"))) + first_executable += int(bool(first.get("executable"))) + final_executable += int(bool(record.get("published_revision") or record.get("active_revision"))) + for target in record.get("requirement_targets") or (): + state = str(target.get("status") or "pending") if isinstance(target, dict) else "pending" + targets[state if state in targets else "pending"] += 1 + usage = record.get("usage") if isinstance(record.get("usage"), dict) else {} + usage_records = usage.get("records") if isinstance(usage.get("records"), list) else [] + calls += len(usage_records) + for item in usage_records: + if not isinstance(item, dict): + continue + prompt_tokens += int(item.get("prompt_tokens") or 0) + completion_tokens += int(item.get("completion_tokens") or 0) + duration_ms += int(record.get("duration_ms") or 0) + total = len(values) + rate = lambda count: count / total if total else 0.0 + return { + "schema_version": "cad.single-stage.eval-summary.v1", + "tasks": total, + "first_pass_schema_rate": rate(first_schema), + "first_pass_executable_rate": rate(first_executable), + "final_executable_rate": rate(final_executable), + "requirement_targets": targets, + "calls": calls, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "duration_ms": duration_ms, + } diff --git a/backend/app/cad_agent/evals/token_baseline.py b/backend/app/cad_agent/evals/token_baseline.py deleted file mode 100644 index b9f2a47b..00000000 --- a/backend/app/cad_agent/evals/token_baseline.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Provenance-checked token baseline comparison for the v3 release gate. - -The target intentionally does not accept estimated tokens or a hand-written -percentage. A v2 run must record the same fixed requests and author request -configuration before it can be compared with a v3 release report. -""" - -from __future__ import annotations - -from hashlib import sha256 -import json -from pathlib import Path -from statistics import median -from typing import Any - -from app.settings import ProviderConfig, ProviderModel - - -BASELINE_SCHEMA_VERSION = "cad.token-baseline.v1" -BASELINE_PROTOCOL_VERSION = "2.0" -MINIMUM_REPETITIONS = 3 -TOKEN_REDUCTION_TARGET = 0.30 -MEASUREMENTS = {"prompt_tokens", "context_chars"} - - -class TokenBaselineError(ValueError): - """A baseline cannot prove the release token target.""" - - -def request_sha256(request: str) -> str: - return sha256(request.encode("utf-8")).hexdigest() - - -def profile_sha256(profile_path: Path) -> str: - try: - return sha256(profile_path.read_bytes()).hexdigest() - except OSError as error: - raise TokenBaselineError("Runtime profile is unavailable for token-baseline provenance.") from error - - -def author_request_identity(provider: ProviderConfig, model: ProviderModel) -> dict[str, Any]: - """Capture every author setting that can affect prompt-token comparison.""" - return { - "provider": provider.id, - "model": model.id, - "api_style": provider.api_style, - "reasoning_effort": provider.reasoning_effort, - # Chat Completions is explicitly deterministic in StructuredModelGateway. - # Responses models use their configured/provider-default sampling mode. - "sampling": {"temperature": 0 if provider.api_style == "chat_completions" else None}, - } - - -def load_token_baseline(path: Path) -> dict[str, Any]: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise TokenBaselineError("Token baseline report is missing or is not valid JSON.") from error - if not isinstance(payload, dict): - raise TokenBaselineError("Token baseline report must be a JSON object.") - if payload.get("schema_version") != BASELINE_SCHEMA_VERSION: - raise TokenBaselineError(f"Token baseline must use {BASELINE_SCHEMA_VERSION}.") - if payload.get("protocol_version") != BASELINE_PROTOCOL_VERSION: - raise TokenBaselineError("Token baseline must be a measured pre-v3 (2.0) report.") - if payload.get("measurement") not in MEASUREMENTS: - raise TokenBaselineError("Token baseline measurement must be prompt_tokens or context_chars.") - return payload - - -def validate_token_baseline_provenance( - baseline: dict[str, Any], - *, - scenarios: list[dict[str, Any]], - author_identity: dict[str, Any], - runtime_profile_hash: str, -) -> list[str]: - """Reject an incomparable baseline before any billable v3 API call.""" - errors: list[str] = [] - if baseline.get("schema_version") != BASELINE_SCHEMA_VERSION: - errors.append(f"schema_version must equal {BASELINE_SCHEMA_VERSION}") - if baseline.get("protocol_version") != BASELINE_PROTOCOL_VERSION: - errors.append("protocol_version must equal 2.0") - if baseline.get("author") != author_identity: - errors.append("author provider/model/API mode/reasoning/sampling does not match the v3 release") - if baseline.get("runtime_profile_sha256") != runtime_profile_hash: - errors.append("runtime profile hash does not match the v3 release") - measurement = str(baseline.get("measurement") or "") - if measurement not in MEASUREMENTS: - errors.append("measurement must be prompt_tokens or context_chars") - baseline_scenarios = baseline.get("scenarios") - if not isinstance(baseline_scenarios, list): - return [*errors, "scenarios must be an array"] - by_scenario = { - str(item.get("scenario") or ""): item - for item in baseline_scenarios - if isinstance(item, dict) and item.get("scenario") - } - for scenario in scenarios: - scenario_id = str(scenario.get("id") or "") - expected_request_hash = request_sha256(str(scenario.get("request") or "")) - baseline_scenario = by_scenario.get(scenario_id) - if not isinstance(baseline_scenario, dict): - errors.append(f"missing baseline scenario {scenario_id}") - continue - if baseline_scenario.get("request_sha256") != expected_request_hash: - errors.append(f"baseline request hash differs for {scenario_id}") - repetitions = baseline_scenario.get("repetitions") - if not isinstance(repetitions, list): - errors.append(f"baseline {scenario_id} repetitions must be an array") - continue - by_repetition = { - int(item.get("repetition")): item - for item in repetitions - if isinstance(item, dict) and isinstance(item.get("repetition"), int) - } - expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1)) - if not expected_repetitions.issubset(by_repetition): - errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}") - continue - for repetition in expected_repetitions: - run = by_repetition[repetition] - if _non_negative_int(run.get("author_metric")) is None or _non_negative_int(run.get("plan_review_metric")) is None: - errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics") - for field in ("completed", "final_review_passed", "deterministic_claims_pass"): - if not isinstance(run.get(field), bool): - errors.append(f"baseline {scenario_id} repetition {repetition} has non-boolean {field}") - return errors - - -def compare_token_baseline( - baseline: dict[str, Any], - *, - scenarios: list[dict[str, Any]], - v3_results: list[dict[str, Any]], - author_identity: dict[str, Any], - runtime_profile_hash: str, -) -> dict[str, Any]: - """Return a complete, fail-closed comparison for the release report.""" - errors = validate_token_baseline_provenance( - baseline, scenarios=scenarios, author_identity=author_identity, - runtime_profile_hash=runtime_profile_hash, - ) - measurement = str(baseline.get("measurement") or "") - - baseline_scenarios = baseline.get("scenarios") - if not isinstance(baseline_scenarios, list): - errors.append("scenarios must be an array") - baseline_scenarios = [] - by_scenario = { - str(item.get("scenario") or ""): item - for item in baseline_scenarios - if isinstance(item, dict) and item.get("scenario") - } - current_by_scenario: dict[str, list[dict[str, Any]]] = {} - for result in v3_results: - if isinstance(result, dict) and isinstance(result.get("scenario"), str): - current_by_scenario.setdefault(result["scenario"], []).append(result) - - baseline_tokens: list[int] = [] - current_tokens: list[int] = [] - baseline_completed: list[bool] = [] - current_completed: list[bool] = [] - baseline_final_review: list[bool] = [] - current_final_review: list[bool] = [] - baseline_deterministic: list[bool] = [] - current_deterministic: list[bool] = [] - per_scenario: list[dict[str, Any]] = [] - - for scenario in scenarios: - scenario_id = str(scenario.get("id") or "") - expected_request_hash = request_sha256(str(scenario.get("request") or "")) - baseline_scenario = by_scenario.get(scenario_id) - if not isinstance(baseline_scenario, dict): - errors.append(f"missing baseline scenario {scenario_id}") - continue - if baseline_scenario.get("request_sha256") != expected_request_hash: - errors.append(f"baseline request hash differs for {scenario_id}") - continue - repetitions = baseline_scenario.get("repetitions") - if not isinstance(repetitions, list) or len(repetitions) < MINIMUM_REPETITIONS: - errors.append(f"baseline {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions") - continue - current = current_by_scenario.get(scenario_id, []) - if len(current) < MINIMUM_REPETITIONS: - errors.append(f"v3 {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions") - continue - baseline_by_repetition = { - int(item.get("repetition")) : item - for item in repetitions - if isinstance(item, dict) and isinstance(item.get("repetition"), int) - } - current_by_repetition = { - int(item.get("repetition")): item - for item in current - if isinstance(item.get("repetition"), int) - } - expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1)) - if not expected_repetitions.issubset(baseline_by_repetition): - errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}") - continue - if not expected_repetitions.issubset(current_by_repetition): - errors.append(f"v3 {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}") - continue - - scenario_baseline_tokens: list[int] = [] - scenario_current_tokens: list[int] = [] - for repetition in sorted(expected_repetitions): - baseline_run = baseline_by_repetition[repetition] - author_tokens = _non_negative_int(baseline_run.get("author_metric")) - plan_review_tokens = _non_negative_int(baseline_run.get("plan_review_metric")) - if author_tokens is None or plan_review_tokens is None: - errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics") - continue - current_run = current_by_repetition[repetition] - usage = current_run.get("usage") if isinstance(current_run.get("usage"), dict) else {} - v3_author_tokens = _v3_author_metric(usage, measurement) - if v3_author_tokens is None: - errors.append(f"v3 {scenario_id} repetition {repetition} has invalid author {measurement} usage") - continue - scenario_baseline_tokens.append(author_tokens + plan_review_tokens) - scenario_current_tokens.append(v3_author_tokens) - baseline_completed.append(bool(baseline_run.get("completed"))) - current_completed.append(str((current_run.get("projection") or {}).get("phase") or "") == "COMPLETED") - baseline_final_review.append(bool(baseline_run.get("final_review_passed"))) - current_final_review.append(str((current_run.get("terminal") or {}).get("lifecycle") or "") == "completed") - baseline_deterministic.append(bool(baseline_run.get("deterministic_claims_pass"))) - checks = current_run.get("checks") if isinstance(current_run.get("checks"), dict) else {} - current_deterministic.append(bool(checks.get("deterministic_claims_pass"))) - if len(scenario_baseline_tokens) == MINIMUM_REPETITIONS and len(scenario_current_tokens) == MINIMUM_REPETITIONS: - baseline_tokens.extend(scenario_baseline_tokens) - current_tokens.extend(scenario_current_tokens) - per_scenario.append({ - "scenario": scenario_id, - "baseline_median_metric": median(scenario_baseline_tokens), - "v3_median_author_metric": median(scenario_current_tokens), - }) - - baseline_median = median(baseline_tokens) if baseline_tokens else None - current_median = median(current_tokens) if current_tokens else None - reduction = (1 - current_median / baseline_median) if baseline_median and current_median is not None else None - baseline_valid = not errors - checks = { - "baseline_valid": baseline_valid, - "median_metric_reduction": baseline_valid and reduction is not None and reduction >= TOKEN_REDUCTION_TARGET, - "completion_rate_not_lower": baseline_valid and _rate(current_completed) >= _rate(baseline_completed) if baseline_completed and current_completed else False, - "final_review_rate_not_lower": baseline_valid and _rate(current_final_review) >= _rate(baseline_final_review) if baseline_final_review and current_final_review else False, - "deterministic_claim_rate_not_lower": baseline_valid and _rate(current_deterministic) >= _rate(baseline_deterministic) if baseline_deterministic and current_deterministic else False, - "plan_review_calls_removed": baseline_valid and all( - not str(record.get("tool") or "").startswith("modeling_plan") - for result in v3_results if isinstance(result, dict) - for record in ((result.get("usage") or {}).get("records") or []) - if isinstance(record, dict) - ), - } - return { - "schema_version": "cad.token-comparison.v1", - "baseline_protocol_version": baseline.get("protocol_version"), - "measurement": measurement, - "baseline_median_metric": baseline_median, - "v3_median_author_metric": current_median, - "median_metric_reduction": reduction, - "target_median_metric_reduction": TOKEN_REDUCTION_TARGET, - "baseline_completion_rate": _rate(baseline_completed), - "v3_completion_rate": _rate(current_completed), - "baseline_final_review_rate": _rate(baseline_final_review), - "v3_final_review_rate": _rate(current_final_review), - "baseline_deterministic_claim_rate": _rate(baseline_deterministic), - "v3_deterministic_claim_rate": _rate(current_deterministic), - "per_scenario": per_scenario, - "errors": errors, - "checks": checks, - } - - -def _non_negative_int(value: Any) -> int | None: - return value if isinstance(value, int) and value >= 0 else None - - -def _v3_author_metric(usage: dict[str, Any], measurement: str) -> int | None: - records = usage.get("records") - if not isinstance(records, list): - return None - author_records = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"] - if not author_records: - return None - if measurement == "prompt_tokens" and not all(item.get("usage_available") is True for item in author_records): - return None - field = "prompt_tokens" if measurement == "prompt_tokens" else "context_chars" - values = [ - _non_negative_int(item.get(field)) - for item in author_records - ] - return sum(value for value in values if value is not None) if all(value is not None for value in values) else None - - -def _rate(values: list[bool]) -> float | None: - return sum(values) / len(values) if values else None diff --git a/backend/app/cad_agent/evals/usable_smoke.py b/backend/app/cad_agent/evals/usable_smoke.py deleted file mode 100644 index 39b9bda4..00000000 --- a/backend/app/cad_agent/evals/usable_smoke.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Run real-provider CAD tasks with a usable-model success criterion. - -This evaluator is intentionally narrower than the release gate in ``live.py``. -It answers one operational question: can the current workflow reliably finish -ordinary prompts with a downloadable/previewable checkpoint, even if some -acceptance claims remain best-effort warnings. -""" - -from __future__ import annotations - -import argparse -import asyncio -from dataclasses import replace -from datetime import datetime, timezone -import json -from pathlib import Path -import secrets -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 BACKEND_ROOT, get_settings - - -DEFAULT_PROMPTS = ( - "生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。", - "生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。", - "生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。", -) - - -def _arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.") - parser.add_argument("--author-provider") - parser.add_argument("--author-model") - parser.add_argument("--review-provider") - parser.add_argument("--review-model") - parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.") - parser.add_argument("--max-wall-seconds", type=int, default=1200) - parser.add_argument("--max-model-calls", type=int, default=60) - return parser.parse_args() - - -def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str: - terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {}) - for source in (terminal, projection): - code = source.get("code") or source.get("last_error") if isinstance(source, dict) else "" - if isinstance(code, str) and code: - return code - return "" - - -def _artifact_ok(artifact_root: Path, revision_id: str) -> bool: - if not revision_id: - return False - revision_root = artifact_root / "revisions" / revision_id - return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json")) - - -def _classify_failure(code: str, projection: dict[str, Any]) -> str: - if str(projection.get("lifecycle") or "") == "waiting_retry": - return "configuration_or_service" - if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}: - return "code_or_flow" - if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}: - return "configuration_or_service" - if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}: - return "model_output_or_best_effort" - return "unknown" - - -async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]: - settings = get_settings() - try: - 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) - except ValueError as error: - return {"status": "blocked", "error": str(error), "results": []} - isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations") - services = compose_v3(isolated) - services.workflow.config = replace( - services.workflow.config, - max_turns=max(8, arguments.max_model_calls * 3), - max_model_calls=arguments.max_model_calls, - ) - prompts = tuple(arguments.prompt or DEFAULT_PROMPTS) - results: list[dict[str, Any]] = [] - for index, prompt in enumerate(prompts, start=1): - task_id = f"cad_{secrets.token_hex(6)}" - services.workflow.create_task(task_id, prompt) - events: list[dict[str, Any]] = [] - started = datetime.now(timezone.utc) - try: - async with asyncio.timeout(arguments.max_wall_seconds): - async for name, payload in services.workflow.run( - task_id=task_id, - author=ModelIdentity(author_provider.id, author_model.id), - reviewer=ModelIdentity(review_provider.id, review_model.id), - ): - events.append({"name": name, "payload": payload}) - except TimeoutError: - events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}}) - projection = services.repository.get_task_projection(task_id) or {} - artifact_root = (isolated.task_root / task_id).resolve() - revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "") - code = _result_code(events, projection) - usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id) - results.append({ - "index": index, - "task_id": task_id, - "prompt": prompt, - "success": usable, - "failure_layer": "" if usable else _classify_failure(code, projection), - "code": code, - "phase": projection.get("phase"), - "lifecycle": projection.get("lifecycle"), - "active_revision": revision_id, - "verification_status": projection.get("verification_status"), - "artifact_root": str(artifact_root), - "duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000), - "usage": services.repository.usage_summary(task_id), - "event_audit": [ - { - "name": item.get("name"), - "status": (item.get("payload") or {}).get("status"), - "lifecycle": (item.get("payload") or {}).get("lifecycle"), - "code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"), - "tool": (item.get("payload") or {}).get("tool"), - } - for item in events - ], - }) - return { - "status": "passed" if results and all(item["success"] for item in results) else "failed", - "schema_version": "cad.usable-smoke.v1", - "author": {"provider": author_provider.id, "model": author_model.id}, - "reviewer": {"provider": review_provider.id, "model": review_model.id}, - "results": results, - } - - -def main() -> int: - arguments = _arguments() - report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - report_root.mkdir(parents=True, exist_ok=True) - try: - result = asyncio.run(_run(arguments, report_root)) - except KeyboardInterrupt: - raise - except BaseException as error: - result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []} - result.update({"report_root": str(report_root.resolve())}) - report_path = report_root / "usable-smoke-report.json" - report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") - print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False)) - return 0 if result["status"] == "passed" else 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/app/cad_agent/ports.py b/backend/app/cad_agent/ports.py index dc88cff5..9672943b 100644 --- a/backend/app/cad_agent/ports.py +++ b/backend/app/cad_agent/ports.py @@ -5,60 +5,13 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any, Protocol -from .domain.state import TaskPhase, TaskState +from .domain.state import TaskState class AdapterUnavailable(RuntimeError): """A bounded external-service outage; handlers must preserve checkpoints.""" -@dataclass(frozen=True, slots=True) -class AuthorGuidanceSelection: - """Non-authoritative author context selected from the local guidance corpus.""" - - version: str = "" - section_ids: tuple[str, ...] = () - content: str = "" - enabled: bool = False - fallback_reason: str = "" - - def usage_metadata(self) -> dict[str, object]: - return { - "guidance_version": self.version, - "guidance_section_ids": list(self.section_ids), - "guidance_chars": len(self.content), - "guidance_enabled": self.enabled, - "guidance_fallback_reason": self.fallback_reason, - } - - -class AuthorGuidance(Protocol): - """Select bounded local author guidance without interpreting user intent.""" - - def select( - self, - *, - phase: TaskPhase, - atomic_id: str, - repair_required: bool, - supported_atomic_ids: tuple[str, ...], - ) -> AuthorGuidanceSelection: ... - - -class NullAuthorGuidance: - """Compatibility default that retains the pre-guidance author prompt.""" - - def select( - self, - *, - phase: TaskPhase, - atomic_id: str, - repair_required: bool, - supported_atomic_ids: tuple[str, ...], - ) -> AuthorGuidanceSelection: - return AuthorGuidanceSelection(fallback_reason="guidance_not_configured") - - @dataclass(frozen=True, slots=True) class InvocationRecord: invocation_id: str @@ -68,7 +21,7 @@ class InvocationRecord: @dataclass(frozen=True, slots=True) -class CandidateStage: +class StagingRevision: stage_id: str output_dir: str @@ -106,38 +59,21 @@ class TaskRepository(Protocol): class ArtifactStore(Protocol): def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ... - def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ... - def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ... - def read_source_index(self, task_id: str) -> dict[str, str]: ... + def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ... def read_source_requirements(self, task_id: str) -> str: ... def source_image_paths(self, task_id: str) -> list[str]: ... - def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ... - def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ... - def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ... def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ... def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ... def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ... - def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ... - def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ... - def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: ... - def stage_output_dir(self, task_id: str, stage_id: str) -> str: ... + def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: ... def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ... - def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: ... - def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ... - def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: ... - def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: ... + def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ... class CadRuntime(Protocol): def supported_atomic_ids(self) -> tuple[str, ...]: ... 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, 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 compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], 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]]]: ... @@ -146,13 +82,5 @@ class ModelGateway(Protocol): async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ... -class ReviewGateway(Protocol): - async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: ... - - -class VerifierExecutor(Protocol): - def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: ... - - class EventPublisher(Protocol): async def publish(self, event: dict[str, Any]) -> None: ... diff --git a/backend/app/main.py b/backend/app/main.py index d9e025d0..b1321793 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,13 +7,11 @@ 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 from app.services.attachments import attachment_record, classify_upload, extract_document_text from app.services.image_processing import image_metadata -from app.services.review_renderer import renderer_status from app.settings import get_settings @@ -57,12 +55,6 @@ async def config() -> dict[str, Any]: for model in provider.models ], }) - try: - settings.resolve_review_model() - renderer_ready, renderer_detail = renderer_status() - review_error = "" if renderer_ready else renderer_detail - except ValueError as error: - review_error = str(error) return { "default_provider": settings.default_provider_id, "default_model": settings.llm_model, @@ -71,8 +63,6 @@ async def config() -> dict[str, Any]: "configured": settings.llm_configured, "library_samples": library.count(), "autonomous_generation": settings.autonomous_generation, - "review_configured": not review_error, - "review_error": review_error, } @@ -123,7 +113,7 @@ async def upload_conversation_attachment( if current is None: raise HTTPException(status_code=404, detail="Conversation not found") active_task_id = str(current.get("current_task_id") or "") - active_task = agent.v3.repository.get_task_projection(active_task_id) if active_task_id else None + active_task = agent.cad.repository.get_task_projection(active_task_id) if active_task_id else None if str((active_task or {}).get("lifecycle") or "") == "running": raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state") kind = classify_upload(filename, file.content_type or "", len(data)) @@ -145,101 +135,25 @@ async def upload_conversation_attachment( async def read_task(task_id: str) -> JSONResponse: try: safe_id = safe_task_id(task_id) - task = agent.v3.repository.get_task_projection(safe_id) + task = agent.cad.repository.get_task_projection(safe_id) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error if task is None: raise HTTPException(status_code=404, detail="Task not found") - task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") - state = agent.v3.repository.get_state(safe_id) - task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None - task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None - task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary")) - task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"]) - 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.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["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) + task["preview_revision"] = str(task.get("active_revision") or "") + state = agent.cad.repository.get_state(safe_id) + task["requirements_analysis"] = agent.cad.artifacts.read_json(safe_id, state.requirements_path) if state and state.requirements_path else None + task["authoring_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.authoring_path) if state and state.authoring_path else None + task["runtime_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.runtime_cdsl_path) if state and state.runtime_cdsl_path else None + task["compile_audit"] = agent.cad.artifacts.read_json(safe_id, state.compile_audit_path) if state and state.compile_audit_path else None + task["diagnostics"] = agent.cad.artifacts.read_json(safe_id, state.diagnostics_path) if state and state.diagnostics_path else None + task["claim_report"] = agent.cad.artifacts.read_json(safe_id, "documents/claim-report.json") + result_path = agent.cad.artifacts.artifact_path(safe_id, state.completion_path) if state and state.completion_path else None + task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path and result_path.is_file() else None + task["usage"] = agent.cad.repository.usage_summary(safe_id) return JSONResponse(task) -def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]: - """Project frozen claims with the newest committed verification evidence. - - This is API-only data derived from SQLite-backed ledger entries and the - state-referenced frozen contract. It never parses Markdown or exposes a - staged candidate as a task checkpoint. - """ - 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", "feature_node_verified", "final_visual_reviewed"}: - continue - values = event.get("claim_results") - if not isinstance(values, list): - values = event.get("coverage") - if not isinstance(values, list): - continue - for value in values: - if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest: - latest[value["claim_id"]] = value - result: list[dict[str, Any]] = [] - for requirement in (contract or {}).get("requirements") or (): - if not isinstance(requirement, dict): - continue - for claim in requirement.get("acceptance_claims") or (): - if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str): - continue - evidence = latest.get(claim["claim_id"], {}) - result.append({ - "requirement_id": str(requirement.get("requirement_id") or ""), - "claim_id": claim["claim_id"], - "claim_kind": str(claim.get("claim_kind") or ""), - "deterministic": claim.get("verification_mode") == "deterministic", - "status": str(evidence.get("status") or "pending"), - "evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {}, - }) - return result - - -def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Expose server-evaluated checklist progress without parsing Markdown.""" - by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)} - progress: list[dict[str, Any]] = [] - for requirement in (contract or {}).get("requirements") or (): - if not isinstance(requirement, dict): - continue - statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)] - progress.append({ - "requirement_id": str(requirement.get("requirement_id") or ""), - "statement": str(requirement.get("statement") or ""), - "status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending", - }) - return progress - - @app.delete("/v1/tasks/{task_id}") async def cancel_task(task_id: str) -> JSONResponse: try: @@ -281,19 +195,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: try: safe_id = safe_task_id(task_id) - path = agent.v3.artifacts.artifact_path(safe_id, artifact_path) + path = agent.cad.artifacts.artifact_path(safe_id, artifact_path) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error if not path.is_file(): raise HTTPException(status_code=404, detail="Artifact not found") - task = agent.v3.repository.get_task_projection(safe_id) or {} + task = agent.cad.repository.get_task_projection(safe_id) or {} parts = artifact_path.split("/") revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else "" - published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else "" + published_revision = str(task.get("published_revision") or "") active_revision = str(task.get("active_revision") or task.get("current_revision") or "") if not revision_id: - # The task directory also contains candidate staging, agent audit and - # frozen-input files. None of those are a public artifact surface. + if artifact_path == "completion-result.md" and str(task.get("completion_path") or "") == artifact_path: + return FileResponse(path, filename=path.name) raise HTTPException(status_code=403, detail="This task artifact is not public") if revision_id == published_revision: @@ -302,30 +216,15 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: f"revisions/{revision_id}/model.step", f"revisions/{revision_id}/model.glb", f"revisions/{revision_id}/rebuild-report.json", + f"revisions/{revision_id}/build-diagnostics.json", + f"revisions/{revision_id}/renders/render-manifest.json", } if artifact_path not in published_paths: raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable") return FileResponse(path, filename=path.name) - 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. + # A running task can render its active executable prefix, but its staging + # inputs and diagnostics remain private until publication. if ( str(task.get("lifecycle") or "") != "running" or revision_id != active_revision diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 3d8b80c6..eeff302d 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -1,5 +1,4 @@ -"""HTTP/SSE delivery adapter for the protocol v3 workflow.""" - +"""HTTP/SSE delivery adapter for the single-stage CAD protocol.""" from __future__ import annotations import asyncio @@ -10,8 +9,7 @@ import secrets from typing import Any from app.cad_agent.application.workflow import ModelIdentity -from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability -from app.cad_agent.composition import V3Services, compose_v3 +from app.cad_agent.composition import CadServices, compose_cad_services from app.cad_agent.domain.errors import ErrorCode from app.cad_agent.domain.state import TaskPhase, transition from app.models.contracts import ChatMessage @@ -25,480 +23,174 @@ def text_from_message(message: ChatMessage) -> str: return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip() -def _response_language(text: str) -> str: - cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff") - latin = sum(1 for char in text if char.isascii() and char.isalpha()) - return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English" - - _EVENT_LABELS = { - "image_observation": "参考图片观察", - "requirements_document_ready": "需求文档已冻结", - "completion_target_ready": "完成目标已冻结", - "requirements_compiled": "需求合同已编译", - "modeling_plan_ready": "建模计划已冻结", - "completion_result_ready": "完成结果已就绪", - "model_protocol_check": "模型协议检查", - "action_selection": "动作选择", - "tool_call": "建模工具", - "candidate_result": "候选构建", - "candidate_review": "候选独立复核", - "final_review": "最终独立复核", + "requirements_ready": "需求分析", + "authoring_cdsl_ready": "完整 CDSL", + "cdsl_compiled": "CDSL 编译", + "build_result": "CAD 构建", + "repair_started": "CDSL 修复", "task_terminal": "生成任务", - "state_changed": "任务状态", } -def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]: +def _progress(name: str, payload: dict[str, Any]) -> dict[str, Any]: lifecycle = str(payload.get("lifecycle") or "") - result = payload.get("result") if isinstance(payload.get("result"), dict) else {} - status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running") + status = "error" if lifecycle == "failed" or payload.get("status") in {"failed", "repair_required"} else "waiting" if lifecycle == "waiting_for_user" else "success" if lifecycle == "completed" else "running" return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status} -def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None: - part_id = str(part.get("id") or "") - if part_id: - for index, existing in enumerate(parts): - if str(existing.get("id") or "") == part_id: - parts[index] = part - return - parts.append(part) - - class AgentService: - """Delivery boundary: no CAD state transitions or provider calls live here.""" - def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None: - self.settings = settings - self.store = store # Conversation/attachment store, not v3 CAD state. - self.library = library - self.v3: V3Services = compose_v3(settings) + self.settings, self.store, self.library = settings, store, library + self.cad: CadServices = compose_cad_services(settings) self._autonomous_runs: dict[str, asyncio.Task[None]] = {} async def resume_running_tasks(self) -> None: - task_ids = self.v3.repository.running_task_ids() - try: - author_provider, author_model = self.settings.resolve_model(None, None) - review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) - except ValueError as error: - await self._park_startup_tasks( - task_ids, - ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED, - str(error), - retryable=False, - ) - return - try: - author_capability, reviewer_capability = await asyncio.gather( - verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=author_provider.id, - model_id=author_model.id, - role="author", - ), - verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=review_provider.id, - model_id=review_model.id, - role="reviewer", - ), - ) - except Exception as error: - # Startup recovery must never bypass a production capability gate. - # Connectivity failures are recoverable, but they may not leave a - # task falsely marked running without a worker. - await self._park_startup_tasks( - task_ids, - ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, - str(error), - retryable=True, - ) - return - if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"): - await self._park_startup_tasks( - task_ids, - ErrorCode.MODEL_PROTOCOL_CHECK_PENDING, - "Model protocol check is temporarily unavailable.", - retryable=True, - ) - return - if not author_capability.get("supported") or not reviewer_capability.get("supported"): - await self._park_startup_tasks( - task_ids, - ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED, - "Selected author or reviewer did not pass the v3 conformance suite.", - retryable=False, - ) - return if not self.settings.resume_running_tasks_on_startup: return - for task_id in task_ids: - if task_id in self._autonomous_runs: - continue - self._autonomous_runs[task_id] = asyncio.create_task( - self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)), - name=f"resume-autonomous-cad-v3-{task_id}", - ) - - async def _park_startup_tasks( - self, - task_ids: list[str], - error: ErrorCode, - message: str, - *, - retryable: bool, - ) -> None: - """Make a failed startup gate durable instead of leaving phantom runs.""" - event_name = "waiting_retry" if retryable else "failed_model_capability" - for task_id in task_ids: - state = self.v3.repository.get_state(task_id) - if state is None or task_id in self._autonomous_runs: - continue - try: - next_state = transition( - state, - "waiting_retry" if retryable else "failed", - error=error, - ) - except ValueError: - continue - if self.v3.repository.compare_and_swap(next_state, events=[{ - "event": event_name, - "code": error.value, - "message": message[:1000], - "startup_recovery": True, - }]): - await self.v3.outbox.dispatch_pending(task_id=task_id) - - async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None: try: - async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer): - # A resumed task has no active SSE client, but its durable - # state events must still leave the transactional outbox. - await self.v3.outbox.dispatch_pending(task_id=task_id) + provider, model = self.settings.resolve_model(None, None) + except ValueError: + return + author = ModelIdentity(provider.id, model.id) + for task_id in self.cad.repository.running_task_ids(): + if task_id not in self._autonomous_runs: + self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, author), name=f"resume-cad-single-stage-{task_id}") + + async def _consume_discarding(self, task_id: str, author: ModelIdentity) -> None: + try: + async for _name, _payload in self.cad.workflow.run(task_id=task_id, author=author): + await self.cad.outbox.dispatch_pending(task_id=task_id) finally: - # Flush a final transition emitted immediately before the worker - # exits, such as WAITING_RETRY or FAILED_INTERNAL. - await self.v3.outbox.dispatch_pending(task_id=task_id) + await self.cad.outbox.dispatch_pending(task_id=task_id) self._autonomous_runs.pop(task_id, None) async def cancel(self, task_id: str) -> dict[str, Any] | None: - state = self.v3.repository.get_state(task_id) + state = self.cad.repository.get_state(task_id) if state is None: return None if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED) - if self.v3.repository.compare_and_swap(cancelled, events=[{ - "event": "task_cancelled", - "from_phase": state.phase.value, - "active_revision": state.active_revision, - "checkpoint_preserved": bool(state.active_revision), - }]): - await self.v3.outbox.dispatch_pending(task_id=task_id) - # Persist the terminal transition before interrupting the coroutine. - # A concurrent worker will fail its optimistic CAS instead of reviving - # the task after the caller has requested cancellation. + self.cad.repository.compare_and_swap(cancelled, events=[{"event": "task_cancelled", "active_revision": state.active_revision}]) + await self.cad.outbox.dispatch_pending(task_id=task_id) running = self._autonomous_runs.pop(task_id, None) if running and not running.done(): running.cancel() - return self.v3.repository.get_task_projection(task_id) + return self.cad.repository.get_task_projection(task_id) async def resume_retry(self, task_id: str) -> dict[str, Any] | None: - """Explicitly restart one durably parked infrastructure retry. - - ``WAITING_FOR_USER`` is deliberately excluded: it requires new user - input, while this endpoint is only the controlled recovery route for - bounded provider/render/storage failures. - """ - state = self.v3.repository.get_state(task_id) + state = self.cad.repository.get_state(task_id) if state is None: return None - if state.phase.value != "WAITING_RETRY": - raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint") - running = self._autonomous_runs.get(task_id) - if running is not None and not running.done(): + if state.phase != TaskPhase.FAILED or state.retry_from_phase is None: + raise ValueError("Only a retryable failed CAD task can be resumed") + if task_id in self._autonomous_runs and not self._autonomous_runs[task_id].done(): raise ValueError("The CAD task is already running") - author_provider, author_model = self.settings.resolve_model(None, None) - review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) - author_capability = await verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=author_provider.id, - model_id=author_model.id, - role="author", - ) - reviewer_capability = await verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=review_provider.id, - model_id=review_model.id, - role="reviewer", - ) - if not author_capability.get("supported") or not reviewer_capability.get("supported"): - raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite") - if not self.v3.workflow.resume(task_id): - raise ValueError("The CAD task no longer has a recoverable retry checkpoint") - self._autonomous_runs[task_id] = asyncio.create_task( - self._consume_discarding( - task_id, - ModelIdentity(author_provider.id, author_model.id), - ModelIdentity(review_provider.id, review_model.id), - ), - name=f"resume-autonomous-cad-v3-{task_id}", - ) - return self.v3.repository.get_task_projection(task_id) + provider, model = self.settings.resolve_model(None, None) + if not self.cad.workflow.resume(task_id): + raise ValueError("The CAD task no longer has a retry checkpoint") + self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, ModelIdentity(provider.id, model.id)), name=f"resume-cad-single-stage-{task_id}") + return self.cad.repository.get_task_projection(task_id) - async def stream( - self, - messages: list[ChatMessage], - conversation_id: str | None, - selected_task_id: str | None, - provider_id: str | None = None, - model_id: str | None = None, - viewer_context: list[dict[str, Any]] | None = None, - ) -> AsyncIterator[bytes]: + async def stream(self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None) -> AsyncIterator[bytes]: del viewer_context - latest_user = next((message for message in reversed(messages) if message.role == "user"), None) - if latest_user is None or not text_from_message(latest_user): + latest = next((item for item in reversed(messages) if item.role == "user"), None) + if latest is None or not text_from_message(latest): yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."}) yield event("done", {}) return - request = text_from_message(latest_user) + request = text_from_message(latest) conversation = self.store.ensure_conversation(conversation_id) selected = str(selected_task_id or conversation.get("current_task_id") or "") - current = self.v3.repository.get_task_projection(selected) if selected else None - resumed_task_id = "" - if current and str(current.get("lifecycle") or "") == "waiting_for_user": - state = self.v3.repository.get_state(selected) - if state is not None: - terminal = self.v3.workflow.waiting_for_user_terminal(selected, state) - fields = [ - {"path": "/requirements/clarification", "message": str(question)} - for question in terminal.get("questions") or () - if str(question).strip() - ] - fields.extend( - {"path": "/requirements", "message": str(issue)} - for issue in terminal.get("issues") or () - if str(issue).strip() - ) - if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id): - yield event("cad_error", { - "stage": "request", - "message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]), - "fieldErrors": fields, - "taskId": selected, - "blockerType": terminal.get("blockerType"), - "userActionRequired": terminal.get("userActionRequired"), - }) - yield event("done", {}) - return - resumed_task_id = selected - if current and str(current.get("lifecycle") or "") == "running": - yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"}) + current = self.cad.repository.get_task_projection(selected) if selected else None + resumed = False + if current and current.get("lifecycle") == "waiting_for_user": + resumed = self.cad.workflow.resume_with_user_clarification(selected, request, message_id=latest.id) + if not resumed: + yield event("cad_error", {"stage": "request", "message": "The CAD task cannot apply this clarification."}) + yield event("done", {}) + return + if current and current.get("lifecycle") == "running": + yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成后才能继续。"}) yield event("done", {}) return - # A terminal task is immutable; follow-up text creates a new task. - task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}" - if not resumed_task_id: + task_id = selected if resumed else f"cad_{secrets.token_hex(6)}" + if not resumed: try: source_blocks, image_inputs = self._task_inputs(conversation, request) - self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) + self.cad.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) except ValueError as error: yield event("cad_error", {"stage": "request", "message": str(error)}) yield event("done", {}) return - conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id) - yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."}) - + self.store.append_conversation_message(conversation["conversation_id"], latest.model_dump(), task_id) + yield event("progress", _progress("task_started", {"taskId": task_id, "message": "CAD task started."})) try: - author_provider, author_model = self.settings.resolve_model(provider_id, model_id) - review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) + provider, model = self.settings.resolve_model(provider_id, model_id) except ValueError as error: - state = self.v3.repository.get_state(task_id) - if state is not None: + state = self.cad.repository.get_state(task_id) + if state: failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) - self.v3.repository.compare_and_swap(failed, events=[{ - "event": "model_configuration_invalid", - "message": str(error)[:1000], - "issues": [str(error)[:1000]], - }]) - terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False} - yield event("task_terminal", terminal) + self.cad.repository.compare_and_swap(failed, events=[{"event": "model_configuration_invalid", "message": str(error)}]) yield event("cad_error", {"stage": "configuration", "message": str(error)}) yield event("done", {}) return - + author = ModelIdentity(provider.id, model.id) queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() parts: list[dict[str, Any]] = [] - sequence = 0 async def consume() -> None: - nonlocal sequence - - async def dispatch_state_events() -> None: - """Publish state notifications only through the outbox. - - The dispatcher preserves at-least-once semantics. The SSE - event ID derives from the durable outbox row, so reconnecting - clients can safely de-duplicate a delivery replay. - """ - nonlocal sequence - for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id): - sequence += 1 - event_name = str(outbox_event.get("event") or "state_changed") - payload = { - "taskId": task_id, - "eventId": f"outbox_{outbox_event['event_id']}", - "sequence": sequence, - "timestamp": now_iso(), - "outboxEvent": event_name, - "message": event_name.replace("_", " "), - } - await queue.put(("progress", _visible_progress("state_changed", payload))) - try: - capability_terminal = await self._ensure_task_capabilities( - task_id, - ModelIdentity(author_provider.id, author_model.id), - ModelIdentity(review_provider.id, review_model.id), - queue, - ) - if capability_terminal is not None: - sequence += 1 - capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()} - _upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)}) - await queue.put(("task_terminal", capability_terminal)) - return - async for name, payload in self.v3.workflow.run( - task_id=task_id, - author=ModelIdentity(author_provider.id, author_model.id), - reviewer=ModelIdentity(review_provider.id, review_model.id), - ): - sequence += 1 - decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()} - _upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)}) - if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed": - _upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": { - "stage": "generation", - "message": str(decorated.get("message") or "CAD autonomous generation failed."), - "tool": str(decorated.get("tool") or ""), - "fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [], - }}) + async for name, payload in self.cad.workflow.run(task_id=task_id, author=author): + decorated = {**payload, "taskId": task_id, "eventId": f"{task_id}_{secrets.token_hex(4)}", "timestamp": now_iso()} + parts.append({"type": "data-cad-progress", "id": decorated["eventId"], "data": _progress(name, decorated)}) await queue.put((name, decorated)) - await dispatch_state_events() - except Exception as error: - sequence += 1 - terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False} - _upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)}) - _upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}}) - await queue.put(("task_terminal", terminal)) + await self.cad.outbox.dispatch_pending(task_id=task_id) + if name == "task_terminal" and decorated.get("lifecycle") == "completed": + projection = self.cad.repository.get_task_projection(task_id) or {} + result = self._result_payload(task_id, projection) + if result is not None: + await queue.put(("cad_result", result)) finally: self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id) self._autonomous_runs.pop(task_id, None) await queue.put(None) - self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}") + self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"cad-single-stage-{task_id}") while True: - try: - item = await asyncio.wait_for(queue.get(), timeout=15) - except asyncio.TimeoutError: - yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()}) - continue + item = await queue.get() if item is None: break name, payload = item - if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed": - yield event("cad_error", { - "stage": "generation", - "message": str(payload.get("message") or "CAD autonomous generation failed."), - "tool": str(payload.get("tool") or ""), - "fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [], - }) + if name == "task_terminal" and payload.get("lifecycle") == "failed": + yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD generation failed."), "code": payload.get("code")}) yield event(name, payload) yield event("done", {}) - async def _ensure_task_capabilities( - self, - task_id: str, - author: ModelIdentity, - reviewer: ModelIdentity, - queue: asyncio.Queue[tuple[str, dict[str, Any]] | None], - ) -> dict[str, Any] | None: - roles = (("author", author), ("reviewer", reviewer)) - cached = { - role: cached_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - provider_id=model.provider_id, - model_id=model.model_id, - role=role, - ) - for role, model in roles - } - unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")] - if unsupported: - return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}") - missing = [(role, model) for role, model in roles if cached[role] is None] - if not missing: + @staticmethod + def _result_payload(task_id: str, projection: dict[str, Any]) -> dict[str, Any] | None: + revision_id = str(projection.get("published_revision") or projection.get("active_revision") or "") + revisions = projection.get("revisions") if isinstance(projection.get("revisions"), list) else [] + revision = next((item for item in revisions if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + if not isinstance(revision, dict): return None - - state = self.v3.repository.get_state(task_id) - if state is None: - return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False} - waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) - if not self.v3.repository.compare_and_swap(waiting, events=[{ - "event": "model_protocol_check_pending", - "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, - "message": "模型协议检查中,完成后将自动继续。", - }]): - return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False} - await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"})) - try: - results = await asyncio.gather(*( - verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=model.provider_id, - model_id=model.model_id, - role=role, - ) - for role, model in missing - )) - except Exception as error: - return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False} - unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")] - if unavailable: - return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False} - unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")] - if unsupported: - return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}") - if not self.v3.workflow.resume(task_id): - return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False} - await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"})) - return None - - def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]: - state = self.v3.repository.get_state(task_id) - if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}: - failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) - self.v3.repository.compare_and_swap(failed, events=[{ - "event": "model_protocol_unsupported", - "message": message, - "issues": [message], - }]) - return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False} + required = ("cdsl_path", "step_path", "glb_path", "report_path") + if not all(isinstance(revision.get(path), str) and revision[path] for path in required): + return None + return { + "taskId": task_id, + "revisionId": revision_id, + "cdslPath": revision["cdsl_path"], + "stepPath": revision["step_path"], + "glbPath": revision["glb_path"], + "reportPath": revision["report_path"], + "summary": str(revision.get("summary") or "CDSL CAD model"), + "referenceIds": list(revision.get("reference_ids") or []), + "engine": str(revision.get("engine") or "cdsl_only"), + "lifecycle": str(projection.get("lifecycle") or "completed"), + } def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: - """Freeze message paragraphs and attachment blocks before task creation.""" blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()] image_inputs: list[dict[str, str]] = [] conversation_id = str(conversation.get("conversation_id") or "") @@ -509,51 +201,24 @@ class AgentService: continue if str(attachment.get("conversation_id") or "") != conversation_id: raise ValueError("Attachment does not belong to this conversation") - attachment_id = str(attachment.get("id") or "") - relative_path = str(attachment.get("path") or "") - expected_digest = str(attachment.get("sha256") or "") - if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest): + attachment_id, relative_path, digest = str(attachment.get("id") or ""), str(attachment.get("path") or ""), str(attachment.get("sha256") or "") + if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", digest): raise ValueError("Attachment metadata is incomplete") - binary_path = self.store.conversation_attachment_path(conversation_id, relative_path) - if not binary_path.is_file(): - raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}") - if sha256(binary_path.read_bytes()).hexdigest() != expected_digest: - raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}") + binary = self.store.conversation_attachment_path(conversation_id, relative_path) + if not binary.is_file() or sha256(binary.read_bytes()).hexdigest() != digest: + raise ValueError(f"Attachment is unavailable: {attachment.get('name') or attachment_id}") kind = str(attachment.get("kind") or "") if kind == "document": - extracted_path = str(attachment.get("extracted_path") or "") - if not extracted_path: - raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}") - text_path = self.store.conversation_attachment_path(conversation_id, extracted_path) + text_path = self.store.conversation_attachment_path(conversation_id, str(attachment.get("extracted_path") or "")) if not text_path.is_file(): - raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}") + raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}") text = text_path.read_text(encoding="utf-8", errors="replace").strip() elif kind == "image": - # The binary is integrity-checked above. Do not claim that a - # text-only author has interpreted visual content; the source - # block is still a stable, reviewable attachment reference. - text = ( - f"Visual attachment {attachment.get('name') or attachment_id} " - f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). " - "It is a visual reference and requires explicit visual verification." - ) - image_inputs.append({ - "path": str(binary_path), - "mime": str(attachment.get("mime") or "image/*"), - "sha256": expected_digest, - }) + text = f"Visual attachment {attachment.get('name') or attachment_id} (SHA-256 {digest})." + image_inputs.append({"path": str(binary), "mime": str(attachment.get("mime") or "image/*"), "sha256": digest}) else: raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}") if not text: raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}") - blocks.append({ - "text": text, - "attachment": { - "attachment_id": attachment_id, - "name": str(attachment.get("name") or attachment_id), - "kind": kind, - "mime": str(attachment.get("mime") or "application/octet-stream"), - "sha256": expected_digest, - }, - }) + blocks.append({"text": text, "attachment": {"attachment_id": attachment_id, "name": str(attachment.get("name") or attachment_id), "kind": kind, "mime": str(attachment.get("mime") or "application/octet-stream"), "sha256": digest}}) return blocks, image_inputs diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index 2ea2d5a1..78f9a1fd 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -47,7 +47,7 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]: except (OSError, json.JSONDecodeError) as error: raise RuntimeError("The local engine schema document is unavailable or invalid") from error if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict): - raise RuntimeError("The local engine schema has no v3 operation contract registry") + raise RuntimeError("The local engine schema has no operation contract registry") return schema @@ -108,10 +108,11 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None: raise ValueError(f"CDSL schema violation at {location}: {error.message}") -def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: +def validate_cdsl_shape(cdsl: dict[str, Any], engine: Any) -> None: + """Validate the static Runtime CDSL contract without resolving topology.""" if not isinstance(cdsl, dict): raise ValueError("CDSL must be a JSON object") - if cdsl.get("schema") != "cad.cdsl.llm.v1": + if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}: raise ValueError("Unsupported CDSL schema") _validate_cdsl_json_schema(cdsl, engine) part_id = str(cdsl.get("part_id") or "") @@ -125,6 +126,14 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: sketches = cdsl.get("geometry", {}).get("sketches") if not isinstance(features, list) or not features or not isinstance(sketches, list): raise ValueError("CDSL requires a feature list and a geometry.sketches array") + if cdsl.get("schema") == "cad.runtime.v1": + bodies = cdsl.get("bodies") + if not isinstance(bodies, list) or not bodies: + raise ValueError("Runtime CDSL requires server-assigned bodies") + body_ids = [str(body.get("id") or "") for body in bodies if isinstance(body, dict)] + body_names = [str(body.get("name") or "") for body in bodies if isinstance(body, dict)] + if len(body_ids) != len(bodies) or len(body_ids) != len(set(body_ids)) or len(body_names) != len(set(body_names)): + raise ValueError("Runtime CDSL bodies must have unique server IDs and local names") sketch_ids = {str(sketch.get("id")) for sketch in sketches} semantic_contract = _engine_schema(engine) try: @@ -185,6 +194,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: raise ValueError("Polygon profiles require vertices") elif profile_type not in engine.SHAPE_GENERATORS: raise ValueError(f"Unsupported CDSL profile: {profile_type}") + + +def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: + """Validate static CDSL and the current executable topology semantics.""" + validate_cdsl_shape(cdsl, engine) try: analysis = engine.analyze_cdsl(copy.deepcopy(cdsl)) except Exception as error: diff --git a/backend/app/services/review_renderer.py b/backend/app/services/render_bundle.py similarity index 91% rename from backend/app/services/review_renderer.py rename to backend/app/services/render_bundle.py index 1c57d6cc..47eb8415 100644 --- a/backend/app/services/review_renderer.py +++ b/backend/app/services/render_bundle.py @@ -1,8 +1,8 @@ -"""Deterministic, CPU-only CAD technical renders for visual review. +"""Deterministic, CPU-only CAD technical render bundles. OpenCascade computes exact visible/hidden edges from the revision STEP file. Pillow rasterizes the resulting technical drawings. Neither stage needs a web -browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence +browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts consistent on macOS, Linux, and Windows workers. """ @@ -26,7 +26,7 @@ VISIBLE_EDGE_RGB = (34, 54, 69) HIDDEN_EDGE_RGB = (142, 157, 170) -class ReviewRenderError(RuntimeError): +class RenderBundleError(RuntimeError): """The fixed-view renderer was unavailable or produced incomplete evidence.""" @@ -34,7 +34,7 @@ def renderer_status() -> tuple[bool, str]: """Verify that the pure-Python/OCC renderer dependencies are importable.""" try: _render_modules() - except ReviewRenderError as error: + except RenderBundleError as error: return False, str(error) return True, "" @@ -45,7 +45,7 @@ def _render_modules() -> tuple[Any, Any, Any]: pillow_draw = importlib.import_module("PIL.ImageDraw") import_step = importlib.import_module("build123d").import_step except (ImportError, AttributeError) as error: - raise ReviewRenderError( + raise RenderBundleError( "Python technical renderer is unavailable; install backend requirements (build123d and Pillow)" ) from error return pillow_image, pillow_draw, import_step @@ -73,7 +73,7 @@ def _shape_bounds(shape: Any) -> list[float]: box = shape.bounding_box() bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)] if not all(math.isfinite(value) for value in bounds): - raise ReviewRenderError("STEP review source has invalid bounds") + raise RenderBundleError("STEP render source has invalid bounds") return bounds @@ -128,7 +128,7 @@ def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]: def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]: points = [point for edge in edges for point in _edge_points(edge, 0.5)] if not points: - raise ReviewRenderError("Hidden-line projection produced no drawable edges") + raise RenderBundleError("Hidden-line projection produced no drawable edges") xs, ys = zip(*points) return min(xs), max(xs), min(ys), max(ys) @@ -242,7 +242,7 @@ def _render_view( camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"] ) except Exception as error: - raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error + raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error visible_edges, hidden_edges = list(visible), list(hidden) frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent) rendered = _rasterize( @@ -254,12 +254,12 @@ def _render_view( intentional_crop=target_extent > 0, ) if not rendered["diagnostics"]["valid"]: - raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}") + raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}") return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered} def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str: - """Create compact whole-model evidence for routine reviewer calls.""" + """Create compact whole-model images for published CAD artifacts.""" pillow_image, pillow_draw, _ = _render_modules() canonical = [item for item in views if item["id"] in CANONICAL_VIEWS] if not canonical: @@ -283,27 +283,27 @@ def render_checkpoint( *, step_path: Path, output_dir: Path, - review_targets: list[dict[str, Any]] | None = None, + detail_targets: list[dict[str, Any]] | None = None, include_canonical: bool = True, ) -> dict[str, Any]: """Render STEP geometry into stable canonical and bounded node-detail views.""" del settings ready, detail = renderer_status() if not ready: - raise ReviewRenderError(detail) + raise RenderBundleError(detail) if not step_path.is_file(): - raise ReviewRenderError(f"STEP review source is missing: {step_path.name}") + raise RenderBundleError(f"STEP render source is missing: {step_path.name}") _, _, import_step = _render_modules() try: shape = import_step(str(step_path)) except Exception as error: - raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error + raise RenderBundleError(f"Unable to read STEP render source: {error}") from error bounds = _shape_bounds(shape) output_dir.mkdir(parents=True, exist_ok=True) jobs: list[tuple[str, dict[str, Any] | None]] = [] if include_canonical: jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS) - jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3])) + jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3])) views = [ _render_view( shape=shape, @@ -317,14 +317,14 @@ def render_checkpoint( ] canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")} if include_canonical and canonical != set(CANONICAL_VIEWS): - raise ReviewRenderError("Python review renderer did not produce every canonical view") + raise RenderBundleError("Python render bundle generator did not produce every canonical view") contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else "" manifest = { "schema_version": "cad.render-manifest.v2", "renderer": "python-occ-hlr-pillow", "source": {"type": "step", "path": str(step_path), "bounds_mm": bounds}, "high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"}, - "review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"}, + "render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"}, "contact_sheet_path": contact_sheet_path, "views": views, } @@ -343,22 +343,22 @@ def render_section( """Create an actual OpenCascade section drawing, not a clipped viewport. It intentionally uses the same deterministic Pillow raster path as the - seven canonical review views. The output contains compact contour evidence - suitable for a multimodal author without sending a STEP file or full B-rep. + seven canonical render views. The output contains compact contour evidence + suitable for inspection without sending a STEP file or full B-rep. """ del settings ready, detail = renderer_status() if not ready: - raise ReviewRenderError(detail) + raise RenderBundleError(detail) if not step_path.is_file(): - raise ReviewRenderError(f"STEP section source is missing: {step_path.name}") + raise RenderBundleError(f"STEP section source is missing: {step_path.name}") origin = _number_list(origin_mm, size=3) direction = _number_list(normal, size=3) if origin is None or direction is None: - raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers") + raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers") length = math.sqrt(sum(value * value for value in direction)) if length <= 1e-9: - raise ReviewRenderError("Section normal must not be zero") + raise RenderBundleError("Section normal must not be zero") normal_unit = [value / length for value in direction] _, _, import_step = _render_modules() try: @@ -372,9 +372,9 @@ def render_section( section = b3d.section(shape, section_by=plane) edges = list(section.edges()) except Exception as error: - raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error + raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error if not edges: - raise ReviewRenderError("Section plane does not intersect the model") + raise RenderBundleError("Section plane does not intersect the model") # Choose a deterministic right-handed in-plane frame. Projecting exact # OCC section edges into this frame preserves holes and internal contours. @@ -406,7 +406,7 @@ def render_section( if len(line) >= 2: projected.append(line) if not projected: - raise ReviewRenderError("Section operation produced no drawable contours") + raise RenderBundleError("Section operation produced no drawable contours") xs = [point[0] for line in projected for point in line] ys = [point[1] for line in projected for point in line] minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 51f1f5b1..05b849df 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -1,6 +1,6 @@ -"""Conversation and attachment storage for the v3 delivery boundary. +"""Conversation and attachment storage for the CAD delivery boundary. -CAD task state deliberately does not live here. Protocol v3 owns mutable task +CAD task state deliberately does not live here. The Authoring protocol owns mutable task state in ``SqliteTaskRepository`` and immutable task artifacts in ``FileArtifactStore``. """ diff --git a/backend/app/settings.py b/backend/app/settings.py index 49a6c07d..80273c85 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -65,15 +65,6 @@ class Settings: llm_timeout_s: float default_provider_id: str providers: tuple[ProviderConfig, ...] - review_provider_id: str = "" - review_model_id: str = "" - agent_tool_calls_per_cycle: int = 12 - agent_consecutive_no_progress_limit: int = 6 - agent_format_error_repeat_limit: int = 3 - agent_context_char_limit: int = 14000 - agent_author_guidance_enabled: bool = True - agent_author_guidance_max_chars: int = 3600 - agent_render_cache: bool = True autonomous_generation: bool = True resume_running_tasks_on_startup: bool = True @@ -102,29 +93,6 @@ class Settings: raise ValueError("The selected model is not enabled for this provider") return provider, model - def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]: - """Return the independently configured visual reviewer, never an author fallback.""" - provider_id = self.review_provider_id - if not provider_id: - raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider") - provider = self.provider_for(provider_id) - if provider is None: - raise ValueError("The configured visual review provider is unavailable") - model_id = self.review_model_id or "" - if not model_id: - raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model") - model = provider.model(model_id) - if model is None or not model.vision: - raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model") - return provider, model - - def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]: - """Require the candidate judge to be a separately configured model.""" - provider, model = self.resolve_review_model() - if provider.id == author_provider.id and model.id == author_model.id: - raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model") - return provider, model - def _reasoning_effort(value: str) -> str: effort = value.strip().lower() @@ -207,15 +175,6 @@ def get_settings() -> Settings: llm_timeout_s=llm_timeout_s, default_provider_id=default_provider_id, providers=providers, - review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(), - review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(), - agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))), - agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))), - agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))), - agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))), - agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True), - agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))), - agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True), autonomous_generation=True, # Production instances recover durable runs by default. Test workers # can disable this before startup to guarantee they touch only tasks diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index 9fce3d51..1daa9c0f 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -14,7 +14,7 @@ from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFille from OCP.BRepOffset import BRepOffset_Skin from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform -from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol +from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol from OCP.Geom import Geom_SurfaceOfRevolution from OCP.GeomAbs import GeomAbs_Arc from OCP.LocOpe import LocOpe_DPrism @@ -23,7 +23,7 @@ from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL from OCP.TopExp import TopExp_Explorer from OCP.TopTools import TopTools_ListOfShape from OCP.TopoDS import TopoDS -from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCP.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec from .parametric_bend import build_bend_solid from .parametric_gears import build_gear_solid, build_rack_solid @@ -1131,6 +1131,37 @@ class Build123dGeometryAdapter: ) return Solid.make_cylinder(radius_mm, height_mm, build_plane) + @staticmethod + def cylinder_with_topology_delta( + radius_mm: float, + height_mm: float, + axis: AxisSpec | None = None, + ) -> tuple[Solid, TopologyDelta]: + """Build a cylinder with exact OCC witnesses for its two cap faces.""" + origin = axis.origin_mm if axis is not None else (0.0, 0.0, 0.0) + direction = axis.direction if axis is not None else (0.0, 0.0, 1.0) + placement = gp_Ax2( + gp_Pnt(float(origin[0]), float(origin[1]), float(origin[2])), + gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])), + ) + builder = BRepPrimAPI_MakeCylinder(placement, float(radius_mm), float(height_mm)) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCC cylinder operation did not complete") + result = Solid(builder.Solid()) + if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9: + raise ValueError("OCC cylinder operation did not produce a valid solid") + primitive = builder.Cylinder() + relations = tuple( + TopologyDeltaRelation("generated", "face", result.wrapped, (face,), output_role=role) + for face, role in ( + (primitive.BottomFace(), "cylinder.start"), + (primitive.TopFace(), "cylinder.end"), + ) + if not face.IsNull() + ) + return result, TopologyDelta(operation="cylinder", relations=relations) + @staticmethod def intersect(left: Any, right: Any) -> Any: # 布尔交:取两实体公共部分。结果可能为空(不相交或仅边界接触), @@ -1237,7 +1268,21 @@ class Build123dGeometryAdapter: relations.append(TopologyDeltaRelation("preserved", kind, source_value, (source_value,))) if generated: relations.append(TopologyDeltaRelation("generated", kind, source_value, generated)) - return TopologyDelta(operation=operation_name, relations=tuple(relations)) + section_values: tuple[Any, ...] = () + section_edges = getattr(operation, "SectionEdges", None) + if callable(section_edges): + try: + # BRepAlgoAPI boolean builders expose the exact intersection + # edges. Builders without that API simply carry no section + # evidence; callers must not infer it from result geometry. + section_values = tuple(section_edges()) + except (AttributeError, TypeError, ValueError): + section_values = () + return TopologyDelta( + operation=operation_name, + relations=tuple(relations), + section_values=section_values, + ) @staticmethod def _shell_topology_delta( diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index f5ff8c76..fe09e454 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -82,6 +82,19 @@ def _mappings(value: Any): yield from _mappings(child) +def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None) -> list[dict[str, Any]]: + """Read only the selector slot declared by the operation contract.""" + slot = str((contract or {}).get("selector_slot") or "") + if slot == "feature.selectors": + values = node.selectors + elif slot.startswith("params.") and slot.count(".") == 1: + value = node.params.get(slot.removeprefix("params.")) + values = value if isinstance(value, list) else [value] + else: + values = [] + return [value for value in values if isinstance(value, dict)] + + def _transform_member_sources(params: dict[str, Any]) -> set[str]: """Return internal body-member keys named by a transform contract.""" source_ids = {str(value) for value in params.get("source_feature_ids") or ()} @@ -543,18 +556,20 @@ class CapabilityAnalyzer: "Loft currently requires exactly one outer profile without holes", sketch_id=sketch_id, )) + contract_selectors = _contract_selectors(node, contract) + contract_selector_ids = {id(selector) for selector in contract_selectors} for selector in _mappings(params): - if selector.get("output_role") is not None: + if selector.get("output_role") is not None and id(selector) not in contract_selector_ids: blockers.append(self._blocker( node.feature_id, "unsupported_output_role_selector_context", - "Feature output role selectors are only supported in feature.selectors", + "Feature output role selector is outside the operation contract slot", )) - for selector_index, selector in enumerate(node.selectors): + for selector_index, selector in enumerate(contract_selectors): if selector.get("output_role") is None: continue required.append("selector:feature_output_role") - if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face": + if contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face": blockers.append(self._blocker( node.feature_id, "unsupported_output_role_selector", diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index cbab8ca2..fc597dba 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -5,14 +5,16 @@ "description": "Complete self-contained CDSL. Runtime-supported operations can be rebuilt by the local CDSL-only engine; deferred operations are retained for future engine implementations.", "type": "object", "properties": { - "schema": {"const": "cad.cdsl.llm.v1"}, + "schema": {"enum": ["cad.cdsl.llm.v1", "cad.runtime.v1"]}, "schema_version": {"type": "string"}, "kind": {"type": "string", "minLength": 1}, "part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"}, "meta": {"type": "object"}, + "bodies": {"type": "array", "items": {"$ref": "#/$defs/runtimeBody"}}, "geometry": { "type": "object", "properties": { + "selector_intent_version": {"const": "1.0"}, "sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}} }, "required": ["sketches"], @@ -26,6 +28,15 @@ "number": {"type": "number"}, "positive": {"type": "number", "exclusiveMinimum": 0}, "positiveInteger": {"type": "integer", "minimum": 1}, + "runtimeBody": { + "type": "object", + "properties": { + "id": {"type": "string", "pattern": "^body_[0-9]{3}$"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"} + }, + "required": ["id", "name"], + "additionalProperties": false + }, "point2": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 2, "maxItems": 2}, "point3": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 3, "maxItems": 3}, "circleItem": { @@ -541,7 +552,7 @@ }, "featureOutputRole": { "enum": [ - "extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", + "extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face" ] }, @@ -554,6 +565,48 @@ "required": ["owner_feature_id", "output_role"], "additionalProperties": false }, + "selectorIntentSourceQuery": { + "type": "object", + "properties": { + "ast": {}, + "featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"}, + "standard_library": {"type": "string", "minLength": 1} + }, + "required": ["ast", "featurescript_version"], + "additionalProperties": false + }, + "selectorIntent": { + "type": "object", + "properties": { + "version": {"const": "1.0"}, + "kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]}, + "query_family": {"enum": ["CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY", "GEOMETRIC"]}, + "source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"}, + "source_entity": { + "type": "object", + "properties": { + "sketch_id": {"type": "string", "minLength": 1}, + "entity_id": {"type": "string", "minLength": 1} + }, + "required": ["sketch_id", "entity_id"], + "additionalProperties": false + }, + "output_role": {"$ref": "#/$defs/featureOutputRole"}, + "derivation_policy": { + "type": "object", + "properties": { + "allowed": {"type": "array", "minItems": 1, "items": {"enum": ["continuation", "fragment", "merge", "intersection", "boundary", "replacement"]}}, + "multiplicity": {"enum": ["one", "all_fragments", "source_qualified", "none"]} + }, + "required": ["allowed", "multiplicity"], + "additionalProperties": false + }, + "evidence": {"enum": ["kernel_history", "operation_role", "feature_script_query", "explicit_datum", "geometry_hint"]}, + "disambiguation": {"type": "object"} + }, + "required": ["version", "query_family", "source_query", "derivation_policy", "evidence"], + "additionalProperties": false + }, "selectorRef": { "type": "object", "properties": { @@ -570,6 +623,8 @@ "match_mode": {"enum": ["unique", "all"]}, "matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}}, "intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}}, + "selector_intent_version": {"const": "1.0"}, + "selector_intent": {"$ref": "#/$defs/selectorIntent"}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, "required": ["kind", "source", "confidence"], diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index aa7cb729..56b9198a 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -114,6 +114,7 @@ class GeometryAdapter(Protocol): def cut(self, body: Any, tool: Any) -> Any: ... def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... + def cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ... def thread_solid(self, spec: ThreadSpec) -> Any: ... def bend_solid(self, spec: BendSpec) -> Any: ... def gear_solid(self, spec: GearSpec) -> Any: ... @@ -232,11 +233,15 @@ class ExecutionSession: binding_feature_id = selector.get("binding_feature_id") active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] - failures = [item for item in resolved if item.status != "resolved" or item.record is None] + failures = [item for item in resolved if item.status != "resolved" or (item.record is None and not item.records)] if failures: detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" raise FeatureExecutionError("intersection_selector_component_unresolved", detail) - return [item.record for item in resolved if item.record is not None] + return [ + record + for item in resolved + for record in (item.records or ((item.record,) if item.record is not None else ())) + ] def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: components = selector.get("intersection_of") @@ -1108,6 +1113,8 @@ def _register_added_solid( session: ExecutionSession, node: FeaturePlanNode, solid: Any, + *, + topology_delta: TopologyDelta | None = None, ) -> None: """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). @@ -1121,10 +1128,57 @@ def _register_added_solid( if node.params.get("result_mode") == "new_body": combined = session.adapter.combine(session.body, solid) members = {**session.body_members, node.feature_id: solid} - session.register_body(node.feature_id, combined, replay_node=node, body_members=members) + session.register_body( + node.feature_id, combined, replay_node=node, body_members=members, + topology_delta=topology_delta, + ) return - fused = session.adapter.fuse(session.body, solid) - session.register_body(node.feature_id, fused, replay_node=node) + if session.body is None: + session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta) + return + if topology_delta is None: + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return + # Preserve primitive output roles only through the exact OCC fuse history. + # The temporary records are not selectable snapshots; they provide the + # source handles that let TopologyRegistry carry a role to the final body. + role_records = _direct_output_role_records(session, node, solid, topology_delta) + fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid) + session.register_body( + node.feature_id, fused, replay_node=node, topology_delta=fuse_delta, + topology_predecessors=role_records, + ) + + +def _direct_output_role_records( + session: ExecutionSession, + node: FeaturePlanNode, + solid: Any, + topology_delta: TopologyDelta, +) -> list[TopologyRecord]: + """Attach only builder-proven output roles to a transient primitive snapshot.""" + records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}") + result: list[TopologyRecord] = [] + for record in records: + roles = { + relation.output_role + for relation in topology_delta.relations + if relation.output_role is not None + and relation.kind == record.kind + and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values) + } + if roles: + result.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=record.feature_id, + body_id=record.body_id, + geometry=record.geometry, + value=record.value, + owner_feature_ids=(node.feature_id,), + output_roles=tuple(sorted(roles)), + )) + return result def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: @@ -1187,9 +1241,9 @@ def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> Featu raise ValueError("cylinder_add axis must define origin_mm and direction") axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 - solid = session.adapter.cylinder(radius, height, axis) + solid, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis) # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) + _register_added_solid(session, node, solid, topology_delta=topology_delta) return session.result(node) @@ -1395,10 +1449,12 @@ def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent edges: list[Any] = [] for item in resolved: - if item.record.kind == "edge": - edges.append(item.record.value) - elif item.record.kind == "face": - edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge)) + records = item.records or ((item.record,) if item.record is not None else ()) + for record in records: + if record.kind == "edge": + edges.append(record.value) + elif record.kind == "face": + edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge)) if not edges: raise ValueError("selectors did not resolve any edges") return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges @@ -1412,7 +1468,11 @@ def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any failed = next((item for item in resolved if item.status != "resolved"), None) if failed: raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") - records = [item.record for item in resolved if item.record is not None] + records = [ + record + for item in resolved + for record in (item.records or ((item.record,) if item.record is not None else ())) + ] if not records or any(record.kind != "face" for record in records): raise ValueError("shell selectors must resolve to faces") target_ids = {record.body_id for record in records} diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index d2b06446..770e64bc 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -768,6 +768,13 @@ class TopologyDeltaRelation: source_value: Any result_values: tuple[Any, ...] = () output_role: str | None = None + derivation: str | None = None + source_role: str | None = None + result_role: str | None = None + source_slot: str | None = None + result_slot: str | None = None + coverage: str = "complete" + status: str = "proven" def __post_init__(self) -> None: if self.event not in {"preserved", "modified", "generated", "deleted"}: @@ -778,6 +785,21 @@ class TopologyDeltaRelation: raise ValueError("deleted topology delta relations cannot have result values or an output role") if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): raise ValueError("topology delta output_role must be a non-empty string when provided") + derivation = self.derivation + if derivation is None: + derivation = { + "preserved": "continuation", + "modified": "fragment" if len(self.result_values) > 1 else "continuation", + "generated": "boundary", + "deleted": "replacement", + }[self.event] + object.__setattr__(self, "derivation", derivation) + if derivation not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"}: + raise ValueError(f"unsupported topology derivation {derivation!r}") + if self.coverage not in {"complete", "partial", "none"}: + raise ValueError(f"unsupported topology coverage {self.coverage!r}") + if self.status not in {"proven", "unknown", "rejected"}: + raise ValueError(f"unsupported topology relation status {self.status!r}") @dataclass(frozen=True) @@ -791,6 +813,38 @@ class TopologyDelta: operation: str relations: tuple[TopologyDeltaRelation, ...] = () + # Boolean section edges are not ordinary source continuations. They are + # retained independently so INTERSECT selectors can require this kernel + # evidence instead of matching a nearby edge geometrically. + section_values: tuple[Any, ...] = () + + +@dataclass(frozen=True) +class TopologyLineage: + """An N:M semantic edge backed by an adapter history relation.""" + + source_record_ids: tuple[str, ...] + result_record_ids: tuple[str, ...] + derivation: str + evidence: str + coverage: str + status: str + operation: str + output_role: str | None = None + + def as_dict(self) -> dict[str, Any]: + result = { + "source_record_ids": list(self.source_record_ids), + "result_record_ids": list(self.result_record_ids), + "derivation": self.derivation, + "evidence": self.evidence, + "coverage": self.coverage, + "status": self.status, + "operation": self.operation, + } + if self.output_role is not None: + result["output_role"] = self.output_role + return result @dataclass(frozen=True) @@ -798,13 +852,29 @@ class SelectorResolution: selector: dict[str, Any] status: str record: TopologyRecord | None = None + # ``records`` carries policy-authorized 1:N selector results. ``record`` + # remains the compatibility field for a unique selection and context use. + records: tuple[TopologyRecord, ...] = () candidates: tuple[dict[str, Any], ...] = () diagnostic: RuntimeDiagnostic | None = None + @property + def resolution_mode(self) -> str: + if self.status == "resolved": + if self.records: + return "kernel_lineage" + if self.selector.get("output_role"): + return "operation_role" + if self.selector.get("selector_intent"): + return "kernel_lineage" + return "geometry" + return "unresolved" + def as_dict(self) -> dict[str, Any]: output = { "selector": self.selector, "status": self.status, + "resolution_mode": self.resolution_mode, "candidates": list(self.candidates), } if self.record is not None: @@ -816,8 +886,15 @@ class SelectorResolution: ) if score is not None: output["score"] = score + if self.records: + output["records"] = [record.public_dict() for record in self.records] if self.diagnostic is not None: output["diagnostic"] = self.diagnostic.as_dict() + if self.status == "resolved": + output["evidence"] = { + "source_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))], + "result_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))], + } return output @@ -829,6 +906,7 @@ class TopologyRegistry: self._by_feature: dict[str, list[TopologyRecord]] = {} self._active_body_id: str | None = None self._topology_deltas: list[dict[str, Any]] = [] + self._lineage: list[TopologyLineage] = [] # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 @@ -849,6 +927,10 @@ class TopologyRegistry: """Return serializable evidence derived from exact adapter history.""" return tuple(self._topology_deltas) + def lineage(self) -> tuple[TopologyLineage, ...]: + """Return kernel-backed N:M lineage without heuristic successors.""" + return tuple(self._lineage) + def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: kind = "plane" if isinstance(context, PlaneSpec) else "axis" record = TopologyRecord( @@ -983,10 +1065,25 @@ class TopologyRegistry: if successor_id not in known: known.append(successor_id) if delta_evidence is not None: + operation_lineage = [ + TopologyLineage( + source_record_ids=tuple(item["source_record_ids"]), + result_record_ids=tuple(item["result_record_ids"]), + derivation=str(item["derivation"]), + evidence="kernel_history", + coverage=str(item["coverage"]), + status=str(item["lineage_status"]), + operation=topology_delta.operation, + output_role=item.get("output_role"), + ) + for item in delta_evidence + ] + self._lineage.extend(operation_lineage) self._topology_deltas.append({ "feature_id": feature_id, "operation": topology_delta.operation, "relations": delta_evidence, + "lineage": [lineage.as_dict() for lineage in operation_lineage], }) # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 @@ -1076,7 +1173,14 @@ class TopologyRegistry: "source_record_ids": [record.record_id for record in sources], "result_record_ids": [record.record_id for record in outputs], "proof": "kernel_history", + "derivation": relation.derivation, + "coverage": relation.coverage if len(outputs) == len(relation.result_values) else "partial", + "lineage_status": relation.status, } + for field_name in ("source_role", "result_role", "source_slot", "result_slot"): + value = getattr(relation, field_name) + if value is not None: + item[field_name] = value if relation.output_role is not None: item["output_role"] = relation.output_role role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 @@ -1106,6 +1210,24 @@ class TopologyRegistry: else: relation_links.append(None) evidence.append(item) + if topology_delta.section_values: + section_outputs = [ + record for record in current + if record.kind == "edge" + and any(cls._same_topology_value(record.value, value) for value in topology_delta.section_values) + ] + evidence.append({ + "event": "generated", + "kind": "edge", + "source_record_ids": [], + "result_record_ids": [record.record_id for record in section_outputs], + "proof": "kernel_history", + "derivation": "intersection", + "coverage": "complete" if len(section_outputs) == len(topology_delta.section_values) else "partial", + "lineage_status": "proven" if len(section_outputs) == len(topology_delta.section_values) else "unknown", + "section_edge": True, + "status": "recorded_section_edge", + }) predecessors = { result_id: next(iter(source_ids)) for result_id, source_ids in candidate_sources.items() @@ -1125,6 +1247,8 @@ class TopologyRegistry: item["status"] = "non_unique_or_incomplete" else: item["status"] = "recorded_without_owner_transfer" + if item["coverage"] != "complete" or relation.status != "proven": + item["lineage_status"] = "unknown" return ( predecessors, successors, @@ -1134,6 +1258,45 @@ class TopologyRegistry: evidence, ) + def _intent_lineage_successors( + self, + source_record_id: str, + *, + allowed: set[str], + active_body_id: str | None, + ) -> list[TopologyRecord]: + """Follow only complete, proven CDSL lineage edges to active records.""" + pending = [source_record_id] + visited = {source_record_id} + result_ids: set[str] = set() + while pending: + current = pending.pop() + for edge in self._lineage: + if ( + current not in edge.source_record_ids + or edge.derivation not in allowed + or edge.evidence != "kernel_history" + or edge.coverage != "complete" + or edge.status != "proven" + ): + continue + for record_id in edge.result_record_ids: + if record_id not in visited: + visited.add(record_id) + pending.append(record_id) + result_ids.add(record_id) + active: list[TopologyRecord] = [] + for record in self._records: + if record.record_id not in result_ids: + continue + if active_body_id is not None and not ( + record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ): + continue + active.append(record) + return active + @staticmethod def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: try: @@ -1330,6 +1493,8 @@ class TopologyRegistry: ) -> SelectorResolution: kind = selector.get("kind") owner = selector.get("owner_feature_id") + intent = selector.get("selector_intent") + provenance_intent = isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC" candidates = [record for record in self._records if record.kind == kind] if active_body_id and kind in {"face", "edge", "vertex", "body"}: # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 @@ -1500,6 +1665,49 @@ class TopologyRegistry: record.body_id == active_body_id or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) ) + if not is_active and provenance_intent: + policy = intent.get("derivation_policy") or {} + allowed = { + value for value in policy.get("allowed") or () + if value in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} + } + successors = self._intent_lineage_successors( + record.record_id, + allowed=allowed, + active_body_id=active_body_id, + ) + if len(successors) == 1: + record = successors[0] + is_active = True + elif len(successors) > 1: + if policy.get("multiplicity") == "all_fragments" and "fragment" in allowed: + return SelectorResolution( + selector=selector, + status="resolved", + records=tuple(successors), + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + ) + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + diagnostic=RuntimeDiagnostic( + code="selector_relation_non_unique", + message="The proven topology lineage has more than one active result", + detail={"stable_id": stable_id, "candidate_count": len(successors), "multiplicity": policy.get("multiplicity")}, + ), + ) + else: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_kernel_history_missing", + message="No complete proven lineage reaches an active topology record", + detail={"stable_id": stable_id, "allowed": sorted(allowed)}, + ), + ) if not is_active: successors = [ candidate for candidate in stable_records @@ -1579,6 +1787,24 @@ class TopologyRegistry: detail={"minimum_score": minimum_score}, ), ) + if provenance_intent: + evidence = intent.get("evidence") + code = "selector_geometry_only" if evidence == "geometry_hint" else "selector_kernel_history_missing" + message = ( + "A FeatureScript provenance selector cannot resolve from geometry alone" + if code == "selector_geometry_only" + else "The FeatureScript selector has no stable active record or complete kernel history" + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code=code, + message=message, + detail={"query_family": intent.get("query_family")}, + ), + ) scored: list[tuple[float, TopologyRecord]] = [] for candidate in candidates: # An owner-qualified context selector is deterministic when it has diff --git a/backend/engine/cdsl_engine/semantic_validation.py b/backend/engine/cdsl_engine/semantic_validation.py index 6b300977..81d8ba17 100644 --- a/backend/engine/cdsl_engine/semantic_validation.py +++ b/backend/engine/cdsl_engine/semantic_validation.py @@ -32,6 +32,66 @@ def _mappings(value: Any): yield from _mappings(child) +def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> list[dict[str, Any]]: + """Return the selectors at the operation contract's declared slot.""" + slot = str(contract.get("selector_slot") or "") + if slot == "feature.selectors": + values = feature.get("selectors") or [] + elif slot.startswith("params.") and slot.count(".") == 1: + value = (feature.get("params") or {}).get(slot.removeprefix("params.")) + values = value if isinstance(value, list) else [value] + else: + values = [] + return [value for value in values if isinstance(value, dict)] + + +def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: int) -> None: + """Enforce the provenance boundary before the runtime can bind a selector.""" + intent = selector.get("selector_intent") + if intent is None: + if selector.get("selector_intent_version") is not None: + raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent") + return + if selector.get("selector_intent_version") not in {None, "1.0"}: + raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent version") + if not isinstance(intent, dict) or intent.get("version") != "1.0": + raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent") + if intent.get("kind") not in {None, selector.get("kind")}: + raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind") + family = intent.get("query_family") + derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY"} + if family in derived and not selector.get("owner_feature_id"): + raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id") + policy = intent.get("derivation_policy") or {} + allowed = policy.get("allowed") or [] + if not allowed or any(value not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} for value in allowed): + raise ValueError(f"Feature {feature_id} selector {index} has an invalid lineage derivation policy") + if policy.get("multiplicity") not in {"one", "all_fragments", "source_qualified", "none"}: + raise ValueError(f"Feature {feature_id} selector {index} has an invalid lineage multiplicity") + if policy.get("multiplicity") == "all_fragments" and "fragment" not in allowed: + raise ValueError(f"Feature {feature_id} selector {index} all_fragments policy requires fragment lineage") + if family in derived and intent.get("evidence") == "geometry_hint": + raise ValueError(f"Feature {feature_id} selector {index} cannot use geometry_hint for FeatureScript provenance") + if selector.get("owner_match_required") and intent.get("evidence") == "geometry_hint": + raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback") + source_query = intent.get("source_query") or {} + version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None + if not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version): + raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version") + if intent.get("output_role") is not None and intent.get("output_role") != selector.get("output_role"): + raise ValueError(f"Feature {feature_id} selector {index} intent output role differs from selector output role") + forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"} + stack = [intent] + while stack: + value = stack.pop() + if isinstance(value, dict): + if forbidden.intersection(value): + raise ValueError(f"Feature {feature_id} selector {index} intent contains a runtime identifier") + stack.extend(value.values()) + elif isinstance(value, list): + stack.extend(value) + + @lru_cache(maxsize=1) def _schema() -> dict[str, Any]: path = Path(__file__).with_name("cdsl_schema.json") @@ -69,7 +129,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: """ if not isinstance(cdsl, dict): raise ValueError("CDSL must be a JSON object") - if cdsl.get("schema") != "cad.cdsl.llm.v1": + if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}: raise ValueError("Unsupported CDSL schema") schema_error = _schema_error(cdsl) if schema_error: @@ -124,15 +184,21 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: if feature.get("execution_status") == "deferred": deferred.append(fid) contract = contracts.get(str(feature.get("atomic_id") or "")) or {} - feature_selectors = feature.get("selectors") or [] + feature_selectors = _contract_selectors(feature, contract) output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)} + selector_intent_ids = { + id(selector.get("selector_intent")) + for selector in feature_selectors + if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict) + } for index, selector in enumerate(feature_selectors): + _validate_selector_intent(selector, fid, index) owner = selector.get("owner_feature_id") binding_owner = selector.get("binding_feature_id") if owner is not None and owner not in feature_ids and binding_owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id") if selector.get("output_role") is not None: - if contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face": + if not contract.get("selector_slot") or contract.get("selector_token_kind") != "face": raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role") if selector.get("kind") != "face" or not owner or owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id") @@ -174,8 +240,9 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: selector.get("output_role") is not None and selector.get("kind") is not None and id(selector) not in output_role_selector_ids + and id(selector) not in selector_intent_ids ): - raise ValueError(f"Feature {fid} output role selectors are only supported in feature.selectors") + raise ValueError(f"Feature {fid} output role selector is outside its operation contract slot") if feature.get("atomic_id") == "shell": target_feature_id = (feature.get("params") or {}).get("target_feature_id") if target_feature_id is not None and target_feature_id not in feature_ids: diff --git a/backend/tests/test_agent_service.py b/backend/tests/test_agent_service.py new file mode 100644 index 00000000..f3ee0bbe --- /dev/null +++ b/backend/tests/test_agent_service.py @@ -0,0 +1,80 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository +from app.cad_agent.domain.state import transition +from app.services.agent_service import AgentService + + +def test_result_payload_contains_all_published_artifacts() -> None: + payload = AgentService._result_payload("cad_abcdefghijkl", { + "lifecycle": "completed", + "published_revision": "rev_prefix", + "revisions": [{ + "revision_id": "rev_prefix", + "cdsl_path": "revisions/rev_prefix/model.cdsl.json", + "step_path": "revisions/rev_prefix/model.step", + "glb_path": "revisions/rev_prefix/model.glb", + "report_path": "revisions/rev_prefix/rebuild-report.json", + }], + }) + + assert payload == { + "taskId": "cad_abcdefghijkl", + "revisionId": "rev_prefix", + "cdslPath": "revisions/rev_prefix/model.cdsl.json", + "stepPath": "revisions/rev_prefix/model.step", + "glbPath": "revisions/rev_prefix/model.glb", + "reportPath": "revisions/rev_prefix/rebuild-report.json", + "summary": "CDSL CAD model", + "referenceIds": [], + "engine": "cdsl_only", + "lifecycle": "completed", + } + + +def test_best_effort_projection_retains_prefix_artifact_paths() -> None: + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + state = repository.create_task("cad_abcdefghijkl", "make a plate") + state = transition(state, "analysis_written", requirements_path="documents/requirements-analysis.json") + assert repository.compare_and_swap(state) + state = transition(state, "authoring_written", authoring_path="documents/authoring-cdsl-attempt-01.json") + assert repository.compare_and_swap(state) + state = transition(state, "compiled", runtime_cdsl_path="documents/runtime-cdsl-attempt-01.json") + assert repository.compare_and_swap(state) + state = transition(state, "repair_required", active_revision="rev_prefix") + assert repository.compare_and_swap(state, events=[{ + "event": "build_failed", + "revision_id": "rev_prefix", + "paths": { + "model.cdsl.json": "revisions/rev_prefix/model.cdsl.json", + "model.step": "revisions/rev_prefix/model.step", + "model.glb": "revisions/rev_prefix/model.glb", + "rebuild-report.json": "revisions/rev_prefix/rebuild-report.json", + }, + }]) + state = transition(state, "repair_started", repair_count=1) + assert repository.compare_and_swap(state) + state = transition(state, "repair_required") + assert repository.compare_and_swap(state) + state = transition(state, "repair_started", repair_count=2) + assert repository.compare_and_swap(state) + state = transition(state, "publish_best_effort") + assert repository.compare_and_swap(state, events=[{"event": "repair_budget_exhausted"}]) + state = transition(state, "published") + assert repository.compare_and_swap(state) + + projection = repository.get_task_projection("cad_abcdefghijkl") + assert projection is not None + result = AgentService._result_payload("cad_abcdefghijkl", projection) + assert result is not None + assert result["revisionId"] == "rev_prefix" + assert result["stepPath"] == "revisions/rev_prefix/model.step" + + +def test_result_payload_rejects_incomplete_artifact_sets() -> None: + assert AgentService._result_payload("cad_abcdefghijkl", { + "published_revision": "rev_prefix", + "revisions": [{"revision_id": "rev_prefix", "step_path": "revisions/rev_prefix/model.step"}], + }) is None diff --git a/backend/tests/test_author_guidance.py b/backend/tests/test_author_guidance.py deleted file mode 100644 index fe734dfd..00000000 --- a/backend/tests/test_author_guidance.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -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.adapters.author_guidance import FileAuthorGuidance # noqa: E402 -from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator # noqa: E402 -from app.cad_agent.domain.errors import ErrorCode, WorkflowError # noqa: E402 -from app.cad_agent.domain.state import TaskPhase, TaskState # noqa: E402 - - -GUIDANCE_ROOT = ROOT / "backend" / "agent" / "skills" / "cdsl-author-guidance" -PROFILE = ROOT / "backend" / "engine" / "cdsl_engine" / "profile_schema.json" - - -def atomic_ids() -> tuple[str, ...]: - return tuple(json.loads(PROFILE.read_text(encoding="utf-8"))["operation_contracts"]) - - -class _Repository: - def __init__(self, state: TaskState) -> None: - self.state = state - self.usage_records: list[dict] = [] - - def get_state(self, _task_id: str) -> TaskState: - return self.state - - def ledger_events(self, _task_id: str) -> list[dict]: - return [] - - def record_usage(self, _task_id: str, payload: dict) -> None: - self.usage_records.append(payload) - - def record_tool_audit(self, _task_id: str, _payload: dict) -> None: - pass - - -class _Artifacts: - def read_source_requirements(self, _task_id: str) -> str: - return "Create a symmetric mounting plate." - - def read_json(self, *_args: object) -> None: - return None - - -class _Runtime: - def supported_atomic_ids(self) -> tuple[str, ...]: - return atomic_ids() - - -class AuthorGuidanceTests(unittest.TestCase): - def test_manifest_covers_every_runtime_atomic_and_keeps_coordinate_core_at_minimum_budget(self) -> None: - guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=1_200) - covered: set[str] = set() - for atomic_id in atomic_ids(): - selection = guidance.select( - phase=TaskPhase.FEATURE_PENDING, - atomic_id=atomic_id, - repair_required=False, - supported_atomic_ids=atomic_ids(), - ) - self.assertTrue(selection.enabled, selection.fallback_reason) - self.assertIn("00-author-contract", selection.section_ids) - self.assertIn("03-coordinate-system-and-datums", selection.section_ids) - self.assertLessEqual(len(selection.content), 1_200) - self.assertIn("世界坐标", selection.content) - covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-")) - self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread", "op-bend", "op-gear"}) - - def test_phase_repair_and_budget_selection_are_stable(self) -> None: - guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600) - planning = guidance.select( - phase=TaskPhase.COMPILING_FEATURE_PLAN, - atomic_id="", - repair_required=False, - supported_atomic_ids=atomic_ids(), - ) - repair = guidance.select( - phase=TaskPhase.AWAITING_ACTION, - atomic_id="fillet", - repair_required=True, - supported_atomic_ids=atomic_ids(), - ) - self.assertEqual(planning.section_ids[:2], ("00-author-contract", "03-coordinate-system-and-datums")) - self.assertIn("02-parameters-and-derived-dimensions", planning.section_ids) - self.assertEqual(repair.section_ids[:3], ("00-author-contract", "03-coordinate-system-and-datums", "op-finish")) - self.assertIn("10-repair-and-best-effort", repair.section_ids) - - def test_disabled_missing_and_invalid_corpus_fall_back_without_authoring_failure(self) -> None: - common = { - "phase": TaskPhase.FEATURE_PENDING, - "atomic_id": "extrude_add_blind", - "repair_required": False, - "supported_atomic_ids": atomic_ids(), - } - self.assertEqual(FileAuthorGuidance(GUIDANCE_ROOT, enabled=False).select(**common).fallback_reason, "guidance_disabled") - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:FileNotFoundError") - (root / "manifest.json").write_text("{}", encoding="utf-8") - self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:ValueError") - - def test_author_context_receives_guidance_but_keeps_the_existing_tool_instruction(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), - _Repository(state), - _Artifacts(), - _Runtime(), - object(), - object(), - object(), - object(), - FileAuthorGuidance(GUIDANCE_ROOT), - ) - messages, selection = workflow._author_context(state.task_id, []) - system = str(messages[0]["content"]) - self.assertTrue(selection.enabled) - self.assertIn("Use exactly one offered structured tool call", system) - self.assertIn("Coordinate System And Datums", system) - self.assertIn("世界坐标", system) - - def test_invalid_author_tool_call_retains_guidance_usage_metadata(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) - repository = _Repository(state) - class _Models: - async def call_tool(self, **_kwargs: object) -> dict: - return {"tool_calls": [], "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}} - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), - repository, - _Artifacts(), - _Runtime(), - _Models(), - object(), - object(), - object(), - FileAuthorGuidance(GUIDANCE_ROOT), - ) - tool = {"type": "function", "function": {"name": "write_requirements_document", "parameters": {"type": "object"}}} - result = asyncio.run(workflow._author_turn(state.task_id, ModelIdentity("provider", "model"), [tool], [])) - self.assertIsInstance(result, WorkflowError) - self.assertEqual(result.code, ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertEqual(repository.usage_records[0]["guidance_enabled"], True) - self.assertIn("03-coordinate-system-and-datums", repository.usage_records[0]["guidance_section_ids"]) - self.assertEqual(repository.usage_records[0]["retry_reason"], "invalid_tool_call") - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_authoring_contract.py b/backend/tests/test_authoring_contract.py new file mode 100644 index 00000000..742d69a8 --- /dev/null +++ b/backend/tests/test_authoring_contract.py @@ -0,0 +1,81 @@ +import math + +import pytest + +from app.cad_agent.application.authoring_contract import AuthoringDocument +from app.cad_agent.application.authoring_compiler import AuthoringCompiler, AuthoringCompileError + + +def test_model_cannot_submit_runtime_identity(): + with pytest.raises(ValueError, match="AUTHOR_FORBIDDEN_FIELD"): + AuthoringDocument.model_validate({"feature_id": "feature_001", "bodies": []}) + + +@pytest.mark.parametrize("field", ["task_id", "revision_id", "stable_id", "selector_tokens", "selector token", "host_face", "mirror_plane"]) +def test_model_cannot_submit_any_internal_identity_variant(field): + with pytest.raises(ValueError, match="AUTHOR_FORBIDDEN_FIELD"): + AuthoringDocument.model_validate({field: "internal", "bodies": []}) + + +def test_authoring_rejects_non_mm_and_non_finite_values(): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({"units": "in", "bodies": []}) + with pytest.raises(ValueError, match="non-finite"): + AuthoringDocument.model_validate({"bodies": [{"name": "main", "features": [{"name": "base", "operation": "box_add", "params": {"length_mm": math.nan}}]}]}) + + +@pytest.mark.parametrize("sketch", [ + { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profiles": [{"type": "circle", "diameter_mm": 20}], + }, + { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "radius_mm": 10}, + }, +]) +def test_authoring_rejects_runtime_or_ambiguous_sketch_shapes(sketch): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({ + "bodies": [{"name": "main", "features": [{ + "name": "base", "operation": "extrude_add_blind", "params": {"distance_mm": 8}, "sketch": sketch, + }]}], + }) + + +def test_authoring_rejects_selector_fields_that_are_not_declarative_source_intent(): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({ + "bodies": [{"name": "main", "features": [{ + "name": "base", "operation": "cylinder_add", "params": {}, + "selectors": [{"kind": "face", "source": "other.top_planar_face", "role": "host_face"}], + }]}], + }) + + +def test_compiler_assigns_deterministic_ids_and_orders_dependencies(): + document = {"bodies": [{"name": "main", "features": [ + {"name": "hole", "operation": "hole_blind", "depends_on": ["base"]}, + {"name": "base", "operation": "extrude_add_blind"}, + ]}]} + runtime, audit = AuthoringCompiler(lambda operation: {"atomic_id": operation}).compile(document) + assert [item["id"] for item in runtime["features"]] == ["feature_002", "feature_001"] + assert runtime["features"][1]["depends_on"] == ["feature_002"] + assert audit["feature_ids"] == {"hole": "feature_001", "base": "feature_002"} + + +def test_compiler_rejects_unknown_operation(): + with pytest.raises(AuthoringCompileError) as error: + AuthoringCompiler(lambda _: (_ for _ in ()).throw(ValueError("unknown"))).compile({ + "bodies": [{"name": "main", "features": [{"name": "base", "operation": "bad"}]}] + }) + assert error.value.code == "OPERATION_UNSUPPORTED" + + +def test_compiler_preserves_the_forbidden_field_error_code(): + with pytest.raises(AuthoringCompileError) as error: + AuthoringCompiler(lambda operation: {"atomic_id": operation}).compile({ + "bodies": [{"name": "main", "features": [{"name": "base", "operation": "box_add", "params": {}}]}], + "feature_id": "feature_001", + }) + assert error.value.code == "AUTHOR_FORBIDDEN_FIELD" diff --git a/backend/tests/test_authoring_runtime.py b/backend/tests/test_authoring_runtime.py new file mode 100644 index 00000000..a07736f9 --- /dev/null +++ b/backend/tests/test_authoring_runtime.py @@ -0,0 +1,219 @@ +import math +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.artifact_store import FileArtifactStore +from app.cad_agent.adapters.runtime import ProfileCadRuntime +from app.cad_agent.ports import AdapterUnavailable +from app.cad_agent.application.single_stage import SingleStageExecutor +from app.services.engine_service import validate_cdsl, validate_cdsl_shape +from app.settings import get_settings + + +def _base_feature() -> dict: + return { + "name": "base", + "operation": "extrude_add_blind", + "params": {"distance_mm": 8, "result_mode": "new_body"}, + "sketch": { + "workplane": { + "origin_mm": [0, 0, 0], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1], + }, + "profile": { + "type": "polygon", + "vertices": [[-40, -25], [40, -25], [40, 25], [-40, 25]], + }, + }, + } + + +def _authoring(*features: dict) -> dict: + return { + "schema_version": "cad.author.v1", + "units": "mm", + "bodies": [{"name": "main", "features": list(features)}], + } + + +def test_authoring_compiles_to_runtime_cdsl_and_publishes_one_revision() -> None: + runtime = ProfileCadRuntime(get_settings()) + authoring = _authoring(_base_feature()) + runtime_cdsl, audit = runtime.compile_authoring(authoring) + + assert runtime_cdsl["schema"] == "cad.runtime.v1" + assert runtime_cdsl["bodies"] == [{"id": "body_001", "name": "main"}] + assert runtime_cdsl["features"][0]["id"] == "feature_001" + assert audit["feature_ids"] == {"base": "feature_001"} + validate_cdsl_shape(runtime_cdsl, runtime.engine) + validate_cdsl(runtime_cdsl, runtime.engine) + + with TemporaryDirectory() as temporary: + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + task_id = "cad_abcdefghijkl" + artifacts.initialize_task(task_id, "make a rectangular plate") + result = SingleStageExecutor(None, artifacts, runtime).execute(task_id, authoring) + + assert result["status"] == "completed" + revision = str(result["revision_id"]) + assert result["executed_feature_ids"] == ["feature_001"] + for relative in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json", "renders/render-manifest.json"): + assert artifacts.artifact_path(task_id, f"revisions/{revision}/{relative}").is_file() + + +def test_selector_failure_keeps_the_successful_prefix() -> None: + runtime = ProfileCadRuntime(get_settings()) + invalid_fillet = { + "name": "bad_fillet", + "operation": "fillet", + "depends_on": ["base"], + "params": {"radius_mm": 1}, + # extrude.end is a face output. Using it for an edge-only operation + # is a deliberate selector-kind failure, not a geometry fallback. + "selectors": [{"kind": "edge", "source": "base.top_planar_face"}], + } + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature(), invalid_fillet)) + + with TemporaryDirectory() as temporary: + built, diagnostics = runtime.rebuild_best_effort( + runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_selector_failure", + ) + + assert built["executed_feature_ids"] == ["feature_001"] + assert diagnostics[0]["feature_id"] == "feature_002" + assert diagnostics[0]["code"] == "SELECTOR_KIND_MISMATCH" + + +def test_cylinder_cap_selector_is_compiled_into_hole_host_face_and_executes() -> None: + runtime = ProfileCadRuntime(get_settings()) + base = { + "name": "base_flange", + "operation": "cylinder_add", + "params": { + "radius_mm": 30, + "height_mm": 10, + "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + }, + } + bore = { + "name": "center_bore", + "operation": "hole_wizard", + "params": { + "hole_type": "simple", + "diameter_mm": 12, + "depth_mm": 10, + "end_condition": {"type": "through_all", "solidworks_code": 1}, + "positions": [{"mm": [0, 0, 10]}], + }, + "selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}], + } + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(base, bore)) + compiled_bore = runtime_cdsl["features"][1] + assert compiled_bore["depends_on"] == ["feature_001"] + assert "selectors" not in compiled_bore + assert compiled_bore["params"]["host_face"] == { + "kind": "face", + "output_role": "cylinder.end", + "owner_feature_id": "feature_001", + "source": "runtime_snapshot", + "confidence": 1.0, + "match_mode": "unique", + } + + with TemporaryDirectory() as temporary: + built, diagnostics = runtime.rebuild_best_effort( + runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_cylinder_host", + ) + + assert diagnostics == [] + assert built["executed_feature_ids"] == ["feature_001", "feature_002"] + + +def test_authoring_circle_diameter_is_lowered_to_runtime_radius() -> None: + runtime = ProfileCadRuntime(get_settings()) + authoring = _authoring({ + "name": "round_base", + "operation": "extrude_add_blind", + "params": {"distance_mm": 8, "result_mode": "new_body"}, + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "diameter_mm": 20, "center_mm": [0, 0]}, + }, + }) + compiled, _audit = runtime.compile_authoring(authoring) + assert compiled["geometry"]["sketches"][0]["profile"] == { + "type": "circle", "center": [0.0, 0.0], "radius_mm": 10.0, + } + + +def test_flange_bolt_host_is_built_before_source_topology_is_replaced() -> None: + """An exposed base cap remains a valid bolt host before later fusions/cuts.""" + runtime = ProfileCadRuntime(get_settings()) + circle_sketch = lambda z, normal, diameter: { + "workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": normal}, + "profile": {"type": "circle", "diameter_mm": diameter, "center_mm": [0, 0]}, + } + bolt_positions = [ + {"mm": [43 * math.cos(math.radians(angle)), 43 * math.sin(math.radians(angle)), 12]} + for angle in range(0, 360, 45) + ] + authoring = _authoring( + { + "name": "base_flange", "operation": "cylinder_add", + "params": {"radius_mm": 60, "height_mm": 12, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}, + }, + { + "name": "rear_shallow_pad", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"], + "params": {"distance_mm": 4, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(0, [0, 0, -1], 105), + }, + { + "name": "rear_guide_boss", "operation": "extrude_add_blind", "depends_on": ["rear_shallow_pad"], + "params": {"distance_mm": 12, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(0, [0, 0, -1], 38), + }, + { + "name": "bolt_holes", "operation": "hole_wizard", + "params": { + "hole_type": "counterbore", "diameter_mm": 8, "depth_mm": 12, + "end_condition": {"type": "through_all_both", "solidworks_code": 7}, + "counterbore": {"diameter_mm": 10, "depth_mm": 4}, "positions": bolt_positions, + }, + "selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}], + }, + { + "name": "front_hub_boss", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"], + "params": {"distance_mm": 14, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(12, [0, 0, 1], 56), + }, + { + "name": "center_through_cut", "operation": "extrude_cut_through", "depends_on": ["front_hub_boss", "rear_guide_boss"], + "params": {"end_condition": {"type": "through_all_both", "solidworks_code": 7}}, + "sketch": circle_sketch(0, [0, 0, 1], 32), + }, + ) + compiled, audit = runtime.compile_authoring(authoring) + assert audit["implicit_selector_dependencies"] == {"bolt_holes": ["base_flange"]} + + with TemporaryDirectory() as temporary: + rebuilt = runtime.rebuild(compiled, temporary, "cad_abcdefghijkl", "flange_source_order") + + assert rebuilt["health"]["feature_count"] == 6 + + +def test_service_failure_during_prefix_export_is_not_reclassified_as_a_model_error(monkeypatch) -> None: + runtime = ProfileCadRuntime(get_settings()) + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature())) + + def unavailable(*_args, **_kwargs): + raise AdapterUnavailable("renderer unavailable") + + monkeypatch.setattr(runtime, "rebuild", unavailable) + with TemporaryDirectory() as temporary: + try: + runtime.rebuild_best_effort(runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_service_failure") + except AdapterUnavailable as error: + assert "renderer unavailable" in str(error) + else: + raise AssertionError("service failure was incorrectly converted into a model repair diagnostic") diff --git a/backend/tests/test_cad_agent_v3.py b/backend/tests/test_cad_agent_v3.py deleted file mode 100644 index e5138dc5..00000000 --- a/backend/tests/test_cad_agent_v3.py +++ /dev/null @@ -1,1117 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -from hashlib import sha256 -import json -from pathlib import Path -import sqlite3 -import sys -import tempfile -import unittest -from unittest.mock import AsyncMock, patch - - -ROOT = Path(__file__).resolve().parents[2] -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, 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, - NextAction, - StatelessCandidateReview, - StatelessGeometryConclusion, - canonical_validate, - canonical_validate_schema, - compiled_requirements_schema, - sanitize_compiled_requirements_arguments, - stateless_final_review_schema, - stateless_next_action_schema, - stateless_rollback_checkpoint_schema, -) -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, WorkflowError -from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment -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 -from app.services.library import CdslLibrary -from app.services.storage import WorkspaceStore -from app.settings import ProviderConfig, ProviderModel, Settings - - -def settings(root: Path) -> Settings: - author = ProviderConfig("author", "Author", "https://author.invalid/v1", "author-key", (ProviderModel("author-model"),)) - reviewer = ProviderConfig("reviewer", "Reviewer", "https://reviewer.invalid/v1", "reviewer-key", (ProviderModel("reviewer-model", vision=True),)) - 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=author.base_url, - llm_api_key=author.api_key, - llm_model="author-model", - llm_timeout_s=1, - default_provider_id="author", - providers=(author, reviewer), - review_provider_id="reviewer", - review_model_id="reviewer-model", - ) - - -def requirements_document() -> MarkdownDocument: - return MarkdownDocument(markdown="""# Design Understanding - -Simple functional flange. - -# Explicit User Requirements - -- Create a simple flange. - -# Engineering Defaults and Assumptions - -- Use a circular body, central through bore, and four equally spaced mounting holes. - -# Dimensions and Coordinate Convention - -- Units are mm. The body is diameter 100 and thickness 10; bore diameter 30; four holes diameter 10 on radius 35. - -# Open Uncertainties - -- None. -""") - - -def completion_target() -> MarkdownDocument: - return MarkdownDocument(markdown="""# Completion Target - -- [ ] One connected cylindrical flange body, 100 mm outer diameter and 10 mm thickness. -- [ ] Centered 30 mm through bore. -- [ ] Four 10 mm mounting holes on a circular pattern of 35 mm pitch radius. -""") - - -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}}, - {"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}}]}, - ]}) - - -def modeling_plan() -> MarkdownDocument: - return MarkdownDocument(markdown="# Modeling Plan\n\n1. Create the circular flange body.\n2. Cut the centered bore.\n3. Add the circular mounting-hole pattern.\n") - - -def walk_keys(value: object) -> set[str]: - keys: set[str] = set() - if isinstance(value, dict): - keys.update(str(key) for key in value) - for item in value.values(): - keys.update(walk_keys(item)) - elif isinstance(value, list): - for item in value: - keys.update(walk_keys(item)) - return keys - - -class CadV3ProtocolTests(unittest.TestCase): - def test_state_machine_has_no_requirements_review_phase(self) -> None: - self.assertNotIn("REVIEWING_REQUIREMENTS", {phase.value for phase in TaskPhase}) - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0) - document = transition(state, "requirements_document_written", requirements_document_path="requirements.md") - target = transition(document, "completion_target_written", completion_target_path="completion-target.md") - compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json") - approved = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md") - self.assertEqual(approved.phase, TaskPhase.AWAITING_ACTION) - self.assertNotIn("requirements_finalized", {event for _phase, event in legal_transitions()}) - - def test_waiting_retry_resumes_exact_source_phase(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0) - waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) - self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements_document") - resumed = transition(waiting, retry_resume_event(waiting) or "") - self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT) - self.assertIsNone(resumed.retry_from_phase) - - def test_llm_schemas_exclude_server_owned_runtime_ids(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - schemas = [ - MarkdownDocument.model_json_schema(), - compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 2), - stateless_next_action_schema(list(runtime.supported_atomic_ids())), - StatelessCandidateReview.model_json_schema(), - StatelessGeometryConclusion.model_json_schema(), - stateless_final_review_schema(2), - ] - forbidden = { - "task_id", "working_head", "requirement_id", "requirement_ids", "claim_id", - "candidate_id", "action_id", "evidence_id", "evidence_refs", "source_id", "source_ids", - "draft_id", "attachment_id", "record_ids", - } - for schema in schemas: - self.assertFalse(walk_keys(schema) & forbidden, walk_keys(schema) & forbidden) - - def test_compiled_requirements_ignores_non_executable_extra_fields(self) -> None: - schema = compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1) - raw = json.dumps({ - "assumptions": ["top-level notes from the compiler are not executable"], - "requirements": [{ - "statement": "model-added copy of the checklist text", - "assumptions": [], - "acceptance_claims": [{ - "claim_kind": "single_connected_body", - "expected": {}, - "evidence": "not part of the compiler contract", - }], - }], - }) - sanitized = sanitize_compiled_requirements_arguments(raw) - self.assertIsInstance(sanitized, str) - self.assertIsNone(canonical_validate_schema(sanitized, schema)) - parsed = canonical_validate(sanitized, CompiledRequirementsSpec) - self.assertIsInstance(parsed, CompiledRequirementsSpec) - self.assertEqual(parsed.requirements[0].acceptance_claims[0].claim_kind, "single_connected_body") - - def test_dynamic_tokens_are_enum_constrained(self) -> None: - rollback = stateless_rollback_checkpoint_schema(["checkpoint_one"]) - self.assertEqual(rollback["properties"]["checkpoint_token"], {"enum": ["checkpoint_one"]}) - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - schema = fragment_schema(runtime.operation_contract("hole_blind"), selector_tokens=["selector_one"], reference_tokens=[]) - 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))) - contract = runtime.operation_contract("extrude_add_blind") - fragment = { - "sketch": {"workplane": {"origin_mm": [0, -6, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 60}}, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 12}}, - } - self.assertTrue(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True)) - fragment["sketch"]["workplane"]["origin_mm"] = [0, 0, -6] - self.assertEqual(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True), []) - - def test_runtime_keeps_executable_feature_prefix_when_later_feature_is_invalid(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.1.0", - "kind": "part", - "part_id": "partial_rebuild", - "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": "not_an_engine_operation", "params": {}, "depends_on": ["feature_001"]}, - ], - } - rebuilt, failures = runtime.rebuild_best_effort(cdsl, str(Path(temporary) / "candidate"), "partial_rebuild", "candidate") - self.assertEqual(rebuilt["executed_feature_ids"], ["feature_001"]) - self.assertEqual(len(failures), 1) - self.assertEqual(failures[0]["feature_id"], "feature_002") - - def test_action_submission_keeps_partial_feature_batch_for_review(self) -> None: - class PassingVerifier: - 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": "pass", - "evidence": {}, - } - for claim in claims - ] - - 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, PassingVerifier()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - proposal = NextAction( - working_head=state.working_head, - intent="Create a two-feature batch.", - requirement_ids=["req_001"], - atomic_id="extrude_add_blind", - expected_change="Keep the executable part of the batch.", - ) - self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted) - fragment = { - "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}}, - } - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.1.0", - "kind": "part", - "part_id": "partial_batch", - "geometry": {"sketches": []}, - "features": [ - {"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": []}, - {"id": "feature_002", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": ["feature_001"]}, - ], - } - audit = { - "schema_version": "cad.v3.fragment-audit.v1", - "atomic_id": "extrude_add_blind", - "fragment_hash": "hash", - "contract_hash": runtime.operation_contract("extrude_add_blind")["contract_hash"], - "assigned_feature_ids": ["feature_001", "feature_002"], - "assigned_sketch_ids": [], - "selector_snapshot_id": "", - "selector_tokens": [], - "reference_snapshot_id": "", - "reference_tokens": [], - } - rebuilt = { - "executed_feature_ids": ["feature_001"], - "health": {"solid_count": 1}, - "topology": {"records": []}, - "report": {}, - "render_manifest": {}, - "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json"}, - } - operation_failures = [{"feature_index": 1, "feature_id": "feature_002", "message": "failed after feature_001"}] - with patch.object(runtime, "materialize_fragment", return_value=(cdsl, audit)), patch.object(runtime, "rebuild_best_effort", return_value=(rebuilt, operation_failures)): - result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment") - self.assertIsInstance(result, Accepted) - reviewing = repository.get_state(task_id) - self.assertEqual(reviewing.phase, TaskPhase.CANDIDATE_REVIEW) - candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {} - self.assertEqual(candidate["executed_feature_ids"], ["feature_001"]) - self.assertEqual(candidate["operation_failures"], operation_failures) - - def test_best_effort_transition_completes_from_an_executable_checkpoint(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.AWAITING_ACTION, 7, active_revision="rev_001", repair_required=True) - completed = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False) - self.assertEqual(completed.phase, TaskPhase.COMPLETED) - self.assertFalse(completed.repair_required) - - def test_review_rejection_publishes_the_executable_checkpoint_for_repair(self) -> None: - 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())) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - proposal = NextAction( - working_head=state.working_head, - intent="Create the base body.", - requirement_ids=["req_001"], - atomic_id="extrude_add_blind", - expected_change="Create the circular body.", - ) - self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted) - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 50}, - }, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 10}}, - } - self.assertIsInstance(actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment"), Accepted) - reviewing = repository.get_state(task_id) - candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {} - review = CandidateReview( - candidate_id=reviewing.candidate_id, - working_head=reviewing.pending_action.working_head, - verdict="reject", - claim_coverage=[ - {"claim_id": str(item["claim_id"]), "status": str(item["status"]), "evidence_refs": []} - for item in candidate["claim_results"] - ], - evidence=["Base body is executable."], - issues=["The bore and bolt holes remain to be added."], - ) - result = actions.record_candidate_review(task_id, review, invocation_id="review") - self.assertIsInstance(result, Accepted) - self.assertEqual(result.payload["status"], "accepted_with_issues") - published = repository.get_state(task_id) - self.assertEqual(published.phase, TaskPhase.AWAITING_ACTION) - self.assertEqual(published.active_revision, "rev_001") - self.assertTrue(published.repair_required) - completed = actions.finalize_best_effort(task_id, reason=ErrorCode.NO_PROGRESS_LIMIT, invocation_id="best_effort") - self.assertIsInstance(completed, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.COMPLETED) - - def test_root_checkpoint_is_not_offered_as_a_rollback_target(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(configured) - initial = repository.create_task("cad_123456abcdef", "Create a flange.") - document = transition(initial, "requirements_document_written", requirements_document_path="requirements.md") - target = transition(document, "completion_target_written", completion_target_path="completion-target.md") - compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json") - awaiting = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md", repair_required=True) - self.assertTrue(repository.compare_and_swap(document)) - self.assertTrue(repository.compare_and_swap(target)) - self.assertTrue(repository.compare_and_swap(compiled)) - self.assertTrue(repository.compare_and_swap(awaiting, events=[{ - "event": "geometry_conclusion", - "decision": "rollback", - "working_head": awaiting.working_head, - }])) - actions = ActionCommandHandler(repository, artifacts, runtime, default_registry()) - self.assertFalse(actions.rollback_available(awaiting.task_id)) - - def test_geometry_conclusion_is_stateless_for_the_author(self) -> None: - schema = StatelessGeometryConclusion.model_json_schema() - self.assertFalse({"working_head", "evidence_refs"} & walk_keys(schema)) - - def test_outer_cylinder_span_merges_oppositely_oriented_two_sided_faces(self) -> None: - def outer_face(record_id: str, direction: list[float], bbox: list[float]) -> dict[str, object]: - return { - "record_id": record_id, - "geometry": { - "surface_type": "cylinder", - "cylinder_role": "outer", - "radius_mm": 60.0, - "axis_origin_mm": [0.0, 0.0, 0.0], - "axis_direction": direction, - "bbox_mm": bbox, - }, - } - - facts = {"topology": {"records": [ - outer_face("upper", [0.0, 0.0, -1.0], [-60.0, -60.0, 0.0, 60.0, 60.0, 6.0]), - outer_face("lower", [0.0, 0.0, 1.0], [-60.0, -60.0, -6.0, 60.0, 60.0, 0.0]), - ]}} - result = default_registry().evaluate( - "outer_cylindrical_surface", - {"diameter_mm": 120.0, "count": 1, "axial_span_mm": 12.0}, - facts, - ) - self.assertEqual(result["status"], "pass") - self.assertEqual(result["evidence"]["axial_spans_mm"], [12.0]) - self.assertEqual(result["evidence"]["tolerance_mm"], 0.1) - - def test_compiler_persists_default_tolerance_for_axial_outer_cylinder(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - 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}}, - {"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) - state = repository.get_state(task_id) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - first_claim = contract["requirements"][0]["acceptance_claims"][0] - self.assertEqual(first_claim["expected"]["tolerance_mm"], 0.1) - - def test_record_bound_compiler_claims_are_visualized_before_contract_freeze(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - 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}}, - {"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", "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) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a stepped hub adapter.") - artifacts.initialize_task(task_id, "Create a stepped hub adapter.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, MarkdownDocument(markdown="""# Completion Target - -- [ ] A centered solid cylindrical flange body is present with 120 mm outer diameter and 12 mm thickness. -"""), invocation_id="completion_target") - compiled = CompiledRequirementsSpec.model_validate({"requirements": [{ - "assumptions": [], - "acceptance_claims": [ - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 120, "count": 1, "tolerance_mm": 0.1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "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 = contract["requirements"][0]["acceptance_claims"] - self.assertEqual([claim["claim_kind"] for claim in claims], ["outer_cylindrical_surface"]) - self.assertEqual(claims[0]["expected"]["axial_span_mm"], 12) - self.assertTrue(any("Global bbox Z verifier" in warning for warning in contract["verification_warnings"])) - - def test_requirements_markdown_is_not_rejected_for_missing_headings(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - result = handler.submit_requirements_document(task_id, MarkdownDocument(markdown="A simple circular flange with a bore."), invocation_id="plain_markdown") - self.assertIsInstance(result, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_COMPLETION_TARGET) - - def test_server_bound_action_accepts_more_than_five_checklist_targets(self) -> None: - action = NextAction( - working_head="cad_123456abcdef:root:v4", - intent="Create the flange body.", - requirement_ids=[f"req_{position:03d}" for position in range(1, 8)], - atomic_id="extrude_add_blind", - expected_change="Add the first solid body.", - ) - self.assertEqual(len(action.requirement_ids), 7) - - def test_markdown_documents_freeze_before_compiled_flange_contract(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - self.assertIsInstance(handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document"), Accepted) - self.assertIsInstance(handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target"), Accepted) - self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile"), Accepted) - self.assertIsInstance(handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan"), Accepted) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.AWAITING_ACTION) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - self.assertEqual(len(contract["requirements"]), 3) - claim_kinds = {claim["claim_kind"] for item in contract["requirements"] for claim in item["acceptance_claims"]} - self.assertTrue({"single_connected_body", "through_cylindrical_bore", "circular_hole_pattern"}.issubset(claim_kinds)) - self.assertTrue((artifacts.task_dir(task_id) / "requirements.md").is_file()) - self.assertTrue((artifacts.task_dir(task_id) / "completion-target.md").is_file()) - self.assertTrue((artifacts.task_dir(task_id) / "modeling-plan.md").is_file()) - - def test_invalid_verifier_contract_is_rejected_without_state_change(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create one solid.") - artifacts.initialize_task(task_id, "Create one solid.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - invalid = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 0}}]}] * 3}) - before = repository.get_state(task_id) - result = handler.submit_compiled_spec(task_id, invalid, invocation_id="invalid_spec") - self.assertIsInstance(result, Rejected) - 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) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a coherent flange.") - artifacts.initialize_task(task_id, "Create a coherent flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - path = handler.write_completion_result( - task_id, state, - claim_results=[{"claim_id": f"claim_{position:03d}", "status": "pass", "evidence": {"measured": True}} for position in range(1, 6)], - review={"visual_claims": []}, - ) - self.assertEqual(path, "completion-result.md") - result = (artifacts.task_dir(task_id) / path).read_text(encoding="utf-8") - target = (artifacts.task_dir(task_id) / "completion-target.md").read_text(encoding="utf-8") - requirements = (artifacts.task_dir(task_id) / "requirements.md").read_text(encoding="utf-8") - self.assertIn("Engineering Defaults", requirements) - self.assertIn("Centered 30 mm through bore", target) - self.assertIn("Centered 30 mm through bore.: pass", result) - - def test_sqlite_schema_contains_only_current_requirement_paths(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - database = Path(temporary) / "state.sqlite3" - SqliteTaskRepository(database) - with sqlite3.connect(database) as connection: - columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")} - self.assertIn("requirements_spec_path", columns) - self.assertIn("requirements_document_path", columns) - self.assertIn("completion_target_path", columns) - self.assertIn("modeling_plan_path", columns) - self.assertIn("clarification_path", columns) - self.assertNotIn("requirements_draft_path", columns) - self.assertNotIn("requirements_review_path", columns) - - def test_role_specific_capability_tools_have_no_review_loop(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - author = conformance_tools(runtime, role="author") - reviewer = conformance_tools(runtime, role="reviewer") - author_names = {item["function"]["name"] for item in author} - reviewer_names = {item["function"]["name"] for item in reviewer} - self.assertTrue({"write_requirements_document", "write_completion_target", "compile_requirements_spec", "write_modeling_plan"}.issubset(author_names)) - self.assertNotIn("review_requirements", author_names | reviewer_names) - self.assertNotIn("get_cdsl_operation_contract", author_names) - self.assertEqual(reviewer_names, {"observe_images", "review_candidate", "review_final"}) - - def test_capability_cache_is_role_scoped(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - runtime = ProfileCadRuntime(settings(root)) - class Models: - def __init__(self) -> None: - self.calls = 0 - async def conformance(self, **_kwargs: object) -> dict[str, object]: - self.calls += 1 - return {"supported": True, "failures": [], "probe_unavailable": False} - models = Models() - first = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="author")) - second = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="author")) - reviewer = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="reviewer")) - self.assertFalse(first.get("cached", False)) - self.assertTrue(second["cached"]) - self.assertNotEqual(first["schema_hash"], reviewer["schema_hash"]) - self.assertEqual(models.calls, 2) - self.assertIsNotNone(cached_model_capability(repository, runtime, provider_id="p", model_id="m", role="author")) - - def test_unsupported_capability_is_cached_but_transport_failure_is_not(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - runtime = ProfileCadRuntime(settings(root)) - schema_hash = conformance_hash(conformance_tools(runtime, role="reviewer"), role="reviewer") - class Unsupported: - async def conformance(self, **_kwargs: object) -> dict[str, object]: - return {"supported": False, "failures": [{"message": "schema"}], "probe_unavailable": False} - asyncio.run(verify_model_capability(repository, runtime, Unsupported(), provider_id="p", model_id="m", role="reviewer")) - self.assertIsNotNone(repository.model_capability("p", "m", schema_hash)) - class Unavailable: - async def conformance(self, **_kwargs: object) -> dict[str, object]: - return {"supported": False, "failures": [{"message": "network"}], "probe_unavailable": True} - asyncio.run(verify_model_capability(repository, runtime, Unavailable(), provider_id="p2", model_id="m", role="reviewer")) - self.assertIsNone(repository.model_capability("p2", "m", schema_hash)) - - def test_task_started_is_emitted_before_capability_work(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - message = ChatMessage.model_validate({"id": "user_1", "role": "user", "parts": [{"type": "text", "text": "Create a plate."}]}) - async def first_chunk() -> bytes: - stream = service.stream([message], None, None) - chunk = await anext(stream) - await stream.aclose() - return chunk - with patch("app.services.agent_service.verify_model_capability", AsyncMock()) as capability: - chunk = asyncio.run(first_chunk()).decode("utf-8") - self.assertIn("task_started", chunk) - capability.assert_not_awaited() - self.assertEqual(len(service.v3.repository.running_task_ids()), 1) - - def test_cached_capabilities_skip_normal_request_probe(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - service.v3.workflow.create_task(task_id, "Create a plate.") - queue: asyncio.Queue = asyncio.Queue() - with patch("app.services.agent_service.cached_model_capability", return_value={"supported": True}), patch( - "app.services.agent_service.verify_model_capability", AsyncMock() - ) as verify: - result = asyncio.run(service._ensure_task_capabilities( - task_id, ModelIdentity("author", "author-model"), ModelIdentity("reviewer", "reviewer-model"), queue, - )) - self.assertIsNone(result) - verify.assert_not_awaited() - self.assertTrue(queue.empty()) - - def test_missing_cache_is_visible_and_auto_resumes_same_task(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - service.v3.workflow.create_task(task_id, "Create a plate.") - queue: asyncio.Queue = asyncio.Queue() - with patch("app.services.agent_service.cached_model_capability", return_value=None), patch( - "app.services.agent_service.verify_model_capability", AsyncMock(return_value={"supported": True, "probe_unavailable": False}) - ) as verify: - result = asyncio.run(service._ensure_task_capabilities( - task_id, ModelIdentity("author", "author-model"), ModelIdentity("reviewer", "reviewer-model"), queue, - )) - self.assertIsNone(result) - self.assertEqual(verify.await_count, 2) - self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT) - events = [queue.get_nowait(), queue.get_nowait()] - self.assertEqual([item[1]["status"] for item in events], ["waiting", "success"]) - - def test_image_bytes_are_frozen_and_sent_to_vision(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - artifacts = FileArtifactStore(root / "tasks") - task_id = "cad_123456abcdef" - source = root / "reference.png" - png = b"\x89PNG\r\n\x1a\nreference-bytes" - source.write_bytes(png) - artifacts.initialize_task(task_id, "Match the image.", image_inputs=[{ - "path": str(source), "mime": "image/png", "sha256": sha256(png).hexdigest(), - }]) - frozen = Path(artifacts.source_image_paths(task_id)[0]) - class Models: - def __init__(self) -> None: - self.messages: list[list[dict[str, object]]] = [] - async def call_tool(self, *, messages: list[dict[str, object]], **_kwargs: object) -> dict[str, object]: - self.messages.append(messages) - return {"tool_calls": [], "usage": {}} - models = Models() - gateway = RenderedReviewGateway(models) - tool = {"type": "function", "function": {"name": "observe_images", "parameters": {"type": "object"}}} - asyncio.run(gateway.review(kind="image_observation", payload={"reference_image_paths": [str(frozen)]}, tool=tool, provider_id="p", model_id="m")) - image_part = models.messages[0][1]["content"][1] - encoded = image_part["image_url"]["url"].split(",", 1)[1] - self.assertEqual(base64.b64decode(encoded), png) - - def test_final_review_schema_has_ordered_visual_decision_count(self) -> None: - visual = stateless_final_review_schema(2)["properties"]["visual_claims"] - self.assertEqual((visual["minItems"], visual["maxItems"]), (2, 2)) - - def test_sqlite_cas_and_outbox_are_atomic(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") - initial = repository.create_task("cad_123456abcdef", "Create a plate.") - changed = transition(initial, "image_observed") - self.assertTrue(repository.compare_and_swap(changed, events=[{"event": "image_observation_ready"}])) - self.assertFalse(repository.compare_and_swap(changed, events=[{"event": "duplicate"}])) - self.assertEqual(len(repository.pending_outbox()), 1) - delivered = asyncio.run(OutboxDispatcher(repository, IdempotentInProcessPublisher()).dispatch_pending()) - self.assertEqual(len(delivered), 1) - self.assertEqual(repository.pending_outbox(), []) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 250d9118..e9f4bb51 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -916,7 +916,6 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(resolved.record.record_id, "sweep:end") self.assertEqual(resolved.record.output_roles, ("sweep.end",)) self.assertEqual(evidence["output_role_status"], "unique_result_snapshot") - registry.replace_body_topology("later", "body:later", [ TopologyRecord("later:end", "face", "later", "body:later", {"center_mm": [0, 0, 10]}, object()), ]) @@ -926,6 +925,103 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(stale.status, "not_found") self.assertEqual(stale.diagnostic.code, "selector_output_role_not_found") + def test_provenance_selector_uses_exact_lineage_not_geometry_successors(self) -> None: + registry = TopologyRegistry() + source = object() + exact_result = object() + geometrically_similar = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source), + ]) + registry.replace_body_topology("later", "body:later", [ + TopologyRecord("later:exact", "face", "later", "body:later", {"center_mm": [10, 0, 0]}, exact_result), + TopologyRecord("later:similar", "face", "later", "body:later", {"center_mm": [0, 0, 0]}, geometrically_similar), + ], topology_delta=TopologyDelta("transform", ( + TopologyDeltaRelation("modified", "face", source, (exact_result,)), + ))) + selector = { + "kind": "face", "owner_feature_id": "base", "stable_id": "base:face", + "source": "runtime_snapshot", "confidence": 1.0, + "geometry": {"center_mm": [0, 0, 0]}, + "selector_intent": { + "version": "1.0", "kind": "face", "query_family": "SWEPT_FACE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["continuation"], "multiplicity": "one"}, + "evidence": "kernel_history", + }, + } + resolution = registry.resolve(selector, active_body_id="body:later") + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "later:exact") + self.assertEqual(registry.lineage()[0].derivation, "continuation") + + def test_provenance_selector_rejects_non_unique_fragment(self) -> None: + registry = TopologyRegistry() + source = object() + first = object() + second = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}, source), + ]) + registry.replace_body_topology("fillet", "body:fillet", [ + TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {"center_mm": [0, 0, 0]}, first), + TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {"center_mm": [1, 0, 0]}, second), + ], topology_delta=TopologyDelta("fillet", ( + TopologyDeltaRelation("modified", "edge", source, (first, second)), + ))) + resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["fragment"], "multiplicity": "one"}, + "evidence": "kernel_history", + }, + }, active_body_id="body:fillet") + self.assertEqual(resolution.status, "ambiguous") + self.assertEqual(resolution.diagnostic.code, "selector_relation_non_unique") + + def test_provenance_selector_returns_all_proven_fragments(self) -> None: + registry = TopologyRegistry() + source = object() + first = object() + second = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", {}, source), + ]) + registry.replace_body_topology("fillet", "body:fillet", [ + TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first), + TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second), + ], topology_delta=TopologyDelta("fillet", ( + TopologyDeltaRelation("modified", "edge", source, (first, second)), + ))) + resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"}, + "evidence": "kernel_history", + }, + }, active_body_id="body:fillet") + self.assertEqual(resolution.status, "resolved") + self.assertIsNone(resolution.record) + self.assertEqual([record.record_id for record in resolution.records], ["fillet:first", "fillet:second"]) + + def test_boolean_section_edges_are_recorded_as_intersection_lineage(self) -> None: + registry = TopologyRegistry() + section_edge = object() + registry.replace_body_topology("boolean", "body:boolean", [ + TopologyRecord("boolean:section", "edge", "boolean", "body:boolean", {}, section_edge), + ], topology_delta=TopologyDelta("intersect", section_values=(section_edge,))) + delta = registry.topology_deltas()[0] + lineage = delta["lineage"][0] + self.assertEqual(lineage["derivation"], "intersection") + self.assertEqual(lineage["result_record_ids"], ["boolean:section"]) + self.assertTrue(delta["relations"][0]["section_edge"]) + def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None: registry = TopologyRegistry() extrude_start = object() diff --git a/backend/tests/test_feature_plan.py b/backend/tests/test_feature_plan.py deleted file mode 100644 index f9d895a0..00000000 --- a/backend/tests/test_feature_plan.py +++ /dev/null @@ -1,357 +0,0 @@ -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/backend/tests/test_live_guidance_comparison.py b/backend/tests/test_live_guidance_comparison.py deleted file mode 100644 index eb1224e0..00000000 --- a/backend/tests/test_live_guidance_comparison.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import sys -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - -from app.cad_agent.evals.live import _fixture, compare_guidance_reports # noqa: E402 - - -def _result(*, scenario: str, repetition: int, author_calls: int, context_chars: int, schema_rejections: int, failure_layer: str = "") -> dict: - return { - "scenario": scenario, - "repetition": repetition, - "outcome": "passed", - "revision_ids": ["revision_001"], - "projection": {"phase": "COMPLETED"}, - "checks": {"deterministic_claims_pass": True}, - "schema_rejection_count": schema_rejections, - "failure_attribution": {"layer": failure_layer} if failure_layer else None, - "scenario_budget": {"max_author_turns": 8, "max_reviewer_turns": 2, "max_total_calls": 10, "max_total_tokens": 1000}, - "usage": {"records": [{"context_chars": context_chars} for _ in range(author_calls)]}, - } - - -def _report(results: list[dict]) -> dict: - return { - "author": {"provider": "author", "model": "model"}, - "reviewer": {"provider": "reviewer", "model": "review"}, - "runtime_profile_sha256": "a" * 64, - "operation_contracts": [{"atomic_id": "extrude_add_blind", "contract_hash": "b" * 64}], - "author_guidance": {"enabled": False, "max_chars": 3600}, - "results": results, - } - - -class LiveGuidanceComparisonTests(unittest.TestCase): - def test_fixture_accepts_multiple_stable_scenarios_in_fixture_order(self) -> None: - selected = _fixture("comprehensive", ["l_bracket", "circular_flange_pcd"]) - self.assertEqual([item["id"] for item in selected], ["circular_flange_pcd", "l_bracket"]) - - def test_comparison_enforces_matched_budget_and_quality_gates(self) -> None: - control = _report([ - _result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=2, failure_layer="cdsl_expression"), - _result(scenario="part_a", repetition=2, author_calls=10, context_chars=1000, schema_rejections=1), - ]) - treatment = _report([ - _result(scenario="part_a", repetition=1, author_calls=11, context_chars=1300, schema_rejections=0), - _result(scenario="part_a", repetition=2, author_calls=11, context_chars=1300, schema_rejections=0), - ]) - treatment["author_guidance"]["enabled"] = True - comparison = compare_guidance_reports(control, treatment) - self.assertEqual(comparison["status"], "passed") - self.assertTrue(comparison["gates"]["median_author_calls_within_ten_percent"]) - self.assertTrue(comparison["gates"]["model_or_cdsl_failure_improved"]) - - def test_comparison_excludes_explicit_unsupported_runtime_capability(self) -> None: - control = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=1)]) - treatment = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1200, schema_rejections=0)]) - treatment["author_guidance"]["enabled"] = True - treatment["results"][0]["ledger"] = [{"operation_failures": [{"message": "unsupported_draft"}]}] - comparison = compare_guidance_reports(control, treatment) - self.assertEqual(comparison["treatment"]["eligible_runs"], 0) - self.assertEqual(comparison["excluded_capability_gaps"]["treatment"], [{"scenario": "part_a", "repetition": 1}]) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_review_renderer.py b/backend/tests/test_render_bundle.py similarity index 93% rename from backend/tests/test_review_renderer.py rename to backend/tests/test_render_bundle.py index 02c6394c..9d1313ad 100644 --- a/backend/tests/test_review_renderer.py +++ b/backend/tests/test_render_bundle.py @@ -9,7 +9,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) -from app.services.review_renderer import render_section # noqa: E402 +from app.services.render_bundle import render_section # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 @@ -32,7 +32,7 @@ def settings(root: Path) -> Settings: ) -class ReviewRendererTests(unittest.TestCase): +class RenderBundleTests(unittest.TestCase): def test_section_uses_build123d_part_operation_and_emits_a_png(self) -> None: import build123d as b3d diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index b3a8e9c5..22a2847e 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -58,24 +58,3 @@ class SettingsModelSelectionTests(unittest.TestCase): self.assertEqual(resolved_provider.id, "alternate") self.assertEqual(resolved_model.id, "alternate-first") - - def test_reviewer_cannot_be_the_same_author_model(self) -> None: - provider = ProviderConfig("openai", "OpenAI", "https://example.invalid/v1", "key", (ProviderModel("gpt-5.5", vision=True),)) - settings = Settings( - task_root=ROOT / "tmp-tasks", - conversation_root=ROOT / "tmp-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="gpt-5.5", - llm_timeout_s=1, - default_provider_id="openai", - providers=(provider,), - review_provider_id="openai", - review_model_id="gpt-5.5", - ) - - author_provider, author_model = settings.resolve_model(None, None) - with self.assertRaisesRegex(ValueError, "must differ"): - settings.resolve_independent_review_model(author_provider, author_model) diff --git a/backend/tests/test_single_stage.py b/backend/tests/test_single_stage.py new file mode 100644 index 00000000..d0c99867 --- /dev/null +++ b/backend/tests/test_single_stage.py @@ -0,0 +1,21 @@ +from app.cad_agent.application.single_stage import SingleStageExecutor + + +class Artifacts: + def __init__(self): self.writes = [] + def write_json_once(self, *_args): self.writes.append(_args); return "x" + def start_staging_revision(self, *_args): + return type("Stage", (), {"stage_id": "stage", "output_dir": "/tmp"})() + def write_stage_json(self, *_args): return "x" + def publish_staging_revision(self, *_args): return {"model.step": "x.step"} + + +class Runtime: + def compile_authoring(self, value): return ({"features": [{"id": "feature_001"}]}, {"feature_ids": {"base": "feature_001"}}) + def rebuild_best_effort(self, *_args): return ({"executed_feature_ids": ["feature_001"]}, []) + + +def test_single_stage_publishes_compiled_document(): + result = SingleStageExecutor(None, Artifacts(), Runtime()).execute("cad_abcdefghijkl", {"bodies": []}) + assert result["status"] == "completed" + assert result["executed_feature_ids"] == ["feature_001"] diff --git a/backend/tests/test_single_stage_evals.py b/backend/tests/test_single_stage_evals.py new file mode 100644 index 00000000..a8b9d31f --- /dev/null +++ b/backend/tests/test_single_stage_evals.py @@ -0,0 +1,27 @@ +from app.cad_agent.evals.single_stage import summarize + + +def test_single_stage_summary_reports_protocol_quality_and_cost() -> None: + report = summarize([ + { + "attempts": [{"schema_valid": True, "executable": False}], + "published_revision": "rev_1", + "requirement_targets": [{"status": "pass"}, {"status": "pending"}], + "usage": {"records": [{"prompt_tokens": 10, "completion_tokens": 2}]}, + "duration_ms": 15, + }, + { + "attempts": [{"schema_valid": False, "executable": False}], + "requirement_targets": [{"status": "fail"}], + "usage": {"records": [{"prompt_tokens": 3, "completion_tokens": 1}]}, + "duration_ms": 5, + }, + ]) + assert report["first_pass_schema_rate"] == 0.5 + assert report["first_pass_executable_rate"] == 0.0 + assert report["final_executable_rate"] == 0.5 + assert report["requirement_targets"] == {"pass": 1, "fail": 1, "pending": 1, "not_applicable": 0} + assert report["calls"] == 2 + assert report["prompt_tokens"] == 13 + assert report["completion_tokens"] == 3 + assert report["duration_ms"] == 20 diff --git a/backend/tests/test_single_stage_workflow.py b/backend/tests/test_single_stage_workflow.py new file mode 100644 index 00000000..fee6faa3 --- /dev/null +++ b/backend/tests/test_single_stage_workflow.py @@ -0,0 +1,180 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.artifact_store import FileArtifactStore +from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository +from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator +from app.cad_agent.application.authoring_compiler import AuthoringCompileError +from app.cad_agent.domain.errors import ErrorCode +from app.cad_agent.domain.state import TaskPhase, transition + + +class Runtime: + def supported_atomic_ids(self): return ("extrude_add_blind",) + def operation_contract(self, _operation): + return {"atomic_id": "extrude_add_blind", "fragment_shape": {"sketch": "required", "selector_tokens": "forbidden"}, "selector_policy": {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}, "reference_policy": {"mode": "none"}, "author_params_schema": {"type": "object", "properties": {"distance_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["distance_mm"], "additionalProperties": False}} + + +class Executor: + def __init__(self, artifacts): self.artifacts = artifacts + def compile(self, _task, _authoring, *, repair_count): + runtime, audit = {"features": []}, {} + runtime_path, audit_path = "documents/runtime.json", "documents/audit.json" + self.artifacts.write_json_once(_task, runtime_path, runtime) + self.artifacts.write_json_once(_task, audit_path, audit) + return {"runtime": runtime, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": "x"} + def build(self, _task, _authoring, _runtime, _audit, *, repair_count, digest=""): + return {"status": "completed", "revision_id": "rev_1", "paths": {"model.step": "revisions/rev_1/model.step"}, "executed_feature_ids": ["feature_001"], "diagnostics": []} + + +class Models: + def __init__(self): self.calls = 0 + async def call_tool(self, **kwargs): + self.calls += 1 + if kwargs["required_tool_name"] == "analyze_requirements": + payload = {"explicit_requirements": ["plate"], "assumptions": [], "acceptance_targets": [], "manual_targets": []} + else: + payload = {"bodies": [{"name": "main", "features": [{"name": "base", "operation": "extrude_add_blind", "params": {"distance_mm": 8}, "sketch": {"workplane": {"origin_mm": [0,0,0], "x_dir": [1,0,0], "normal": [0,0,1]}, "profile": {"type": "circle", "diameter_mm": 20}}}]}]} + import json + return {"tool_calls": [{"function": {"name": kwargs["required_tool_name"], "arguments": json.dumps(payload)}}], "usage": {}} + + +class InvalidAuthorModels(Models): + def __init__(self): + super().__init__() + self.author_calls = 0 + + async def call_tool(self, **kwargs): + if kwargs["required_tool_name"] == "analyze_requirements": + return await super().call_tool(**kwargs) + self.calls += 1 + self.author_calls += 1 + import json + return { + "tool_calls": [{"function": { + "name": "write_authoring_cdsl", + "arguments": json.dumps({"feature_id": "feature_001", "bodies": []}), + }}], + "usage": {}, + } + + +def test_workflow_uses_two_author_calls_and_publishes(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + models = Models() + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), models, Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + events = asyncio.run(_collect(workflow)) + assert models.calls == 2 + assert events[-1][0] == "task_terminal" + assert repository.get_state("cad_abcdefghijkl").phase.value == "COMPLETED" + + +def test_service_retry_returns_to_the_persisted_stage_without_new_authoring_call(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), Models(), Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + created = repository.get_state("cad_abcdefghijkl") + assert created is not None + authoring = transition(created, "analysis_written", requirements_path="documents/requirements-analysis.json") + assert repository.compare_and_swap(authoring) + failed = transition(authoring, "failed", error=ErrorCode.STORAGE_FAILURE) + assert failed.phase == TaskPhase.FAILED + assert failed.retry_from_phase == TaskPhase.AUTHORING_CDSL + assert repository.compare_and_swap(failed) + + assert workflow.resume("cad_abcdefghijkl") + resumed = repository.get_state("cad_abcdefghijkl") + assert resumed is not None + assert resumed.phase == TaskPhase.AUTHORING_CDSL + assert resumed.retry_from_phase is None + + +def test_authoring_schema_failures_use_at_most_two_repairs(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + models = InvalidAuthorModels() + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), models, Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + + asyncio.run(_collect(workflow)) + + state = repository.get_state("cad_abcdefghijkl") + assert state is not None + assert state.phase == TaskPhase.FAILED + assert state.repair_count == 2 + assert models.author_calls == 3 + + +def test_author_operation_context_exposes_server_injected_selector_contract(): + context = WorkflowCoordinator._author_operation_contract({ + "fragment_shape": {"sketch": "forbidden", "selector_tokens": "required"}, + "selector_policy": { + "slot": "params.host_face", "token_kind": "face", "min_items": 1, "max_items": 1, + }, + "author_params_schema": {"type": "object", "properties": {}, "additionalProperties": False}, + }) + + assert context["sketch"] == "forbidden" + assert context["selector"] == { + "required": True, + "kind": "face", + "min_items": 1, + "max_items": 1, + "destination": "params.host_face", + "source_syntax": ".", + } + assert context["authoring_sketch_template"] is None + + +def test_schema_repair_hint_contains_the_exact_authoring_sketch_form(): + assert "diameter_mm" in WorkflowCoordinator._repair_hint( + "AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile", + ) + + +def test_repair_can_change_unexecuted_features_after_compile_failure(): + previous = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {}}, + {"name": "bad_hole", "operation": "hole_wizard", "params": {}}, + ]}]} + replacement = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {}}, + {"name": "bad_hole", "operation": "hole_wizard", "params": {"diameter_mm": 8}}, + ]}]} + WorkflowCoordinator._validate_repair_document( + previous, + replacement, + {"diagnostics": [{"code": "SELECTOR_NOT_FOUND", "path": "features.bad_hole.selectors"}]}, + {"feature_ids": {"base": "feature_001", "bad_hole": "feature_002"}}, + ) + + +def test_repair_cannot_change_executed_feature_without_targeted_diagnostic(): + previous = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {"height_mm": 8}}, + ]}]} + replacement = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {"height_mm": 9}}, + ]}]} + try: + WorkflowCoordinator._validate_repair_document( + previous, + replacement, + {"diagnostics": [], "executed_feature_ids": ["feature_001"]}, + {"feature_ids": {"base": "feature_001"}}, + ) + except AuthoringCompileError as error: + assert error.code == "AUTHOR_SCHEMA_INVALID" + else: + raise AssertionError("an executed feature was changed without a diagnostic target") + + +async def _collect(workflow): + return [item async for item in workflow.run(task_id="cad_abcdefghijkl", author=ModelIdentity("p", "m"))] diff --git a/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md b/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md new file mode 100644 index 00000000..2a9c00ca --- /dev/null +++ b/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md @@ -0,0 +1,139 @@ +# CADFS 全量能力快照对比 + +## 范围 + +本文档对比第一次归档的 CADFS 全量运行与最新归档的全量运行。两份快照均包含 +9,347 个源样本。 + +| 快照 | 报告生成时间 | 证据目录 | +| --- | --- | --- | +| 第一次全量运行 | 2026-09-02 20:40:56 | `cadfs_to_cdsl/output-history/20260907-185128/` | +| 最新全量运行 | 2026-09-08 21:59:59 | `cadfs_to_cdsl/output-history/20260908-215959/` | + +一个 executor 已注册,仅表示运行时能识别该原子操作;它本身不表示全部 CADFS +参数变体、拓扑 selector、body 生命周期或源模型都能正确重建。下文的全量比较与 +能力缺口表才是实际覆盖范围的证据。 + +## 已注册的原子操作 + +| 原子操作组 | 第一次全量运行 | 最新全量运行 | 变化 | +| --- | --- | --- | --- | +| 基础实体 | `sphere_add` | `box_add`, `cylinder_add`, `sphere_add` | 新增方体与圆柱体 | +| 盲拉伸/加料拉伸 | `extrude_add_blind`, `extrude_add_two_sided` | 保留原有操作,新增 `extrude_add_blind_with_hole` | 新增 selector 绑定的端盖孔拉伸 | +| 切除拉伸 | `extrude_cut_blind` | 保留原有操作,新增 `extrude_cut_through`、`extrude_cut_two_sided` | 新增贯穿与双向切除 | +| 曲面拉伸 | 未注册 | `extrude_surface` | 新增仅生成曲面的拉伸路径 | +| 回转 | `revolve_add`, `revolve_cut` | 保留原有操作,新增 `revolve_surface` | 新增曲面回转路径 | +| 放样 | 未注册 | `loft_add`, `loft_add_with_cap_face` | 新增实体放样与 `CAP_FACE` 驱动放样 | +| 扫掠 | 未注册 | `sweep_add` | 新增实体扫掠路径 | +| 抽壳 | 未注册 | `shell` | 新增抽壳路径 | +| 多 body 布尔 | 未注册 | `boolean_bodies` | 新增 body 的 union/subtract/intersect | +| 孔 | `hole_blind`, `hole_counterbore`, `hole_countersink`, `hole_wizard` | 相同 | 无新原子操作 | +| 修饰特征 | `fillet`, `chamfer` | 相同 | 无新原子操作;覆盖和 selector 处理有改进 | +| 阵列 | `pattern_linear`, `pattern_mirror` | 保留原有操作,新增 `pattern_circular` | 新增环形阵列;replay/body 处理也有改进 | +| 基准几何 | `reference_plane`, `reference_axis` | 相同 | 无新原子操作;CADFS 基准面变体覆盖增加 | +| 螺纹 | 未注册 | `thread_add`, `thread_cut` | 新增加料/切除螺纹 | +| 钣金折弯 | 未注册 | `bend_add` | 已注册;这两份 CADFS 全量快照未单独证明其 CADFS 映射与几何接受率 | + +## 全量能力覆盖 + +计数是被记录为能力缺口的样本数。减少表示更多样本进入支持的转换/运行时路径, +但不表示每个新增可执行模型都已通过几何验收。 + +| 类别 | 能力 | 第一次缺口 | 最新缺口 | 变化 | 当前解释 | +| --- | --- | ---: | ---: | ---: | --- | +| 新覆盖路径 | `revolve_surface` | 367 | 0 | -367 | 已观察到的样本均进入支持的运行时路径 | +| 新覆盖路径 | `extrude_cut_through_all` | 301 | 0 | -301 | 不再因贯穿切除产生此能力缺口 | +| 新覆盖路径 | `extrude_cut_two_sided` | 260 | 0 | -260 | 不再因双向切除产生此能力缺口 | +| 新覆盖路径 | `extrude_add_through_all` | 3 | 0 | -3 | 不再因贯穿加料产生此能力缺口 | +| 新覆盖路径 | `extrude_extent:up_to_next` | 48 | 0 | -48 | 不再因 up-to-next 产生此能力缺口 | +| 新覆盖路径 | `reference_plane:line_angle` | 121 | 0 | -121 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:plane_point` | 46 | 0 | -46 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:three_point` | 34 | 0 | -34 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:mid_plane` | 24 | 0 | -24 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:line_point` | 22 | 0 | -22 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:curve_point` | 12 | 0 | -12 | 不再因该基准面变体产生此能力缺口 | +| 主要覆盖提升 | `extrude` | 3,861 | 1,197 | -2,664 | 最大降幅;派生 profile 与 selector 变体仍待完成 | +| 主要覆盖提升 | `shell` | 696 | 57 | -639 | 主路径已存在;面选择器变体仍待完成 | +| 主要覆盖提升 | `fillet` | 1,914 | 1,417 | -497 | 覆盖增加;OCC 可行性和后继选择仍阻塞大量模型 | +| 主要覆盖提升 | `revolve` | 741 | 344 | -397 | 实体回转覆盖增加 | +| 主要覆盖提升 | `loft` | 308 | 68 | -240 | 主放样路径和部分 cap-face 形式已覆盖 | +| 主要覆盖提升 | `chamfer` | 589 | 381 | -208 | 覆盖增加;selector 与内核失败仍存在 | +| 主要覆盖提升 | `hole` | 623 | 386 | -237 | 更多孔变体进入运行时 | +| 主要覆盖提升 | `sweep` | 326 | 133 | -193 | 实体扫掠覆盖增加;路径变体仍待完成 | +| 主要覆盖提升 | `booleanBodies` | 187 | 92 | -95 | 多 body 路径覆盖增加 | +| 主要覆盖提升 | `cPlane` | 147 | 84 | -63 | 更多基准面路径成功 lower | +| 主要覆盖提升 | `mirror` | 262 | 158 | -104 | 阵列/body replay 覆盖增加 | +| 主要覆盖提升 | `circularPattern` | 228 | 74 | -154 | 阵列/body replay 覆盖增加 | +| 主要覆盖提升 | `transform` | 1 | 230 | +229 | 更多 transform 语义被识别并诊断;不代表完整支持 | +| 派生拓扑 | `extrude_profile_topology:cap_edge` | 133 | 91 | -42 | 部分 `CAP_EDGE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:cap_face` | 200 | 183 | -17 | 部分 `CAP_FACE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:intersect` | 382 | 362 | -20 | 部分 `INTERSECT` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:offset_face` | 18 | 9 | -9 | 部分 `OFFSET_FACE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:swept_face` | 99 | 94 | -5 | 部分 `SWEPT_FACE` 覆盖 | + +## 最新快照中新分类的未完成边界 + +这些条目在最新全量报告中被新分类或单独拆出。它们表示已知未完成语义,不能视为 +已完成能力。 + +| 能力缺口 | 最新受影响样本 | 含义 | +| --- | ---: | --- | +| `shell_face_selector` | 221 | 这些 source history 的 shell remove-face selector 尚不能解析 | +| `delete_bodies` | 121 | body 生命周期中的删除语义不完整 | +| `sweep_path` | 68 | 扫掠路径变体尚不能表达或执行 | +| `shell_outward` | 19 | 向外抽壳方向尚未完成 | +| `extrude_draft_extent` | 15 | 带拔模拉伸的终止语义尚未完成 | +| `sweep_remove` | 6 | 扫掠切除语义尚未完成 | +| `circular_pattern_remove_source` | 1 | 环形阵列移除源 body 的生命周期尚未完成 | +| `extrude_cap_edge_profile` | 1 | `CAP_EDGE` 派生拉伸 profile 尚未完成 | + +## 端到端结果 + +结果表区分可执行工件和几何验收。`RP` 是工程验收口径;`strict` 是更严格的诊断, +不能隐藏已 RP 通过的工件。 + +| 指标 | 第一次全量运行 | 最新全量运行 | 变化 | +| --- | ---: | ---: | ---: | +| 候选 CDSL 文件 | 8,108 | 8,633 | +525 | +| 完整转换 | 2,427 | 5,367 | +2,940 | +| 无可执行特征的 deferred | 2,602 | 693 | -1,909 | +| 重建 STEP 文件 | 1,812 | 6,134 | +4,322 | +| 比较报告 | 1,719 | 6,090 | +4,371 | +| RP 通过模型 | 1,159 (12.40%) | 2,031 (21.73%) | +872 | +| Strict 通过模型 | 578 (6.18%) | 905 (9.68%) | +327 | +| 比较超时或 worker 失败 | 56 | 44 | -12 | +| 已重建但几何拒绝 | 568 | 4,059 | +3,491 | +| 重建失败 | 617 | 2,144 | +1,527 | +| 解析/lowering 失败 | 3 | 21 | +18 | + +当更多 partial 或原先不支持的 history 能进入 engine 后,被拒绝的 STEP 明显增加。 +这些 STEP 工件和诊断必须保留;它们是 converter/runtime 的证据,不是验收通过的重建。 + +## 当前失败优先级 + +最新全量运行记录了以下主要重建失败类别。 + +| 失败类别 | 受影响重建 | 优先工作 | +| --- | ---: | --- | +| Selector 找不到 | 982 | 保留拓扑 provenance,实现精确的后继/output-role 绑定 | +| Fillet 内核失败 | 347 | 改善通用可行性处理,并保留有界 OCC 诊断 | +| Chamfer 内核失败 | 174 | 改善通用可行性处理,并保留有界 OCC 诊断 | +| BRep API 失败 | 139 | 调查操作特定的内核前置条件与拓扑输入 | +| Compound 拉伸不支持 | 83 | 完成显式多 body/Compound 的拉伸语义 | +| Selector 歧义 | 82 | 改善 selector provenance,确定性拒绝不唯一候选 | +| 终止目标不可达或空 | 71 | 完成以目标为基础的 extent/trim 语义 | +| Boolean 操作失败 | 98 | 完成通用多 body boolean 输入和结果生命周期 | +| Shell 操作失败 | 46 | 完成 remove-face 及向内/向外抽壳语义 | + +## 状态 + +最新快照显示转换和可执行 STEP 覆盖已有明显扩展,但尚未完成全量重建。只有当一项 +能力同时具备 FeatureScript lowering、CDSL contract、runtime/adapter 行为、selector/body +生命周期、单元覆盖、多个真实语料回归与 RP 比较证据时,才能标记为完成。 + +## 证据 + +- `cadfs_to_cdsl/output-history/20260907-185128/summary.json` +- `cadfs_to_cdsl/output-history/20260907-185128/full_run_report.md` +- `cadfs_to_cdsl/output-history/20260908-215959/summary.json` +- `cadfs_to_cdsl/output-history/20260908-215959/full_run_report.md` diff --git a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md index 2a63c5a8..23edad0c 100644 --- a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md +++ b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md @@ -35,6 +35,11 @@ feature history 生成工程相似的 STEP。 - 若已有实现反复依赖样本化补丁、不能表达已出现的通用语义或受内核 API 结构性限制, 必须评估替代方案,不得沿错误方向继续累积补丁。替换需要可复现根因、成熟实现或最小 原型的对照、contract/迁移影响评估和回归计划;单个样本或偶发内核失败不足以触发重写。 +- 每项 FeatureScript operation、query 或枚举语义的 lowering,必须对照源文件声明的 + FeatureScript/standard library 版本对应的官方 API/query contract,并在能力矩阵记录 + 文档或标准库来源、已采用的语义与未覆盖边界。当前官网或其它版本的文档不得替代源版本 + 的默认值和行为。文档只用于确定源语义,不能替代 CDSL provenance、OCC builder history + 或 selector 的唯一性证据。 - 每项能力必须同时具备:FeatureScript lowering、CDSL schema/semantic validation、 runtime/adapter 实现、selector/body 语义、单元测试、多个语料回归和比较工件;缺少 任一层只能标记为“部分完成”。 @@ -495,8 +500,9 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为 部分完成。 3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。 -4. 原子语义矩阵包含正向、边界和拒绝测试;至少多个真实语料样本覆盖不同几何和 - 生命周期组合。 +4. 能力矩阵记录对应 FeatureScript API/query 的源版本文档或标准库依据、采用的语义和 + 未覆盖边界;原子语义矩阵包含正向、边界和拒绝测试,且至少多个真实语料样本覆盖 + 不同几何和生命周期组合。 5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余 exception 均更新到本地台账。 6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为 diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py index c2ca377d..a2c1ecdc 100644 --- a/cadfs_to_cdsl/featurescript_parser.py +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import Any from .featurescript_lexer import Token, lex from .ir import Call, FeatureIR, ModelIR, SketchIR @@ -130,7 +131,18 @@ def _arg_map(call: Call) -> dict[str, Any]: def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR: - parser = Parser(source); calls = parser.statements(); model = ModelIR(sample_id, raw_source=source) + parser = Parser(source); calls = parser.statements() + version = re.search(r"\bFeatureScript\s+(\d+(?:\.\d+)*)\s*;", source) + standard_library = re.search( + r"\bimport\s*\(\s*path\s*:\s*[\"']([^\"']*onshape/std/[^\"']*)[\"']", + source, + ) + model = ModelIR( + sample_id, + raw_source=source, + featurescript_version=version.group(1) if version else None, + standard_library=standard_library.group(1) if standard_library else None, + ) for call in calls: if call.name == "newSketch": definition = _arg_map(call) diff --git a/cadfs_to_cdsl/ir.py b/cadfs_to_cdsl/ir.py index d4abb10c..40568e76 100644 --- a/cadfs_to_cdsl/ir.py +++ b/cadfs_to_cdsl/ir.py @@ -37,3 +37,7 @@ class ModelIR: sketches: list[SketchIR] = field(default_factory=list) steps: list[Any] = field(default_factory=list) raw_source: str = "" + # Source metadata is retained independently from the lowered CDSL so a + # selector can be audited against the FeatureScript API contract it used. + featurescript_version: str | None = None + standard_library: str | None = None diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index 45ba9b16..dacf8fac 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -49,6 +49,55 @@ def plain(value: Any) -> Any: return value +def _selector_intent( + value: Any, + *, + query_family: str, + kind: str, + evidence: str, + allowed: tuple[str, ...] = ("continuation",), + multiplicity: str = "one", + output_role: str | None = None, + disambiguation: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Keep the source query as semantics, separate from runtime topology IDs.""" + query = parse_query(value) + intent: dict[str, Any] = { + "version": "1.0", + "kind": kind, + "query_family": query_family, + # This placeholder is populated from the enclosing ModelIR once the + # FeatureScript source header has been parsed. + "source_query": {"ast": query.ast, "featurescript_version": "0"}, + "derivation_policy": {"allowed": list(allowed), "multiplicity": multiplicity}, + "evidence": evidence, + } + if query.source_sketch and query.source_entity: + intent["source_entity"] = {"sketch_id": query.source_sketch, "entity_id": query.source_entity} + if output_role is not None: + intent["output_role"] = output_role + if disambiguation is not None: + intent["disambiguation"] = disambiguation + return intent + + +def _finalize_selector_intents(cdsl: dict[str, Any], model: ModelIR) -> None: + """Bind source-version metadata after all FeatureScript selectors lower.""" + source_version = model.featurescript_version or "0" + for feature in cdsl.get("features") or []: + for selector in feature.get("selectors") or []: + intent = selector.get("selector_intent") if isinstance(selector, dict) else None + if not isinstance(intent, dict): + continue + selector["selector_intent_version"] = "1.0" + source_query = intent.get("source_query") + if not isinstance(source_query, dict): + continue + source_query["featurescript_version"] = source_version + if model.standard_library: + source_query["standard_library"] = model.standard_library + + def _bool(value: Any) -> bool: return value is True or (isinstance(value, str) and value.lower() == "true") @@ -573,6 +622,17 @@ def _shell_offset_face_output_role_selector( }, "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="OFFSET_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary", "replacement"), + output_role="shell.offset_face", + disambiguation={"type": "true_dependency", "sources": [ + {"owner_feature_id": source["owner_feature_id"], "output_role": source["output_role"]}, + ]}, + ), } @@ -1792,6 +1852,14 @@ def _cap_face_output_role_selector( "output_role": f"{role_prefix}.{'start' if query.is_start else 'end'}", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="CAP_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary", "continuation"), + output_role=f"{role_prefix}.{'start' if query.is_start else 'end'}", + ), } @@ -4800,6 +4868,8 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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) cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": model.sample_id, - "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")})}, + "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")}), + "source_featurescript": {"version": model.featurescript_version or "0", **({"standard_library": model.standard_library} if model.standard_library else {})}}, "geometry": {"sketches": sketches}, "features": features} + _finalize_selector_intents(cdsl, model) return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history) diff --git a/cadfs_to_cdsl/query_parser.py b/cadfs_to_cdsl/query_parser.py index c0f9c13c..c7b4fa72 100644 --- a/cadfs_to_cdsl/query_parser.py +++ b/cadfs_to_cdsl/query_parser.py @@ -15,6 +15,18 @@ class QueryInfo: source_entity: str | None = None is_start: bool | None = None calls: list[str] = field(default_factory=list) + # The AST is intentionally lossless for the parser's value model. Query + # aliases may be resolved for lowering, but their nested query semantics + # must remain auditable and cannot be collapsed into a geometry hint. + ast: dict[str, Any] | list[Any] | str | float | bool | None = None + query_combinators: list[str] = field(default_factory=list) + filters: list[str] = field(default_factory=list) + body_scope: list[str] = field(default_factory=list) + disambiguation: list[str] = field(default_factory=list) + + def __post_init__(self) -> None: + if self.ast is None: + self.ast = {} def as_dict(self) -> dict[str, Any]: return asdict(self) @@ -29,10 +41,31 @@ def walk_calls(value: Any): for item in value.values(): yield from walk_calls(item) +def query_ast(value: Any) -> dict[str, Any] | list[Any] | str | float | bool | None: + """Serialize parsed FeatureScript query syntax without evaluating it.""" + if isinstance(value, Call): + return {"call": value.name, "args": [query_ast(arg) for arg in value.args], "line": value.line} + if isinstance(value, list): + return [query_ast(item) for item in value] + if isinstance(value, dict): + return {str(key): query_ast(item) for key, item in value.items()} + if value is None or isinstance(value, (str, float, bool, int)): + return value + return str(value) + + def parse_query(value: Any) -> QueryInfo: - info = QueryInfo() + info = QueryInfo(ast=query_ast(value)) for call in walk_calls(value): info.calls.append(call.name) + if call.name in {"qUnion", "qIntersection", "qSubtraction", "qAdjacent"}: + info.query_combinators.append(call.name) + if call.name in {"qBodyType", "qOwnerBody"}: + info.body_scope.append(call.name) + if call.name in {"TDD", "trueDependencyDisambiguation"}: + info.disambiguation.append(call.name) + if call.name in {"qBodyType", "qOwnerBody", "qAdjacent"}: + info.filters.append(call.name) if call.name in {"makeQuery", "qCreatedBy"} and call.args: owner = symbolic_string(call.args[0]) if "F" in owner: diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 1f7b2145..938f09bc 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -152,6 +152,18 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list for placeholder in targets: records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id")) output_role = str(placeholder.get("output_role") or "").strip() + intent = placeholder.get("selector_intent") + if ( + isinstance(intent, dict) + and intent.get("query_family") != "GEOMETRIC" + and not output_role + ): + # Prefix rebuilding is retained as a diagnostic adapter. + # It must not turn an unproven FeatureScript provenance + # query into a geometry-scored stable selector. + raise ValueError( + f"{feature['id']}: selector_kernel_history_missing after prefix rebuild" + ) if output_role: # Builder output roles are not geometry placeholders. They # remain in the bound CDSL so runtime can resolve the diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index 3c9b9950..69075369 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -1041,10 +1041,15 @@ class LoweringTests(unittest.TestCase): cap_extrude = features["f_F5"] self.assertEqual(cap_extrude["atomic_id"], "extrude_from_face") self.assertNotIn("sketch_id", cap_extrude) - self.assertEqual(cap_extrude["selectors"], [{ + selector = cap_extrude["selectors"][0] + self.assertEqual({key: selector[key] for key in ( + "kind", "owner_feature_id", "output_role", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F3", "output_role": "extrude.start", "source": "runtime_snapshot", "confidence": 1.0, - }]) + }) + self.assertEqual(selector["selector_intent_version"], "1.0") + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") from engine.cdsl_engine.runtime import rebuild_cdsl with tempfile.TemporaryDirectory() as directory: @@ -1691,6 +1696,21 @@ class LoweringTests(unittest.TestCase): self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability") self.assertIn("extrude_profile_topology:cap_face", result.diagnostics[0]["capability"]) + def test_cap_face_selector_retains_versioned_provenance_intent(self): + source = SOURCE.replace( + '\n});\n', + '\n extrude(context, id + "F2", {"entities":makeQuery(id + "F1.opExtrude", "CAP_FACE", FACE, {"isStart":false}), "depth":10 * mm});\n});\n', + ) + result = lower_model(parse_featurescript(source, "cap-intent"), {}) + self.assertEqual(result.status, "converted_complete") + selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(selector["selector_intent_version"], "1.0") + self.assertEqual(selector["selector_intent"]["source_query"]["featurescript_version"], "1511") + self.assertEqual(selector["selector_intent"]["evidence"], "operation_role") + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + 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/cadfs_to_cdsl/tests/test_parser.py b/cadfs_to_cdsl/tests/test_parser.py index 0b388338..4d9a9952 100644 --- a/cadfs_to_cdsl/tests/test_parser.py +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -52,6 +52,16 @@ class ParserTests(unittest.TestCase): value = parse_query(query) self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0")) self.assertFalse(value.is_start) + self.assertEqual(value.ast["call"], "makeQuery") + self.assertEqual(value.ast["args"][0]["call"], "__binary__") + + def test_source_version_and_standard_library_are_retained(self): + source = '''FeatureScript 1511; + import(path : "onshape/std/geometry.fs", version : "1511.0"); + export const f = defineFeature(function(context, id, definition) {});''' + model = parse_featurescript(source, "versioned") + self.assertEqual(model.featurescript_version, "1511") + self.assertEqual(model.standard_library, "onshape/std/geometry.fs") def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self): source = r''' diff --git a/cadfs_to_cdsl/tests/test_selector_binding.py b/cadfs_to_cdsl/tests/test_selector_binding.py index 32dc3ec4..ef0130c7 100644 --- a/cadfs_to_cdsl/tests/test_selector_binding.py +++ b/cadfs_to_cdsl/tests/test_selector_binding.py @@ -123,10 +123,14 @@ class SelectorBindingTests(unittest.TestCase): bound, evidence = bind_candidate_selectors(candidate.cdsl) selector = next(item for item in bound["features"] if item["id"] == "f_F3")["selectors"][0] - self.assertEqual(selector, { + self.assertEqual({key: selector[key] for key in ( + "kind", "owner_feature_id", "output_role", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F1", "output_role": "extrude.end", "source": "runtime_snapshot", "confidence": 1.0, }) + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(selector["selector_intent"]["evidence"], "operation_role") self.assertNotIn("stable_id", selector) self.assertNotIn("snapshot_id", selector) self.assertNotIn("geometry", selector) @@ -155,11 +159,15 @@ class SelectorBindingTests(unittest.TestCase): self.assertEqual(first["snapshot_id"], "body:f_F2:face:1") self.assertEqual(first["source"], "runtime_snapshot") self.assertEqual(first["confidence"], 1.0) - self.assertEqual(offset, { + self.assertEqual({key: offset[key] for key in ( + "kind", "owner_feature_id", "output_role", "output_role_source", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F2", "output_role": "shell.offset_face", "output_role_source": {"owner_feature_id": "f_F1", "output_role": "extrude.start"}, "source": "runtime_snapshot", "confidence": 1.0, }) + self.assertEqual(offset["selector_intent"]["query_family"], "OFFSET_FACE") + self.assertEqual(offset["selector_intent"]["disambiguation"]["type"], "true_dependency") binding = next(item for item in evidence if item["feature_id"] == "f_F3") self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1") self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5") diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 22a6d798..40049575 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -21,7 +21,7 @@ import type { } from "@/lib/cad-types"; import { AgentThread } from "./agent-thread"; import { CadViewerPreview } from "./cad-viewer-preview"; -import { MarkdownDocument } from "./rich-content"; +import { JsonTree, MarkdownDocument } from "./rich-content"; import type { AssistantRuntime } from "@assistant-ui/react"; type LoadState = "loading" | "ready" | "error"; @@ -557,27 +557,10 @@ function StudioShell({ {!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} - {config?.autonomous_generation && !config.review_configured ?
最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}
: null}
@@ -587,27 +570,18 @@ function StudioShell({ ); } -function TaskDocuments({ task, onSelectRevision }: { task: TaskRecord | null; onSelectRevision: (revisionId: string) => void }) { - const documents = [ - ["需求文档", task?.requirements_markdown], - ["完成目标", task?.completion_target_markdown], +function TaskDocuments({ task }: { task: TaskRecord | null }) { + const structured = [ + ["需求分析", task?.requirements_analysis], + ["Authoring CDSL", task?.authoring_cdsl], + ["编译审计", task?.compile_audit], + ["构建诊断", task?.diagnostics], ] as const; - if (!documents.some(([, markdown]) => markdown) && !task?.feature_nodes?.length) return null; + if (!structured.some(([, value]) => value) && !task?.completion_result_markdown) 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)} + {task?.repair_count !== undefined ?
修复
{task.repair_count} / {task.repair_budget ?? 2}
: null} + {structured.map(([title, value]) => value ?
{title}
: null)} + {task?.completion_result_markdown ?
完成报告{task.completion_result_markdown}
: null}
; } diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index 1735a15b..f4a9afff 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -1,9 +1,9 @@ "use client"; -import { AlertTriangle, Box, Check, Download, Eye, FileCheck, Loader2, RotateCcw, Search, Wrench } from "lucide-react"; +import { AlertTriangle, Box, Check, Download, FileCheck, Loader2, RotateCcw, Search } from "lucide-react"; import { encodeArtifactUrl } from "@/lib/cad-artifacts"; import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; -import { JsonTree, MarkdownDocument } from "./rich-content"; +import { MarkdownDocument } from "./rich-content"; export function TextPart({ text }: { text: string }) { if (!text.trim()) return null; @@ -15,26 +15,20 @@ export function CadProgressPart({ data }: { data: CadProgress }) { const isRunning = status === "running"; const isError = status === "error"; const isWaiting = status === "waiting"; - const statusLabel = isRunning ? "进行中" : isWaiting ? data.lifecycle === "waiting_retry" ? "等待重试" : "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status; - const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError || isWaiting ? AlertTriangle : Check; - const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []); + const statusLabel = isRunning ? "进行中" : isWaiting ? "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status; + const Icon = data.step === "repair_started" ? RotateCcw : data.step === "build_result" ? Box : data.step === "cdsl_compiled" ? Search : data.step === "requirements_ready" || data.step === "authoring_cdsl_ready" ? FileCheck : isError || isWaiting ? AlertTriangle : Check; const documentMarkdown = data.markdown || ""; return (
{isRunning ?
{data.message ?
{data.message}
: null} {data.questions?.length ?
    {data.questions.map((question, index) =>
  • {question}
  • )}
: null} {data.issues?.length ?
    {data.issues.map((issue, index) =>
  • {issue}
  • )}
: null} - {data.verificationWarnings?.length ?
验证风险 ({data.verificationWarnings.length})
    {data.verificationWarnings.map((warning, index) =>
  • {warning}
  • )}
: null} {documentMarkdown ?
文档内容{documentMarkdown}
: null} - {data.arguments ?
调用参数
: null} - {data.result !== undefined ?
执行结果
: null} - {evidence.length ?
证据 ({evidence.length})
    {evidence.map((item, index) =>
  • {item}
  • )}
: null}
); } @@ -59,7 +53,6 @@ export function CadResultPart({ data }: { data: CadResult }) { {data.referenceIds.length} 个参考
{data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} - {data.verificationWarnings?.length ?
验证风险{data.verificationWarnings.join(";")}
: null} {downloads.length ?
{downloads.map(([label, path]) => ( @@ -84,7 +77,6 @@ export function CadErrorPart({ data }: { data: CadError }) {
{data.message}
- {data.tool ? {data.tool} : null} {data.fieldErrors?.length ?
字段错误 ({data.fieldErrors.length})
    {data.fieldErrors.map((error, index) =>
  • {error.path || "/"}{error.message ? `: ${error.message}` : ""}
  • )}
: null}
); diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index 0c3c2256..3ec3a491 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -30,8 +30,6 @@ function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boo engine: current.engine || "cdsl_only", checkpoint, lifecycle: task.lifecycle || "completed", - verificationStatus: task.verification_status, - verificationWarnings: task.verification_warnings || [], }; } @@ -44,10 +42,7 @@ export function activeCheckpointPreview(task: TaskRecord | null): CadResult | nu export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null { if (!task) 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; + if (task.lifecycle === "failed" && !task.published_revision) 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-messages.ts b/frontend/src/lib/cad-messages.ts index a19be29e..b10cd1a0 100644 --- a/frontend/src/lib/cad-messages.ts +++ b/frontend/src/lib/cad-messages.ts @@ -62,7 +62,7 @@ export function restoreTaskProjection(messages: CadUIMessage[], task: TaskRecord )); if (alreadyVisible) return messages; const status = task.lifecycle === "failed" ? "error" - : task.lifecycle === "waiting_for_user" || task.lifecycle === "waiting_retry" ? "waiting" + : task.lifecycle === "waiting_for_user" ? "waiting" : task.lifecycle === "completed" ? "success" : "running"; const progress: CadProgress = { step, @@ -73,10 +73,7 @@ export function restoreTaskProjection(messages: CadUIMessage[], task: TaskRecord message: task.message || (terminal ? "CAD 任务已停止。" : "CAD 任务正在运行。"), questions: task.questions || [], issues: task.issues || [], - blockerType: task.blocker_type, userActionRequired: Boolean(task.user_action_required), - verificationStatus: task.verification_status, - verificationWarnings: task.verification_warnings || [], }; return [...messages, { id: `projection_${task.task_id}_${task.state_version || 0}`, diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index c1618a1e..01e2dd75 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -9,10 +9,18 @@ import type { CadUIMessage } from "./cad-types"; test("maps backend cad_result SSE into an AI SDK data part", () => { const chunk = backendEventToUiChunk({ event: "cad_result", - data: { taskId: "cad_abc", revisionId: "rev_001" }, + data: { + taskId: "cad_abc", revisionId: "rev_001", cdslPath: "model.cdsl.json", + stepPath: "model.step", glbPath: "model.glb", reportPath: "rebuild-report.json", + summary: "plate", referenceIds: [], engine: "cdsl_only", + }, }, "text_1"); assert.equal(chunk?.type, "data-cad-result"); - assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" }); + assert.deepEqual("data" in chunk! ? chunk.data : null, { + taskId: "cad_abc", revisionId: "rev_001", cdslPath: "model.cdsl.json", + stepPath: "model.step", glbPath: "model.glb", reportPath: "rebuild-report.json", + summary: "plate", referenceIds: [], engine: "cdsl_only", + }); }); test("keeps progressive revisions as separate data parts", () => { @@ -21,23 +29,13 @@ test("keeps progressive revisions as separate data parts", () => { assert.notEqual(first?.id, second?.id); }); -test("maps final repair review into a blocking progress state", () => { +test("maps a single-stage build repair into a blocking progress state", () => { const chunk = backendEventToUiChunk({ - event: "final_review", data: { taskId: "cad_abc", result: { status: "repair", evidence: ["missing round"] } }, + event: "build_result", data: { taskId: "cad_abc", status: "repair_required", message: "host face is ambiguous" }, }, "text_1"); assert.equal(chunk?.type, "data-cad-progress"); assert.deepEqual("data" in chunk! ? chunk.data : null, { - step: "final_review", label: "最终独立复核", status: "error", message: "", taskId: "cad_abc", result: { status: "repair", evidence: ["missing round"] }, - }); -}); - -test("maps a rejected independent candidate review into a blocking progress state", () => { - const chunk = backendEventToUiChunk({ - event: "candidate_review", data: { taskId: "cad_abc", result: { status: "rejected", evidence: ["base is disconnected"] } }, - }, "text_1"); - assert.equal(chunk?.type, "data-cad-progress"); - assert.deepEqual("data" in chunk! ? chunk.data : null, { - step: "candidate_review", label: "候选独立复核", status: "error", message: "", taskId: "cad_abc", result: { status: "rejected", evidence: ["base is disconnected"] }, + step: "build_result", label: "CAD 构建", status: "error", message: "host face is ambiguous", taskId: "cad_abc", }); }); @@ -46,7 +44,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { event: "cad_error", data: { stage: "generation", - tool: "compile_requirements_spec", + tool: "write_authoring_cdsl", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }, @@ -54,7 +52,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { assert.equal(chunk?.type, "data-cad-error"); assert.deepEqual("data" in chunk! ? chunk.data : null, { stage: "generation", - tool: "compile_requirements_spec", + tool: "write_authoring_cdsl", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }); @@ -124,17 +122,17 @@ test("keeps explicit runtime issues visible when generation stops", () => { }); }); -test("gives repeated tool events unique ordered parts", () => { - const first = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "running" } }, "text_1", 4); - const second = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "success" } }, "text_1", 5); +test("gives repeated build events unique ordered parts", () => { + const first = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", status: "repair_required" } }, "text_1", 4); + const second = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", status: "completed" } }, "text_1", 5); assert.notEqual(first?.id, second?.id); assert.equal("data" in first! ? (first.data as { sequence?: number }).sequence : null, 4); assert.equal("data" in second! ? (second.data as { sequence?: number }).sequence : null, 5); }); -test("reuses an invocation id so tool completion updates its running card", () => { - const running = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "running" } }, "text_1", 4); - const complete = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "success" } }, "text_1", 5); +test("reuses an event id for build card updates", () => { + const running = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", eventId: "build_1", status: "repair_required" } }, "text_1", 4); + const complete = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", eventId: "build_1", status: "completed" } }, "text_1", 5); assert.equal(running?.id, complete?.id); }); @@ -180,21 +178,6 @@ 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-stream.ts b/frontend/src/lib/cad-stream.ts index ef330a5d..faa72884 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -20,15 +20,11 @@ export function backendEventToUiChunk( data: { ...item.data, sequence }, }; } - if (["image_observation", "requirements_document_ready", "completion_target_ready", "requirements_compiled", "modeling_plan_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) { - const review = item.data.review && typeof item.data.review === "object" - ? item.data.review as Record - : null; + if (["requirements_ready", "authoring_cdsl_ready", "cdsl_compiled", "build_result", "repair_started", "task_terminal"].includes(item.event)) { const lifecycle = String(item.data.lifecycle || ""); const status = item.event === "task_terminal" - ? (lifecycle === "failed" ? "error" : lifecycle === "waiting_for_user" || lifecycle === "waiting_retry" ? "waiting" : "success") - : String((item.data.result as Record | undefined)?.status || "") === "rejected" - || String((item.data.result as Record | undefined)?.status || "") === "repair" + ? (lifecycle === "failed" ? "error" : lifecycle === "waiting_for_user" ? "waiting" : "success") + : String(item.data.status || "") === "repair_required" ? "error" : String(item.data.status || "running"); const taskId = String(item.data.taskId || "task"); @@ -37,37 +33,25 @@ export function backendEventToUiChunk( ? { eventId, sequence, - ...(review ? { review } : {}), } : {}; return { type: "data-cad-progress", id: `event_${eventId}`, data: { step: item.event, label: ({ - image_observation: "参考图片观察", requirements_document_ready: "需求文档已冻结", completion_target_ready: "完成目标已冻结", requirements_compiled: "需求合同已编译", modeling_plan_ready: "建模计划已冻结", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务", + requirements_ready: "需求分析", authoring_cdsl_ready: "完整 CDSL", cdsl_compiled: "CDSL 编译", build_result: "CAD 构建", repair_started: "CDSL 修复", task_terminal: "生成任务", } as Record)[item.event], status, ...metadata, message: String( item.data.message || item.data.reason - || (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : "") - || (review?.evidence instanceof Array ? review.evidence.join(";") : "") - || (review?.issues instanceof Array ? review.issues.map((issue) => typeof issue === "object" && issue ? String((issue as Record).message || "") : String(issue)).filter(Boolean).join(";") : ""), + || (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : ""), ), ...(item.data.taskId ? { taskId } : {}), - ...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}), ...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}), ...(Array.isArray(item.data.questions) ? { questions: item.data.questions.map(String).filter(Boolean) } : {}), ...(Array.isArray(item.data.issues) ? { issues: item.data.issues.map(String).filter(Boolean) } : {}), - ...(item.data.blockerType ? { blockerType: String(item.data.blockerType) } : {}), ...(typeof item.data.userActionRequired === "boolean" ? { userActionRequired: item.data.userActionRequired } : {}), - ...(item.data.verificationStatus ? { verificationStatus: String(item.data.verificationStatus) } : {}), - ...(Array.isArray(item.data.verificationWarnings) ? { verificationWarnings: item.data.verificationWarnings.map(String).filter(Boolean) } : {}), ...(item.data.clarificationPath ? { clarificationPath: String(item.data.clarificationPath) } : {}), ...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}), ...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}), - ...(item.data.tool ? { tool: String(item.data.tool) } : {}), - ...(item.data.invocationId ? { invocationId: String(item.data.invocationId) } : {}), - ...(item.data.arguments && typeof item.data.arguments === "object" ? { arguments: item.data.arguments as Record } : {}), - ...(item.data.result !== undefined ? { result: item.data.result } : {}), - ...(Array.isArray(item.data.evidence) ? { evidence: item.data.evidence.map(String) } : {}), }, }; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 7dc98db1..7f0be71b 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -10,25 +10,12 @@ export type CadProgress = { message?: string; markdown?: string; taskId?: string; - nodeId?: string; - tool?: string; - invocationId?: string; - arguments?: Record; - result?: unknown; - evidence?: string[]; questions?: string[]; issues?: string[]; - blockerType?: string; userActionRequired?: boolean; - verificationStatus?: "verified" | "completed_with_risks" | string; - verificationWarnings?: string[]; - review?: Record; clarificationPath?: string; - lifecycle?: "running" | "completed" | "failed" | "waiting_retry" | "waiting_for_user" | string; - attempt?: number; - maxAttempts?: number; + lifecycle?: "running" | "completed" | "failed" | "waiting_for_user" | string; path?: string; - contractHash?: string; }; export type CadResult = { @@ -43,8 +30,6 @@ export type CadResult = { engine: string; checkpoint?: boolean; lifecycle?: "running" | "completed" | "failed" | string; - verificationStatus?: "verified" | "completed_with_risks" | string; - verificationWarnings?: string[]; }; export type CadError = { @@ -97,12 +82,6 @@ export type TaskRevision = { engine?: string; error?: string; visibility?: "checkpoint" | "final" | "superseded" | string; - parent_revision_id?: string; - branch_id?: string; - step_review_path?: string; - candidate_review_path?: string; - render_manifest_path?: string; - visual_review_path?: string; }; export type TaskRecord = { @@ -111,71 +90,34 @@ export type TaskRecord = { current_revision: string; active_revision?: string; published_revision?: string; - lifecycle?: "running" | "completed" | "failed" | "cancelled" | "waiting_retry" | "waiting_for_user" | string; + lifecycle?: "running" | "completed" | "failed" | "cancelled" | "waiting_for_user" | string; phase?: string; state_version?: number; - active_candidate_id?: string; - requirements_spec?: Record | null; - requirements_spec_path?: string; + repair_count?: number; + repair_budget?: number; + requirements_path?: string; + authoring_path?: string; + runtime_cdsl_path?: string; + compile_audit_path?: string; + diagnostics_path?: string; + completion_path?: string; clarification_path?: string; - requirements_contract?: Record | null; - requirements_contract_path?: string; - requirements_markdown?: string | null; - requirements_document_path?: string; - completion_target_markdown?: string | null; - completion_target_path?: string; - feature_plan?: { + requirements_analysis?: Record | null; + authoring_cdsl?: Record | null; + runtime_cdsl?: Record | null; + compile_audit?: Record | null; + diagnostics?: Record | null; + claim_report?: { schema_version?: string; - parent_plan_hash?: string; - replaces_node_ids?: string[]; - nodes?: Array>; - final_claim_ids?: string[]; + claims?: Array<{ target?: string; status?: "pass" | "fail" | "pending" | "not_applicable" | string; verification?: 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<{ - requirement_id: string; - claim_id: string; - claim_kind: string; - deterministic: boolean; - status: "pass" | "pending" | "fail" | "unavailable" | string; - evidence?: Record; - }>; - checklist_progress?: Array<{ - requirement_id: string; - statement: string; - status: "pass" | "pending" | "fail" | string; - }>; - pending_action?: { action_id: string; working_head: string; intent: string; requirement_ids: string[]; atomic_id: string; expected_change: string; contract_hash: string } | null; - action_ledger_summary?: Array>; - usage?: { calls: number; prompt_tokens: number; completion_tokens: number; context_chars: number }; + usage?: { calls: number; records: Array> }; preview_revision?: string; message?: string; questions?: string[]; issues?: string[]; - blocker_type?: string; user_action_required?: boolean; - verification_status?: "verified" | "completed_with_risks" | string; - verification_warnings?: string[]; revisions: TaskRevision[]; }; @@ -191,6 +133,4 @@ export type BackendConfig = { configured: boolean; library_samples: number; autonomous_generation?: boolean; - review_configured?: boolean; - review_error?: string; }; -- 2.52.0 From 21a51859267e74e7d239b3e9e3337d4b7a54ca4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BA=B7?= Date: Wed, 9 Sep 2026 18:41:56 +0800 Subject: [PATCH 10/10] integrate ganjihong engine refactor --- .gitignore | 1 + AGENTS.md | 101 + backend/.env | 15 +- backend/README.md | 57 +- backend/agent/skills/cad-authoring/SKILL.md | 165 + .../00-author-contract.md | 1 - .../01-brief-and-assumptions.md | 1 - .../02-parameters-and-derived-dimensions.md | 1 - .../03-coordinate-system-and-datums.md | 1 - .../04-construction-and-feature-order.md | 1 - .../05-profiles-workplanes-and-cuts.md | 1 - ...-hosted-features-selectors-and-topology.md | 1 - .../07-patterns-symmetry-and-repetition.md | 1 - .../08-finishing-and-boolean-risk.md | 1 - ...9-evidence-visual-review-and-validation.md | 1 - .../10-repair-and-best-effort.md | 1 - .../skills/cdsl-author-guidance/README.md | 63 - .../skills/cdsl-author-guidance/manifest.json | 82 - .../skills/cdsl-author-guidance/op-bend.md | 1 - .../cdsl-author-guidance/op-extrude-add.md | 1 - .../cdsl-author-guidance/op-extrude-cut.md | 1 - .../skills/cdsl-author-guidance/op-finish.md | 1 - .../skills/cdsl-author-guidance/op-gear.md | 1 - .../skills/cdsl-author-guidance/op-hole.md | 1 - .../skills/cdsl-author-guidance/op-loft.md | 1 - .../skills/cdsl-author-guidance/op-pattern.md | 1 - .../cdsl-author-guidance/op-primitives.md | 1 - .../cdsl-author-guidance/op-reference.md | 1 - .../skills/cdsl-author-guidance/op-revolve.md | 1 - .../skills/cdsl-author-guidance/op-sphere.md | 1 - .../skills/cdsl-author-guidance/op-thread.md | 1 - backend/app/cad_agent/__init__.py | 4 +- backend/app/cad_agent/adapters/__init__.py | 2 +- .../app/cad_agent/adapters/artifact_store.py | 114 +- .../app/cad_agent/adapters/author_guidance.py | 191 -- .../app/cad_agent/adapters/event_publisher.py | 2 +- .../app/cad_agent/adapters/review_gateway.py | 64 - backend/app/cad_agent/adapters/runtime.py | 1014 +------ .../cad_agent/adapters/sqlite_repository.py | 445 +-- .../app/cad_agent/adapters/structured_llm.py | 37 +- backend/app/cad_agent/adapters/verifier.py | 19 - backend/app/cad_agent/application/__init__.py | 2 +- .../cad_agent/application/action_handlers.py | 1529 ---------- .../application/authoring_compiler.py | 300 ++ .../application/authoring_contract.py | 169 ++ .../application/authoring_guidance.py | 16 + .../app/cad_agent/application/capabilities.py | 53 +- .../cad_agent/application/llm_contracts.py | 412 --- .../app/cad_agent/application/requirements.py | 670 ---- backend/app/cad_agent/application/results.py | 29 - .../app/cad_agent/application/single_stage.py | 99 + backend/app/cad_agent/application/workflow.py | 2692 +++-------------- backend/app/cad_agent/composition.py | 68 +- backend/app/cad_agent/domain/__init__.py | 2 +- .../app/cad_agent/domain/claim_matching.py | 20 - backend/app/cad_agent/domain/errors.py | 26 +- backend/app/cad_agent/domain/feature_plan.py | 265 -- .../cad_agent/domain/operation_contract.py | 160 +- backend/app/cad_agent/domain/state.py | 255 +- backend/app/cad_agent/evals/TOKEN_BASELINE.md | 21 - backend/app/cad_agent/evals/__init__.py | 2 +- .../cad_agent/evals/create_isolated_task.py | 8 +- .../evals/fixtures/comprehensive.json | 423 --- .../app/cad_agent/evals/fixtures/release.json | 78 - backend/app/cad_agent/evals/live.py | 990 ------ .../app/cad_agent/evals/resume_one_step.py | 90 - backend/app/cad_agent/evals/single_stage.py | 55 + backend/app/cad_agent/evals/token_baseline.py | 297 -- backend/app/cad_agent/evals/usable_smoke.py | 164 - backend/app/cad_agent/ports.py | 84 +- backend/app/main.py | 145 +- backend/app/services/agent_service.py | 545 +--- backend/app/services/engine_service.py | 20 +- .../{review_renderer.py => render_bundle.py} | 52 +- backend/app/services/storage.py | 4 +- backend/app/settings.py | 41 - .../engine/cdsl_engine/build123d_adapter.py | 51 +- backend/engine/cdsl_engine/capabilities.py | 23 +- backend/engine/cdsl_engine/cdsl_schema.json | 59 +- .../engine/cdsl_engine/executors/common.py | 72 +- .../cdsl_engine/executors/primitives.py | 4 +- backend/engine/cdsl_engine/runtime_types.py | 2 + .../engine/cdsl_engine/semantic_validation.py | 79 +- backend/engine/cdsl_engine/session.py | 9 +- backend/engine/cdsl_engine/topology.py | 225 ++ backend/tests/test_agent_service.py | 80 + backend/tests/test_author_guidance.py | 158 - backend/tests/test_authoring_contract.py | 81 + backend/tests/test_authoring_runtime.py | 219 ++ backend/tests/test_cad_agent_v3.py | 1117 ------- .../test_engine_extrude_draft_contract.py | 120 +- .../tests/test_engine_runtime_foundation.py | 98 +- backend/tests/test_feature_plan.py | 357 --- .../tests/test_live_guidance_comparison.py | 71 - ...view_renderer.py => test_render_bundle.py} | 4 +- backend/tests/test_settings.py | 21 - backend/tests/test_single_stage.py | 21 + backend/tests/test_single_stage_evals.py | 27 + backend/tests/test_single_stage_workflow.py | 180 ++ .../CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md | 139 + cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md | 10 +- cadfs_to_cdsl/featurescript_parser.py | 14 +- cadfs_to_cdsl/ir.py | 4 + cadfs_to_cdsl/lowering.py | 72 +- cadfs_to_cdsl/query_parser.py | 35 +- cadfs_to_cdsl/selector_binding.py | 12 + cadfs_to_cdsl/tests/test_lowering.py | 24 +- cadfs_to_cdsl/tests/test_parser.py | 10 + cadfs_to_cdsl/tests/test_selector_binding.py | 12 +- frontend/src/components/agent-studio.tsx | 50 +- frontend/src/components/cad-message-parts.tsx | 16 +- frontend/src/lib/cad-artifacts.ts | 7 +- frontend/src/lib/cad-messages.ts | 5 +- frontend/src/lib/cad-stream.test.ts | 59 +- frontend/src/lib/cad-stream.ts | 26 +- frontend/src/lib/cad-types.ts | 96 +- 116 files changed, 3596 insertions(+), 12259 deletions(-) create mode 100644 backend/agent/skills/cad-authoring/SKILL.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/00-author-contract.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/README.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/manifest.json delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-bend.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-extrude-add.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-finish.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-gear.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-hole.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-loft.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-pattern.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-primitives.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-reference.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-revolve.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-sphere.md delete mode 100644 backend/agent/skills/cdsl-author-guidance/op-thread.md delete mode 100644 backend/app/cad_agent/adapters/author_guidance.py delete mode 100644 backend/app/cad_agent/adapters/review_gateway.py delete mode 100644 backend/app/cad_agent/adapters/verifier.py delete mode 100644 backend/app/cad_agent/application/action_handlers.py create mode 100644 backend/app/cad_agent/application/authoring_compiler.py create mode 100644 backend/app/cad_agent/application/authoring_contract.py create mode 100644 backend/app/cad_agent/application/authoring_guidance.py delete mode 100644 backend/app/cad_agent/application/llm_contracts.py delete mode 100644 backend/app/cad_agent/application/requirements.py delete mode 100644 backend/app/cad_agent/application/results.py create mode 100644 backend/app/cad_agent/application/single_stage.py delete mode 100644 backend/app/cad_agent/domain/claim_matching.py delete mode 100644 backend/app/cad_agent/domain/feature_plan.py delete mode 100644 backend/app/cad_agent/evals/TOKEN_BASELINE.md delete mode 100644 backend/app/cad_agent/evals/fixtures/comprehensive.json delete mode 100644 backend/app/cad_agent/evals/fixtures/release.json delete mode 100644 backend/app/cad_agent/evals/live.py delete mode 100644 backend/app/cad_agent/evals/resume_one_step.py create mode 100644 backend/app/cad_agent/evals/single_stage.py delete mode 100644 backend/app/cad_agent/evals/token_baseline.py delete mode 100644 backend/app/cad_agent/evals/usable_smoke.py rename backend/app/services/{review_renderer.py => render_bundle.py} (91%) create mode 100644 backend/tests/test_agent_service.py delete mode 100644 backend/tests/test_author_guidance.py create mode 100644 backend/tests/test_authoring_contract.py create mode 100644 backend/tests/test_authoring_runtime.py delete mode 100644 backend/tests/test_cad_agent_v3.py delete mode 100644 backend/tests/test_feature_plan.py delete mode 100644 backend/tests/test_live_guidance_comparison.py rename backend/tests/{test_review_renderer.py => test_render_bundle.py} (93%) create mode 100644 backend/tests/test_single_stage.py create mode 100644 backend/tests/test_single_stage_evals.py create mode 100644 backend/tests/test_single_stage_workflow.py create mode 100644 cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md diff --git a/.gitignore b/.gitignore index 0747fbf3..d7fa50d5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ build/ # Runtime data and generated local artifacts backend/data/ backend/live-evals/ +cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md data/cadfs-sample/ json_to_cdsl/input/ onshape_to_cdsl/input/ diff --git a/AGENTS.md b/AGENTS.md index 67adbaf4..001202bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,3 +29,104 @@ ## 改动评审指引 修改本系统时,优先保证执行确定性、部分结果可持久化、失败可诊断,以及终止行为有边界。避免加入语义审查关卡、要求复制 ID 的协议,或会在没有改善可执行模型的情况下无限消耗调用的重试机制。 + +## CADFS 全量能力开发 + +CADFS 到 CDSL 到 STEP 的工作是在实现通用绘图引擎和通用 converter,不是为回归样本 +编写修复脚本。能力实现必须以 FeatureScript 语义、显式 CDSL contract、body +生命周期和内核拓扑为边界,并对同类输入普遍成立。 + +- 禁止按 `sample_id`、路径、特定 feature ID、特定坐标、尺寸或 gold STEP 测量值分支; + 禁止从原 STEP 回填 FeatureScript 未提供的参数,或为某模型硬编码 selector、profile、 + 偏移量、布尔策略和默认尺寸。 +- 不得将不支持或不唯一的语义伪装成盲拉伸、当前 body、默认 union、任意相近拓扑元素 + 或静默跳过。必须保留最佳可执行前缀和 STEP,并给出稳定、可归因的能力诊断。 +- 优先在 schema、semantic validation、runtime state、body graph 和 adapter 的 + kernel-level topology delta 中实现能力;lowering 层只能表达源语义,不能承担样本化 + 的几何补丁。受限算法必须有通用、可验证的适用条件和拒绝路径。 +- 一项能力只有在 lowering、CDSL contract、runtime/adapter、selector/body 语义、 + 单元测试和多个真实语料回归均具备后才能标为完成。操作名称覆盖不等于参数、拓扑来源、 + 几何类型或 body 生命周期覆盖。 +- 严格比较用于诊断;工程验收遵循 `rp.passed`。不得降低 RP 阈值、关闭诊断、跳过 + feature 或修改 source 参数来换取通过。source STEP 损坏或历史/STEP 精度不一致必须 + 作为有证据的 source exception 单独报告。 +- 每次涉及 CADFS lowering、CDSL schema、runtime、adapter、selector 或测试的改动, + 都必须同步更新 `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`;全量能力 + 计划以 `cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md` 为准。 + +## CADFS 修改、文档与回归纪律 + +以下规则适用于每一次 CADFS converter、CDSL engine、selector、比较器、回归集或相关 +测试的修改;它们是仓库规则,不依赖当前对话上下文。 + +### 修改前与实现方式 + +- 先用完整 FeatureScript history、`candidate.cdsl.json`、`bound.cdsl.json`、 + `diagnostics.json`、`rebuild.json`、`comparison.json` 和 source/rebuild 工件定位失败层: + converter/lowering、schema/semantic validation、selector binding、runtime/adapter、 + source STEP/FeatureScript 不一致,或 comparison infrastructure。没有证据不得把失败 + 归因于任一层,也不得先改阈值或样本数据。 +- 修改必须遵循目标模块已有的命名、分层、格式、注释语言和错误处理模式。优先扩展既有 + contract、helper 和架构边界;没有明确收益时不得进行无关重构或引入平行实现。 +- 先实现可复用的几何和拓扑语义,再将 CADFS source lower 到该 contract。任何只为单个 + 形状、数值、feature history 或截图成立的逻辑均视为缺陷,不得提交。 +- 内核无法完成某个输入时,保留有界的失败和诊断;不得用较小的 dress-up 半径、替代孔型、 + 固定 extent、隐式 fuse 或未经来源证明的几何补偿伪造结果。 +- 当前实现若被证明确实违反 source/contract 语义、无法表达全量已出现的通用输入、 + 受内核 API 的结构性限制,或反复需要样本化补丁,应停止继续叠加补丁并评估替代方案。 + 替换前必须具备可复现失败、根因证据、与成熟开源/官方 API 或最小原型的对照、对 + CDSL/body/selector 兼容性的影响评估,以及受影响回归的迁移计划。 +- 不得因单个样本、偶发 OCC 失败、一次性能波动或主观偏好轻易重写成熟路径。只有新方案 + 能以更少特例、更完整的通用语义和可验证的回归证据解决结构性问题时,才替换旧方案; + 替换过程中保留旧工件和比较基线,分阶段迁移并记录回滚边界。 + +### 文档同步 + +- 每次实现、修复、扩展或确认某项 CADFS 能力后,必须在同一工作变更中更新 + `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`:记录能力状态、通用语义、 + 已验证边界、未覆盖边界、受影响样本/能力矩阵和测试/比较证据;完成项必须勾选, + 未完成项不得因单个样本通过而勾选。 +- 上述 `.local.md` 是本地工作台账,必须保持在 `.gitignore` 中,绝不加入 Git 提交。 + 每次 CADFS 相关代码变更后都要同步,即使最终只得到失败诊断或发现 source exception。 +- 新增能力、能力范围、验收口径、全量快照或实施优先级变化时,同步更新受版本控制的 + `cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md`。它定义全量能力矩阵和路线; + `CADFS_RECONSTRUCTION_TARGET.md` 记录核心回归与当前近期目标,两者不得冲突。 +- 从全量 output 发现新的操作、参数变体、拓扑来源、body lifecycle 或 comparison failure + 类型时,必须先登记为待办和能力矩阵条目,再选择多个真实样本回归;不得只加入一个 + “代表模型”就宣称覆盖完成。 +- 文档中的计数、样本 ID、状态和通过标准必须来自当前工件。必须区分 strict、RP 工程相似、 + 几何拒绝、可执行前缀、转换/运行时失败、比较超时和 source exception。 + +### 回归与可视证据 + +- 每次代码修改至少运行受影响的原子/单元测试和真实语料样本;涉及共享 runtime、 + selector、body lifecycle 或 comparison 时,还必须运行核心 17、相应扩展能力矩阵和 + 可控的全量 shard。报告未运行的范围和原因,不能将缓存旧工件当作新代码的证据。 +- 对用户要求查看或人工判定的样本,生成并保留 source STEP 与 rebuild STEP 的一致视角 + 对比截图;截图只辅助人工审查,最终分类仍以完整 history、B-rep 比较和诊断为准。 +- 任何失败都保留最后可执行 STEP、GLB(如可生成)、comparison/diagnostic 工件和前缀 + 信息。不可执行或不相似不允许清理、覆盖或隐藏已有可用工件。 +- 提交前执行与改动比例相称的测试、`git diff --check`,并确认本地能力台账未被 staged。 + 除非用户明确要求,不得提交、push、覆盖用户未提交修改或变更回归基线。 + +### 开源实现与 SimpleCADAPI 参考 + +- 在自行设计 shell、sweep、loft、boolean、fillet/chamfer、transform、topology tracking、 + body graph 或比较基础设施前,必须先检索成熟的开源实现、官方 OCCT/OCP API 和已有 + 项目依赖;优先复用经过测试的算法、内核调用模式或小范围实现,而不是重新发明基础 + B-rep 算法。外部代码的许可证、版本兼容性、异常语义和维护状态必须先核实。 +- 本地首选参考是 `/Users/lk/Downloads/SimpleCADAPI-master 4`。重点阅读其 + `src/simplecadapi/topology/tracking.py`、`kernel/ocp_booleans.py`、 + `kernel/ocp_topology.py`、`kernel/ocp_transforms.py` 及对应 tests/docs:其中的 + OCC builder history、`Modified`/`Generated`/`IsDeleted`、section edges、same-domain + cleanup、显式 shape transform、shell/sweep/loft 调用边界是当前 engine 的优先参考。 +- 借鉴必须经过本项目的 CDSL schema、runtime body graph、selector provenance 和 + 回归测试适配。不得直接替换本项目的 CDSL contract,也不得照搬其“强制单一 Solid” + 的 union/cut 语义,因为 CADFS 需要保留独立 body、copy、keep tools 和 pattern + instance 生命周期。 +- 每次因开源参考新增或调整能力时,在本地能力台账记录参考来源、采用的通用语义、 + 未采用部分及理由、许可证/依赖影响和本项目回归证据。无法安全采用时,也应记录 + 评估结论,避免后续重复实现或重复调研。 +- 发现现有方案方向错误或外部方案明显更适合时,可替换而不是继续打补丁;但必须满足 + “修改前与实现方式”中的结构性根因和对照验证门槛,并在台账记录替换理由、迁移影响、 + 保留的 contract、回归结果和可回滚边界。 diff --git a/backend/.env b/backend/.env index a92ffe62..08eb7c71 100644 --- a/backend/.env +++ b/backend/.env @@ -2,7 +2,7 @@ # CDSL_DEFAULT_PROVIDER=deepseek # CDSL_DEFAULT_MODEL=deepseek-v4-flash CDSL_DEFAULT_PROVIDER=openai -CDSL_DEFAULT_MODEL=gpt-5.4-mini +CDSL_DEFAULT_MODEL=gpt-5.5 # DeepSeek. Fill in your own API key below. CDSL_LLM_BASE_URL=https://api.deepseek.com/v1 @@ -11,9 +11,6 @@ CDSL_LLM_MODEL=deepseek-v4-flash,deepseek-v4-pro,deepseek-v4-flash-vision-exp CDSL_LLM_TIMEOUT_S=90 CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp -# Final autonomous-task publication uses this independent vision reviewer. -CDSL_REVIEW_PROVIDER=openai -CDSL_REVIEW_MODEL=gpt-5.5 # Optional OpenAI provider. Comma-separate enabled models; list vision models # separately so image attachments can be routed safely. @@ -30,13 +27,3 @@ CDSL_KIMI_BASE_URL=https://api.moonshot.cn/v1 CDSL_KIMI_API_KEY= CDSL_KIMI_MODELS=moonshot-v1-8k CDSL_KIMI_VISION_MODELS= - -# Autonomous CDSL agent limits. These protect an active modelling head, not -# the total feature count of a CAD task. -CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12 -CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3 -CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6 -CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT=3 -CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6 -CDSL_AGENT_CONTEXT_CHAR_LIMIT=14000 -CDSL_AGENT_RENDER_CACHE=true diff --git a/backend/README.md b/backend/README.md index d2b5371d..2aac94a7 100644 --- a/backend/README.md +++ b/backend/README.md @@ -20,53 +20,36 @@ CDSL_OPENAI_REASONING_EFFORT=medium ``` Use `low`, `medium`, or `high` according to the latency/cost versus quality -tradeoff. The setting is sent as Chat Completions' `reasoning_effort` field to -authoring, streaming, and visual-review requests. Leave it empty to use the +tradeoff. The setting is sent as the provider's `reasoning_effort` field to +requirements analysis and Authoring CDSL generation. Leave it empty to use the provider/model default. The selected OpenAI-compatible endpoint must support the requested value. ## Autonomous CDSL Agent Configuration -The autonomous agent writes one frozen free-form `requirements.md`, then -observes, measures, renders and appends one CDSL feature at a time. Its author -uses normal function calls; no provider strict JSON Schema capability or -complete modelling DAG is required. Candidate fragments are rebuilt in a -staging directory through `cdsl_only` before a checkpoint can be committed. +Each task has one bounded workflow: -Final publication requires a separately configured vision-capable review model -and the Python OpenCascade/Pillow technical renderer. The agent may build and -inspect intermediate checkpoints without image review; a final run fails -closed if its independent review configuration is unavailable. +```text +request analysis -> complete cad.author.v1 -> server compilation -> runtime build +-> at most two complete repairs -> final or best-effort publication +``` + +The model outputs only local body/feature names and declarative selectors. +The server validates strict schemas, allocates Runtime CDSL identities, +compiles references, executes dependencies, and preserves the last executable +prefix. STEP is the primary artifact; GLB and the CPU-only OpenCascade/Pillow +render bundle are generated from the same published revision. ```dotenv -# Must name one configured provider and one model listed in that provider's -# CDSL__VISION_MODELS setting. It is intentionally not inferred -# from the authoring model. -CDSL_REVIEW_PROVIDER=deepseek -CDSL_REVIEW_MODEL=deepseek-v4-flash-vision-exp -CDSL_DEEPSEEK_VISION_MODELS=deepseek-v4-flash-vision-exp - # Install Python rendering dependencies. The renderer reads the revision STEP # file and creates canonical images without a browser or GPU driver. pip install -r requirements.txt - -# Limits apply to the current checkpoint head, never to total task complexity. -CDSL_AGENT_TOOL_CALLS_PER_CYCLE=12 -CDSL_AGENT_CANDIDATE_ATTEMPTS_PER_HEAD=3 -CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT=6 -CDSL_AGENT_MAX_FEATURES_PER_FRAGMENT=6 -CDSL_AGENT_CONTEXT_CHAR_LIMIT=24000 -CDSL_AGENT_RENDER_CACHE=true ``` -The author chooses each coherent 1-6 feature batch. Every rebuilt batch is -rendered and independently reviewed before it can become a checkpoint; only -an accepted reviewer verdict advances the working model. Every checkpoint is rebuilt from its fully materialized CDSL through the -`cdsl_only` runtime. Checkpoint GLB files are preview-only; STEP, CDSL, and -reports are available only after the task reaches `COMPLETED`. - -The backend assigns feature and sketch IDs, appends causal dependencies and -expands only opaque current-snapshot selector tokens. It does not compile -geometry templates or correct workplanes, profiles, sizes, directions or -boolean semantics authored by the model. Failed candidates remain auditable -but never become revisions. +The initial generation plus two repairs are the only model calls allowed after +requirements analysis. A repair returns a complete replacement Authoring CDSL +and may only alter diagnosed features. Runtime selector ambiguity, missing +selectors, and unavailable dependencies produce stable diagnostics rather than +topology guesses. Requirement compliance is reported independently as +`pass`, `fail`, `pending`, or `not_applicable`; a partial executable model is +still published after the repair budget is exhausted. diff --git a/backend/agent/skills/cad-authoring/SKILL.md b/backend/agent/skills/cad-authoring/SKILL.md new file mode 100644 index 00000000..5644d30b --- /dev/null +++ b/backend/agent/skills/cad-authoring/SKILL.md @@ -0,0 +1,165 @@ +# Authoring CDSL Modeling Guide + +Write one complete `cad.author.v1` document. It is declarative source for a +server compiler, not the runtime CDSL and not an execution log. Return only +the schema-valid object requested by the tool. + +## Contract Boundary + +Use lower-case local `name` values for bodies and features. They are symbols +within this document only. Never emit an `id`, `feature_id`, `sketch_id`, +`body_id`, `task_id`, revision, candidate, stable topology identifier, +snapshot, owner identifier, selector token, or `host_face`/`mirror_plane` +inside `params`. The server allocates identities and injects selector values +into the destination stated by the operation contract. + +Use millimetres and a right-handed coordinate system unless the request says +otherwise. Put every reasonable but unstated design choice in `assumptions`. +Do not turn an assumed dimension into a deterministic acceptance target. + +## Modeling Brief + +Before authoring, derive this internal brief from the requirements: + +- part or multi-body intent; explicit dimensions and units; +- functional datums, origin, base plane, and positive directions; +- primary volumes, holes, pockets, bosses, ribs, patterns, and finishing; +- explicitly verifiable targets versus manual targets; +- assumptions that do not affect fit, safety, or compliance. + +Dimensioned request facts take precedence over proportions inferred from an +image. Ask for clarification only when a missing interface, scale, safety, or +compliance value makes construction impossible. Otherwise choose a practical +engineering default and record it as an assumption. + +## Construction Order + +Choose the simplest supported construction whose parameters directly express +the requested dimensions. Use a stable order: + +1. establish the body and functional coordinate frame; +2. create primary additive volume(s); +3. create any selector-hosted feature before its named source output is + changed by a fuse, cut, shell, pattern, or finishing operation; +4. add remaining bosses, ribs, and other major additive geometry; +5. make remaining pockets, bores, and through features; +6. apply patterns, then fillets and chamfers last. + +Every feature must list each feature it actually uses in `depends_on`. +Prefer one complete profile-driven feature for a planar silhouette. Use +primitives when their axis, radius, and height directly express the part. +For through cuts, choose the operation's through extent and make the tool +cross the material; never rely on coincident faces or a guessed nearby face. +Delay dress-up operations because they can alter downstream topology. + +The selector-source rule is an intentional exception to a generic “all adds, +then all cuts” sequence. For example, a bolt circle hosted on an original +flange cap must be placed immediately after the base when later additive +fusions replace that exact cap. A required final through-cut can still follow +all additive features and the earlier bolt operation. + +Use only the supplied operation list. Follow each operation's parameter schema +exactly, including required sketch state. Do not invent unsupported operation +parameters, implicit booleans, or a substitute operation after a capability +error. + +## Sketches And Coordinates + +Keep a sketch to exactly `workplane` and `profile`. The workplane declares its +origin, `x_dir`, and normal. Profile coordinates are local to that workplane. +For primitive axes, `origin_mm` is the start-cap center and `direction` is the +positive build direction. For a selector-hosted hole, position coordinates are +world coordinates unless the operation contract explicitly says otherwise. + +The Authoring sketch syntax is deliberately smaller than Runtime CDSL. For +every sketch operation, emit exactly this shape. `profile` is singular, +circles use the requested `diameter_mm`, and the local center is `center_mm`: + +```json +{ + "workplane": { + "origin_mm": [0, 0, 12], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1] + }, + "profile": { + "type": "circle", + "diameter_mm": 56, + "center_mm": [0, 0] + } +} +``` + +Do not write `profiles`, `plane`, `support`, `radius_mm`, `center`, or any +other key inside an Authoring sketch. The compiler derives Runtime radius and +sketch identity. Use a primitive such as `cylinder_add` when its axis, radius, +and height directly express the requested geometry and no sketch is needed. + +Name features for their manufacturing role, for example `base_plate`, +`front_hub_boss`, `center_bore`, and `bolt_holes`. Names make dependencies and +repair diagnostics readable; they are not server identities. + +## Selectors + +The operation metadata states whether `selectors` are required, their kind, +cardinality, and server-side destination. Put only declarative selectors in a +feature's `selectors` array. Never write the destination field itself inside +`params`. + +For an output face, use an exact local role embedded in `source` and state a +unique match: + +```json +{ + "kind": "face", + "source": "front_hub_boss.top_planar_face", + "match": "unique" +} +``` + +Do not add `role`, `query`, `host_face`, a face index, a coordinate selector, +or a Runtime selector token. The `source` value is the complete local intent. +The compiler adds its source feature as an auditable graph dependency; include +other true construction dependencies in `depends_on` yourself. + +`top_planar_face` and `end_face` mean the positive-direction cap of a supported +extrude, sweep, loft, or cylinder. `bottom_planar_face` and `start_face` mean +the opposite cap. Select the most recent feature whose output is known to be +the required host; do not select a similar face by location, face index, or +proximity. + +Translate a request's descriptive `max_z`/`min_z` wording into these output +roles before writing CDSL. Never emit `base.max_z_face` or `base.min_z_face`. +For a cylinder built in `+Z`, the top cap is `top_planar_face` and the bottom +cap is `bottom_planar_face`; reverse-direction features swap their world-Z +position but retain their own start/end roles. + +`hole_wizard` requires exactly one `face` selector. It uses that selector as +its host face, so a central bore on a hub should select the hub cap, while a +bolt circle on an exposed flange should select the flange cap. A selector must +name a host that contains every requested hole position. If that cannot be +made unique, redesign the feature sequence or omit the unsupported feature; +never guess a face. + +Before creating a hosted hole, calculate each position against the actual host +face. A local boss can be the global highest face while being too small to host +a larger bolt circle. In that case use the exposed flange cap at the bolt +radius, with its own plane height, rather than the global maximum-Z cap. Place +the hosted holes while that source cap's exact provenance is still active: +before a later fusion or cut would split, remove, or replace it. A final +through-cut may still follow all additive features, so a bolt circle can be +hosted before an unrelated final boss and before that final cut. + +## Acceptance And Repair + +Describe only user-requested, measurable acceptance targets in +`acceptance_targets`; leave inferred dimensions in `assumptions`. On repair, +return a complete replacement document. Preserve feature names and every +feature listed in `executed_feature_ids`, except a feature explicitly named by +the diagnostic. Features that were never executed may be changed freely to +repair an invalid selector, parameter, dependency, or geometry construction. + +Read structured diagnostics literally. Fix their named cause with the smallest +document change, then return the entire document. Do not add internal IDs, +weaken a requested value, silently delete a failed feature, or replace a +failed selector with an arbitrary topology element. diff --git a/backend/agent/skills/cdsl-author-guidance/00-author-contract.md b/backend/agent/skills/cdsl-author-guidance/00-author-contract.md deleted file mode 100644 index 32fe74e1..00000000 --- a/backend/agent/skills/cdsl-author-guidance/00-author-contract.md +++ /dev/null @@ -1 +0,0 @@ -以当前 schema、`operation_contract` 与 topology token 为准;每次只执行一个原子操作,不编造字段或选择器。数值须为有限 mm/deg。保留最后可执行 checkpoint,并如实报告未满足项。 diff --git a/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md b/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md deleted file mode 100644 index 1c850d90..00000000 --- a/backend/agent/skills/cdsl-author-guidance/01-brief-and-assumptions.md +++ /dev/null @@ -1 +0,0 @@ -先区分显式事实、图像观察、工程默认值和未知项。提取单位、外形、功能面、孔/槽、配合关系、关键尺寸及可验证目标。默认值只能补足常见零件的非关键构造,不能把未说明尺寸伪装成用户要求或确定性验收值。只有安全、配合、合规或可建模性确实取决于一个缺失事实时,才提出一个聚焦澄清;其余不确定性记录为假设或风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md b/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md deleted file mode 100644 index 035276fe..00000000 --- a/backend/agent/skills/cdsl-author-guidance/02-parameters-and-derived-dimensions.md +++ /dev/null @@ -1 +0,0 @@ -把尺寸当作模型契约:先识别主控的长度、宽度、厚度、直径、中心距、节距、数量、半径和角度,再从它们推导重复位置、对称偏移和余量。所有尺寸明确使用 mm,角度使用 deg;长度、直径、深度、节距和圆角半径必须为合理正有限值。阵列优先由中心线、数量、节距、半径或角度推导,避免难以追溯的点坐标常数。提交前以包围盒、比例、壁厚/材料余量和目标特征数量做常识检查。 diff --git a/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md b/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md deleted file mode 100644 index ff1b0c06..00000000 --- a/backend/agent/skills/cdsl-author-guidance/03-coordinate-system-and-datums.md +++ /dev/null @@ -1 +0,0 @@ -世界坐标为右手 mm;根特征只在 contract 允许时用 `XY`/`+Z`。`workplane.origin_mm`、`x_dir`、`normal` 定义局部 frame;孔位是世界坐标。后续特征仅用已验证 datum/token,不能猜测最后生成面。 diff --git a/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md b/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md deleted file mode 100644 index 28e2331d..00000000 --- a/backend/agent/skills/cdsl-author-guidance/04-construction-and-feature-order.md +++ /dev/null @@ -1 +0,0 @@ -优先把零件身份和主控尺寸写进稳定根特征:根体、主要增材体、主要切除、孔/槽、重复特征、最后的圆角/倒角。每个节点只承担一个原子意图,依赖边只表示直接几何前提。默认形成连通单体;确需多体时必须由目标和 contract 支持。避免把视觉装饰、细小倒角或易碎布尔放在主形体之前。重规划时保留已完成节点和可执行检查点,只替换最小必要子图。 diff --git a/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md b/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md deleted file mode 100644 index 289c3ecf..00000000 --- a/backend/agent/skills/cdsl-author-guidance/05-profiles-workplanes-and-cuts.md +++ /dev/null @@ -1 +0,0 @@ -轮廓必须闭合、不自交、无零长或重叠边,并清楚区分外环和内环。先验证 workplane 的原点、`x_dir`、`normal` 与局部轮廓方向;翻转方向使用 contract 允许的字段,不凭视觉猜测。切除从实际材料面进入,深度覆盖目标材料并满足当前预检;避免刚好停在共面边界。对薄壁、近相切、重叠工具和零厚度结果保持余量。切除失败先检查宿主、方向、深度和轮廓,再考虑更换建模顺序。 diff --git a/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md b/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md deleted file mode 100644 index 56109b58..00000000 --- a/backend/agent/skills/cdsl-author-guidance/06-hosted-features-selectors-and-topology.md +++ /dev/null @@ -1 +0,0 @@ -宿主特征只能使用当前 revision 的测量 topology 和服务端给出的不透明 selector token;不得按边/面列表下标、历史名称或“最后一个面”猜选。选择前核对 token 的 kind、中心、法向、包围盒和 surface_type 是否覆盖预期材料区域。布尔、孔、阵列、圆角后拓扑可能变化,旧 token 和 reference 不可假定仍有效;依赖新拓扑时重新观察。reference token 只按当前 contract 放入允许槽位。选择不确定时请求 topology,而不是提交模糊 selector。 diff --git a/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md b/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md deleted file mode 100644 index 6a1fa24b..00000000 --- a/backend/agent/skills/cdsl-author-guidance/07-patterns-symmetry-and-repetition.md +++ /dev/null @@ -1 +0,0 @@ -对称和重复优先通过 `pattern_linear`、`pattern_mirror` 及其 contract 参数表达。先完成一个正确的源特征,再用中心面、中心线、方向、数量、节距、半径或角度定义重复关系;不要用零散手填坐标代替可追溯模式。镜像平面和阵列方向应来自已建立的 datum 或当前测量 token。阵列前确认源特征、间距和数量不会重叠、越界或使材料变成零厚度。 diff --git a/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md b/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md deleted file mode 100644 index 3ab5a436..00000000 --- a/backend/agent/skills/cdsl-author-guidance/08-finishing-and-boolean-risk.md +++ /dev/null @@ -1 +0,0 @@ -圆角和倒角仅在主形体、切除和孔稳定后执行,并只选择唯一、当前有效的边 token;禁止“所有边”式回退。半径/距离必须小于邻近材料可容纳范围,避免相邻圆角相交。布尔操作避开共面终止、近相切和重复工具重叠;若风险高,优先以更稳定的主轮廓、顺序或足够余量表达。失败时不要重复原片段,先诊断受影响的面、边、深度和拓扑。 diff --git a/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md b/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md deleted file mode 100644 index 8183f587..00000000 --- a/backend/agent/skills/cdsl-author-guidance/09-evidence-visual-review-and-validation.md +++ /dev/null @@ -1 +0,0 @@ -确定性几何事实与视觉审查职责不同:包围盒、实体数、孔深或贯穿状态只能证明已测量的 claim,不能证明整体设计语义。使用当前 contract、预检结果、claim evidence、render manifest 和 recent failures 作决定。视觉不符时给出具体的形状、位置、方向或比例差异作为修复依据,不能把它伪装成确定性通过。仅在几何改变后重新审查;STEP/checkpoint 是主工件,GLB 和渲染是派生审查证据,不能替代 CAD 几何。 diff --git a/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md b/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md deleted file mode 100644 index 145d9f5f..00000000 --- a/backend/agent/skills/cdsl-author-guidance/10-repair-and-best-effort.md +++ /dev/null @@ -1 +0,0 @@ -修复先读错误和证据,定位最小责任点,再改最小的 CDSL/计划部分并重新执行依赖检查。常见原因包括开环/自交轮廓、零或负尺寸、切除方向或深度错误、错误 host frame、布尔后的旧 selector、过大圆角和直径/半径混淆。不要原样重试已失败片段。运行时不支持的能力应作为风险或缺口保留并继续发布最佳可执行模型,不能发明新 atom 或删除有效 checkpoint。 diff --git a/backend/agent/skills/cdsl-author-guidance/README.md b/backend/agent/skills/cdsl-author-guidance/README.md deleted file mode 100644 index 96da5a74..00000000 --- a/backend/agent/skills/cdsl-author-guidance/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# CDSL Author Guidance Corpus - -This corpus is a Chinese-first, non-authoritative author aid. The runtime -operation contract, fragment schema, topology/reference tokens, preflight and -verifier evidence always win over these Markdown files. The manifest maps -only workflow phase, scheduled atomic operation and repair state; it never -classifies the user's part request. - -## Source Migration - -| Source reference | CDSL target sections | Intentionally excluded | -| --- | --- | --- | -| `cad-brief.md` | `01`, `02`, `09` | Python/file workflow | -| `parameters.md` | `02`, `07` | sidecars, animation, viewer control | -| `positioning.md` | `03`, `06`, `op-reference` | assemblies, joints, `Location`, imported STEP placement | -| `build123d-modeling.md` | `03` through `08`, operation appendices | build123d APIs, labels, colors and assembly source | -| `build123d-modeling.zh-CN.md` | all Chinese terminology and rule review | a duplicate competing rule set | -| `inspection-and-validation.md` | `09`, `10` | CLI paths and selector syntax | -| `snapshot-review.md` | `09` | renderer commands | -| `repair-loop.md` | `10`, `05`, `06`, `08` | build123d-only remediation syntax | -| `step-generation.md` | `00`, `09` | Python generator commands | -| `supported-exports.md` | `09` | mesh tolerance and exporter-specific flags | - -## Selection Contract - -- Requirements authoring selects `00` to `03`. -- Feature planning selects `00`, `02`, `03`, `04`, `07`, and `08`. -- A scheduled feature selects `00`, `03` to `06`, `08`, and its current - operation appendix. -- Repair selects `00`, `03`, `06`, `09`, `10`, and its operation appendix. -- Final validation selects `00`, `09`, and `10`. - -At a bounded prompt budget, contract, coordinate/datum, and the scheduled -operation appendix are mandatory. Other sections are included in stable -priority order. A malformed corpus or an unsupported operation registry -falls back to the original short author prompt and records fallback metadata -with the author usage record. - -## Evaluation Commands - -Run the six matched control scenarios three times each, first without and -then with guidance: - -```bash -PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance off \ - --scenario rectangular_mounting_plate --scenario circular_flange_pcd \ - --scenario obround_slot_plate --scenario rounded_rectangular_pocket \ - --scenario double_hole_linkage_arm --scenario l_bracket - -PYTHONPATH=backend python -m app.cad_agent.evals.live --suite comprehensive --repetitions 3 --author-guidance on \ - --scenario rectangular_mounting_plate --scenario circular_flange_pcd \ - --scenario obround_slot_plate --scenario rounded_rectangular_pocket \ - --scenario double_hole_linkage_arm --scenario l_bracket - -PYTHONPATH=backend python -m app.cad_agent.evals.live --compare-guidance-reports CONTROL/report.json TREATMENT/report.json -``` - -The comparator excludes declared validation gaps and engine-declared -`unsupported_*` capability gaps from prompt quality metrics, checks paired -model/runtime/contract/budget equivalence, and -requires the treatment's checkpoint/completion rates not to regress, median -author calls to stay within 10 percent, and either schema/decision or CDSL -expression failures to improve. diff --git a/backend/agent/skills/cdsl-author-guidance/manifest.json b/backend/agent/skills/cdsl-author-guidance/manifest.json deleted file mode 100644 index cd4de229..00000000 --- a/backend/agent/skills/cdsl-author-guidance/manifest.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "schema_version": "cdsl.author-guidance.manifest.v1", - "version": "2026-09-09.1", - "sections": [ - {"id": "00-author-contract", "file": "00-author-contract.md", "title": "00 Author Contract", "priority": 100, "mandatory": true}, - {"id": "01-brief-and-assumptions", "file": "01-brief-and-assumptions.md", "title": "01 Brief And Assumptions", "priority": 70, "mandatory": false}, - {"id": "02-parameters-and-derived-dimensions", "file": "02-parameters-and-derived-dimensions.md", "title": "02 Parameters And Derived Dimensions", "priority": 80, "mandatory": false}, - {"id": "03-coordinate-system-and-datums", "file": "03-coordinate-system-and-datums.md", "title": "03 Coordinate System And Datums", "priority": 100, "mandatory": true}, - {"id": "04-construction-and-feature-order", "file": "04-construction-and-feature-order.md", "title": "04 Construction And Feature Order", "priority": 70, "mandatory": false}, - {"id": "05-profiles-workplanes-and-cuts", "file": "05-profiles-workplanes-and-cuts.md", "title": "05 Profiles Workplanes And Cuts", "priority": 90, "mandatory": false}, - {"id": "06-hosted-features-selectors-and-topology", "file": "06-hosted-features-selectors-and-topology.md", "title": "06 Hosted Features Selectors And Topology", "priority": 90, "mandatory": false}, - {"id": "07-patterns-symmetry-and-repetition", "file": "07-patterns-symmetry-and-repetition.md", "title": "07 Patterns Symmetry And Repetition", "priority": 60, "mandatory": false}, - {"id": "08-finishing-and-boolean-risk", "file": "08-finishing-and-boolean-risk.md", "title": "08 Finishing And Boolean Risk", "priority": 60, "mandatory": false}, - {"id": "09-evidence-visual-review-and-validation", "file": "09-evidence-visual-review-and-validation.md", "title": "09 Evidence Visual Review And Validation", "priority": 80, "mandatory": false}, - {"id": "10-repair-and-best-effort", "file": "10-repair-and-best-effort.md", "title": "10 Repair And Best Effort", "priority": 80, "mandatory": false}, - {"id": "op-extrude-add", "file": "op-extrude-add.md", "title": "Operation Appendix Extrude Add", "priority": 100, "mandatory": true}, - {"id": "op-extrude-cut", "file": "op-extrude-cut.md", "title": "Operation Appendix Extrude Cut", "priority": 100, "mandatory": true}, - {"id": "op-loft", "file": "op-loft.md", "title": "Operation Appendix Loft", "priority": 100, "mandatory": true}, - {"id": "op-revolve", "file": "op-revolve.md", "title": "Operation Appendix Revolve", "priority": 100, "mandatory": true}, - {"id": "op-hole", "file": "op-hole.md", "title": "Operation Appendix Hole", "priority": 100, "mandatory": true}, - {"id": "op-reference", "file": "op-reference.md", "title": "Operation Appendix Reference", "priority": 100, "mandatory": true}, - {"id": "op-pattern", "file": "op-pattern.md", "title": "Operation Appendix Pattern", "priority": 100, "mandatory": true}, - {"id": "op-finish", "file": "op-finish.md", "title": "Operation Appendix Finish", "priority": 100, "mandatory": true}, - {"id": "op-sphere", "file": "op-sphere.md", "title": "Operation Appendix Sphere", "priority": 100, "mandatory": true}, - {"id": "op-primitives", "file": "op-primitives.md", "title": "Operation Appendix Primitives", "priority": 100, "mandatory": true}, - {"id": "op-thread", "file": "op-thread.md", "title": "Operation Appendix Thread", "priority": 100, "mandatory": true}, - {"id": "op-bend", "file": "op-bend.md", "title": "Operation Appendix Bend", "priority": 100, "mandatory": true}, - {"id": "op-gear", "file": "op-gear.md", "title": "Operation Appendix Gear", "priority": 100, "mandatory": true} - ], - "phase_sections": { - "DEFAULT": ["00-author-contract", "03-coordinate-system-and-datums", "09-evidence-visual-review-and-validation"], - "DRAFTING_REQUIREMENTS_DOCUMENT": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], - "DRAFTING_COMPLETION_TARGET": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], - "COMPILING_REQUIREMENTS": ["00-author-contract", "01-brief-and-assumptions", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums"], - "COMPILING_FEATURE_PLAN": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"], - "REPLANNING_FEATURE_SUBGRAPH": ["00-author-contract", "02-parameters-and-derived-dimensions", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "07-patterns-symmetry-and-repetition", "08-finishing-and-boolean-risk"], - "FEATURE_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "04-construction-and-feature-order", "05-profiles-workplanes-and-cuts", "06-hosted-features-selectors-and-topology", "08-finishing-and-boolean-risk"], - "AWAITING_ACTION": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], - "ACTION_PENDING": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"] - }, - "repair_sections": ["00-author-contract", "03-coordinate-system-and-datums", "06-hosted-features-selectors-and-topology", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], - "final_sections": ["00-author-contract", "09-evidence-visual-review-and-validation", "10-repair-and-best-effort"], - "operation_sections": { - "extrude_add_blind": ["op-extrude-add"], - "extrude_add_blind_with_hole": ["op-extrude-add"], - "extrude_add_two_sided": ["op-extrude-add"], - "extrude_from_face": ["op-extrude-add"], - "extrude_surface": ["op-extrude-add"], - "extrude_cut_blind": ["op-extrude-cut"], - "extrude_cut_two_sided": ["op-extrude-cut"], - "extrude_cut_through": ["op-extrude-cut"], - "loft_add": ["op-loft"], - "loft_add_with_cap_face": ["op-loft"], - "revolve_add": ["op-revolve"], - "revolve_cut": ["op-revolve"], - "revolve_surface": ["op-revolve"], - "sweep_add": ["op-loft"], - "hole_blind": ["op-hole"], - "hole_counterbore": ["op-hole"], - "hole_countersink": ["op-hole"], - "hole_wizard": ["op-hole"], - "reference_plane": ["op-reference"], - "reference_axis": ["op-reference"], - "pattern_linear": ["op-pattern"], - "pattern_mirror": ["op-pattern"], - "pattern_circular": ["op-pattern"], - "fillet": ["op-finish"], - "chamfer": ["op-finish"], - "shell": ["op-finish"], - "boolean_bodies": ["op-finish"], - "transform_bodies": ["op-finish"], - "delete_bodies": ["op-finish"], - "sphere_add": ["op-sphere"], - "box_add": ["op-primitives"], - "cylinder_add": ["op-primitives"], - "thread_add": ["op-thread"], - "thread_cut": ["op-thread"], - "bend_add": ["op-bend"], - "gear_add": ["op-gear"], - "rack_add": ["op-gear"] - } -} diff --git a/backend/agent/skills/cdsl-author-guidance/op-bend.md b/backend/agent/skills/cdsl-author-guidance/op-bend.md deleted file mode 100644 index 8e9918b6..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-bend.md +++ /dev/null @@ -1 +0,0 @@ -`bend_add` 以中面折线链定义等厚钣金折弯:正的 `thickness_mm`、`width_mm` 和至少一翼的 `chain`;非末翼必须给 `bend_angle_deg`(内角,0 < angle < 180,90 为直角折弯),可选 `inner_radius_mm`(>= 0,外半径恒为内半径加板厚)与 `side`(+1 / -1,折弯方向)。`frame` 可选,缺省为世界 XY 平面(首翼沿 +X,厚度沿法向)。相邻折弯圆角会消耗直段长度(约 `inner_radius + thickness/2` 的切线距离),每翼剩余直段必须为正,否则规格非法。仅用于意图明确的钣金折弯;单翼链退化为平板,普通平板轮廓应保留草图历史表达而非用 bend_add 替代。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md deleted file mode 100644 index 754cacb0..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-extrude-add.md +++ /dev/null @@ -1 +0,0 @@ -`extrude_add_blind` 和 `extrude_add_two_sided` 必须使用闭合草图和 contract 允许的正距离。根挤出遵守根 `XY` datum;后续增材先确认草图 frame 与已有实体的连接。双向挤出分别核对两个方向的长度与材料范围;`reverse` 只用于当前 frame 的方向修正,不能代替错误的 workplane。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md b/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md deleted file mode 100644 index f88a19b2..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-extrude-cut.md +++ /dev/null @@ -1 +0,0 @@ -`extrude_cut_blind` 使用闭合草图、当前允许的正距离和正确宿主 frame。从实际材料面进入,方向由 workplane normal 与 contract 的 `reverse` 决定;深度应覆盖目标材料,不能刚好停在共面边界。`extrude_cut_two_sided` 必须分别提供正向和反向的距离与终止条件,不能以单侧深度近似双向切除。`extrude_cut_through` 只接受明确的 `end_condition`,由现有主体跨度决定穿透距离,不能伪造盲向深度。切除失败时先检查轮廓、宿主、方向、深度和材料覆盖,而不是盲目加大距离。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-finish.md b/backend/agent/skills/cdsl-author-guidance/op-finish.md deleted file mode 100644 index 296f7647..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-finish.md +++ /dev/null @@ -1 +0,0 @@ -`fillet` 与 `chamfer` 仅接受当前 revision 中唯一且合格的 edge selector token。`shell` 必须保留显式目标 body 与待移除 face token。`boolean_bodies`、`transform_bodies`、`delete_bodies` 只操作 contract 指定且仍独立存在的 body;不以当前 body 或隐式 union 兜底。失败时保留主体并报告风险。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-gear.md b/backend/agent/skills/cdsl-author-guidance/op-gear.md deleted file mode 100644 index bc47d341..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-gear.md +++ /dev/null @@ -1 +0,0 @@ -`gear_add` 与 `rack_add` 是渐开线齿轮/齿条的原生图元:正的 `module_mm`、`teeth_count`(齿轮 8–200,齿条 1–2000)与明确的 `axis`。齿轮 `axis.origin_mm` 是 `z=0` 端面圆心、`axis.direction` 为齿轴;齿条 `axis.direction` 是齿的伸出方向、`axis.origin_mm` 是长度起点端面与厚度起始面及齿谷平面的交点,有效长度精确等于 `teeth_count * pi * module`。`helix_angle_rad > 0` 生成斜齿(螺旋沿轴推进);加 `herringbone: true` 得到人字齿(双螺旋在齿宽中点对称反转,无退刀槽)。齿数低于 17 时存在根切风险,引擎照常生成并附诊断说明,不静默修正;如需无根切请提高齿数或改用变位设计(当前不支持变位)。齿轮孔、键槽等后续特征用 hole/extrude_cut 沿同一 axis 叠加。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-hole.md b/backend/agent/skills/cdsl-author-guidance/op-hole.md deleted file mode 100644 index 455e5be8..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-hole.md +++ /dev/null @@ -1 +0,0 @@ -孔 atom 需要当前宿主面的有效 selector token。`positions[].mm` 使用该宿主面上的绝对世界坐标,先核对点在面区域内与法向方向。直径、深度、沉孔/沉头参数以 contract 为准,深度覆盖预期材料;多孔共享一个原子操作时保持同一规格和同一宿主。不要把点写成面局部偏移或裸数组。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-loft.md b/backend/agent/skills/cdsl-author-guidance/op-loft.md deleted file mode 100644 index 30303069..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-loft.md +++ /dev/null @@ -1 +0,0 @@ -`loft_add` 在 `params.profile_sketch_ids` 中按放样方向列出至少两条不同的闭合草图。每条截面必须解析为一条无孔外轮廓;截面拓扑和 workplane frame 必须稳定对应。`loft_add_with_cap_face` 只能使用 contract 许可的 cap-face token。`sweep_add` 必须保留闭合截面与显式、非退化路径,不以放样或挤出替代。不要把 selector 选中的实体面当作放样截面,除非 contract 明确支持。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-pattern.md b/backend/agent/skills/cdsl-author-guidance/op-pattern.md deleted file mode 100644 index 8bf48548..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-pattern.md +++ /dev/null @@ -1 +0,0 @@ -`pattern_linear` 只复制当前 contract 允许且存在的源 feature reference;方向是明确世界/基准方向,数量和 spacing 为合理值。`pattern_mirror` 使用存在的镜像 plane reference,先确认源与平面关系以及复制后不会重叠或意外合并。pattern 不代替新的宿主选择;下游特征若依赖新面,重新读取 topology。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-primitives.md b/backend/agent/skills/cdsl-author-guidance/op-primitives.md deleted file mode 100644 index 09e10d85..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-primitives.md +++ /dev/null @@ -1 +0,0 @@ -`box_add` 和 `cylinder_add` 是世界坐标原生图元。按 operation contract 提供正尺寸以及明确的 `center_mm` 或 axis。仅在目标确为长方体或圆柱体时使用;由轮廓驱动的几何保留草图、放样等历史表达。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-reference.md b/backend/agent/skills/cdsl-author-guidance/op-reference.md deleted file mode 100644 index 20e1e31f..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-reference.md +++ /dev/null @@ -1 +0,0 @@ -`reference_plane` 用有限非零 `normal` 和与其不平行的 `x_dir` 定义局部 frame;`origin_mm` 是世界点。`reference_axis` 用有限非零 `direction` 和世界原点定义。它们只建立可追溯 datum,不直接制造实体;先于依赖它的旋转、镜像、阵列或定位特征,并依照 contract 的 reference token 规则引用。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-revolve.md b/backend/agent/skills/cdsl-author-guidance/op-revolve.md deleted file mode 100644 index 161997dc..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-revolve.md +++ /dev/null @@ -1 +0,0 @@ -`revolve_add` 和 `revolve_cut` 的轴必须由明确 datum 或 contract 中的世界坐标轴表达,并按预检要求位于正确的草图关系中。核对 axis origin、direction、角度和 `reverse`;完整回转避免轮廓跨轴造成自交,局部回转避免与现有材料近相切。切除回转仍必须覆盖目标材料。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-sphere.md b/backend/agent/skills/cdsl-author-guidance/op-sphere.md deleted file mode 100644 index 99c82b4b..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-sphere.md +++ /dev/null @@ -1 +0,0 @@ -`sphere_add` 用明确的世界中心和正有限半径定义。确认它与目标实体的连接意图:需要单体时应有足够相交,独立体仅在需求允许多体时使用。球体位置从 datum 或主尺寸导出,不把视图坐标误当世界坐标。 diff --git a/backend/agent/skills/cdsl-author-guidance/op-thread.md b/backend/agent/skills/cdsl-author-guidance/op-thread.md deleted file mode 100644 index 38e932da..00000000 --- a/backend/agent/skills/cdsl-author-guidance/op-thread.md +++ /dev/null @@ -1 +0,0 @@ -`thread_add` 和 `thread_cut` 需要明确的 axis、正的大小径、螺距和长度,且各参数必须物理一致。螺纹切除必须有已有宿主实体;需求为实体螺纹时,不能以光滑孔替代。 diff --git a/backend/app/cad_agent/__init__.py b/backend/app/cad_agent/__init__.py index e1c19aa3..edece57e 100644 --- a/backend/app/cad_agent/__init__.py +++ b/backend/app/cad_agent/__init__.py @@ -1,7 +1,7 @@ -"""Autonomous CAD protocol v3. +"""Single-stage Authoring CDSL protocol. The package intentionally separates policy from I/O. Delivery code composes these modules with adapters; it must not bypass the command handlers. """ -PROTOCOL_VERSION = "3.0" +PROTOCOL_VERSION = "cad.single-stage.v1" diff --git a/backend/app/cad_agent/adapters/__init__.py b/backend/app/cad_agent/adapters/__init__.py index 9743a47b..3a9d210e 100644 --- a/backend/app/cad_agent/adapters/__init__.py +++ b/backend/app/cad_agent/adapters/__init__.py @@ -1 +1 @@ -"""Infrastructure adapters for the v3 ports.""" +"""Infrastructure adapters for the single-stage ports.""" diff --git a/backend/app/cad_agent/adapters/artifact_store.py b/backend/app/cad_agent/adapters/artifact_store.py index 4267a0d5..2816d5ea 100644 --- a/backend/app/cad_agent/adapters/artifact_store.py +++ b/backend/app/cad_agent/adapters/artifact_store.py @@ -1,4 +1,4 @@ -"""Immutable v3 file artifacts with staging manifests and atomic publication.""" +"""Immutable single-stage artifacts with staging manifests and atomic publication.""" from __future__ import annotations @@ -8,10 +8,9 @@ import os from pathlib import Path import re import secrets -import shutil from typing import Any -from app.cad_agent.ports import CandidateStage +from app.cad_agent.ports import StagingRevision _SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$") @@ -27,7 +26,7 @@ class FileArtifactStore: def task_dir(self, task_id: str) -> Path: if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id): - raise ValueError("Invalid v3 task id") + raise ValueError("Invalid CAD task id") return self.root / task_id def artifact_path(self, task_id: str, relative_path: str) -> Path: @@ -43,7 +42,7 @@ class FileArtifactStore: ) -> None: root = self.task_dir(task_id) (root / "documents").mkdir(parents=True, exist_ok=True) - (root / "actions").mkdir(exist_ok=True) + (root / "events").mkdir(exist_ok=True) (root / "revisions").mkdir(exist_ok=True) (root / ".staging").mkdir(exist_ok=True) source = root / "source-requirements.md" @@ -67,14 +66,14 @@ class FileArtifactStore: if images: self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images}) - def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: + def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: """Mirror committed SQLite events into an append-only JSONL audit log. SQLite remains authoritative. Replaying this method after an interruption appends only missing committed sequences and rejects a divergent line instead of rewriting audit history. """ - path = self._path(task_id, "actions/action-ledger.jsonl") + path = self._path(task_id, "events/event-ledger.jsonl") existing: dict[int, dict[str, Any]] = {} if path.is_file(): for raw in path.read_text(encoding="utf-8").splitlines(): @@ -83,18 +82,18 @@ class FileArtifactStore: value = json.loads(raw) sequence = value.get("sequence") if isinstance(value, dict) else None if not isinstance(sequence, int) or sequence < 1: - raise ValueError("Action ledger contains an invalid sequence") + raise ValueError("Event ledger contains an invalid sequence") existing[sequence] = value missing: list[str] = [] for event in events: sequence = event.get("sequence") if not isinstance(sequence, int) or sequence < 1: - raise ValueError("Action ledger event has an invalid sequence") - entry = {"schema_version": "cad.action-ledger.v1", **event} + raise ValueError("Event ledger event has an invalid sequence") + entry = {"schema_version": "cad.event-ledger.v1", **event} previous = existing.get(sequence) if previous is not None: if previous != entry: - raise ValueError("Action ledger diverges from committed SQLite event") + raise ValueError("Event ledger diverges from committed SQLite event") continue missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n") if missing: @@ -103,7 +102,7 @@ class FileArtifactStore: handle.writelines(missing) handle.flush() os.fsync(handle.fileno()) - return "actions/action-ledger.jsonl" + return "events/event-ledger.jsonl" def write_source_index( self, @@ -136,9 +135,8 @@ class FileArtifactStore: """Return immutable source paragraphs and attachment blocks. Source identifiers are assigned only after this method returns, so - callers cannot choose IDs. Attachment metadata is deliberately a - closed, server-provided projection: it lets a reviewer trace the - source without treating upload metadata as arbitrary LLM input. + callers cannot choose IDs. Attachment metadata is a closed, + server-provided projection rather than arbitrary model input. """ supplied = source_blocks if supplied is None: @@ -195,19 +193,6 @@ class FileArtifactStore: if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file() ] - def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: - return self.read_json(task_id, artifact_path or "documents/requirements-spec.json") - - def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: - # State points to the immutable artifact used as program input. The - # fixed name is a read-only convenience view for terminal tasks. - return self.read_json(task_id, artifact_path or "requirements-contract.json") - - def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: - if not invocation_id: - return self.write_json_once(task_id, "requirements-contract.json", payload) - return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id) - def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: path = self._path(task_id, relative_path) if not path.is_file(): @@ -225,36 +210,20 @@ class FileArtifactStore: self._write_once(path, text) return relative_path - def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: - return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None - - def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: - return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None - - def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: + def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: root = self.task_dir(task_id) / ".staging" stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20] directory = root / stable_id directory.mkdir(parents=True, exist_ok=True) self._write_json_once(directory / "input.json", payload) - return CandidateStage(stable_id, str(directory.resolve())) - - def stage_output_dir(self, task_id: str, stage_id: str) -> str: - return str(self._stage_path(task_id, stage_id, "")) + return StagingRevision(stable_id, str(directory.resolve())) def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: path = self._stage_path(task_id, stage_id, relative_path) self._write_json_once(path, payload) return relative_path - def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: - path = self._stage_path(task_id, stage_id, relative_path) - if not path.is_file(): - return None - value = json.loads(path.read_text(encoding="utf-8")) - return value if isinstance(value, dict) else None - - def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: + def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: source = self._stage_path(task_id, stage_id, "") target = self._path(task_id, f"revisions/{revision_id}") if target.exists(): @@ -262,9 +231,8 @@ class FileArtifactStore: if manifest is None: raise RuntimeError("Published revision has no valid manifest") return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]} - # Render/rebuild reports contain paths for the reviewer. Rebase those - # paths while artifacts are still mutable staging output, so they point - # at the revision after the atomic directory rename. + # Rebase report paths while artifacts are still mutable staging output, + # so they point at the revision after the atomic directory rename. self._rebase_staged_paths(source, target) manifest = self._create_manifest(source) self._write_json_once(source / "manifest.json", manifest) @@ -272,39 +240,6 @@ class FileArtifactStore: os.replace(source, target) return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]} - def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: - """Find a manifest-verified candidate already renamed before its DB CAS. - - Publishing artifacts and advancing SQLite cannot share a transaction. - The stage id persisted inside ``candidate.json`` makes a post-rename - recovery deterministic and prevents another build or revision. - """ - revisions = self.task_dir(task_id) / "revisions" - if not revisions.is_dir(): - return None - for revision in sorted(revisions.iterdir()): - if not revision.is_dir() or self._manifest(revision) is None: - continue - candidate_path = revision / "candidate.json" - if not candidate_path.is_file(): - continue - try: - candidate = json.loads(candidate_path.read_text(encoding="utf-8")) - except json.JSONDecodeError: - continue - if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id: - return revision.name, candidate - return None - - def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: - root = self.task_dir(task_id) / ".staging" - if not root.is_dir(): - return - for directory in root.iterdir(): - if not directory.is_dir() or directory.name in referenced_stage_ids: - continue - shutil.rmtree(directory) - def _path(self, task_id: str, relative_path: str) -> Path: if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts): raise ValueError("Invalid artifact relative path") @@ -316,7 +251,7 @@ class FileArtifactStore: def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path: if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id): - raise ValueError("Invalid candidate stage id") + raise ValueError("Invalid staging revision id") root = self.task_dir(task_id).resolve() stage = (root / ".staging" / stage_id).resolve() if root not in stage.parents: @@ -351,13 +286,6 @@ class FileArtifactStore: temporary.write_bytes(data) os.replace(temporary, path) - def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str: - digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12] - safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32] - if not safe_invocation: - raise ValueError("Artifact invocation ID is invalid") - return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload) - @classmethod def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None: cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n") @@ -371,8 +299,8 @@ class FileArtifactStore: relative = path.relative_to(directory).as_posix() files[relative] = sha256(path.read_bytes()).hexdigest() if not files: - raise RuntimeError("Candidate staging directory has no artifacts") - return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files} + raise RuntimeError("Staging revision has no artifacts") + return {"schema_version": "cad.single-stage.artifact-manifest.v1", "files": files} @staticmethod def _manifest(directory: Path) -> dict[str, Any] | None: diff --git a/backend/app/cad_agent/adapters/author_guidance.py b/backend/app/cad_agent/adapters/author_guidance.py deleted file mode 100644 index 4cae1ef5..00000000 --- a/backend/app/cad_agent/adapters/author_guidance.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Bounded, file-backed author guidance for the CDSL workflow. - -The corpus is deliberately non-authoritative: contracts, schemas, topology -tokens, and server preflight always remain the executable source of truth. -Loading errors return an empty selection so authoring continues with the -pre-guidance prompt instead of turning documentation into an availability -dependency. -""" - -from __future__ import annotations - -import json -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from app.cad_agent.domain.state import TaskPhase -from app.cad_agent.ports import AuthorGuidanceSelection - - -_MIN_CHARS = 1_200 -_MAX_CHARS = 6_000 - - -@dataclass(frozen=True, slots=True) -class _Section: - section_id: str - title: str - priority: int - mandatory: bool - content: str - - @property - def block(self) -> str: - return f"## {self.title}\n{self.content.strip()}" - - -@dataclass(frozen=True, slots=True) -class _Corpus: - version: str - sections: dict[str, _Section] - phase_sections: dict[str, tuple[str, ...]] - repair_sections: tuple[str, ...] - final_sections: tuple[str, ...] - operation_sections: dict[str, tuple[str, ...]] - - -class FileAuthorGuidance: - """Read and select the checked-in corpus deterministically. - - Selection depends exclusively on workflow state and the runtime operation - registry. It intentionally receives neither the user's request nor image - observations, so it cannot become an implicit part-family classifier. - """ - - def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None: - self.root = root - self.enabled = enabled - self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars)) - self._corpus: _Corpus | None = None - self._load_error = "" - - def select( - self, - *, - phase: TaskPhase, - atomic_id: str, - repair_required: bool, - supported_atomic_ids: tuple[str, ...], - ) -> AuthorGuidanceSelection: - if not self.enabled: - return AuthorGuidanceSelection(fallback_reason="guidance_disabled") - corpus = self._load() - if corpus is None: - return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable") - supported = set(supported_atomic_ids) - if set(corpus.operation_sections) != supported: - return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch") - if atomic_id and atomic_id not in supported: - return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id") - - if repair_required: - requested = list(corpus.repair_sections) - elif phase == TaskPhase.FINAL_VALIDATION: - requested = list(corpus.final_sections) - else: - requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ()))) - if atomic_id: - requested.extend(corpus.operation_sections[atomic_id]) - requested = list(dict.fromkeys(requested)) - if not requested: - return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections") - - mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory] - optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory] - optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id))) - selected: list[str] = [] - text = "" - for section_id in [*mandatory, *optional]: - block = corpus.sections[section_id].block - candidate = block if not text else f"{text}\n\n{block}" - if len(candidate) <= self.max_chars: - text = candidate - selected.append(section_id) - elif section_id in mandatory: - # Do not silently drop contract, datum, or operation guidance. - return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget") - return AuthorGuidanceSelection( - version=corpus.version, - section_ids=tuple(selected), - content=text, - enabled=True, - ) - - def _load(self) -> _Corpus | None: - if self._corpus is not None: - return self._corpus - if self._load_error: - return None - try: - manifest_path = self.root / "manifest.json" - raw = json.loads(manifest_path.read_text(encoding="utf-8")) - if not isinstance(raw, dict): - raise ValueError("manifest is not an object") - version = raw.get("version") - if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version: - raise ValueError("manifest version is invalid") - raw_sections = raw.get("sections") - if not isinstance(raw_sections, list) or not raw_sections: - raise ValueError("manifest sections are invalid") - sections: dict[str, _Section] = {} - root = self.root.resolve() - for item in raw_sections: - if not isinstance(item, dict): - raise ValueError("section declaration is invalid") - section_id = item.get("id") - filename = item.get("file") - title = item.get("title") - priority = item.get("priority") - mandatory = item.get("mandatory", False) - if ( - not isinstance(section_id, str) or not section_id - or not isinstance(filename, str) or not filename - or not isinstance(title, str) or not title - or not isinstance(priority, int) or isinstance(priority, bool) - or not isinstance(mandatory, bool) - or section_id in sections - ): - raise ValueError("section metadata is invalid") - path = (self.root / filename).resolve() - if root not in path.parents or not path.is_file(): - raise ValueError("section file is unavailable") - content = path.read_text(encoding="utf-8").strip() - if not content: - raise ValueError("section content is empty") - sections[section_id] = _Section(section_id, title, priority, mandatory, content) - - def identifiers(value: Any, field: str) -> tuple[str, ...]: - if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value): - raise ValueError(f"{field} is invalid") - return tuple(dict.fromkeys(value)) - - raw_phases = raw.get("phase_sections") - if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases: - raise ValueError("phase sections are invalid") - phase_sections = { - phase: identifiers(section_ids, f"phase {phase}") - for phase, section_ids in raw_phases.items() - if isinstance(phase, str) - } - if len(phase_sections) != len(raw_phases): - raise ValueError("phase name is invalid") - operation_sections = { - atomic_id: identifiers(section_ids, f"operation {atomic_id}") - for atomic_id, section_ids in (raw.get("operation_sections") or {}).items() - if isinstance(atomic_id, str) - } - if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}): - raise ValueError("operation sections are invalid") - self._corpus = _Corpus( - version=version, - sections=sections, - phase_sections=phase_sections, - repair_sections=identifiers(raw.get("repair_sections"), "repair sections"), - final_sections=identifiers(raw.get("final_sections"), "final sections"), - operation_sections=operation_sections, - ) - return self._corpus - except (OSError, ValueError, TypeError, json.JSONDecodeError) as error: - self._load_error = f"guidance_load_failed:{type(error).__name__}" - return None diff --git a/backend/app/cad_agent/adapters/event_publisher.py b/backend/app/cad_agent/adapters/event_publisher.py index 34e24d8a..0a6c5df9 100644 --- a/backend/app/cad_agent/adapters/event_publisher.py +++ b/backend/app/cad_agent/adapters/event_publisher.py @@ -1,4 +1,4 @@ -"""In-process idempotent event delivery for the v3 delivery boundary.""" +"""In-process idempotent event delivery for the single-stage protocol.""" from __future__ import annotations diff --git a/backend/app/cad_agent/adapters/review_gateway.py b/backend/app/cad_agent/adapters/review_gateway.py deleted file mode 100644 index 6711d323..00000000 --- a/backend/app/cad_agent/adapters/review_gateway.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Independent rendered-review adapter for protocol v3.""" - -from __future__ import annotations - -import base64 -import json -from pathlib import Path -from typing import Any - -from app.cad_agent.adapters.structured_llm import StructuredModelGateway -from app.cad_agent.ports import AdapterUnavailable - - -class RenderedReviewGateway: - def __init__(self, models: StructuredModelGateway) -> None: - self.models = models - - async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: - name = str((tool.get("function") or {}).get("name") or "") - if not name: - raise RuntimeError("Review tool is missing a name") - public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"} - content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}] - if kind in {"image_observation", "final"}: - for raw_path in payload.get("reference_image_paths") or (): - path = Path(str(raw_path)) - if path.is_file(): - content.append(self._image_part(path)) - if kind in {"candidate", "final"}: - manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {} - for path in self._evidence_paths(manifest): - content.append(self._image_part(path)) - return await self.models.call_tool( - messages=[ - {"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."}, - {"role": "user", "content": content}, - ], - tool=tool, - provider_id=provider_id, - model_id=model_id, - required_tool_name=name, - ) - - @staticmethod - def _evidence_paths(manifest: dict[str, Any]) -> list[Path]: - selected: list[Path] = [] - contact = Path(str(manifest.get("contact_sheet_path") or "")) - if contact.is_file(): - selected.append(contact) - wanted = {"top", "front", "right", "isometric"} - for item in manifest.get("views") or (): - if not isinstance(item, dict) or str(item.get("id") or "") not in wanted: - continue - path = Path(str(item.get("path") or "")) - if path.is_file(): - selected.append(path) - if not selected: - raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable") - return selected[:5] - - @staticmethod - def _image_part(path: Path) -> dict[str, Any]: - media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png" - return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}} diff --git a/backend/app/cad_agent/adapters/runtime.py b/backend/app/cad_agent/adapters/runtime.py index 65658289..4d6de80d 100644 --- a/backend/app/cad_agent/adapters/runtime.py +++ b/backend/app/cad_agent/adapters/runtime.py @@ -3,22 +3,20 @@ from __future__ import annotations from copy import deepcopy -from hashlib import sha256 import json import math from pathlib import Path from typing import Any from app.cad_agent.domain.operation_contract import ( - SEMANTIC_PREFLIGHT_NAMES, OperationContractError, canonical_hash, - validate_fragment, + is_authoring_schema_closed, validate_operation_contract, ) -from app.cad_agent.domain.verifier_registry import default_registry -from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl -from app.services.review_renderer import ReviewRenderError, render_checkpoint +from app.cad_agent.ports import AdapterUnavailable +from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl, validate_cdsl_shape +from app.services.render_bundle import RenderBundleError, render_checkpoint from app.settings import Settings from vendor.cdsl_preview_runtime import step_to_glb @@ -27,56 +25,43 @@ class RuntimeAdapterError(RuntimeError): pass +class RuntimeServiceUnavailable(AdapterUnavailable): + """A renderer or artifact service outage that must not spend a repair.""" + + class ProfileCadRuntime: """Adapter that owns engine imports; application code sees only its port.""" def __init__(self, settings: Settings) -> None: self.settings = settings self.engine = load_engine(settings) - self._semantic_preflight_handlers = { - "sketch_workplane": self._preflight_sketch_workplane, - "profile_non_self_intersecting": self._preflight_profile_non_self_intersecting, - "host_face_exists": self._preflight_host_face_exists, - "hole_positions_on_host_plane": self._preflight_hole_positions_on_host_plane, - "cut_exit_distance": self._preflight_cut_exit_distance, - "requires_active_solid": self._preflight_requires_active_solid, - "revolve_axis_on_sketch": self._preflight_revolve_axis_on_sketch, - "reference_plane_nonzero_normal": self._preflight_reference_plane_nonzero_normal, - "reference_axis_nonzero_direction": self._preflight_reference_axis_nonzero_direction, - "selected_edges_exist": self._preflight_selected_edges_exist, - "source_features_exist": self._preflight_source_features_exist, - "mirror_plane_exists": self._preflight_mirror_plane_exists, - "loft_profiles_exist": self._preflight_loft_profiles_exist, - "loft_profiles_closed": self._preflight_loft_profiles_closed, - "loft_profiles_single_region": self._preflight_loft_profiles_single_region, - } schema_path = Path(str(self.engine.__file__)).with_name("profile_schema.json") try: self._profile = json.loads(schema_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as error: raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile schema is unavailable") from error - self._contracts = self._profile.get("operation_contracts") - if not isinstance(self._contracts, dict): - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no v3 operation contracts") - declared = {str(item) for item in self._contracts} + profile_contracts = self._profile.get("operation_contracts") + if not isinstance(profile_contracts, dict): + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no operation contracts") + declared = {str(item) for item in profile_contracts} registered = {str(item) for item in getattr(self.engine, "SUPPORTED_ATOMIC_IDS", ())} if declared != registered: raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime and profile operation registries disagree") - verifier_kinds = set(default_registry().claim_kinds) - for contract in self._contracts.values(): + self._contracts: dict[str, dict[str, Any]] = {} + for atomic_id, contract in profile_contracts.items(): + if not isinstance(contract, dict): + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not an object") + # A legacy opaque operation schema is still executable internally, + # but cannot enter the sole LLM-facing Authoring protocol. + if not is_authoring_schema_closed(contract.get("author_params_schema")): + continue try: validate_operation_contract(contract) except OperationContractError as error: raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error - unknown_preflights = set(contract["semantic_preflight"]) - set(self._semantic_preflight_handlers) - unknown_verifiers = set(contract["candidate_verifiers"]) - verifier_kinds - if unknown_preflights or unknown_verifiers: - raise RuntimeAdapterError( - "RUNTIME_CONTRACT_INVALID: operation contract references an unavailable " - f"{'preflight' if unknown_preflights else 'candidate verifier'}" - ) - if set(self._semantic_preflight_handlers) != SEMANTIC_PREFLIGHT_NAMES: - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime preflight registry is incomplete") + self._contracts[str(atomic_id)] = contract + if not self._contracts: + raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no strict authoring operations") def supported_atomic_ids(self) -> tuple[str, ...]: return tuple(sorted(self._contracts)) @@ -90,170 +75,21 @@ class ProfileCadRuntime: result["registry_revision"] = str(self._profile.get("schema_version") or "") return result - def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]: - if not isinstance(topology, dict): - return {} - snapshot_id = str(topology.get("snapshot_id") or "") - if not snapshot_id: - return {} - result: dict[str, dict[str, Any]] = {} - for record in topology.get("records") or (): - if not isinstance(record, dict) or not record.get("executable"): - continue - record_id = str(record.get("record_id") or "") - kind = str(record.get("kind") or "") - if not record_id or kind not in {"face", "edge", "plane", "axis", "body", "vertex"}: - continue - token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16] - geometry = deepcopy(record.get("geometry") or {}) - owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""] - result[token] = { - "token": token, - "kind": kind, - "snapshot_id": snapshot_id, - "selector": {"kind": kind, "stable_id": record_id, "owner_feature_id": str(owners[0] or ""), "geometry": geometry, "source": "runtime_snapshot", "snapshot_id": snapshot_id, "confidence": 1.0}, - "geometry": geometry, - } - return result + def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + """Compile model-facing Authoring CDSL using server-owned contracts.""" + from app.cad_agent.application.authoring_compiler import AuthoringCompiler + from app.cad_agent.application.authoring_compiler import AuthoringCompileError - def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]: - """Return opaque, head-scoped feature references for pattern contracts.""" - result: dict[str, str] = {} - document_hash = canonical_hash(cdsl) if isinstance(cdsl, dict) else "root" - for feature in (cdsl or {}).get("features") or (): - if not isinstance(feature, dict) or not isinstance(feature.get("id"), str) or not feature["id"]: - continue - feature_id = feature["id"] - 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, 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 - # a candidate merely by bypassing that handler-level validation. + runtime, audit = AuthoringCompiler(self.operation_contract).compile(document) try: - validate_operation_contract(contract) - except OperationContractError as error: - raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error - current = self.operation_contract(str(contract.get("atomic_id") or "")) - if ( - contract.get("contract_hash") != current["contract_hash"] - or contract.get("registry_revision") != current["registry_revision"] - ): - raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not the current verified registry entry") - selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") - selector_kind = str((contract.get("selector_policy") or {}).get("token_kind") or "") - allowed_selectors = [ - token - for token, value in selector_tokens.items() - if selector_shape == "required" - and isinstance(value, dict) - and value.get("kind") == selector_kind - ] - errors = validate_fragment( - contract, - fragment, - selector_tokens=allowed_selectors, - reference_tokens=list(reference_tokens), - root_xy_datum=not bool((base_cdsl or {}).get("features")), - ) - if errors: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: fragment no longer matches the active operation schema: " - + errors[0]["message"] - ) - materialized = deepcopy(fragment) - reference = contract["reference_policy"] - if reference["mode"] == "snapshot_bound": - slot = str(reference["slot"]).removeprefix("params.") - supplied = materialized.get("feature", {}).get("params", {}).get(slot, []) - 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] - 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": [], - } - geometry = document.setdefault("geometry", {}) - sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None - 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": list(direct_dependencies)} - if contract["fragment_shape"]["sketch"] == "required": - sketch_id = f"sketch_{len(sketches) + 1:03d}" - sketch = materialized["sketch"] - sketches.append({"id": sketch_id, "workplane": deepcopy(sketch["workplane"]), "profile": deepcopy(sketch["profile"])}) - output["sketch_id"] = sketch_id - selected = [selector_tokens[token]["selector"] for token in feature.get("selector_tokens", [])] - slot = contract["selector_policy"]["slot"] - if slot == "params.host_face": - # A topology selector is authoritative while accepting the - # action, but a later pattern replays this feature after its own - # cut may have split the selected B-rep face. Lower the accepted - # planar selector to a concrete, world-aligned host frame and - # transform the author-supplied world coordinates into that - # frame. The token remains in the fragment audit for provenance; - # the materialized CDSL is stable under replay. - output["params"]["host_face"], output["params"]["positions"] = self._materialized_hole_host_frame( - selector_tokens[str(feature["selector_tokens"][0])], - output["params"].get("positions"), - ) - elif slot == "params.mirror_plane": - output["params"]["mirror_plane"] = selected[0] - elif slot == "feature.selectors": - output["selectors"] = selected - features.append(output) - try: - self._validate_finite_tree(document) - self._validate_materialized_runtime_types(output) - validate_cdsl(document, self.engine) + self._validate_finite_tree(runtime) + validate_cdsl_shape(runtime, self.engine) except Exception as error: - message = str(error) - # A JSON-schema failure after server materialization is a registry - # drift: the author could not have supplied the missing server - # field. Engine capability analysis, however, can reject a - # schema-valid author sketch (for example an unresolvable analytic - # contour). That is a recoverable action precondition failure, - # not a deployment defect. - if "CDSL engine runtime preflight failed:" in message: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: materialized fragment is not executable by the engine: " - + message - ) from error - raise RuntimeAdapterError( - "RUNTIME_CONTRACT_INVALID: materialized fragment violates the engine CDSL schema: " - + message + raise AuthoringCompileError( + "RUNTIME_CONTRACT_INVALID", + f"compiled Runtime CDSL is not executable: {error}", ) from error - 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]} + return runtime, audit 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. @@ -298,7 +134,7 @@ class ProfileCadRuntime: self._write_json(report_path, report) return {"preview": preview, "path": "model.glb"} - def render_review_bundle(self, output_dir: str) -> dict[str, Any]: + def render_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" @@ -308,20 +144,20 @@ class ProfileCadRuntime: 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. + """Build one complete checkpoint and derive all publishable artifacts.""" 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) + manifest = self.render_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. raise - except ReviewRenderError as error: - raise RuntimeAdapterError(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error + except RenderBundleError as error: + raise RuntimeServiceUnavailable(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error except Exception as error: raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error @@ -355,6 +191,7 @@ class ProfileCadRuntime: unavailable = [value for value in dependencies if value not in accepted_ids] if unavailable: failures.append({ + "code": "SELECTOR_DEPENDENCY_UNAVAILABLE" if feature.get("selectors") else "DEPENDENCY_UNAVAILABLE", "feature_index": index, "feature_id": feature_id, "message": "Feature was skipped because an earlier dependency did not execute.", @@ -364,14 +201,19 @@ class ProfileCadRuntime: candidate = self._feature_subset(cdsl, [*accepted, feature]) try: latest = self.rebuild(candidate, output_dir, task_id, revision_id) + except (OSError, AdapterUnavailable): + # Preview/render/storage failures are service faults. Preserve + # the staging input and let the workflow replay this exact + # document without spending an Authoring repair. + raise except Exception as error: - failures.append({"feature_index": index, "feature_id": feature_id, "message": str(error)[:1000]}) + failures.append(self._build_failure(index, feature_id, feature, error)) continue accepted.append(deepcopy(feature)) accepted_ids.add(feature_id) if not accepted: if failures: - raise RuntimeAdapterError(str(failures[0].get("message") or "RUNTIME_EXECUTION_FAILURE: no feature could be rebuilt")) + return {}, failures raise RuntimeAdapterError("RUNTIME_EXECUTION_FAILURE: CDSL document has no executable features") # A failed later attempt may have left partial files in the stage. # Rebuild the retained feature set once so all published artifacts are @@ -379,6 +221,46 @@ class ProfileCadRuntime: latest = self.rebuild(self._feature_subset(cdsl, accepted), output_dir, task_id, revision_id) return {**latest, "executed_feature_ids": sorted(accepted_ids)}, failures + @staticmethod + def _build_failure(index: int, feature_id: str, feature: dict[str, Any], error: Exception) -> dict[str, Any]: + diagnostic = getattr(error, "diagnostic", None) + message = str(getattr(diagnostic, "message", "") or error) + diagnostic_code = str(getattr(diagnostic, "code", "") or "") + if not diagnostic_code: + diagnostic_code = next(( + code for code in ( + "selector_output_role_not_found", "selector_geometry_mismatch", + "selector_output_role_ambiguous", "selector_relation_non_unique", + "selector_output_role_owner_required", "selector_output_role_active_body_required", + "selector_owner_required", "selector_output_role_mixed_evidence", + "unsupported_output_role_selector", "invalid_output_role_selector", + ) + if code in message + ), "") + selector_code = { + "selector_output_role_not_found": "SELECTOR_NOT_FOUND", + "selector_geometry_mismatch": "SELECTOR_NOT_FOUND", + "selector_output_role_ambiguous": "SELECTOR_AMBIGUOUS", + "selector_relation_non_unique": "SELECTOR_AMBIGUOUS", + "selector_output_role_owner_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_output_role_active_body_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_owner_required": "SELECTOR_DEPENDENCY_UNAVAILABLE", + "selector_output_role_mixed_evidence": "SELECTOR_KIND_MISMATCH", + "unsupported_output_role_selector": "SELECTOR_KIND_MISMATCH", + "invalid_output_role_selector": "SELECTOR_KIND_MISMATCH", + }.get(diagnostic_code, "ENGINE_EXECUTION_FAILED") + provenance = getattr(error, "selector_resolutions", None) + return { + "code": selector_code, + "runtime_code": diagnostic_code or "execution_failed", + "feature_index": index, + "feature_id": feature_id, + "atomic_id": str(feature.get("atomic_id") or ""), + "input_summary": {"params": sorted((feature.get("params") or {}).keys()), "selector_count": len(feature.get("selectors") or [])}, + "message": message[:1000], + "selector_provenance": provenance if isinstance(provenance, list) else [], + } + @staticmethod def _feature_subset(cdsl: dict[str, Any], features: list[dict[str, Any]]) -> dict[str, Any]: document = deepcopy(cdsl) @@ -392,458 +274,10 @@ 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) -> 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"] - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if contract["fragment_shape"]["selector_tokens"] == "required": - if not all(token in selector_tokens and selector_tokens[token]["kind"] == policy["token_kind"] for token in supplied): - 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") - workplane = sketch.get("workplane") if isinstance(sketch, dict) else None - if not isinstance(workplane, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane is unavailable") - normal, x_dir = workplane.get("normal"), workplane.get("x_dir") - if not isinstance(normal, list) or not isinstance(x_dir, list) or self._norm(normal) <= 1e-9 or self._norm(x_dir) <= 1e-9 or self._norm(self._cross(normal, x_dir)) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane vectors are degenerate") - - def _preflight_profile_non_self_intersecting(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - sketch = fragment.get("sketch") - profile = sketch.get("profile") if isinstance(sketch, dict) else None - if not isinstance(profile, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch profile is unavailable") - if profile.get("type") == "polygon": - vertices = profile.get("vertices") - if not isinstance(vertices, list) or len(vertices) < 3 or self._polygon_self_intersects(vertices): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: polygon profile self-intersects") - 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": - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face is absent or stale") - - def _materialized_hole_host_frame( - self, - selected: dict[str, Any], - positions: Any, - ) -> tuple[dict[str, Any], list[dict[str, list[float]]]]: - """Lower a verified planar host selector into replay-stable CDSL data. - - Hole position inputs are world coordinates at the author boundary. - The engine's frame form uses local coordinates, so both values must - be converted together. Keeping only the selector makes a later - pattern replay depend on a face that the source cut has already - subdivided, which is neither stable nor geometrically meaningful. - """ - geometry = selected.get("geometry") if isinstance(selected, dict) else None - center = geometry.get("center_mm") if isinstance(geometry, dict) else None - normal = geometry.get("normal") if isinstance(geometry, dict) else None - if not self._valid_vector3(center) or not self._valid_vector3(normal, require_nonzero=True): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face has no usable plane frame") - if not isinstance(positions, list) or not positions: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are unavailable for host-frame materialization") - origin = [float(value) for value in center] - unit_normal = [float(value) / self._norm(normal) for value in normal] - seed = [1.0, 0.0, 0.0] if abs(unit_normal[0]) < 0.9 else [0.0, 1.0, 0.0] - x_raw = [seed[index] - self._dot(seed, unit_normal) * unit_normal[index] for index in range(3)] - x_length = self._norm(x_raw) - if x_length <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face cannot define a stable x direction") - x_dir = [value / x_length for value in x_raw] - y_dir = self._cross(unit_normal, x_dir) - local_positions: list[dict[str, list[float]]] = [] - for position in positions: - point = position.get("mm") if isinstance(position, dict) else None - if not self._valid_vector3(point): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point") - offset = [float(point[index]) - origin[index] for index in range(3)] - local_positions.append({"mm": [ - self._dot(offset, x_dir), - self._dot(offset, y_dir), - self._dot(offset, unit_normal), - ]}) - return ( - {"frame": {"origin_mm": origin, "x_dir": x_dir, "y_dir": y_dir, "normal": unit_normal}}, - local_positions, - ) - - def _preflight_hole_positions_on_host_plane(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - positions = fragment.get("feature", {}).get("params", {}).get("positions") - if not isinstance(positions, list) or not positions or not all(isinstance(item, dict) and isinstance(item.get("mm"), list) and len(item["mm"]) == 3 for item in positions): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are invalid for the host plane") - supplied = fragment.get("feature", {}).get("selector_tokens", []) - host = selectors.get(supplied[0]) if len(supplied) == 1 else None - geometry = host.get("geometry") if isinstance(host, dict) else None - normal = geometry.get("normal") if isinstance(geometry, dict) else None - center = geometry.get("center_mm") 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 self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(center): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole host must be an observable planar face") - unit_normal = [float(component) / self._norm(normal) for component in normal] - tolerance = 1e-5 - if not self._valid_bbox(bbox): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face has no usable topology bounds") - for position in positions: - point = position["mm"] - if not self._valid_vector3(point): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point") - offset = [float(point[index]) - float(center[index]) for index in range(3)] - if abs(self._dot(offset, unit_normal)) > tolerance: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not on the selected host plane") - if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)): - 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): - 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") - if not isinstance(contours, list) or not contours: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: analytic profile requires at least one contour") - tolerance = 1e-7 - for contour_index, contour in enumerate(contours): - segments = contour.get("segments") if isinstance(contour, dict) else None - if not isinstance(segments, list) or not segments: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is empty") - if any(segment.get("type") == "circle" for segment in segments if isinstance(segment, dict)): - if len(segments) != 1 or segments[0].get("type") != "circle": - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} cannot mix a full circle with other segments") - continue - endpoints: list[tuple[tuple[float, float], tuple[float, float]]] = [] - for segment_index, segment in enumerate(segments): - if not isinstance(segment, dict) or segment.get("type") not in {"line", "arc"}: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an unsupported segment") - start = self._point2(segment.get("start")) - end = self._point2(segment.get("end")) - if start is None or end is None: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} has non-finite endpoints") - if self._distance2(start, end) <= tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} is degenerate") - if segment.get("type") == "arc": - center = self._point2(segment.get("center")) - radius = segment.get("radius_mm") - if center is None or not isinstance(radius, (int, float)) or isinstance(radius, bool) or not math.isfinite(float(radius)) or float(radius) <= 0: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} has an invalid circle") - arc_tolerance = max(tolerance, float(radius) * 1e-7) - if abs(self._distance2(start, center) - float(radius)) > arc_tolerance or abs(self._distance2(end, center) - float(radius)) > arc_tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} endpoints are not on the declared circle") - endpoints.append((start, end)) - for segment_index in range(1, len(endpoints)): - if self._distance2(endpoints[segment_index - 1][1], endpoints[segment_index][0]) > tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is discontinuous before segment {segment_index}") - if contour.get("closed") is True and self._distance2(endpoints[-1][1], endpoints[0][0]) > tolerance: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is not closed") - line_segments = [segment for segment, raw in zip(endpoints, segments) if raw.get("type") == "line"] - for first, (a, b) in enumerate(line_segments): - for second, (c, d) in enumerate(line_segments): - if second <= first + 1 or (first == 0 and second == len(line_segments) - 1 and contour.get("closed") is True): - continue - if self._segments_intersect(a, b, c, d): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an obvious self-intersection") - - @classmethod - def _validate_materialized_runtime_types(cls, feature: dict[str, Any]) -> None: - atomic_id = str(feature.get("atomic_id") or "") - if not atomic_id.startswith("hole_"): - return - params = feature.get("params") if isinstance(feature.get("params"), dict) else {} - host = params.get("host_face") if isinstance(params.get("host_face"), dict) else None - frame = host.get("frame") if isinstance(host, dict) and isinstance(host.get("frame"), dict) else None - if not isinstance(frame, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame is unavailable") - for field in ("origin_mm", "x_dir", "y_dir", "normal"): - if not cls._valid_vector3(frame.get(field), require_nonzero=field != "origin_mm"): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame.{field} is invalid") - x_dir, y_dir, normal = frame["x_dir"], frame["y_dir"], frame["normal"] - if abs(cls._dot(x_dir, y_dir)) > 1e-6 or abs(cls._dot(x_dir, normal)) > 1e-6 or abs(cls._dot(y_dir, normal)) > 1e-6: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host frame is not orthogonal") - positions = params.get("positions") - if not isinstance(positions, list) or not positions: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole positions are unavailable") - for index, position in enumerate(positions): - point = position.get("mm") if isinstance(position, dict) else None - if not cls._valid_vector3(point) or abs(float(point[2])) > 1e-5: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole position {index} is invalid in the host frame") - @staticmethod def _validate_finite_tree(value: Any, path: str = "$") -> None: - if value is None: - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must not be null") if isinstance(value, float) and not math.isfinite(value): - raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must be finite") + raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: non-finite number at {path}") if isinstance(value, dict): for key, child in value.items(): ProfileCadRuntime._validate_finite_tree(child, f"{path}.{key}") @@ -851,278 +285,6 @@ class ProfileCadRuntime: for index, child in enumerate(value): ProfileCadRuntime._validate_finite_tree(child, f"{path}[{index}]") - @staticmethod - def _point2(value: Any) -> tuple[float, float] | None: - if not isinstance(value, list) or len(value) != 2: - return None - if not all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value): - return None - return float(value[0]), float(value[1]) - - @staticmethod - def _distance2(left: tuple[float, float], right: tuple[float, float]) -> float: - return math.hypot(left[0] - right[0], left[1] - right[1]) - - @classmethod - def _point_in_planar_face(cls, point: list[float], loops: list[Any], normal: list[float], tolerance: float) -> bool: - drop_axis = max(range(3), key=lambda index: abs(float(normal[index]))) - - def project(value: Any) -> tuple[float, float] | None: - if not cls._valid_vector3(value): - return None - coordinates = [float(value[index]) for index in range(3) if index != drop_axis] - return coordinates[0], coordinates[1] - - projected_point = project(point) - if projected_point is None: - return False - polygons: list[list[tuple[float, float]]] = [] - for loop in loops: - polygon = [project(vertex) for vertex in loop] if isinstance(loop, list) else [] - if len(polygon) >= 3 and all(vertex is not None for vertex in polygon): - polygons.append([vertex for vertex in polygon if vertex is not None]) - if not polygons: - return False - - def area(polygon: list[tuple[float, float]]) -> float: - return abs(sum( - polygon[index][0] * polygon[(index + 1) % len(polygon)][1] - - polygon[(index + 1) % len(polygon)][0] * polygon[index][1] - for index in range(len(polygon)) - )) / 2 - - def contains(polygon: list[tuple[float, float]]) -> bool: - x, y = projected_point - inside = False - for index, first in enumerate(polygon): - second = polygon[(index + 1) % len(polygon)] - if cls._distance_to_segment_2d(projected_point, first, second) <= tolerance: - return True - if (first[1] > y) != (second[1] > y): - crossing_x = (second[0] - first[0]) * (y - first[1]) / (second[1] - first[1]) + first[0] - if x < crossing_x: - inside = not inside - return inside - - outer = max(polygons, key=area) - return contains(outer) and not any(contains(inner) for inner in polygons if inner is not outer) - - @staticmethod - def _distance_to_segment_2d(point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]) -> float: - dx, dy = end[0] - start[0], end[1] - start[1] - length_squared = dx * dx + dy * dy - if length_squared <= 1e-18: - return math.hypot(point[0] - start[0], point[1] - start[1]) - fraction = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_squared)) - return math.hypot(point[0] - (start[0] + fraction * dx), point[1] - (start[1] + fraction * dy)) - - def _preflight_cut_exit_distance(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, require_through: bool) -> None: - if not require_through: - return - params = fragment.get("feature", {}).get("params", {}) - depth = params.get("depth_mm", params.get("distance_mm")) - supplied = fragment.get("feature", {}).get("selector_tokens", []) - if not isinstance(depth, (int, float)): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cut depth is unavailable") - if supplied: - host = selectors[supplied[0]]["geometry"] - bbox = host.get("bbox_mm") if isinstance(host, dict) else None - normal = host.get("normal") or host.get("plane_normal") if isinstance(host, dict) else None - thickness = self._span_from_bbox(bbox, normal) - else: - bbox = self._active_body_bbox(selectors) - normal = (fragment.get("sketch") or {}).get("workplane", {}).get("normal") - thickness = self._span_from_bbox(bbox, normal) - if thickness is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cannot prove through-cut exit distance from the active topology") - if float(depth) <= thickness + 1e-6: - raise RuntimeAdapterError( - "RUNTIME_PRECONDITION_FAILED: " - f"cut depth {float(depth):g} mm must exceed measured host-body thickness {thickness:g} mm" - ) - - def _preflight_requires_active_solid(self, _fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # A committed active CDSL document exists only after a candidate with - # a solid has been accepted. Reference and subtraction operations - # therefore cannot be the first feature of a part. - if not isinstance(base, dict) or not base.get("features"): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: operation requires an active solid checkpoint") - - def _preflight_revolve_axis_on_sketch(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - axis = fragment.get("feature", {}).get("params", {}).get("axis") - direction = axis.get("direction") if isinstance(axis, dict) else None - origin = axis.get("origin_mm") if isinstance(axis, dict) else None - workplane = fragment.get("sketch", {}).get("workplane") if isinstance(fragment.get("sketch"), dict) else None - plane_origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None - plane_normal = workplane.get("normal") if isinstance(workplane, dict) else None - if not self._valid_vector3(direction, require_nonzero=True) or not self._valid_vector3(origin) or not self._valid_vector3(plane_origin) or not self._valid_vector3(plane_normal, require_nonzero=True): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis or sketch plane is invalid") - unit_direction = [float(component) / self._norm(direction) for component in direction] - unit_normal = [float(component) / self._norm(plane_normal) for component in plane_normal] - if abs(self._dot(unit_direction, unit_normal)) > 1e-6: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not parallel to the sketch plane") - axis_offset = [float(origin[index]) - float(plane_origin[index]) for index in range(3)] - if abs(self._dot(axis_offset, unit_normal)) > 1e-5: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not on the sketch plane") - - def _preflight_reference_plane_nonzero_normal(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - plane = fragment.get("feature", {}).get("params", {}).get("plane") - normal = plane.get("normal") if isinstance(plane, dict) else None - if not isinstance(normal, list) or self._norm(normal) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference plane normal is degenerate") - - def _preflight_reference_axis_nonzero_direction(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None: - axis = fragment.get("feature", {}).get("params", {}).get("axis") - direction = axis.get("direction") if isinstance(axis, dict) else None - if not isinstance(direction, list) or self._norm(direction) <= 1e-9: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference axis direction is degenerate") - - def _preflight_selected_edges_exist(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 not supplied or not all(isinstance(selectors.get(token), dict) and selectors[token].get("kind") == "edge" for token in supplied): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected edge is absent or stale") - - def _preflight_source_features_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - params = fragment.get("feature", {}).get("params", {}) - known = {str(feature.get("id") or "") for feature in ((base or {}).get("features") or ()) if isinstance(feature, dict)} - if not set(params.get("source_feature_ids") or ()).issubset(known): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: source feature is not part of current head") - - def _preflight_mirror_plane_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") != "plane": - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: mirror plane is absent or stale") - - def _loft_profile_sketches(self, fragment: dict[str, Any], base: dict[str, Any] | None) -> list[dict[str, Any]]: - """Resolve ``profile_sketch_ids`` against the base document's sketches.""" - ids = [str(value) for value in fragment.get("feature", {}).get("params", {}).get("profile_sketch_ids") or ()] - sketches = (base or {}).get("geometry", {}).get("sketches") if isinstance((base or {}).get("geometry"), dict) else None - by_id = { - str(sketch.get("id") or ""): sketch - for sketch in (sketches or ()) - if isinstance(sketch, dict) - } - resolved: list[dict[str, Any]] = [] - for sketch_id in ids: - sketch = by_id.get(sketch_id) - if not isinstance(sketch, dict): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch is not part of current head") - resolved.append(sketch) - if not resolved: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft has no profile sketches") - return resolved - - def _preflight_loft_profiles_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - self._loft_profile_sketches(fragment, base) - - def _preflight_loft_profiles_closed(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # circle/polygon 草图类型闭合性由 schema 保证;analytic_contours 需要 - # 每条参与轮廓显式 closed。 - for sketch in self._loft_profile_sketches(fragment, base): - profile = sketch.get("profile") if isinstance(sketch.get("profile"), dict) else None - if profile is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch has no profile") - if profile.get("type") == "analytic_contours": - contours = profile.get("contours") - if not isinstance(contours, list) or not contours: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile has no contours") - for contour in contours: - if not isinstance(contour, dict) or not contour.get("closed"): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile contour is not closed") - - def _preflight_loft_profiles_single_region(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None: - # 每个放样截面必须是单连通区域:analytic_contours 只允许恰好一条 - # outer 闭合轮廓,不得携带 inner 环或 open 段。 - for sketch in self._loft_profile_sketches(fragment, base): - profile = sketch.get("profile") if isinstance(sketch.get("profile"), dict) else None - if profile is None: - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile sketch has no profile") - if profile.get("type") == "analytic_contours": - contours = [contour for contour in (profile.get("contours") or []) if isinstance(contour, dict)] - outer = [contour for contour in contours if contour.get("role") == "outer" and contour.get("closed")] - if len(outer) != 1 or len(outer) != len(contours): - raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: loft profile must be a single closed region") - - @staticmethod - def _polygon_self_intersects(vertices: list[Any]) -> bool: - points = [(float(point[0]), float(point[1])) for point in vertices if isinstance(point, list) and len(point) == 2] - if len(points) != len(vertices): - return True - segments = list(zip(points, [*points[1:], points[0]])) - for first, (a, b) in enumerate(segments): - for second, (c, d) in enumerate(segments): - if second <= first + 1 or (first == 0 and second == len(segments) - 1): - continue - if ProfileCadRuntime._segments_intersect(a, b, c, d): - return True - return False - - @staticmethod - def _segments_intersect(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> bool: - def orientation(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float: - return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) - - left = orientation(a, b, c) - right = orientation(a, b, d) - low = orientation(c, d, a) - high = orientation(c, d, b) - return (left > 0) != (right > 0) and (low > 0) != (high > 0) - - @staticmethod - def _active_body_bbox(selector_tokens: dict[str, dict[str, Any]]) -> list[float] | None: - for token in selector_tokens.values(): - if token.get("kind") != "body": - continue - geometry = token.get("geometry") - bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None - if isinstance(bbox, list) and len(bbox) == 6 and all(isinstance(value, (int, float)) for value in bbox): - return [float(value) for value in bbox] - return None - - @staticmethod - def _span_from_bbox(bbox: Any, normal: Any) -> float | None: - if not isinstance(bbox, list) or len(bbox) != 6 or not isinstance(normal, list) or len(normal) != 3: - return None - if not all(isinstance(value, (int, float)) for value in [*bbox, *normal]): - return None - length = math.sqrt(sum(float(value) ** 2 for value in normal)) - if length <= 1e-9: - return None - return sum( - abs(float(normal[index]) / length) * abs(float(bbox[index + 3]) - float(bbox[index])) - for index in range(3) - ) - - @staticmethod - def _norm(vector: list[float]) -> float: - return math.sqrt(sum(float(item) ** 2 for item in vector)) - - @staticmethod - def _dot(left: list[float], right: list[float]) -> float: - return sum(float(left[index]) * float(right[index]) for index in range(3)) - - @staticmethod - def _valid_vector3(value: Any, *, require_nonzero: bool = False) -> bool: - valid = ( - isinstance(value, list) - and len(value) == 3 - and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value) - ) - return valid and (not require_nonzero or ProfileCadRuntime._norm(value) > 1e-9) - - @staticmethod - def _valid_bbox(value: Any) -> bool: - return ( - isinstance(value, list) - and len(value) == 6 - and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value) - and all(float(value[index]) <= float(value[index + 3]) for index in range(3)) - ) - - @staticmethod - def _cross(a: list[float], b: list[float]) -> list[float]: - return [float(a[1]) * float(b[2]) - float(a[2]) * float(b[1]), float(a[2]) * float(b[0]) - float(a[0]) * float(b[2]), float(a[0]) * float(b[1]) - float(a[1]) * float(b[0])] - @staticmethod def _write_json(path: Path, payload: dict[str, Any]) -> None: path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/backend/app/cad_agent/adapters/sqlite_repository.py b/backend/app/cad_agent/adapters/sqlite_repository.py index 7ec0d95e..b5cc84c4 100644 --- a/backend/app/cad_agent/adapters/sqlite_repository.py +++ b/backend/app/cad_agent/adapters/sqlite_repository.py @@ -1,5 +1,8 @@ -"""SQLite repository: the sole mutable state authority for protocol v3.""" +"""SQLite authority for ``cad.single-stage.v1`` tasks. +There is no reader or migration for the deleted feature-DAG protocol. Opening +a legacy task database drops its task data before creating this schema. +""" from __future__ import annotations from contextlib import contextmanager @@ -10,11 +13,13 @@ from threading import RLock from typing import Any, Iterator from app.cad_agent.domain.errors import ErrorCode -from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState +from app.cad_agent.domain.state import TaskPhase, TaskState from app.cad_agent.ports import InvocationRecord class SqliteTaskRepository: + PROTOCOL_VERSION = "cad.single-stage.v1" + def __init__(self, database_path: Path) -> None: self.database_path = database_path self.database_path.parent.mkdir(parents=True, exist_ok=True) @@ -35,271 +40,151 @@ class SqliteTaskRepository: def _initialize(self) -> None: with self._lock, self._connection() as connection: - # 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.2'" not in str(existing[0] or ""): + existing = connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").fetchone() + if existing is not None and self.PROTOCOL_VERSION not in str(existing[0] or ""): self.protocol_reset = True - connection.executescript(""" - DROP TABLE IF EXISTS outbox; - DROP TABLE IF EXISTS tool_audits; - DROP TABLE IF EXISTS usage_records; - DROP TABLE IF EXISTS invocations; - DROP TABLE IF EXISTS ledger; - DROP TABLE IF EXISTS model_capabilities; - DROP TABLE IF EXISTS tasks; - """) - connection.executescript( - """ + # This database is dedicated to CAD task state. An old + # protocol has no safe reader or migration, so remove every + # persisted task table and let SQLite remove their indexes. + tables = connection.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + for row in tables: + name = str(row[0]).replace('"', '""') + connection.execute(f'DROP TABLE IF EXISTS "{name}"') + connection.executescript(f""" CREATE TABLE IF NOT EXISTS tasks ( task_id TEXT PRIMARY KEY, - protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'), - request TEXT NOT NULL, - phase TEXT NOT NULL, - state_version INTEGER NOT NULL, - active_revision TEXT NOT NULL DEFAULT '', - pending_action_json TEXT, - candidate_id TEXT NOT NULL DEFAULT '', - candidate_stage_id TEXT NOT NULL DEFAULT '', - repair_required INTEGER NOT NULL DEFAULT 0, - last_error TEXT, - retry_from_phase TEXT NOT NULL DEFAULT '', - requirements_spec_path TEXT NOT NULL DEFAULT '', - 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 '', + protocol_version TEXT NOT NULL CHECK(protocol_version = '{self.PROTOCOL_VERSION}'), + request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL, + active_revision TEXT NOT NULL DEFAULT '', repair_count INTEGER NOT NULL DEFAULT 0, + last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '', + requirements_path TEXT NOT NULL DEFAULT '', authoring_path TEXT NOT NULL DEFAULT '', + runtime_cdsl_path TEXT NOT NULL DEFAULT '', compile_audit_path TEXT NOT NULL DEFAULT '', + diagnostics_path TEXT NOT NULL DEFAULT '', completion_path 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, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS invocations ( - invocation_id TEXT PRIMARY KEY, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - idempotency_key TEXT NOT NULL, - status TEXT NOT NULL, - result_json TEXT, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE(task_id, idempotency_key) + invocation_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id), + idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(task_id,idempotency_key) ); CREATE TABLE IF NOT EXISTS ledger ( - sequence INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - event_json TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id), + event_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS outbox ( - event_id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - event_json TEXT NOT NULL, - published_at TEXT + event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id), + event_json TEXT NOT NULL, published_at TEXT ); CREATE TABLE IF NOT EXISTS usage_records ( - usage_id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - usage_json TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + usage_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id), + usage_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS tool_audits ( - audit_id INTEGER PRIMARY KEY AUTOINCREMENT, - task_id TEXT NOT NULL REFERENCES tasks(task_id), - audit_json TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + audit_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id), + audit_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS model_capabilities ( - provider_id TEXT NOT NULL, - model_id TEXT NOT NULL, - schema_hash TEXT NOT NULL, - supported INTEGER NOT NULL, - report_json TEXT NOT NULL, - checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY(provider_id, model_id, schema_hash) + provider_id TEXT NOT NULL, model_id TEXT NOT NULL, schema_hash TEXT NOT NULL, + supported INTEGER NOT NULL, report_json TEXT NOT NULL, checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(provider_id,model_id,schema_hash) ); - """ - ) + """) 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.2', ?, ?, 0)", - (task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value), + "INSERT OR IGNORE INTO tasks(task_id,protocol_version,request,phase,state_version) VALUES (?,?,?,?,0)", + (task_id, self.PROTOCOL_VERSION, request, TaskPhase.ANALYZING_REQUEST.value), ) state = self.get_state(task_id) if state is None: - raise RuntimeError("SQLite task insert was not visible") + raise RuntimeError("Task was not persisted") return state def get_state(self, task_id: str) -> TaskState | None: with self._lock, self._connection() as connection: - row = connection.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone() - return self._state(row) if row is not None else None + row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone() + return self._state(row) if row else None def get_task_projection(self, task_id: str) -> dict[str, Any] | None: state = self.get_state(task_id) if state is None: return None events = self.ledger_events(task_id) - 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": "" 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") 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 = [ - str(item) for item in frozen.get("verification_warnings") or () if str(item) - ] if isinstance(frozen, dict) else [] - status_event = next(( - item for item in reversed(events) - if item.get("event") in { - "requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted", - "no_progress_limit", - "candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure", - "failed_author_format", "runtime_contract_invalid", - "completed_best_effort", - } - ), {}) if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {} - 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 [] + # A later repair can end at authoring or compilation after an earlier + # build already published an executable prefix. Its terminal event + # intentionally contains diagnostics rather than a duplicate path + # map, so keep the most recent event that actually delivered a + # revision. Otherwise the completed projection loses the existing + # STEP/GLB links exactly when best-effort publication matters most. + result: dict[str, Any] = {} + paths: dict[str, Any] = {} + for item in reversed(events): + if item.get("event") not in { + "build_completed", "published_best_effort", "build_failed", + }: + continue + candidate_paths = item.get("paths") + if isinstance(candidate_paths, dict) and candidate_paths: + result, paths = item, candidate_paths + break return { - "schema_version": "3.2", - "task_id": state.task_id, - "phase": state.phase.value, - "lifecycle": self._lifecycle(state.phase), - "state_version": state.version, - "active_revision": state.active_revision, - "current_revision": state.active_revision, + "schema_version": self.PROTOCOL_VERSION, "task_id": task_id, "phase": state.phase.value, + "lifecycle": self._lifecycle(state.phase), "state_version": state.version, + "active_revision": state.active_revision, "current_revision": state.active_revision, "published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "", - "pending_action": self._pending_payload(state.pending_action), - "active_candidate_id": state.candidate_id, - "repair_required": state.repair_required, + "repair_count": state.repair_count, "repair_budget": 2, "last_error": state.last_error.value if state.last_error else "", - "retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "", - "requirements_spec_path": state.requirements_spec_path, - "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": ( - "completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED) - else "verified" if state.phase == TaskPhase.COMPLETED - else "pending" - ), - "verification_warnings": verification_warnings, - "message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "", - "questions": questions, - "issues": issues, - "blocker_type": ( - "requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER - else str(status_event.get("event") or "") if isinstance(status_event, dict) else "" - ), - "user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions), - "action_ledger_summary": events[-12:], - "revisions": revisions, + "requirements_path": state.requirements_path, "authoring_path": state.authoring_path, + "runtime_cdsl_path": state.runtime_cdsl_path, "compile_audit_path": state.compile_audit_path, + "diagnostics_path": state.diagnostics_path, "completion_path": state.completion_path, + "message": str(result.get("message") or ""), + "revisions": ([{ + "revision_id": state.active_revision, "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED else "checkpoint", + "cdsl_path": paths.get("model.cdsl.json", ""), "step_path": paths.get("model.step", ""), + "glb_path": paths.get("model.glb", ""), "report_path": paths.get("rebuild-report.json", ""), + }] if state.active_revision else []), + "action_ledger_summary": events[-16:], } - @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() - return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows] + rows = connection.execute("SELECT sequence,event_json,created_at FROM ledger WHERE task_id=? ORDER BY sequence", (task_id,)).fetchall() + return [{"sequence": int(row["sequence"]), "at": str(row["created_at"]), **json.loads(row["event_json"])} for row in rows] - def invocation_records(self, task_id: str) -> list[dict[str, Any]]: - with self._lock, self._connection() as connection: - rows = connection.execute( - "SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id", - (task_id,), - ).fetchall() - return [ - { - "invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]), - "status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None, - "created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]), - } - for row in rows - ] - - def compare_and_swap( - self, - state: TaskState, - *, - events: list[dict[str, Any]] = (), - invocation_id: str | None = None, - invocation_result: dict[str, Any] | None = None, - ) -> bool: + def compare_and_swap(self, state: TaskState, *, events: list[dict[str, Any]] = (), invocation_id: str | None = None, invocation_result: dict[str, Any] | None = None) -> bool: if (invocation_id is None) != (invocation_result is None): - raise ValueError("Invocation completion requires both invocation_id and invocation_result") - previous_version = state.version - 1 - if previous_version < 0: - raise ValueError("State version must advance exactly once") + raise ValueError("Invocation completion requires both values") + previous = state.version - 1 with self._lock, self._connection() as connection: connection.execute("BEGIN IMMEDIATE") try: - 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 = ?, 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, - json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None, - state.candidate_id, state.candidate_stage_id, - 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, - ), - ) + cursor = connection.execute(""" + UPDATE tasks SET phase=?,state_version=?,active_revision=?,repair_count=?,last_error=?,retry_from_phase=?, + requirements_path=?,authoring_path=?,runtime_cdsl_path=?,compile_audit_path=?,diagnostics_path=?,completion_path=?,clarification_path=?,updated_at=CURRENT_TIMESTAMP + WHERE task_id=? AND state_version=? + """, ( + state.phase.value, state.version, state.active_revision, state.repair_count, + state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "", + state.requirements_path, state.authoring_path, state.runtime_cdsl_path, state.compile_audit_path, + state.diagnostics_path, state.completion_path, state.clarification_path, state.task_id, previous, + )) if cursor.rowcount != 1: connection.execute("ROLLBACK") return False - for event in events: - encoded = json.dumps(event, ensure_ascii=True, sort_keys=True) - connection.execute("INSERT INTO ledger(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded)) - connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded)) - if invocation_id is not None and invocation_result is not None: - finished = connection.execute( - """UPDATE invocations - SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP - WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""", - (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id), - ) - if finished.rowcount != 1: - raise ValueError("Invocation is not an active record for this state transition") + for item in events: + encoded = json.dumps(item, ensure_ascii=True, sort_keys=True) + connection.execute("INSERT INTO ledger(task_id,event_json) VALUES (?,?)", (state.task_id, encoded)) + connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (state.task_id, encoded)) + if invocation_id is not None: + updated = connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND task_id=? AND status='processing'", (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id)) + if updated.rowcount != 1: + raise ValueError("Invocation is not active") connection.execute("COMMIT") return True except Exception: @@ -310,144 +195,92 @@ class SqliteTaskRepository: with self._lock, self._connection() as connection: connection.execute("BEGIN IMMEDIATE") try: - existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone() - if existing is not None: + row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND idempotency_key=?", (task_id, idempotency_key)).fetchone() + if row is None: + connection.execute("INSERT INTO invocations(invocation_id,task_id,idempotency_key,status) VALUES (?,?,?,'processing')", (invocation_id, task_id, idempotency_key)) connection.execute("COMMIT") - return self._invocation(existing) - connection.execute("INSERT INTO invocations(invocation_id, task_id, idempotency_key, status) VALUES (?, ?, ?, 'processing')", (invocation_id, task_id, idempotency_key)) + return InvocationRecord(invocation_id, idempotency_key, "processing") connection.execute("COMMIT") - return InvocationRecord(invocation_id, idempotency_key, "processing") + return self._invocation(row) except Exception: connection.execute("ROLLBACK") raise def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None: with self._lock, self._connection() as connection: - row = connection.execute( - "SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?", - (task_id, invocation_id), - ).fetchone() - return self._invocation(row) if row is not None else None + row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND invocation_id=?", (task_id, invocation_id)).fetchone() + return self._invocation(row) if row else None def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None: with self._lock, self._connection() as connection: - row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone() - if row is None: - raise ValueError("Unknown invocation") - if str(row["status"]) == "finished": - # A winning concurrent command has already recorded the only - # durable result for this idempotency key. Never overwrite it. - return - cursor = connection.execute( - "UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'", - (json.dumps(result, ensure_ascii=True), invocation_id), - ) - if cursor.rowcount != 1: - raise ValueError("Invocation could not be completed") + connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND status='processing'", (json.dumps(result, ensure_ascii=True), invocation_id)) def append_outbox(self, task_id: str, event: dict[str, Any]) -> None: with self._lock, self._connection() as connection: - connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True))) + connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True))) def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]: + query = "SELECT event_id,task_id,event_json FROM outbox WHERE published_at IS NULL" + args: tuple[Any, ...] = () + if task_id is not None: + query += " AND task_id=?" + args += (task_id,) + query += " ORDER BY event_id LIMIT ?" + args += (limit,) with self._lock, self._connection() as connection: - if task_id is None: - rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall() - else: - rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall() - return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows] + rows = connection.execute(query, args).fetchall() + return [{"event_id": int(row["event_id"]), "task_id": str(row["task_id"]), **json.loads(row["event_json"])} for row in rows] def mark_outbox_published(self, event_id: int) -> None: with self._lock, self._connection() as connection: - connection.execute("UPDATE outbox SET published_at = CURRENT_TIMESTAMP WHERE event_id = ? AND published_at IS NULL", (event_id,)) + connection.execute("UPDATE outbox SET published_at=CURRENT_TIMESTAMP WHERE event_id=? AND published_at IS NULL", (event_id,)) def record_usage(self, task_id: str, payload: dict[str, Any]) -> None: with self._lock, self._connection() as connection: - connection.execute("INSERT INTO usage_records(task_id, usage_json) VALUES (?, ?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True))) + connection.execute("INSERT INTO usage_records(task_id,usage_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True))) def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None: - """Persist a diagnostic structured-output audit separately from usage.""" with self._lock, self._connection() as connection: - connection.execute( - "INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)", - (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)), - ) + connection.execute("INSERT INTO tool_audits(task_id,audit_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True))) def tool_audits(self, task_id: str) -> list[dict[str, Any]]: with self._lock, self._connection() as connection: - rows = connection.execute( - "SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id", - (task_id,), - ).fetchall() - return [ - {"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} - for row in rows - ] + rows = connection.execute("SELECT audit_id,audit_json,created_at FROM tool_audits WHERE task_id=? ORDER BY audit_id", (task_id,)).fetchall() + return [{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} for row in rows] def usage_summary(self, task_id: str) -> dict[str, Any]: with self._lock, self._connection() as connection: - rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id = ? ORDER BY usage_id", (task_id,)).fetchall() - values = [json.loads(row["usage_json"]) for row in rows] - return { - "calls": len(values), - "prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)), - "completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)), - "context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)), - "records": values, - } + rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id=? ORDER BY usage_id", (task_id,)).fetchall() + return {"calls": len(rows), "records": [json.loads(row["usage_json"]) for row in rows]} def running_task_ids(self) -> list[str]: - phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY}) - placeholders = ", ".join("?" for _ in phases) + terminal = tuple(item.value for item in (TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER)) with self._lock, self._connection() as connection: - rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall() + rows = connection.execute("SELECT task_id FROM tasks WHERE phase NOT IN (?,?,?,?) ORDER BY created_at", terminal).fetchall() return [str(row["task_id"]) for row in rows] def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None: with self._lock, self._connection() as connection: - row = connection.execute("SELECT supported, report_json, checked_at FROM model_capabilities WHERE provider_id = ? AND model_id = ? AND schema_hash = ?", (provider_id, model_id, schema_hash)).fetchone() - return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None + row = connection.execute("SELECT supported,report_json,checked_at FROM model_capabilities WHERE provider_id=? AND model_id=? AND schema_hash=?", (provider_id, model_id, schema_hash)).fetchone() + return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": str(row["checked_at"])} if row else None def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None: with self._lock, self._connection() as connection: - connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT(provider_id, model_id, schema_hash) DO UPDATE SET supported = excluded.supported, report_json = excluded.report_json, checked_at = CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True))) + connection.execute("""INSERT INTO model_capabilities(provider_id,model_id,schema_hash,supported,report_json) VALUES (?,?,?,?,?) + ON CONFLICT(provider_id,model_id,schema_hash) DO UPDATE SET supported=excluded.supported,report_json=excluded.report_json,checked_at=CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True))) @staticmethod def _state(row: sqlite3.Row) -> TaskState: - raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None - pending = PendingAction( - 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"]), - active_revision=str(row["active_revision"] or ""), pending_action=pending, - candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""), - repair_required=bool(row["repair_required"]), + active_revision=str(row["active_revision"]), repair_count=int(row["repair_count"]), last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None, retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None, - requirements_spec_path=str(row["requirements_spec_path"] or ""), - 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 ""), + requirements_path=str(row["requirements_path"]), authoring_path=str(row["authoring_path"]), + runtime_cdsl_path=str(row["runtime_cdsl_path"]), compile_audit_path=str(row["compile_audit_path"]), + diagnostics_path=str(row["diagnostics_path"]), completion_path=str(row["completion_path"]), clarification_path=str(row["clarification_path"]), ) - @staticmethod - 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, "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: return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None) @@ -460,6 +293,6 @@ class SqliteTaskRepository: return "failed" if phase == TaskPhase.CANCELLED: return "cancelled" - if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}: - return phase.value.lower() + if phase == TaskPhase.WAITING_FOR_USER: + return "waiting_for_user" return "running" diff --git a/backend/app/cad_agent/adapters/structured_llm.py b/backend/app/cad_agent/adapters/structured_llm.py index 53532ef8..f786905b 100644 --- a/backend/app/cad_agent/adapters/structured_llm.py +++ b/backend/app/cad_agent/adapters/structured_llm.py @@ -1,4 +1,4 @@ -"""The sole OpenAI-compatible structured-output adapter for protocol v3. +"""The sole OpenAI-compatible structured-output adapter for Authoring CDSL. Raw tool arguments are intentionally preserved. Callers must run their Pydantic/JSON-Schema canonical validator before causing any state transition. @@ -170,39 +170,12 @@ class StructuredModelGateway: @staticmethod def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]: - """Give capability probes the same selector fact production exposes. - - A required opaque selector is not inferable from a generic request for - a minimal example. Production fragment turns expose a topology token - before the fragment tool, so the probe must do the same while still - letting the provider generate every other schema field itself. - """ + """Probe the two standalone structured documents used by this protocol.""" function = tool.get("function") if isinstance(tool.get("function"), dict) else {} name = str(function.get("name") or "") - parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {} - feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None - feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {} - feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else [] - selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict) instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative." - if selector_required: - atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..." - instruction += ( - " The current topology exposes opaque selector token `sel_conformance`. " - f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)}," - "\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. " - "Because feature.selector_tokens is required, it is author input and must not be moved into params." - ) - if name == "review_candidate": - instruction += ( - " This is a candidate-review probe: include every required top-level and nested field; " - "in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them." - ) - elif name == "review_final": - instruction += ( - " This is a final-review probe: include every required top-level and nested field; " - "in particular provide a verdict and claim_coverage. Use pass only where the schema permits it." - ) + if name == "write_authoring_cdsl": + instruction += " Return cad.author.v1 with document-local names only; do not include runtime IDs, stable selectors, snapshots, or selector tokens." return [{"role": "system", "content": instruction}] def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]: @@ -256,7 +229,7 @@ class StructuredModelGateway: force_tool_name: bool = True, ) -> dict[str, Any]: if len(tools) != 1 or not required_tool_name: - raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.") + raise StructuredModelError("The Authoring protocol requires exactly one named tool per provider call.") request_options = provider.request_options if include_reasoning else {} if provider.api_style == "responses": payload: dict[str, Any] = { diff --git a/backend/app/cad_agent/adapters/verifier.py b/backend/app/cad_agent/adapters/verifier.py deleted file mode 100644 index aafd0e2f..00000000 --- a/backend/app/cad_agent/adapters/verifier.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Adapter exposing the pure verifier registry through the application port.""" - -from __future__ import annotations - -from typing import Any - -from app.cad_agent.domain.verifier_registry import VerifierRegistry - - -class RegistryVerifierExecutor: - def __init__(self, registry: VerifierRegistry) -> None: - self.registry = registry - - def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: - results: list[dict[str, Any]] = [] - for claim in claims: - result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts) - results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result}) - return results diff --git a/backend/app/cad_agent/application/__init__.py b/backend/app/cad_agent/application/__init__.py index 8eb3caf7..210f4a3d 100644 --- a/backend/app/cad_agent/application/__init__.py +++ b/backend/app/cad_agent/application/__init__.py @@ -1 +1 @@ -"""Application command handlers and context assembly for protocol v3.""" +"""Application workflow and compiler for the Authoring CDSL protocol.""" diff --git a/backend/app/cad_agent/application/action_handlers.py b/backend/app/cad_agent/application/action_handlers.py deleted file mode 100644 index 0f7294ba..00000000 --- a/backend/app/cad_agent/application/action_handlers.py +++ /dev/null @@ -1,1529 +0,0 @@ -"""Action selection, candidate, review, and completion command handlers.""" - -from __future__ import annotations - -from hashlib import sha256 -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, - GeometryConclusion, - NextAction, - RollbackCheckpoint, -) -from app.cad_agent.application.results import Accepted, Rejected -from app.cad_agent.domain.errors import ErrorCode, WorkflowError -from app.cad_agent.domain.operation_contract import canonical_hash, validate_fragment -from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState, reject_stale_head, transition -from app.cad_agent.ports import ArtifactStore, CadRuntime, TaskRepository, VerifierExecutor - - -class ActionCommandHandler: - def __init__(self, repository: TaskRepository, artifacts: ArtifactStore, runtime: CadRuntime, verifiers: VerifierExecutor) -> None: - self.repository = repository - self.artifacts = artifacts - self.runtime = runtime - self.verifiers = verifiers - - def available_atomic_ids(self, task_id: str, state: TaskState | None = None) -> tuple[str, ...]: - """Return operations whose mandatory snapshot inputs exist now.""" - state = state or self.repository.get_state(task_id) - topology = self.artifacts.read_topology(task_id, state.active_revision) if state is not None else None - selector_tokens = self.runtime.selector_tokens(topology) - active_cdsl = self.artifacts.read_active_cdsl(task_id, state.active_revision) if state is not None else None - reference_tokens = self.runtime.reference_tokens(active_cdsl) - has_active_solid = isinstance(active_cdsl, dict) and bool(active_cdsl.get("features")) - available: list[str] = [] - for atomic_id in self.runtime.supported_atomic_ids(): - contract = self.runtime.operation_contract(atomic_id) - shape = contract.get("fragment_shape") if isinstance(contract.get("fragment_shape"), dict) else {} - if str(shape.get("selector_tokens") or "forbidden") == "required": - policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - required_kind = str(policy.get("token_kind") or "") - if not any(token.get("kind") == required_kind for token in selector_tokens.values() if isinstance(token, dict)): - continue - reference_policy = contract.get("reference_policy") if isinstance(contract.get("reference_policy"), dict) else {} - if reference_policy.get("mode") == "snapshot_bound": - minimum = int(reference_policy.get("min_items") or 1) - if len(reference_tokens) < minimum: - continue - if "requires_active_solid" in (contract.get("semantic_preflight") or ()) and not has_active_solid: - continue - 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: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "A next action is not allowed in the current workflow state.")) - if not self.repair_action_ready(task_id, state): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Record a current geometry conclusion before selecting a repair action.")) - stale = reject_stale_head(state, proposal.working_head) - if stale: - return Rejected(stale) - contract = self._requirements_contract(task_id, state) - if not isinstance(contract, dict): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements contract is not frozen.")) - requirement_ids = {str(item.get("requirement_id") or "") for item in contract.get("requirements") or () if isinstance(item, dict)} - if not set(proposal.requirement_ids).issubset(requirement_ids): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Action references a requirement outside the frozen contract.")) - if proposal.atomic_id not in self.runtime.supported_atomic_ids(): - return Rejected(WorkflowError(ErrorCode.RUNTIME_CONTRACT_INVALID, "Action selects an operation absent from the verified runtime registry.")) - if proposal.atomic_id not in self.available_atomic_ids(task_id, state): - return Rejected(WorkflowError( - ErrorCode.AUTHOR_DECISION_REJECTED, - "Action requires selector or feature-reference facts unavailable from the current geometry snapshot.", - )) - operation = self.runtime.operation_contract(proposal.atomic_id) - key = self._key(task_id, "next_action", state.working_head, proposal.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - action = PendingAction( - action_id=f"act_{state.version + 1:03d}", working_head=state.working_head, intent=proposal.intent, - requirement_ids=tuple(proposal.requirement_ids), atomic_id=proposal.atomic_id, expected_change=proposal.expected_change, - contract_hash=str(operation["contract_hash"]), idempotency_key=key, - ) - next_state = transition(state, "action_proposed", pending_action=action) - payload = {"action_id": action.action_id, "working_head": action.working_head, "atomic_id": action.atomic_id, "contract_hash": action.contract_hash} - if not self._commit_invocation(next_state, [{"event": "proposed", **payload, "intent": action.intent, "requirement_ids": list(action.requirement_ids), "expected_change": action.expected_change}], invocation, payload): - return Rejected(self._stale()) - return Accepted(payload) - - def diagnostic_evidence_refs(self, task_id: str, state: TaskState | None = None) -> tuple[str, ...]: - """Return the server-generated evidence references for a repair turn. - - These are opaque names for current facts and committed audit evidence, - never model-provided paths or feature IDs. The command handler checks - them again so a schema from an earlier turn cannot authorize a write. - """ - state = state or self.repository.get_state(task_id) - if state is None: - return () - refs = ["evidence_current_state"] - if state.active_revision: - refs.extend(["evidence_current_model", "evidence_current_claims"]) - for event in self.repository.ledger_events(task_id)[-16:]: - if event.get("event") in { - "candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", - "candidate_recovered_rejected", "candidate_operation_skipped", "final_review_repair", - } or (event.get("event") == "accepted" and event.get("repair_required")): - sequence = event.get("sequence") - if isinstance(sequence, int): - refs.append(f"evidence_ledger_{sequence}") - return tuple(dict.fromkeys(refs)) - - def repair_diagnostics(self, task_id: str, state: TaskState | None = None) -> list[dict[str, Any]]: - """Return bounded, server-measured evidence from failed candidates. - - A repair turn runs against the last accepted checkpoint, while its - useful facts often live in an immutable rejected candidate stage. An - opaque ledger reference alone forces the author to rediscover those - facts or repeat a failed construction. This projection deliberately - exposes only compact build/verification outcomes, never the rejected - fragment or a server-selected next operation. - """ - state = state or self.repository.get_state(task_id) - if state is None or not state.repair_required: - return [] - relevant = { - "candidate_rejected", - "candidate_build_failed", - "candidate_recovery_failed", - "candidate_recovered_rejected", - "candidate_operation_skipped", - "runtime_precondition_rejected", - "final_review_repair", - "accepted", - } - diagnostics: list[dict[str, Any]] = [] - for event in reversed(self.repository.ledger_events(task_id)): - if event.get("event") not in relevant: - continue - sequence = event.get("sequence") - item: dict[str, Any] = { - "event": str(event.get("event") or ""), - "evidence_ref": f"evidence_ledger_{sequence}" if isinstance(sequence, int) else "evidence_current_state", - } - for field in ("candidate_id", "action_id"): - value = event.get(field) - if isinstance(value, str) and value: - item[field] = value - message = event.get("message") - if isinstance(message, str) and message: - item["message"] = message[:500] - for field in ("issues", "failed_checklist_items"): - values = event.get(field) - if isinstance(values, list): - item[field] = [str(value)[:500] for value in values[:8] if isinstance(value, str)] - failures = event.get("operation_failures") - if isinstance(failures, list): - item["operation_failures"] = [ - {key: str(value)[:500] for key, value in failure.items() if key in {"feature_id", "message"}} - for failure in failures[:8] - if isinstance(failure, dict) - ] - fragment_hash = event.get("fragment_hash") - if isinstance(fragment_hash, str) and fragment_hash: - item["fragment_hash"] = fragment_hash - stage_id = event.get("stage_id") - candidate = None - if isinstance(stage_id, str) and stage_id: - try: - candidate = self.artifacts.read_stage_json(task_id, stage_id, "candidate.json") - except (OSError, ValueError, json.JSONDecodeError): - # The ledger remains authoritative if an abandoned stage - # is unavailable; diagnostic projection must not turn an - # existing repair into a new service failure. - candidate = None - if isinstance(candidate, dict): - atomic_id = candidate.get("actual_atomic_id") - if isinstance(atomic_id, str) and atomic_id: - item["atomic_id"] = atomic_id - health = candidate.get("health") if isinstance(candidate.get("health"), dict) else {} - bbox = health.get("bbox_mm") if isinstance(health.get("bbox_mm"), dict) else {} - measured = { - key: health[key] - for key in ("solid_count", "feature_count", "volume_mm3") - if isinstance(health.get(key), (int, float)) - } - if isinstance(bbox.get("dimensions"), list): - measured["bbox_dimensions_mm"] = bbox["dimensions"][:3] - if measured: - item["measured"] = measured - claim_results = candidate.get("claim_results") - if isinstance(claim_results, list): - failures: list[dict[str, Any]] = [] - for claim in claim_results: - if not isinstance(claim, dict) or claim.get("status") not in {"fail", "unavailable"}: - continue - evidence = claim.get("evidence") if isinstance(claim.get("evidence"), dict) else {} - failures.append({ - "claim_id": str(claim.get("claim_id") or ""), - "claim_kind": str(claim.get("claim_kind") or ""), - "status": str(claim.get("status") or ""), - "evidence": evidence, - }) - if failures: - item["failed_claims"] = failures[:8] - operation_results = candidate.get("operation_verifier_results") - if isinstance(operation_results, list): - operation_blockers: list[dict[str, Any]] = [] - for result in operation_results: - if not isinstance(result, dict) or result.get("status") == "pass": - continue - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - operation_blockers.append({ - "claim_id": str(result.get("claim_id") or ""), - "claim_kind": str(result.get("claim_kind") or ""), - "status": str(result.get("status") or ""), - "evidence": evidence, - }) - if operation_blockers: - # An operation may be rejected because it produced no - # net change. That result is often pending relative - # to the full requirements contract, but it is still - # a definite blocker for this specific action. - item["operation_blockers"] = operation_blockers[:8] - review = None - if isinstance(stage_id, str) and stage_id: - try: - review = self.artifacts.read_stage_json(task_id, stage_id, "candidate-review.json") - except (OSError, ValueError, json.JSONDecodeError): - review = None - if isinstance(review, dict): - evidence = review.get("evidence") - issues = review.get("issues") - if isinstance(evidence, list): - item["review_evidence"] = [str(value)[:500] for value in evidence[:8] if isinstance(value, str)] - if isinstance(issues, list): - item["review_issues"] = [str(value)[:500] for value in issues[:8] if isinstance(value, str)] - diagnostics.append(item) - if len(diagnostics) == 3: - break - return diagnostics - - def checkpoint_tokens(self, task_id: str, state: TaskState | None = None) -> dict[str, str]: - """Map opaque rollback tokens to the current linear checkpoint lineage.""" - state = state or self.repository.get_state(task_id) - if state is None: - return {} - accepted = [ - event for event in self.repository.ledger_events(task_id) - if event.get("event") == "accepted" and isinstance(event.get("revision_id"), str) - ] - by_revision = {str(event["revision_id"]): event for event in accepted} - if state.active_revision and state.active_revision not in by_revision: - return {} - lineage: list[str] = [] - cursor = state.active_revision - while cursor: - event = by_revision.get(cursor) - if event is None or cursor in lineage: - return {} - lineage.append(cursor) - parent = event.get("parent_revision") - if isinstance(parent, str): - cursor = parent - continue - # v3 events written before parent_revision existed were linear. - position = accepted.index(event) - cursor = str(accepted[position - 1]["revision_id"]) if position else "" - lineage.reverse() - return {"checkpoint_root": "", **{f"checkpoint_{revision}": revision for revision in lineage}} - - def rollback_available(self, task_id: str, state: TaskState | None = None) -> bool: - state = state or self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None: - return False - has_earlier_checkpoint = any( - revision != state.active_revision - for revision in self.checkpoint_tokens(task_id, state).values() - ) - return has_earlier_checkpoint and any( - event.get("event") == "geometry_conclusion" - and event.get("decision") == "rollback" - and event.get("working_head") == state.working_head - for event in reversed(self.repository.ledger_events(task_id)) - ) - - def repair_action_ready(self, task_id: str, state: TaskState | None = None) -> bool: - """Require a structured diagnosis before repairing failed geometry. - - A rollback is itself a resolved repair decision. For a new feature, - the author must instead record a return-to-action-selection conclusion - after the latest failed candidate. The ordering matters: a diagnosis - used to select a prior repair action cannot authorize retries after - that new action fails on the same checkpoint. - """ - state = state or self.repository.get_state(task_id) - if state is None or not state.repair_required: - return True - repair_triggers = { - "candidate_rejected", - "candidate_build_failed", - "candidate_recovery_failed", - "candidate_recovered_rejected", - "final_review_repair", - } - for event in reversed(self.repository.ledger_events(task_id)): - event_name = event.get("event") - if event_name == "rollback": - return True - if event_name == "geometry_conclusion" and event.get("decision") == "return_to_action_selection": - return True - if event_name in repair_triggers: - return False - return False - - def record_geometry_conclusion(self, task_id: str, conclusion: GeometryConclusion, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None or state.phase not in {TaskPhase.ACTION_PENDING, TaskPhase.AWAITING_ACTION}: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Geometry diagnosis is only available while repairing or selecting an action.")) - stale = reject_stale_head(state, conclusion.working_head) - if stale: - return Rejected(stale) - available = set(self.diagnostic_evidence_refs(task_id, state)) - if not set(conclusion.evidence_refs).issubset(available): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Geometry conclusion references evidence outside the current server snapshot.")) - if conclusion.decision == "rollback" and state.phase != TaskPhase.AWAITING_ACTION: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Return to action selection before requesting a checkpoint rollback.")) - key = self._key(task_id, "geometry_conclusion", state.working_head, conclusion.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - if conclusion.decision == "return_to_action_selection" and state.phase == TaskPhase.ACTION_PENDING: - next_state = transition(state, "diagnosis_return_to_action_selection", repair_required=True) - else: - next_state = transition(state, "diagnosis_recorded", repair_required=True) - event = { - "event": "geometry_conclusion", - "working_head_before": state.working_head, - "working_head": next_state.working_head, - "evidence_refs": list(conclusion.evidence_refs), - "root_cause": conclusion.root_cause, - "decision": conclusion.decision, - "corrective_intent": conclusion.corrective_intent or "", - } - result = {"decision": conclusion.decision, "phase": next_state.phase.value, "working_head": next_state.working_head} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def rollback_checkpoint(self, task_id: str, rollback: RollbackCheckpoint, *, 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: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Checkpoint rollback requires an idle action-selection state.")) - stale = reject_stale_head(state, rollback.working_head) - if stale: - return Rejected(stale) - if not self.rollback_available(task_id, state): - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Record a current geometry conclusion with decision=rollback before rolling back.")) - target = self.checkpoint_tokens(task_id, state).get(rollback.checkpoint_token) - if target is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Rollback checkpoint token is not in the active lineage.")) - if target == state.active_revision: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Rollback must select an earlier checkpoint.")) - key = self._key(task_id, "rollback", state.working_head, rollback.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - next_state = transition(state, "rollback", active_revision=target, repair_required=True) - event = { - "event": "rollback", - "working_head_before": state.working_head, - "working_head": next_state.working_head, - "checkpoint_token": rollback.checkpoint_token, - "rollback_from_revision": state.active_revision, - "rollback_to_revision": target, - "reason": rollback.reason, - } - result = {"status": "rolled_back", "active_revision": target, "working_head": next_state.working_head} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - 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.")) - contract = self.runtime.operation_contract(action.atomic_id) - if contract["contract_hash"] != action.contract_hash: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "The pending operation contract changed; read the current contract again.")) - base = self.artifacts.read_active_cdsl(task_id, state.active_revision) - topology = self.artifacts.read_topology(task_id, state.active_revision) - selectors = self.runtime.selector_tokens(topology) - references = self.runtime.reference_tokens(base) - selector_policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - expected_selector_kind = str(selector_policy.get("token_kind") or "") - allowed_selector_tokens = ( - [token for token, value in selectors.items() if isinstance(value, dict) and value.get("kind") == expected_selector_kind] - if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") == "required" - else [] - ) - errors = validate_fragment( - contract, - fragment, - selector_tokens=allowed_selector_tokens, - reference_tokens=list(references), - root_xy_datum=not bool(state.active_revision), - ) - if errors: - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "CDSL fragment violates the active operation schema.", field_errors=tuple(errors))) - fragment_hash = canonical_hash(fragment) - exact_fingerprint = canonical_hash({ - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - }) - prior_exact = next(( - event for event in reversed(self.repository.ledger_events(task_id)) - if event.get("failure_exact_fingerprint") == exact_fingerprint - ), None) - if prior_exact is not None: - return Rejected(WorkflowError( - ErrorCode.RUNTIME_PRECONDITION_FAILED, - "This exact CDSL fragment was already proven to fail at the current checkpoint; choose a different fragment or operation path.", - details={ - "duplicate_fragment": True, - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": str(prior_exact.get("normalized_error_code") or ErrorCode.RUNTIME_PRECONDITION_FAILED.value), - }, - )) - key = self._key(task_id, "fragment", action.working_head, {"action_id": action.action_id, "fragment": fragment}) - try: - require_through = self._action_requires_through(task_id, action.requirement_ids) - cdsl, audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=require_through) - except Exception as error: - runtime_error = self._runtime_error(error) - return Rejected(WorkflowError( - runtime_error.code, - runtime_error.message, - field_errors=runtime_error.field_errors, - retryable=runtime_error.retryable, - details={ - **runtime_error.details, - "active_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": runtime_error.code.value, - }, - )) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - candidate_id = "candidate_" + sha256(key.encode("utf-8")).hexdigest()[:16] - try: - stage = self.artifacts.start_candidate_stage(task_id, key, {"schema_version": "cad.v3.candidate-input.v1", "idempotency_key": key, "candidate_id": candidate_id, "action_id": action.action_id, "working_head": action.working_head, "contract_hash": action.contract_hash, "fragment": fragment, "fragment_audit": audit}) - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_stage_storage_failure", - message=str(error), - ) - building = transition(state, "candidate_started", candidate_id=candidate_id, candidate_stage_id=stage.stage_id) - if not self.repository.compare_and_swap(building, events=[{"event": "candidate_building", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "fragment_hash": audit["fragment_hash"]}]): - return Rejected(self._stale()) - try: - rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, stage.output_dir, task_id, candidate_id) - attempted_feature_ids = {str(value) for value in audit.get("assigned_feature_ids") or () if isinstance(value, str)} - executed_feature_ids = {str(value) for value in rebuilt.get("executed_feature_ids") or () if isinstance(value, str)} - if attempted_feature_ids and not attempted_feature_ids.intersection(executed_feature_ids): - next_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - result = {"candidate_id": candidate_id, "status": "skipped", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "operation_failures": operation_failures} - if not self._commit_invocation(next_state, [{ - "event": "candidate_operation_skipped", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "operation_failures": operation_failures, - "message": "The submitted feature did not execute; earlier executable features were retained.", - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError( - ErrorCode.CANDIDATE_BUILD_FAILED, - "The submitted feature could not execute; the previous executable checkpoint was retained.", - details={"operation_failures": operation_failures}, - )) - try: - claim_results = self._evaluate_claims(task_id, rebuilt) - except Exception as error: - failed_state = transition(building, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID) - result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value} - if not self._commit_invocation(failed_state, [{ - "event": "requirements_contract_execution_failed", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "message": str(error)[:1000], - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError( - ErrorCode.REQUIREMENTS_SPEC_INVALID, - "The frozen requirements contract could not be evaluated; no CAD repair was attempted.", - details={"diagnostic": str(error)[:1000]}, - )) - operation_results = self._operation_candidate_results( - action, - contract, - cdsl, - rebuilt, - parent_facts=self._facts(task_id, state.active_revision), - require_through=require_through, - ) - blockers = [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if item.get("status") != "pass"], - ] - candidate = {"schema_version": "cad.v3.candidate.v1", "candidate_id": candidate_id, "stage_id": stage.stage_id, "action_id": action.action_id, "working_head": action.working_head, "actual_atomic_id": action.atomic_id, "fragment_hash": audit["fragment_hash"], "selector_snapshot_id": audit["selector_snapshot_id"], "claim_results": claim_results, "operation_verifier_results": operation_results, "blockers": blockers, "operation_failures": operation_failures, "executed_feature_ids": rebuilt.get("executed_feature_ids") or [], "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"]} - self.artifacts.write_stage_json(task_id, stage.stage_id, "candidate.json", candidate) - review = transition(building, "candidate_built", candidate_id=candidate_id, candidate_stage_id=stage.stage_id) - result = {"candidate_id": candidate_id, "stage_id": stage.stage_id, "status": "awaiting_review", "claim_results": claim_results, "operation_failures": operation_failures} - if not self._commit_invocation(review, [{"event": "candidate_built", "candidate_id": candidate_id, "action_id": action.action_id, "claim_results": claim_results, "operation_failures": operation_failures}], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - except OSError as error: - return self._park_retry( - building, - ErrorCode.STORAGE_FAILURE, - event="candidate_build_storage_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id}, - ) - except Exception as error: - if "RENDER_SERVICE_UNAVAILABLE" in str(error): - return self._park_retry( - building, - ErrorCode.RENDER_SERVICE_UNAVAILABLE, - event="candidate_render_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id}, - ) - if "RUNTIME_EXECUTION_FAILURE" in str(error): - return self._park_retry( - building, - ErrorCode.RUNTIME_EXECUTION_FAILURE, - event="candidate_runtime_execution_failure", - message=str(error), - details={"candidate_id": candidate_id, "stage_id": stage.stage_id, "checkpoint_revision": state.active_revision}, - ) - failed_state = transition(building, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - result = {"candidate_id": candidate_id, "status": "failed", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value} - if not self._commit_invocation(failed_state, [{ - "event": "candidate_build_failed", - "candidate_id": candidate_id, - "stage_id": stage.stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "checkpoint_revision": state.active_revision, - "atomic_id": action.atomic_id, - "fragment_hash": fragment_hash, - "normalized_error_code": ErrorCode.CANDIDATE_BUILD_FAILED.value, - "failure_exact_fingerprint": exact_fingerprint, - "message": str(error)[:1000], - }], invocation, result): - return Rejected(self._stale()) - return Rejected(WorkflowError(ErrorCode.CANDIDATE_BUILD_FAILED, "Candidate build failed; the checkpoint remains unchanged.", details={"diagnostic": str(error)[:1000]})) - - def recover_candidate_build(self, task_id: str) -> Accepted | Rejected: - """Resume one persisted candidate build without inventing another action. - - A complete staging directory is converted to the next state directly. - If a process stopped mid-build, the same stage/idempotency key is used - and an already-written CDSL document is rebuilt in place. - """ - state = self.repository.get_state(task_id) - action = state.pending_action if state else None - if state is None or state.phase != TaskPhase.CANDIDATE_BUILDING or action is None or not state.candidate_stage_id: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "There is no recoverable candidate build.")) - try: - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - if isinstance(candidate, dict): - return self._advance_recovered_candidate(task_id, state, candidate) - source = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "input.json") - if not isinstance(source, dict) or source.get("candidate_id") != state.candidate_id: - return self._park_for_storage_retry( - state, - event="candidate_recovery_storage_failure", - message="Candidate recovery input is unavailable.", - ) - report = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "rebuild-report.json") - topology = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "model.topology.json") - cdsl = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "model.cdsl.json") - if isinstance(report, dict) and isinstance(topology, dict) and isinstance(cdsl, dict): - rebuilt = { - "health": report.get("health") or {}, "topology": topology, "report": report, - "render_manifest": report.get("render_manifest") or {}, - "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"}, - } - else: - base = self.artifacts.read_active_cdsl(task_id, state.active_revision) - fragment = source.get("fragment") - contract = self.runtime.operation_contract(action.atomic_id) - if not isinstance(fragment, dict) or contract.get("contract_hash") != action.contract_hash: - raise RuntimeError("RUNTIME_PRECONDITION_FAILED: candidate recovery contract is stale") - selectors = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) - references = self.runtime.reference_tokens(base) - cdsl, _audit = self.runtime.materialize_fragment(base, fragment, contract, selectors, references, require_through=self._action_requires_through(task_id, action.requirement_ids)) - rebuilt, operation_failures = self.runtime.rebuild_best_effort(cdsl, self.artifacts.stage_output_dir(task_id, state.candidate_stage_id), task_id, state.candidate_id) - if "operation_failures" not in locals(): - operation_failures = [] - claim_results = self._evaluate_claims(task_id, rebuilt) - audit = source.get("fragment_audit") if isinstance(source.get("fragment_audit"), dict) else {} - contract = self.runtime.operation_contract(action.atomic_id) - operation_results = self._operation_candidate_results( - action, - contract, - cdsl, - rebuilt, - parent_facts=self._facts(task_id, state.active_revision), - require_through=self._action_requires_through(task_id, action.requirement_ids), - ) - candidate = { - "schema_version": "cad.v3.candidate.v1", "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, "action_id": action.action_id, - "working_head": action.working_head, "actual_atomic_id": action.atomic_id, - "fragment_hash": audit.get("fragment_hash") or canonical_hash(source.get("fragment") or {}), - "selector_snapshot_id": audit.get("selector_snapshot_id") or "", "claim_results": claim_results, - "operation_verifier_results": operation_results, - "operation_failures": operation_failures, - "blockers": [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if item.get("status") != "pass"], - ], - "health": rebuilt["health"], "render_manifest": rebuilt.get("render_manifest") or {}, "paths": rebuilt["paths"], - } - self.artifacts.write_stage_json(task_id, state.candidate_stage_id, "candidate.json", candidate) - return self._advance_recovered_candidate(task_id, state, candidate) - except OSError as error: - return self._park_for_storage_retry(state, event="candidate_recovery_storage_failure", message=str(error)) - except Exception as error: - if "RENDER_SERVICE_UNAVAILABLE" in str(error): - return self._park_retry( - state, - ErrorCode.RENDER_SERVICE_UNAVAILABLE, - event="candidate_recovery_render_failure", - message=str(error), - details={"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id}, - ) - if "RUNTIME_EXECUTION_FAILURE" in str(error): - return self._park_retry( - state, - ErrorCode.RUNTIME_EXECUTION_FAILURE, - event="candidate_recovery_runtime_execution_failure", - message=str(error), - details={"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "checkpoint_revision": state.active_revision}, - ) - failed = transition(state, "candidate_rejected", candidate_id="", candidate_stage_id="", repair_required=True, error=ErrorCode.CANDIDATE_BUILD_FAILED) - self.repository.compare_and_swap(failed, events=[{ - "event": "candidate_recovery_failed", - "candidate_id": state.candidate_id, - "stage_id": state.candidate_stage_id, - "action_id": action.action_id, - "working_head": action.working_head, - "message": str(error)[:1000], - }]) - return Accepted({"candidate_id": state.candidate_id, "status": "failed", "code": ErrorCode.CANDIDATE_BUILD_FAILED.value, "diagnostic": str(error)[:1000]}) - - def record_candidate_review(self, task_id: str, review: CandidateReview, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - action = state.pending_action if state else None - if state is None or state.phase != TaskPhase.CANDIDATE_REVIEW or action is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Candidate review is not expected in the current workflow phase.")) - if review.candidate_id != state.candidate_id or review.working_head != action.working_head: - return Rejected(WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Candidate review references a stale candidate or head.")) - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - published = None if isinstance(candidate, dict) else self.artifacts.find_published_candidate(task_id, state.candidate_stage_id) - published_revision = published[0] if published else "" - if published: - candidate = published[1] - if not isinstance(candidate, dict): - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review evidence is unavailable.", retryable=True)) - actual = candidate.get("claim_results") if isinstance(candidate.get("claim_results"), list) else [] - expected_ids = {str(item.get("claim_id") or "") for item in actual if isinstance(item, dict)} - submitted = {item.claim_id: item.status for item in review.claim_coverage} - submitted_ids = set(submitted) - if submitted_ids != expected_ids or len(submitted_ids) != len(review.claim_coverage): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Candidate review must cover exactly the current claim set.")) - mismatched_deterministic = [ - str(item.get("claim_id") or "") - for item in actual - if isinstance(item, dict) - and item.get("deterministic") - and submitted.get(str(item.get("claim_id") or "")) != item.get("status") - ] - if mismatched_deterministic: - return Rejected(WorkflowError( - ErrorCode.AUTHOR_FORMAT_INVALID, - "Candidate review must report the server's deterministic claim status exactly.", - details={"claim_ids": mismatched_deterministic}, - )) - deterministic_fail = [item for item in actual if isinstance(item, dict) and item.get("deterministic") and item.get("status") in {"fail", "unavailable"}] - key = self._key(task_id, "candidate_review", action.working_head, review.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - review_payload = review.model_dump(mode="json") - try: - if published_revision: - self.artifacts.write_json_once(task_id, f"revisions/{published_revision}/candidate-review.json", review_payload) - else: - self.artifacts.write_stage_json(task_id, state.candidate_stage_id, "candidate-review.json", review_payload) - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_review_storage_failure", - message=str(error), - ) - operation_failures = candidate.get("operation_failures") if isinstance(candidate.get("operation_failures"), list) else [] - candidate_blockers = candidate.get("blockers") if isinstance(candidate.get("blockers"), list) else [] - needs_repair = review.verdict != "accept" or bool(deterministic_fail) or bool(operation_failures) or bool(candidate_blockers) - revision_id = self._next_revision(task_id) - if published_revision and published_revision != revision_id: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Published candidate revision does not match the current checkpoint lineage.", retryable=True)) - try: - paths = self.artifacts.publish_candidate(task_id, state.candidate_stage_id, revision_id) if not published_revision else {} - except OSError as error: - return self._park_for_storage_retry( - state, - event="candidate_publish_storage_failure", - message=str(error), - ) - next_state = transition(state, "candidate_accepted", active_revision=revision_id, repair_required=needs_repair, error=ErrorCode.CLAIM_VERIFICATION_FAILED if needs_repair else None) - event = {"event": "accepted", "action_id": action.action_id, "working_head_before": action.working_head, "parent_revision": state.active_revision, "revision_id": revision_id, "actual_atomic_id": action.atomic_id, "fragment_hash": candidate.get("fragment_hash"), "selector_snapshot_id": candidate.get("selector_snapshot_id"), "candidate_id": state.candidate_id, "review_path": f"revisions/{revision_id}/candidate-review.json", "coverage": actual, "issues": list(review.issues), "operation_failures": operation_failures, "repair_required": needs_repair} - result = {"candidate_id": state.candidate_id, "revision_id": revision_id, "paths": paths, "status": "accepted_with_issues" if needs_repair else "accepted"} - if not self._commit_invocation(next_state, [event], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def recover_candidate_review(self, task_id: str) -> Accepted | Rejected | None: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.CANDIDATE_REVIEW or not state.candidate_stage_id: - return None - raw = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate-review.json") - if raw is None: - published = self.artifacts.find_published_candidate(task_id, state.candidate_stage_id) - raw = self.artifacts.read_json(task_id, f"revisions/{published[0]}/candidate-review.json") if published else None - if raw is None: - return None - try: - review = CandidateReview.model_validate(raw) - except ValueError as error: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Persisted candidate review is invalid.", details={"diagnostic": str(error)[:1000]})) - return self.record_candidate_review(task_id, review, invocation_id=f"{task_id}_recover_candidate_{secrets.token_hex(8)}") - - def complete_task(self, task_id: str, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task completion is not available for an unknown task.")) - # Completion idempotency is revision-scoped. A duplicate request after - # final validation or publication replays the existing result and can - # never start a second final rebuild/review. - key = self._key(task_id, "complete", state.active_revision, {}) - if state.phase == TaskPhase.FINAL_VALIDATION and state.active_revision: - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Final validation is already in progress for this revision.")) - if state.phase != TaskPhase.AWAITING_ACTION or state.pending_action is not None or state.repair_required: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Task completion is not available until there is no pending action or repair.")) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - deterministic = [item for item in claim_results if item.get("deterministic")] - if not state.active_revision or any(item.get("status") != "pass" for item in deterministic): - return Rejected(WorkflowError(ErrorCode.CLAIM_VERIFICATION_FAILED, "All deterministic claims must pass before final review.", details={"claim_results": claim_results})) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - next_state = transition(state, "final_requested") - result = {"revision_id": state.active_revision, "working_head": next_state.working_head, "claim_results": claim_results} - if not self._commit_invocation(next_state, [{"event": "final_validation_requested", "revision_id": state.active_revision, "claim_results": claim_results}], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def recover_final_review(self, task_id: str) -> Accepted | Rejected | None: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.FINAL_VALIDATION or not state.active_revision: - return None - raw = self.artifacts.read_json(task_id, self._final_review_path(state.active_revision)) - if raw is None: - return None - try: - review = FinalReview.model_validate(raw) - except ValueError as error: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Persisted final review is invalid.", details={"diagnostic": str(error)[:1000]})) - return self.record_final_review(task_id, review, invocation_id=f"{task_id}_recover_final_{secrets.token_hex(8)}") - - def _advance_recovered_candidate(self, task_id: str, state: TaskState, candidate: dict[str, Any]) -> Accepted | Rejected: - action = state.pending_action - if action is None or candidate.get("candidate_id") != state.candidate_id or candidate.get("action_id") != action.action_id: - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate recovery evidence does not match the pending action.", retryable=True)) - claim_results = candidate.get("claim_results") if isinstance(candidate.get("claim_results"), list) else [] - operation_results = candidate.get("operation_verifier_results") if isinstance(candidate.get("operation_verifier_results"), list) else [] - blockers = [ - *self._candidate_blockers(task_id, action.requirement_ids, claim_results), - *[item for item in operation_results if isinstance(item, dict) and item.get("status") != "pass"], - ] - key = str((self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "input.json") or {}).get("idempotency_key") or "") - invocation = self.repository.begin_invocation(task_id, f"{task_id}_recover_build_{secrets.token_hex(8)}", key) if key else None - if invocation is not None and invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - review = transition(state, "candidate_built", candidate_id=state.candidate_id, candidate_stage_id=state.candidate_stage_id) - result = {"candidate_id": state.candidate_id, "stage_id": state.candidate_stage_id, "status": "awaiting_review", "claim_results": claim_results} - committed = self._commit_invocation(review, [{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}], invocation, result) if invocation is not None else self.repository.compare_and_swap(review, events=[{"event": "candidate_recovered", "candidate_id": state.candidate_id, "action_id": action.action_id, "claim_results": claim_results}]) - if not committed: - return Rejected(self._stale()) - return Accepted(result) - - def finalize_best_effort(self, task_id: str, *, reason: ErrorCode, invocation_id: str) -> Accepted | Rejected: - """Publish the last executable checkpoint when further repair is bounded. - - This is deliberately separate from strict final validation. It does - not claim that unmet acceptance targets passed; it makes the usable - model and its measured gaps durable instead of converting a planning - dead-end into a failed task with no deliverable. - """ - state = self.repository.get_state(task_id) - if state is None or not state.active_revision: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Best-effort completion requires an executable checkpoint.")) - if state.phase == TaskPhase.COMPLETED: - return Accepted({"status": "completed", "revision_id": state.active_revision}) - key = self._key(task_id, "best_effort_complete", state.active_revision, {"reason": reason.value}) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - issues = [ - f"{item.get('claim_kind')}: {item.get('status')}" - for item in claim_results - if item.get("status") != "pass" - ] - next_state = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False) - result = {"status": "completed_with_warnings", "revision_id": state.active_revision, "claim_results": claim_results, "issues": issues} - if not self._commit_invocation(next_state, [{ - "event": "completed_best_effort", - "revision_id": state.active_revision, - "termination_code": reason.value, - "message": "Further CAD repair was bounded; the last executable checkpoint was published.", - "issues": issues, - "claim_results": claim_results, - "completion_result_path": "completion-result.md", - }], invocation, result): - return Rejected(self._stale()) - return Accepted(result) - - def record_final_review(self, task_id: str, review: FinalReview, *, invocation_id: str) -> Accepted | Rejected: - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.FINAL_VALIDATION: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Final review is not expected in the current workflow phase.")) - stale = reject_stale_head(state, review.working_head) - if stale: - return Rejected(stale) - claim_results = self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - expected_ids = {str(item.get("claim_id") or "") for item in claim_results} - submitted = {item.claim_id: item.status for item in review.claim_coverage} - submitted_ids = set(submitted) - if submitted_ids != expected_ids or len(submitted_ids) != len(review.claim_coverage): - return Rejected(WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Final review must cover exactly the final claim set.")) - mismatched_deterministic = [ - str(item.get("claim_id") or "") - for item in claim_results - if item.get("deterministic") - and submitted.get(str(item.get("claim_id") or "")) != item.get("status") - ] - if mismatched_deterministic: - return Rejected(WorkflowError( - ErrorCode.AUTHOR_FORMAT_INVALID, - "Final review must report the server's deterministic claim status exactly.", - details={"claim_ids": mismatched_deterministic}, - )) - deterministic_fail = [item for item in claim_results if item.get("deterministic") and item.get("status") != "pass"] - review_coverage = submitted - visual_not_passed = [ - item - for item in claim_results - if not item.get("deterministic") and review_coverage.get(str(item.get("claim_id") or "")) != "pass" - ] - key = self._key(task_id, "final_review", state.working_head, review.model_dump(mode="json")) - invocation = self.repository.begin_invocation(task_id, invocation_id, key) - if invocation.status == "finished" and invocation.result is not None: - return Accepted(invocation.result) - final_review_path = self._final_review_path(state.active_revision) - try: - self.artifacts.write_json_once(task_id, final_review_path, review.model_dump(mode="json")) - except OSError as error: - return self._park_for_storage_retry( - state, - event="final_review_storage_failure", - message=str(error), - ) - if review.verdict != "pass" or deterministic_fail or visual_not_passed: - accepted_fragment = next(( - event.get("fragment_hash") for event in reversed(self.repository.ledger_events(task_id)) - if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision - ), "") - atomic_id = next(( - str(event.get("actual_atomic_id") or "") for event in reversed(self.repository.ledger_events(task_id)) - if event.get("event") == "accepted" and event.get("revision_id") == state.active_revision - ), "") - exact_fingerprint = canonical_hash({ - "active_revision": state.active_revision, - "atomic_id": atomic_id, - "fragment_hash": accepted_fragment, - }) if accepted_fragment and atomic_id else "" - 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_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], - "issues": list(review.issues), - "evidence": list(review.evidence), - "failed_checklist_items": [ - str(requirement.get("statement") or "") - for requirement in (self._requirements_contract(task_id) or {}).get("requirements") or () - if isinstance(requirement, dict) - and any(str(claim.get("claim_id") or "") in {str(item.get("claim_id") or "") for item in deterministic_fail + visual_not_passed} for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)) - ], - "atomic_id": atomic_id, - "fragment_hash": accepted_fragment, - "failure_exact_fingerprint": exact_fingerprint, - "normalized_error_code": ErrorCode.CLAIM_VERIFICATION_FAILED.value if deterministic_fail else ErrorCode.CANDIDATE_REVIEW_REJECTED.value, - }], invocation, result): - return Rejected(self._stale()) - 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": "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], *, 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) 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 [ - { - "claim_id": str(item.get("claim_id") or ""), - "claim_kind": str(item.get("claim_kind") or ""), - "deterministic": bool(item.get("deterministic")), - "status": str(item.get("status") or "pending"), - "evidence": item.get("evidence") if isinstance(item.get("evidence"), dict) else {}, - } - for item in self._evaluate_claims(task_id, self._facts(task_id, state.active_revision)) - if isinstance(item, dict) - ] - - def model_summary(self, task_id: str, state: TaskState) -> dict[str, Any]: - """Expose measurements, not raw topology, in recurring author turns.""" - if not state.active_revision: - return {"revision_id": "", "available": False} - facts = self._facts(task_id, state.active_revision) - health = facts.get("health") if isinstance(facts.get("health"), dict) else {} - topology = facts.get("topology") if isinstance(facts.get("topology"), dict) else {} - counts: dict[str, int] = {} - inner_cylindrical_bores: list[dict[str, Any]] = [] - for record in topology.get("records") or (): - if isinstance(record, dict) and isinstance(record.get("kind"), str): - kind = record["kind"] - counts[kind] = counts.get(kind, 0) + 1 - geometry = record.get("geometry") if isinstance(record.get("geometry"), dict) else {} - radius = geometry.get("radius_mm") - axis_origin = geometry.get("axis_origin_mm") - if ( - geometry.get("surface_type") == "cylinder" - and geometry.get("cylinder_role") == "inner" - and isinstance(radius, (int, float)) - and isinstance(axis_origin, list) - and len(axis_origin) == 3 - and all(isinstance(value, (int, float)) for value in axis_origin) - ): - inner_cylindrical_bores.append({ - "diameter_mm": round(float(radius) * 2, 6), - "axis_origin_mm": [round(float(value), 6) for value in axis_origin], - "through": bool(geometry.get("through")), - }) - bbox = health.get("bbox_mm") if isinstance(health.get("bbox_mm"), dict) else {} - return { - "revision_id": state.active_revision, - "available": bool(health or topology), - "solid_count": health.get("solid_count"), - "bbox_dimensions_mm": bbox.get("dimensions"), - "topology_snapshot_id": str(topology.get("snapshot_id") or ""), - "topology_counts": counts, - # These are compact measured facts, not author-controlled CDSL. - # They make duplicate or misplaced hole repairs observable without - # injecting the full topology snapshot into every author turn. - "inner_cylindrical_bores": inner_cylindrical_bores[:16], - } - - def _facts(self, task_id: str, revision_id: str) -> dict[str, Any]: - if not revision_id: - return {} - report = self.artifacts.read_json(task_id, f"revisions/{revision_id}/rebuild-report.json") or {} - return {"health": report.get("health") or {}, "topology": self.artifacts.read_topology(task_id, revision_id) or {}, "report": report} - - def _action_requires_through(self, task_id: str, requirement_ids: tuple[str, ...]) -> bool: - contract = self._requirements_contract(task_id) or {} - return any(claim.get("claim_kind") == "through_cylindrical_bore" for requirement in contract.get("requirements") or () if isinstance(requirement, dict) and requirement.get("requirement_id") in requirement_ids for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)) - - def _candidate_blockers(self, task_id: str, action_requirements: tuple[str, ...], results: list[dict[str, Any]]) -> list[dict[str, Any]]: - # Global connected-body failure always rejects; action-linked claims - # must not already be false, while future-work claims may remain pending. - return [item for item in results if item.get("status") in {"fail", "unavailable"} and (item.get("claim_kind") in {"solid_count_equals", "single_connected_body"} or item.get("claim_id", "") in self._claim_ids_for_requirements(task_id, action_requirements))] - - def _operation_candidate_results( - self, - action: PendingAction, - contract: dict[str, Any], - cdsl: dict[str, Any], - rebuilt: dict[str, Any], - *, - parent_facts: dict[str, Any], - require_through: bool, - ) -> list[dict[str, Any]]: - """Run the verified operation-level acceptance checks after rebuild. - - Requirement claims prove the user contract. These checks separately - prove that an operation declared in the runtime registry actually made - the kind of change it advertises, even when a user did not include a - matching claim. A blind-hole action does not require the through-bore - verifier unless its linked requirement explicitly requests through - topology. - """ - features = cdsl.get("features") if isinstance(cdsl.get("features"), list) else [] - feature = next( - (item for item in reversed(features) if isinstance(item, dict) and item.get("atomic_id") == action.atomic_id), - {}, - ) - params = feature.get("params") if isinstance(feature, dict) and isinstance(feature.get("params"), dict) else {} - claims: list[dict[str, Any]] = [] - for claim_kind in contract.get("candidate_verifiers") or (): - if claim_kind == "through_cylindrical_bore" and not require_through: - continue - expected: dict[str, Any] - if claim_kind in {"cylindrical_bore", "through_cylindrical_bore"}: - # 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"}}] - increment = len(positions) if isinstance(positions, list) else 1 - prior_count = self._existing_bore_count(claim_kind, float(diameter), parent_facts) - expected = { - "diameter_mm": float(diameter), - # Candidate topology represents the full model, not only - # the feature just submitted. Therefore the operation - # proof must compare against the parent checkpoint plus - # this action's declared number of positions. - "count": prior_count + increment, - "tolerance_mm": 0.01, - } - elif claim_kind in {"single_connected_body", "volume_decreased"}: - expected = {} - else: - return [{"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": str(claim_kind), "deterministic": True, "status": "unavailable", "evidence": {"reason": "operation verifier has no runtime expected-value binding"}}] - claims.append({"claim_id": f"operation_{action.action_id}_{claim_kind}", "claim_kind": claim_kind, "expected": expected}) - facts = { - "health": rebuilt.get("health") or {}, - "parent_health": parent_facts.get("health") or {}, - "topology": rebuilt.get("topology") or {}, - "report": rebuilt.get("report") or {}, - } - results = self.verifiers.evaluate(claims, facts) - for result, claim in zip(results, claims, strict=True): - expected = claim.get("expected") if isinstance(claim.get("expected"), dict) else {} - if claim.get("claim_kind") not in {"cylindrical_bore", "through_cylindrical_bore"}: - continue - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - evidence = dict(evidence) - requested_total = expected.get("count") - if isinstance(requested_total, int): - positions = params.get("positions") - increment = len(positions) if isinstance(positions, list) else 1 - evidence.update({ - "parent_matching_count": requested_total - increment, - "expected_increment": increment, - "expected_total_count": requested_total, - }) - result["evidence"] = evidence - return results - - def _existing_bore_count(self, claim_kind: str, diameter_mm: float, parent_facts: dict[str, Any]) -> int: - """Measure same-diameter bores in the parent with registry semantics. - - The registry owns topology coalescing, including periodic faces from a - single analytic circle. Asking it for one instance provides an exact - count for populated parent geometry without duplicating B-rep logic in - the workflow layer. - """ - topology = parent_facts.get("topology") if isinstance(parent_facts.get("topology"), dict) else {} - if not isinstance(topology.get("records"), list) or not topology["records"]: - return 0 - result = self.verifiers.evaluate([{ - "claim_id": "operation_parent_bore_count", - "claim_kind": claim_kind, - "expected": {"diameter_mm": diameter_mm, "count": 1, "tolerance_mm": 0.01}, - }], parent_facts)[0] - evidence = result.get("evidence") if isinstance(result.get("evidence"), dict) else {} - actual_count = evidence.get("actual_count") - if isinstance(actual_count, int): - return actual_count - matched = evidence.get("matched_cylindrical_faces") - if isinstance(matched, list): - return len(matched) - through = evidence.get("through_bores") - if isinstance(through, list): - return len(through) - return 0 - - def _claim_ids_for_requirements(self, task_id: str, requirement_ids: tuple[str, ...]) -> set[str]: - contract = self._requirements_contract(task_id) or {} - return { - str(claim.get("claim_id") or "") - for requirement in contract.get("requirements") or () - if isinstance(requirement, dict) and requirement.get("requirement_id") in requirement_ids - for claim in requirement.get("acceptance_claims") or () - if isinstance(claim, dict) - } - - def _next_revision(self, task_id: str) -> str: - """Allocate a monotonically increasing immutable revision ID. - - Rollback changes the active lineage but never reuses an artifact - directory. The ledger is the authority for already allocated IDs, - including a directory published just before an interrupted CAS. - """ - values = [ - int(str(event.get("revision_id") or "").removeprefix("rev_")) - for event in self.repository.ledger_events(task_id) - 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() - ] - return f"rev_{max(values, default=0) + 1:03d}" - - def _requirements_contract(self, task_id: str, state: TaskState | None = None) -> dict[str, Any] | None: - state = state or self.repository.get_state(task_id) - if state is None or not state.requirements_contract_path: - return None - return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - - @staticmethod - def _final_review_path(revision_id: str) -> str: - # Revisions are manifest-sealed when a candidate becomes a checkpoint. - # Final review evidence therefore has its own immutable namespace. - return f"reviews/final/{revision_id}/final-review.json" - - def _commit_invocation(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: dict[str, Any]) -> bool: - """Commit state/outbox and its idempotent result in one SQLite transaction.""" - return self.repository.compare_and_swap( - state, - events=events, - invocation_id=invocation.invocation_id, - invocation_result=result, - ) - - @staticmethod - def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str: - encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) - return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode("utf-8")).hexdigest() - - @staticmethod - def _failure_class_fingerprint(active_revision: str, atomic_id: str, code: ErrorCode) -> str: - return sha256(f"{active_revision}|{atomic_id}|{code.value}".encode("utf-8")).hexdigest() - - def _failure_class_events(self, task_id: str, fingerprint: str) -> list[dict[str, Any]]: - return [ - event for event in self.repository.ledger_events(task_id) - if event.get("failure_class_fingerprint") == fingerprint - ] - - @staticmethod - def _stale() -> WorkflowError: - return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.") - - @staticmethod - def _runtime_error(error: Exception) -> WorkflowError: - message = str(error) - code = ( - ErrorCode.RUNTIME_PRECONDITION_FAILED - if "RUNTIME_PRECONDITION_FAILED" in message - else ErrorCode.RUNTIME_CONTRACT_INVALID - if "RUNTIME_CONTRACT_INVALID" in message or "CDSL schema violation" in message - else ErrorCode.RUNTIME_EXECUTION_FAILURE - if "RUNTIME_EXECUTION_FAILURE" in message - else ErrorCode.AUTHOR_FORMAT_INVALID - ) - return WorkflowError(code, message[:1000]) - - def _park_for_storage_retry(self, state: TaskState, *, event: str, message: str) -> Rejected: - """Persist a recoverable artifact failure without changing CAD evidence.""" - return self._park_retry(state, ErrorCode.STORAGE_FAILURE, event=event, message=message) - - def _park_retry( - self, - state: TaskState, - code: ErrorCode, - *, - event: str, - message: str, - details: dict[str, Any] | None = None, - ) -> Rejected: - waiting = transition(state, "waiting_retry", error=code) - payload = { - "event": event, - "code": code.value, - "message": message[:1000], - **(details or {}), - } - if not self.repository.compare_and_swap(waiting, events=[{ - **payload, - }]): - return Rejected(self._stale()) - return Rejected(WorkflowError( - code, - "Candidate render evidence is temporarily unavailable; the checkpoint is preserved." - if code == ErrorCode.RENDER_SERVICE_UNAVAILABLE - else "The CAD runtime failed after validation; retry will resume from the preserved checkpoint." - if code == ErrorCode.RUNTIME_EXECUTION_FAILURE - else "Candidate artifact storage is temporarily unavailable; the checkpoint is preserved.", - retryable=True, - )) diff --git a/backend/app/cad_agent/application/authoring_compiler.py b/backend/app/cad_agent/application/authoring_compiler.py new file mode 100644 index 00000000..6b2c36a5 --- /dev/null +++ b/backend/app/cad_agent/application/authoring_compiler.py @@ -0,0 +1,300 @@ +"""Compile model-facing Authoring CDSL into server-owned runtime CDSL.""" +from __future__ import annotations + +from copy import deepcopy +from hashlib import sha256 +import json +from typing import Any, Callable + +from jsonschema import Draft202012Validator + +from .authoring_contract import AuthoringDocument, validate_finite, validation_error_code + + +class AuthoringCompileError(ValueError): + def __init__(self, code: str, message: str, *, path: str = "") -> None: + self.code, self.path = code, path + super().__init__(message) + + +class AuthoringCompiler: + def __init__(self, operation_contract: Callable[[str], dict[str, Any]], *, version: str = "1") -> None: + self.operation_contract = operation_contract + self.version = version + + def compile(self, raw: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + try: + validate_finite(raw) + doc = AuthoringDocument.model_validate(raw) + except Exception as error: + raise AuthoringCompileError(validation_error_code(error), str(error)) from error + source_features = [feature for body in doc.bodies for feature in body.features] + dependencies, implicit_selector_dependencies = self._effective_dependencies(doc) + ordered = self._topological(doc, dependencies) + # Identity follows the source document, while execution follows the + # dependency graph. A dependency reordering must never renumber IDs. + feature_ids = {feature.name: f"feature_{index:03d}" for index, feature in enumerate(source_features, 1)} + body_ids = {body.name: f"body_{index:03d}" for index, body in enumerate(doc.bodies, 1)} + features: list[dict[str, Any]] = [] + sketches: list[dict[str, Any]] = [] + sketch_index = 0 + source_positions = {feature.name: index for index, feature in enumerate(source_features, 1)} + sketch_ids_by_feature = { + feature.name: f"sketch_{source_positions[feature.name]:03d}" + for feature in source_features + if feature.sketch is not None + } + for feature in ordered: + try: + contract = self.operation_contract(feature.operation) + except Exception as error: + raise AuthoringCompileError("OPERATION_UNSUPPORTED", str(error), path=f"features.{feature.name}.operation") from error + params = self._references( + deepcopy(feature.params), contract, feature_ids, feature.name, + sketch_ids_by_feature=sketch_ids_by_feature, + ) + self._validate_params(contract, params, feature.name) + selectors = [self._selector(item, feature_ids, source_features) for item in feature.selectors] + selector_policy = contract.get("selector_policy") or {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0} + fragment_shape = contract.get("fragment_shape") or {"sketch": "forbidden", "selector_tokens": "forbidden"} + required_selectors = fragment_shape["selector_tokens"] == "required" + runtime_feature_selectors: list[dict[str, Any]] = [] + if required_selectors and not selector_policy["min_items"] <= len(selectors) <= selector_policy["max_items"]: + raise AuthoringCompileError("SELECTOR_NOT_FOUND", f"operation {feature.operation} requires {selector_policy['min_items']}..{selector_policy['max_items']} selectors", path=f"features.{feature.name}.selectors") + if not required_selectors and selectors: + raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} does not accept selectors", path=f"features.{feature.name}.selectors") + if required_selectors and any(item["kind"] != selector_policy["token_kind"] for item in selectors): + raise AuthoringCompileError("SELECTOR_KIND_MISMATCH", f"operation {feature.operation} requires {selector_policy['token_kind']} selectors", path=f"features.{feature.name}.selectors") + if required_selectors: + slot = str(selector_policy["slot"]) + if slot == "feature.selectors": + runtime_feature_selectors = selectors + elif slot.startswith("params."): + parameter = slot.removeprefix("params.") + if "." in parameter: + raise AuthoringCompileError( + "OPERATION_UNSUPPORTED", + f"runtime has no safe selector binding for {slot}", + path=f"features.{feature.name}.selectors", + ) + if parameter in params: + raise AuthoringCompileError( + "AUTHOR_FORBIDDEN_FIELD", + f"{parameter} is server-injected from selectors", + path=f"features.{feature.name}.params.{parameter}", + ) + params[parameter] = selectors[0] if len(selectors) == 1 else selectors + else: + raise AuthoringCompileError( + "OPERATION_UNSUPPORTED", + f"runtime has no safe selector binding for {slot}", + path=f"features.{feature.name}.selectors", + ) + output = { + "id": feature_ids[feature.name], "atomic_id": feature.operation, + "depends_on": [feature_ids[name] for name in dependencies[feature.name]], + "params": params, + **({"selectors": runtime_feature_selectors} if runtime_feature_selectors else {}), + } + needs_sketch = fragment_shape["sketch"] == "required" + if needs_sketch and feature.sketch is None: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} requires sketch", path=f"features.{feature.name}.sketch") + if not needs_sketch and feature.sketch is not None: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", f"operation {feature.operation} does not accept sketch", path=f"features.{feature.name}.sketch") + if feature.sketch is not None: + sketch_index += 1 + sketch_id = f"sketch_{source_positions[feature.name]:03d}" + sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))}) + output["sketch_id"] = sketch_id + features.append(output) + runtime = { + "schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part", + "part_id": "compiled", "meta": {"unit": "mm"}, + "bodies": [ + {"id": body_ids[body.name], "name": body.name} + for body in doc.bodies + ], + "geometry": {"sketches": sketches}, "features": features, + } + digest = sha256(self._canonical_json(raw)).hexdigest() + return runtime, { + "schema_version": "cad.author.compile-audit.v1", "compiler_version": self.version, + "source_sha256": digest, "body_ids": body_ids, "feature_ids": feature_ids, + "sketch_ids": [item["id"] for item in sketches], + "implicit_selector_dependencies": implicit_selector_dependencies, + } + + @staticmethod + def _effective_dependencies(doc: AuthoringDocument) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """Make every declared selector source an auditable graph dependency. + + A selector is already an explicit local source reference. Requiring the + author to repeat that same edge in a second field only creates a + formatting failure; it does not add CAD intent. The compiler therefore + adds the direct source edge deterministically and records it in the + audit. It never selects a substitute topology element. + """ + by_name = {item.name: item for body in doc.bodies for item in body.features} + dependencies: dict[str, list[str]] = {} + implicit: dict[str, list[str]] = {} + for feature in by_name.values(): + values = list(feature.depends_on) + additions: list[str] = [] + for selector in feature.selectors: + source = selector.source.split(".", 1)[0] + if source not in by_name: + raise AuthoringCompileError( + "AUTHOR_REFERENCE_INVALID", + f"unknown selector source: {selector.source}", + path=f"features.{feature.name}.selectors", + ) + if source not in values: + values.append(source) + additions.append(source) + dependencies[feature.name] = values + if additions: + implicit[feature.name] = additions + return dependencies, implicit + + @staticmethod + def _topological(doc: AuthoringDocument, dependencies: dict[str, list[str]]) -> list[Any]: + by_name = {item.name: item for body in doc.bodies for item in body.features} + result: list[Any] = [] + visiting: set[str] = set() + done: set[str] = set() + def visit(name: str) -> None: + if name in visiting: + raise AuthoringCompileError("AUTHOR_CYCLE", f"cyclic feature dependency: {name}") + if name in done: + return + visiting.add(name) + for dependency in dependencies[name]: + visit(dependency) + visiting.remove(name); done.add(name); result.append(by_name[name]) + for body in doc.bodies: + for feature in body.features: + visit(feature.name) + return result + + @staticmethod + def _selector(selector: Any, feature_ids: dict[str, str], source_features: list[Any]) -> dict[str, Any]: + source = selector.source.split(".", 1)[0] + if source not in feature_ids: + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"unknown selector source: {selector.source}") + source_feature = next(item for item in source_features if item.name == source) + role = selector.source.split(".", 1)[1] + role = AuthoringCompiler._runtime_role(role, source_feature.operation) + return {"kind": selector.kind, "output_role": role, "owner_feature_id": feature_ids[source], "source": "runtime_snapshot", "confidence": 1.0, "match_mode": selector.match} + + @staticmethod + def _runtime_sketch(sketch: dict[str, Any]) -> dict[str, Any]: + """Lower the small Authoring sketch language to the generic runtime form.""" + profile = sketch["profile"] + if profile["type"] == "circle": + profile = { + "type": "circle", + "center": profile["center_mm"], + "radius_mm": float(profile["diameter_mm"]) / 2.0, + } + return {"workplane": sketch["workplane"], "profile": profile} + + @staticmethod + def _runtime_role(role: str, operation: str) -> str: + if role in {"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face"}: + return role + family = ( + "extrude" if operation.startswith("extrude") else + "sweep" if operation.startswith("sweep") else + "loft" if operation.startswith("loft") else + "cylinder" if operation == "cylinder_add" else "" + ) + if role in {"top_planar_face", "end_face"} and family: + return family + ".end" + if role in {"bottom_planar_face", "start_face"} and family: + return family + ".start" + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"selector output role {role!r} is not available from {operation}") + + @staticmethod + def _validate_params(contract: dict[str, Any], params: dict[str, Any], feature_name: str) -> None: + schema = contract.get("author_params_schema") or {"type": "object"} + errors = list(Draft202012Validator(schema).iter_errors(params)) + if errors: + first = errors[0] + path = ".".join(str(item) for item in first.absolute_path) + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", first.message, path=f"features.{feature_name}.params.{path}".rstrip(".")) + + @staticmethod + def _references( + params: dict[str, Any], + contract: dict[str, Any], + feature_ids: dict[str, str], + feature_name: str, + *, + sketch_ids_by_feature: dict[str, str], + ) -> dict[str, Any]: + params = AuthoringCompiler._rewrite_named_references( + params, feature_ids, sketch_ids_by_feature, feature_name, + ) + policy = contract.get("reference_policy") or {"mode": "none"} + if policy["mode"] != "snapshot_bound" or policy.get("slot") == "feature.selectors": + return params + key = str(policy["slot"]).removeprefix("params.") + value = params.get(key) + if isinstance(value, list): + names = value + elif value is None and policy["min_items"] == 0: + names = [] + elif isinstance(value, str): + names = [value] + else: + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} must contain local feature names", path=f"features.{feature_name}.params.{key}") + runtime_ids = set(feature_ids.values()) + if not all(isinstance(item, str) and item in runtime_ids for item in names): + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} contains an unknown local feature name", path=f"features.{feature_name}.params.{key}") + if not policy["min_items"] <= len(names) <= policy["max_items"]: + raise AuthoringCompileError("AUTHOR_REFERENCE_INVALID", f"{key} has invalid item count", path=f"features.{feature_name}.params.{key}") + return params + + @staticmethod + def _rewrite_named_references( + value: Any, + feature_ids: dict[str, str], + sketch_ids_by_feature: dict[str, str], + consuming_feature: str, + key: str = "", + ) -> Any: + if isinstance(value, dict): + return { + item_key: AuthoringCompiler._rewrite_named_references( + item_value, feature_ids, sketch_ids_by_feature, consuming_feature, item_key, + ) + for item_key, item_value in value.items() + } + if isinstance(value, list): + return [ + AuthoringCompiler._rewrite_named_references( + item, feature_ids, sketch_ids_by_feature, consuming_feature, key, + ) + for item in value + ] + if not isinstance(value, str): + return value + if key == "profile_sketch_ids": + if value not in sketch_ids_by_feature: + raise AuthoringCompileError( + "AUTHOR_REFERENCE_INVALID", f"unknown local sketch source: {value}", + path=f"features.{consuming_feature}.params.{key}", + ) + return sketch_ids_by_feature[value] + if key.endswith("_feature_id") or key.endswith("_feature_ids"): + if value not in feature_ids: + raise AuthoringCompileError( + "AUTHOR_REFERENCE_INVALID", f"unknown local feature reference: {value}", + path=f"features.{consuming_feature}.params.{key}", + ) + return feature_ids[value] + return value + + @staticmethod + def _canonical_json(value: Any) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") diff --git a/backend/app/cad_agent/application/authoring_contract.py b/backend/app/cad_agent/application/authoring_contract.py new file mode 100644 index 00000000..677736ef --- /dev/null +++ b/backend/app/cad_agent/application/authoring_contract.py @@ -0,0 +1,169 @@ +"""Strict model-facing Authoring CDSL contract. + +This is intentionally separate from the runtime CDSL: model output contains +only document-local names and declarative references. Runtime identities are +allocated by :mod:`authoring_compiler`. +""" +from __future__ import annotations + +import math +import re +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +_NAME = r"^[a-z][a-z0-9_]{0,63}$" +_FORBIDDEN = { + "id", + "task_id", "revision_id", "candidate_id", "action_id", "requirement_id", + "claim_id", "evidence_id", "feature_id", "sketch_id", "body_id", + "stable_id", "snapshot_id", "owner_feature_id", "working_head", + "selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken", + "host_face", "mirror_plane", +} + + +def validation_error_code(error: Exception) -> str: + """Map strict model validation failures to a stable public diagnostic.""" + return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID" + + +class AuthorModel(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + + @model_validator(mode="before") + @classmethod + def reject_internal_fields(cls, value: Any) -> Any: + if isinstance(value, dict): + found = sorted( + key for key in value + if isinstance(key, str) + and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN) + ) + if found: + raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}") + for nested in value.values(): + cls.reject_internal_fields(nested) + return value + if isinstance(value, list): + for item in value: + cls.reject_internal_fields(item) + return value + + +class AuthorWorkplane(AuthorModel): + """A fully explicit local sketch frame in world millimetres.""" + + origin_mm: list[float] = Field(min_length=3, max_length=3) + x_dir: list[float] = Field(min_length=3, max_length=3) + normal: list[float] = Field(min_length=3, max_length=3) + + +class AuthorCircleProfile(AuthorModel): + """A declarative circle expressed with the user-facing diameter.""" + + type: Literal["circle"] + diameter_mm: float = Field(gt=0) + center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2) + + +class AuthorPolygonProfile(AuthorModel): + """A closed polygon in the local sketch workplane.""" + + type: Literal["polygon"] + vertices: list[list[float]] = Field(min_length=3) + + @field_validator("vertices") + @classmethod + def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]: + if any(len(point) != 2 for point in value): + raise ValueError("polygon vertices must have exactly two coordinates") + return value + + +class AuthorSketch(AuthorModel): + """The only authoring sketch form currently accepted by the compiler.""" + + workplane: AuthorWorkplane + profile: AuthorCircleProfile | AuthorPolygonProfile + + +class SelectorIntent(AuthorModel): + """A local feature-output reference, never a Runtime selector token.""" + + kind: Literal["face", "edge", "axis", "plane", "vertex", "body"] + source: str = Field( + min_length=3, + max_length=160, + description="A local feature output in the form ..", + ) + match: Literal["unique", "all"] = "unique" + + @field_validator("source") + @classmethod + def require_feature_output_reference(cls, value: str) -> str: + feature, separator, role = value.partition(".") + if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role): + raise ValueError("selector source must be .") + return value + + +class AuthorFeature(AuthorModel): + name: str = Field(pattern=_NAME) + operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$") + params: dict[str, Any] = Field( + default_factory=dict, + description="Only parameters from this feature operation's supplied params_schema.", + ) + depends_on: list[str] = Field(default_factory=list, max_length=32) + selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32) + sketch: AuthorSketch | None = Field( + default=None, + description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.", + ) + + +class AuthorBody(AuthorModel): + name: str = Field(pattern=_NAME) + features: list[AuthorFeature] = Field(min_length=1, max_length=256) + + +class AuthoringDocument(AuthorModel): + schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$") + units: str = Field(default="mm", pattern=r"^mm$") + coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$") + assumptions: list[str] = Field(default_factory=list, max_length=64) + bodies: list[AuthorBody] = Field(min_length=1, max_length=32) + acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128) + + @model_validator(mode="after") + def validate_symbols(self) -> "AuthoringDocument": + validate_finite(self.model_dump(mode="python")) + bodies = [b.name for b in self.bodies] + if len(bodies) != len(set(bodies)): + raise ValueError("duplicate body name") + names: set[str] = set() + for body in self.bodies: + for feature in body.features: + if feature.name in names: + raise ValueError(f"duplicate feature name: {feature.name}") + names.add(feature.name) + for body in self.bodies: + for feature in body.features: + if len(feature.depends_on) != len(set(feature.depends_on)): + raise ValueError(f"duplicate dependency: {feature.name}") + if any(dep not in names for dep in feature.depends_on): + missing = next(dep for dep in feature.depends_on if dep not in names) + raise ValueError(f"unknown feature reference: {missing}") + return self + + +def validate_finite(value: Any, path: str = "$") -> None: + if isinstance(value, float) and not math.isfinite(value): + raise ValueError(f"non-finite number at {path}") + if isinstance(value, dict): + for key, item in value.items(): + validate_finite(item, f"{path}.{key}") + elif isinstance(value, list): + for index, item in enumerate(value): + validate_finite(item, f"{path}[{index}]") diff --git a/backend/app/cad_agent/application/authoring_guidance.py b/backend/app/cad_agent/application/authoring_guidance.py new file mode 100644 index 00000000..6eb49eb4 --- /dev/null +++ b/backend/app/cad_agent/application/authoring_guidance.py @@ -0,0 +1,16 @@ +"""Prompt material for the model-facing Authoring CDSL contract.""" +from __future__ import annotations + +from functools import lru_cache +from pathlib import Path + + +_GUIDANCE = Path(__file__).resolve().parents[4] / "agent" / "skills" / "cad-authoring" / "SKILL.md" + + +@lru_cache(maxsize=1) +def load_authoring_guidance() -> str: + try: + return _GUIDANCE.read_text(encoding="utf-8")[:12_000] + except OSError: + return "Use cad.author.v1 only. Never create runtime IDs or selector tokens." diff --git a/backend/app/cad_agent/application/capabilities.py b/backend/app/cad_agent/application/capabilities.py index 509be1fa..2956dfbb 100644 --- a/backend/app/cad_agent/application/capabilities.py +++ b/backend/app/cad_agent/application/capabilities.py @@ -6,62 +6,25 @@ from hashlib import sha256 import json from typing import Any, Literal -from app.cad_agent.application.llm_contracts import ( - EmptyCommand, - ImageObservation, - MarkdownDocument, - StatelessCandidateReview, - StatelessGeometryConclusion, - StatelessRollbackCheckpoint, - StatelessTopologyRequest, - compiled_requirements_schema, - 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.application.authoring_contract import AuthoringDocument +from app.cad_agent.application.workflow import RequirementsAnalysis from app.cad_agent.ports import CadRuntime, ModelGateway -CapabilityRole = Literal["author", "reviewer"] +CapabilityRole = Literal["author"] def conformance_tools(runtime: CadRuntime, *, role: CapabilityRole) -> list[dict[str, Any]]: - 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)), - ] - atomic_ids = list(runtime.supported_atomic_ids()) - if not atomic_ids: + if not runtime.supported_atomic_ids(): raise RuntimeError("Runtime has no operations for conformance") - tools = [ - _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("inspect_topology", StatelessTopologyRequest.model_json_schema()), - _tool("record_geometry_conclusion", StatelessGeometryConclusion.model_json_schema()), - _tool("rollback_checkpoint", StatelessRollbackCheckpoint.model_json_schema()), - _tool("complete_task", EmptyCommand.model_json_schema()), + return [ + _tool("analyze_requirements", RequirementsAnalysis.model_json_schema()), + _tool("write_authoring_cdsl", AuthoringDocument.model_json_schema()), ] - for atomic_id in atomic_ids: - contract = runtime.operation_contract(atomic_id) - tools.append(_tool( - f"conformance_{atomic_id}", - fragment_schema(contract, selector_tokens=["sel_conformance"], reference_tokens=["ref_conformance"]), - )) - return tools def conformance_hash(tools: list[dict[str, Any]], *, role: CapabilityRole) -> str: - payload = {"protocol": "cad.v3.2.feature-dag", "role": role, "tools": tools} + payload = {"protocol": "cad.single-stage.v1", "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/llm_contracts.py b/backend/app/cad_agent/application/llm_contracts.py deleted file mode 100644 index b19b727d..00000000 --- a/backend/app/cad_agent/application/llm_contracts.py +++ /dev/null @@ -1,412 +0,0 @@ -"""Canonical schemas for every v3 LLM state-changing command. - -Providers may parse structured output first, but this module validates the raw -tool arguments a second time before a command reaches a handler. -""" - -from __future__ import annotations - -from copy import deepcopy -import hashlib -import json -import math -from typing import Annotated, Any, Literal, TypeVar - -from jsonschema import Draft202012Validator -from pydantic import BaseModel, ConfigDict, Field, JsonValue, RootModel, ValidationError, model_validator - -from app.cad_agent.domain.errors import ErrorCode, WorkflowError - - -class StrictDto(BaseModel): - model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True) - - -ShortText = Annotated[str, Field(min_length=1, max_length=360)] -Identifier = Annotated[str, Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$")] - - -class AcceptanceClaimInput(StrictDto): - claim_kind: Identifier - expected: dict[str, JsonValue] = Field(min_length=0, max_length=24) - - -class SpecRequirementInput(StrictDto): - statement: Annotated[str, Field(min_length=1, max_length=1000)] - assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16) - - -class MarkdownDocument(StrictDto): - """A frozen human-readable design artifact, never an executable payload.""" - markdown: Annotated[str, Field(min_length=1, max_length=16_000)] - - -class CompiledRequirementInput(StrictDto): - """One verifier bundle for one server-parsed checklist item. - - The checklist text, ordering, source bindings, and all identifiers are - intentionally absent: the service owns them after Markdown is frozen. - """ - assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - acceptance_claims: list[AcceptanceClaimInput] = Field(min_length=1, max_length=16) - - -class CompiledRequirementsSpec(StrictDto): - requirements: list[CompiledRequirementInput] = Field(min_length=1, max_length=64) - - -# Kept only so an interrupted process with an already imported old tool schema -# fails at the workflow boundary instead of failing module import. New v3.1 -# tasks never expose or accept this aggregate specification. -class RequirementsSpec(StrictDto): - outcome: Literal["ready"] - summary: Annotated[str, Field(min_length=1, max_length=2000)] - assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=32) - requirements: list[SpecRequirementInput] = Field(min_length=1, max_length=32) - - -class RequirementsClarification(StrictDto): - outcome: Literal["clarification"] - source_quotes: list[Annotated[str, Field(min_length=1, max_length=500)]] = Field(min_length=2, max_length=4) - question: Annotated[str, Field(min_length=1, max_length=500)] - - -class RequirementsAuthorOutput(RootModel[Annotated[RequirementsSpec | RequirementsClarification, Field(discriminator="outcome")]]): - pass - - -class EmptyCommand(StrictDto): - pass - - -class NextAction(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - intent: ShortText - # The server binds this list from every frozen checklist target. It is not - # author input, so a five-item UI-era limit must not reject a valid task. - requirement_ids: list[Identifier] = Field(min_length=1, max_length=64) - atomic_id: Identifier - expected_change: ShortText - - @model_validator(mode="after") - def _requirement_ids_are_unique(self) -> "NextAction": - if len(self.requirement_ids) != len(set(self.requirement_ids)): - raise ValueError("requirement_ids must not contain duplicates") - return self - - -class StatelessNextAction(StrictDto): - intent: ShortText - operation: Identifier - expected_change: ShortText - - -class TopologyRequest(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None - limit: int = Field(default=16, ge=1, le=64) - - -class GeometryConclusion(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - evidence_refs: list[Identifier] = Field(min_length=1, max_length=16) - root_cause: Annotated[str, Field(min_length=1, max_length=360)] - decision: Literal["return_to_action_selection", "rollback"] - corrective_intent: str | None = Field(default=None, min_length=1, max_length=360) - - @model_validator(mode="after") - def _evidence_refs_are_unique(self) -> "GeometryConclusion": - if len(self.evidence_refs) != len(set(self.evidence_refs)): - raise ValueError("evidence_refs must not contain duplicates") - return self - - -class RollbackCheckpoint(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$")] - checkpoint_token: Identifier - reason: Annotated[str, Field(min_length=1, max_length=360)] - - -class StatelessTopologyRequest(StrictDto): - kind: Literal["face", "edge", "vertex", "plane", "axis", "body"] | None = None - limit: int = Field(default=16, ge=1, le=64) - - -class StatelessGeometryConclusion(StrictDto): - root_cause: Annotated[str, Field(min_length=1, max_length=360)] - decision: Literal["return_to_action_selection", "rollback"] - corrective_intent: str | None = Field(default=None, min_length=1, max_length=360) - - -class StatelessRollbackCheckpoint(StrictDto): - checkpoint_token: Identifier - reason: Annotated[str, Field(min_length=1, max_length=360)] - - -class ClaimCoverage(StrictDto): - claim_id: Identifier - status: Literal["pass", "pending", "fail", "not_applicable"] - evidence_refs: list[Identifier] = Field(default_factory=list, max_length=16) - - @model_validator(mode="after") - def _evidence_refs_are_unique(self) -> "ClaimCoverage": - if len(self.evidence_refs) != len(set(self.evidence_refs)): - raise ValueError("evidence_refs must not contain duplicates") - return self - - -class CandidateReview(StrictDto): - candidate_id: Identifier = Field(description="Server-issued candidate ID from the review facts.") - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the review facts.")] - verdict: Literal["accept", "reject"] = Field(description="Required independent decision. Set accept only when the supplied candidate evidence supports every covered claim; otherwise set reject.") - claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied candidate facts.") - evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - - -class StatelessCandidateReview(StrictDto): - verdict: Literal["accept", "reject"] - evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - - -class VisualClaimDecision(StrictDto): - status: Literal["pass", "fail"] - evidence: Annotated[str, Field(min_length=1, max_length=360)] - - -class StatelessFinalReview(StrictDto): - verdict: Literal["pass", "repair"] - visual_claims: list[VisualClaimDecision] = Field(default_factory=list, max_length=128) - evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - - -class ImageMeasurement(StrictDto): - name: Annotated[str, Field(min_length=1, max_length=160)] - value: float | None = None - unit: Literal["mm", "degree", "count", "unknown"] = "unknown" - evidence: Annotated[str, Field(min_length=1, max_length=360)] - confidence: float = Field(ge=0, le=1) - - -class ImageObservation(StrictDto): - summary: Annotated[str, Field(min_length=1, max_length=2000)] - visible_features: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64) - measurements: list[ImageMeasurement] = Field(default_factory=list, max_length=128) - view_directions: list[Annotated[str, Field(min_length=1, max_length=120)]] = Field(default_factory=list, max_length=16) - uncertainties: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64) - assumptions: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=64) - - -class FinalReview(StrictDto): - working_head: Annotated[str, Field(pattern=r"^[a-z0-9_:-]{5,192}$", description="Current server-issued working head from the final review facts.")] - verdict: Literal["pass", "repair"] = Field(description="Required independent final decision. Set pass only when the supplied evidence supports every claim; otherwise set repair.") - claim_coverage: list[ClaimCoverage] = Field(min_length=1, max_length=128, description="Required coverage decision for every claim ID in the supplied final-review facts.") - evidence: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - issues: list[Annotated[str, Field(min_length=1, max_length=360)]] = Field(default_factory=list, max_length=16) - - -def requirements_spec_schema(claim_one_of: dict[str, Any]) -> dict[str, Any]: - schema = RequirementsAuthorOutput.model_json_schema() - requirement = schema.get("$defs", {}).get("SpecRequirementInput") - if isinstance(requirement, dict): - claims = requirement.get("properties", {}).get("acceptance_claims") - if isinstance(claims, dict): - claims["items"] = deepcopy(claim_one_of) - return schema - - -def compiled_requirements_schema(claim_one_of: dict[str, Any], target_count: int) -> dict[str, Any]: - schema = CompiledRequirementsSpec.model_json_schema() - definitions = schema.get("$defs", {}) - requirement = definitions.get("CompiledRequirementInput") if isinstance(definitions, dict) else None - if isinstance(requirement, dict): - claims = requirement.get("properties", {}).get("acceptance_claims") - if isinstance(claims, dict): - claims["items"] = deepcopy(claim_one_of) - requirements = schema.get("properties", {}).get("requirements") - if isinstance(requirements, dict): - requirements["minItems"] = target_count - requirements["maxItems"] = target_count - return schema - - -def sanitize_compiled_requirements_arguments(raw_arguments_json: str) -> str | WorkflowError: - """Drop harmless compiler chatter before strict requirements validation. - - ``compile_requirements_spec`` is a compiler stage: the service only needs - the ordered verifier bundles for the frozen checklist items. Real models - sometimes add explanatory fields such as a top-level ``assumptions`` or - per-item ``statement`` even when the dynamic tool schema forbids them. Those - fields are not executable and are not part of the frozen contract, so they - should not abort a task before modeling starts. - - The verifier ``expected`` payload is intentionally not sanitized here. It - remains governed by the registry's strict per-claim schema because those - values drive deterministic validation. - """ - value = canonical_json_object(raw_arguments_json) - if isinstance(value, WorkflowError): - return value - requirements = value.get("requirements") - sanitized: dict[str, Any] = {} - if isinstance(requirements, list): - sanitized_requirements: list[Any] = [] - for requirement in requirements: - if not isinstance(requirement, dict): - sanitized_requirements.append(requirement) - continue - item: dict[str, Any] = {} - if "assumptions" in requirement: - item["assumptions"] = requirement["assumptions"] - if "acceptance_claims" in requirement: - claims = requirement["acceptance_claims"] - if isinstance(claims, list): - item["acceptance_claims"] = [ - {key: claim[key] for key in ("claim_kind", "expected") if isinstance(claim, dict) and key in claim} - if isinstance(claim, dict) else claim - for claim in claims - ] - else: - item["acceptance_claims"] = claims - sanitized_requirements.append(item) - sanitized["requirements"] = sanitized_requirements - else: - sanitized["requirements"] = requirements - return json.dumps(sanitized, ensure_ascii=False, separators=(",", ":")) - - -def stateless_next_action_schema(atomic_ids: list[str]) -> dict[str, Any]: - schema = StatelessNextAction.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["operation"] = {"enum": atomic_ids} - return schema - - -def stateless_final_review_schema(visual_claim_count: int) -> dict[str, Any]: - schema = StatelessFinalReview.model_json_schema() - properties = schema.get("properties", {}) - visual = properties.get("visual_claims") if isinstance(properties, dict) else None - if isinstance(visual, dict): - visual["minItems"] = visual_claim_count - visual["maxItems"] = visual_claim_count - return schema - - -def stateless_rollback_checkpoint_schema(checkpoint_tokens: list[str]) -> dict[str, Any]: - schema = StatelessRollbackCheckpoint.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["checkpoint_token"] = {"enum": checkpoint_tokens} - return schema - - -def topology_request_schema(working_head: str) -> dict[str, Any]: - schema = TopologyRequest.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["working_head"] = {"const": working_head} - return schema - - -def rollback_checkpoint_schema(working_head: str, checkpoint_tokens: list[str]) -> dict[str, Any]: - """Bind a rollback request to immutable checkpoints in the active lineage.""" - schema = RollbackCheckpoint.model_json_schema() - properties = schema.get("properties", {}) - if isinstance(properties, dict): - properties["working_head"] = {"const": working_head} - properties["checkpoint_token"] = {"enum": checkpoint_tokens} - return schema - - -def _bind_claim_coverage_ids(schema: dict[str, Any], claim_ids: list[str]) -> None: - definitions = schema.get("$defs", {}) - coverage = definitions.get("ClaimCoverage") if isinstance(definitions, dict) else None - if not isinstance(coverage, dict): - return - properties = coverage.get("properties", {}) - if isinstance(properties, dict): - properties["claim_id"] = {"enum": claim_ids} - - -Dto = TypeVar("Dto", bound=StrictDto) - - -def raw_arguments_hash(raw_arguments_json: str) -> str: - return hashlib.sha256(raw_arguments_json.encode("utf-8")).hexdigest() - - -def json_depth(value: Any, current: int = 0) -> int: - if isinstance(value, dict): - return max([current, *(json_depth(item, current + 1) for item in value.values())]) - if isinstance(value, list): - return max([current, *(json_depth(item, current + 1) for item in value)]) - return current - - -def canonical_json_object(raw_arguments_json: str, *, max_bytes: int = 48_000, max_depth: int = 16) -> dict[str, Any] | WorkflowError: - """Bound and parse raw arguments before any schema-specific validation.""" - if len(raw_arguments_json.encode("utf-8")) > max_bytes: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the byte limit.") - try: - value = json.loads(raw_arguments_json) - except json.JSONDecodeError as error: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments are not valid JSON.", field_errors=({"path": "/", "message": error.msg},)) - if not isinstance(value, dict): - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must be a JSON object.") - if json_depth(value) > max_depth: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments exceed the nesting-depth limit.") - if _contains_non_finite_number(value): - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments must not contain NaN or infinite numbers.") - return value - - -def _contains_non_finite_number(value: Any) -> bool: - if isinstance(value, float): - return not math.isfinite(value) - if isinstance(value, dict): - return any(_contains_non_finite_number(item) for item in value.values()) - if isinstance(value, list): - return any(_contains_non_finite_number(item) for item in value) - return False - - -def canonical_validate_schema(raw_arguments_json: str, schema: dict[str, Any]) -> WorkflowError | None: - """Revalidate raw arguments against the current dynamic JSON Schema.""" - value = canonical_json_object(raw_arguments_json) - if isinstance(value, WorkflowError): - return value - errors = [ - {"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message} - for error in sorted(Draft202012Validator(schema).iter_errors(value), key=lambda item: (list(item.absolute_path), item.message)) - ] - if errors: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the active dynamic schema.", field_errors=tuple(errors)) - return None - - -def canonical_validate(raw_arguments_json: str, model: type[Dto], *, max_bytes: int = 48_000, max_depth: int = 16) -> Dto | WorkflowError: - """Parse raw tool arguments once and return field-level DTO failures safely.""" - value = canonical_json_object(raw_arguments_json, max_bytes=max_bytes, max_depth=max_depth) - if isinstance(value, WorkflowError): - return value - try: - return model.model_validate(value) - except ValidationError as error: - fields = tuple({"path": "/" + "/".join(str(part) for part in issue["loc"]), "message": issue["msg"]} for issue in error.errors()) - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Tool arguments do not match the canonical schema.", field_errors=fields) - - -def validate_one_tool_call(tool_calls: list[dict[str, Any]], allowed_name: str) -> tuple[str, str] | WorkflowError: - """Require exactly one known tool call; no provider parser is trusted.""" - if len(tool_calls) != 1: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "Exactly one tool call is required.") - function = tool_calls[0].get("function") if isinstance(tool_calls[0], dict) else None - name = str(function.get("name") or "") if isinstance(function, dict) else "" - raw = str(function.get("arguments") or "") if isinstance(function, dict) else "" - if name != allowed_name: - return WorkflowError(ErrorCode.AUTHOR_FORMAT_INVALID, "The returned tool is not allowed in this workflow state.", details={"expected_tool": allowed_name, "actual_tool": name}) - return name, raw diff --git a/backend/app/cad_agent/application/requirements.py b/backend/app/cad_agent/application/requirements.py deleted file mode 100644 index a7dba993..00000000 --- a/backend/app/cad_agent/application/requirements.py +++ /dev/null @@ -1,670 +0,0 @@ -"""Immutable Markdown-first requirements artifacts and compiled contracts.""" - -from __future__ import annotations - -from copy import deepcopy -from hashlib import sha256 -import json -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 -from app.cad_agent.domain.verifier_registry import VerifierRegistry -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: - """Persist frozen documents and compile their checklist into a contract. - - The model never names targets or internal objects during compilation. The - service derives those values strictly from the immutable checklist. - """ - - 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]]] = {} - - def register_evaluation_contract_oracle(self, task_id: str, required_claims: list[dict[str, Any]], *, validation_capability_gaps: list[dict[str, Any]] | None = None) -> None: - self._evaluation_contract_oracles[task_id] = deepcopy(required_claims) - self._evaluation_capability_gaps[task_id] = [ - {"id": str(item.get("id") or ""), "description": str(item.get("description") or "")} - for item in validation_capability_gaps or () if isinstance(item, dict) - ] - - def evaluation_review_context(self, task_id: str) -> dict[str, Any] | None: - claims = self._evaluation_contract_oracles.get(task_id) - return None if claims is None else { - "evaluation_only": True, - "required_claims": deepcopy(claims), - "known_validation_capability_gaps": deepcopy(self._evaluation_capability_gaps.get(task_id, [])), - } - - @staticmethod - def document_schema() -> dict[str, Any]: - return MarkdownDocument.model_json_schema() - - def compiler_schema(self, task_id: str) -> dict[str, Any]: - return compiled_requirements_schema( - self.registry.expected_one_of_schema(exclude_claim_kinds=_RECORD_BOUND_CLAIMS), - 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) - - def submit_completion_target(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_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: - # 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) - if replay is not None: - return replay - state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.COMPILING_REQUIREMENTS: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "Requirements compilation is not expected in the current workflow phase.")) - targets = self._checklist_items(task_id) - 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), - *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"))) - if invocation.status == "finished" and invocation.result is not None: - return self._restore(invocation.result) - source_ids = list(self.artifacts.read_source_index(task_id)) - observation = self.artifacts.read_json(task_id, "documents/image-observation.json") or {} - warnings = [ - *[str(value) for value in observation.get("uncertainties") or () if str(value)], - *compiler_warnings, - ] - requirements: list[dict[str, Any]] = [] - claim_position = 1 - for position, (target, compiled) in enumerate(zip(targets, normalized_output.requirements, strict=True), 1): - claims: list[dict[str, Any]] = [] - for claim in compiled.acceptance_claims: - definition = self.registry.definition(claim.claim_kind) - claims.append({"claim_id": f"claim_{claim_position:03d}", "claim_kind": claim.claim_kind, "expected": claim.expected, "verification_mode": "deterministic" if definition.deterministic else "visual"}) - 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.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) - contract_path = self.artifacts.write_requirements_contract(task_id, contract, invocation_id=invocation_id) - except OSError as error: - return self._park_for_storage_retry(state, str(error)) - next_state = transition(state, "requirements_compiled", requirements_spec_path=spec_path, requirements_contract_path=contract_path) - result = Accepted({"phase": next_state.phase.value, "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets)}) - if not self._commit(next_state, [{"event": "requirements_compiled", "invocation_id": invocation_id, "contract_hash": contract["contract_hash"], "spec_path": spec_path, "contract_path": contract_path, "target_count": len(targets), "verification_warnings": warnings}], invocation, result): - return Rejected(self._stale()) - return result - - def write_completion_result(self, task_id: str, state: TaskState, *, claim_results: list[dict[str, Any]], review: dict[str, Any]) -> str: - contract = self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - by_id = {str(item.get("claim_id") or ""): item for item in claim_results if isinstance(item, dict)} - visual = iter(review.get("visual_claims") or ()) - rows = ["# Completion Result", "", "## Checklist", ""] - for requirement in contract.get("requirements") or (): - if not isinstance(requirement, dict): - continue - statuses: list[str] = [] - evidence: list[str] = [] - for claim in requirement.get("acceptance_claims") or (): - if not isinstance(claim, dict): - continue - result = next(visual, {}) if claim.get("verification_mode") == "visual" else by_id.get(str(claim.get("claim_id") or ""), {}) - statuses.append(str(result.get("status") or "unknown")) - value = result.get("evidence") - if value: - evidence.append(value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, sort_keys=True)) - rows.append(f"- [{'x' if statuses and all(value == 'pass' for value in statuses) else ' '}] {requirement.get('statement')}: {', '.join(statuses) or 'unknown'}") - rows.extend(f" - Evidence: {value}" for value in evidence) - return self.artifacts.write_text_once(task_id, "completion-result.md", "\n".join(rows).rstrip() + "\n") - - def _write_document(self, task_id: str, document: MarkdownDocument, *, invocation_id: str, phase: TaskPhase, path: str, event: str, validator: Callable[[str], list[dict[str, str]]]) -> Accepted | Rejected: - 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 != phase: - return Rejected(WorkflowError(ErrorCode.AUTHOR_DECISION_REJECTED, "This document is not expected in the current workflow phase.")) - errors = validator(document.markdown) - if errors: - return Rejected(WorkflowError(ErrorCode.REQUIREMENTS_SPEC_INVALID, "Frozen Markdown document does not satisfy its required template.", field_errors=tuple(errors))) - invocation = self.repository.begin_invocation(task_id, invocation_id, self._key(task_id, event, state.working_head, document.model_dump(mode="json"))) - if invocation.status == "finished" and invocation.result is not None: - return self._restore(invocation.result) - try: - written = self.artifacts.write_text_once(task_id, path, document.markdown.strip() + "\n") - except OSError as error: - return self._park_for_storage_retry(state, str(error)) - kwargs = {"requirements_document_path": written} if path == "requirements.md" else {"completion_target_path": written} if path == "completion-target.md" else {"modeling_plan_path": written} - next_state = transition(state, event, **kwargs) - result = Accepted({"phase": next_state.phase.value, "path": written}) - if not self._commit(next_state, [{"event": event, "invocation_id": invocation_id, "path": written}], invocation, result): - return Rejected(self._stale()) - return result - - def _checklist_items(self, task_id: str) -> list[str]: - path = self.artifacts.task_dir(task_id) / "completion-target.md" - text = path.read_text(encoding="utf-8") if path.is_file() else "" - return [match.group(1).strip() for line in text.splitlines() if (match := _CHECKBOX.match(line))] - - def _claim_errors(self, output: CompiledRequirementsSpec) -> list[dict[str, str]]: - errors: list[dict[str, str]] = [] - for requirement_index, requirement in enumerate(output.requirements): - for claim_index, claim in enumerate(requirement.acceptance_claims): - try: - messages = self.registry.validate_expected(claim.claim_kind, claim.expected) - except ValueError: - messages = [{"path": "", "message": "VERIFIER_UNAVAILABLE"}] - 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." - ) - claim.claim_kind = "visual" - claim.expected = {"description": target[:360]} - normalized_claims.append(claim) - continue - if self._is_local_cylindrical_span_bbox(requirement.acceptance_claims, claim, target): - self._move_bbox_z_to_outer_cylindrical_span(requirement.acceptance_claims, claim) - warnings.append( - f"Global bbox Z verifier for checklist item '{target}' was omitted because the target describes a local cylindrical span, not the finished part envelope." - ) - continue - claim.expected = self.registry.normalize_expected(claim.claim_kind, claim.expected) - normalized_claims.append(claim) - requirement.acceptance_claims = normalized_claims or [AcceptanceClaimInput.model_validate({ - "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": - return False - if RequirementsCommandHandler._target_describes_finished_envelope(target): - return False - return any( - getattr(item, "claim_kind", "") == "outer_cylindrical_surface" - for item in claims - ) - - @staticmethod - def _target_describes_finished_envelope(target: str) -> bool: - lowered = target.lower() - return any(token in lowered for token in ( - "overall", - "total", - "finished part", - "entire part", - "whole part", - "bounding box", - "envelope", - "总", - "整体", - "成品", - "全高", - "包围盒", - )) - - @staticmethod - def _move_bbox_z_to_outer_cylindrical_span(claims: list[Any], bbox_claim: Any) -> None: - value = bbox_claim.expected.get("value") - if not isinstance(value, (int, float)): - return - for item in claims: - if getattr(item, "claim_kind", "") != "outer_cylindrical_surface": - continue - expected = getattr(item, "expected", None) - if not isinstance(expected, dict) or "axial_span_mm" in expected: - continue - expected["axial_span_mm"] = value - if "tolerance_mm" not in expected and isinstance(bbox_claim.expected.get("tolerance_mm"), (int, float)): - expected["tolerance_mm"] = bbox_claim.expected["tolerance_mm"] - return - - @staticmethod - def _validate_requirements_document(markdown: str) -> list[dict[str, str]]: - # Markdown is a human-facing semantic artifact. Its content is frozen - # verbatim and is not executable, so headings are guidance for the - # author rather than a server-enforced protocol. - return [] - - @staticmethod - def _validate_completion_target(markdown: str) -> list[dict[str, str]]: - values = [match.group(1).strip() for line in markdown.splitlines() if (match := _CHECKBOX.match(line))] - errors: list[dict[str, str]] = [] - if not values: - errors.append({"path": "/markdown", "message": "Completion target requires at least one unchecked checklist item."}) - if len(values) != len(set(values)): - errors.append({"path": "/markdown", "message": "Completion checklist items must be unique."}) - return errors - - @staticmethod - def _validate_modeling_plan(markdown: str) -> list[dict[str, str]]: - return [] - - def _replay(self, task_id: str, invocation_id: str) -> Accepted | None: - invocation = self.repository.get_invocation(task_id, invocation_id) - return self._restore(invocation.result) if invocation and invocation.status == "finished" and invocation.result else None - - @staticmethod - def _restore(payload: dict[str, Any]) -> Accepted: - return Accepted(payload.get("payload") if isinstance(payload.get("payload"), dict) else payload) - - def _commit(self, state: TaskState, events: list[dict[str, Any]], invocation: Any, result: Accepted) -> bool: - return self.repository.compare_and_swap(state, events=events, invocation_id=invocation.invocation_id, invocation_result={"result_type": "accepted", "payload": result.payload}) - - @staticmethod - def _key(task_id: str, kind: str, head: str, value: dict[str, Any]) -> str: - encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) - return sha256(f"{task_id}|{kind}|{head}|{encoded}".encode()).hexdigest() - - @staticmethod - def _stale() -> WorkflowError: - return WorkflowError(ErrorCode.STALE_WORKING_HEAD, "Task state changed before this command could commit.") - - def _park_for_storage_retry(self, state: TaskState, message: str) -> Rejected: - waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE) - self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.STORAGE_FAILURE.value, "message": message[:1000]}]) - return Rejected(WorkflowError(ErrorCode.STORAGE_FAILURE, "Requirements artifact storage is temporarily unavailable.", retryable=True)) diff --git a/backend/app/cad_agent/application/results.py b/backend/app/cad_agent/application/results.py deleted file mode 100644 index 7a793f69..00000000 --- a/backend/app/cad_agent/application/results.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Typed results returned by all command handlers.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -from app.cad_agent.domain.errors import WorkflowError - - -@dataclass(frozen=True, slots=True) -class Accepted: - payload: dict[str, Any] = field(default_factory=dict) - - -@dataclass(frozen=True, slots=True) -class Rejected: - error: WorkflowError - - -@dataclass(frozen=True, slots=True) -class Waiting: - error: WorkflowError - - -@dataclass(frozen=True, slots=True) -class FailedInternal: - correlation_id: str - message: str diff --git a/backend/app/cad_agent/application/single_stage.py b/backend/app/cad_agent/application/single_stage.py new file mode 100644 index 00000000..e81e1d1c --- /dev/null +++ b/backend/app/cad_agent/application/single_stage.py @@ -0,0 +1,99 @@ +"""Deterministic execution service for the single-stage Authoring protocol. + +The service is deliberately synchronous and side-effect bounded so the HTTP +workflow can call it after one author response (and at most two repairs). +""" +from __future__ import annotations + +from hashlib import sha256 +import json +from typing import Any + +from app.cad_agent.ports import AdapterUnavailable + +class SingleStageExecutor: + MAX_REPAIRS = 2 + + def __init__(self, repository: Any, artifacts: Any, runtime: Any) -> None: + self.repository, self.artifacts, self.runtime = repository, artifacts, runtime + + def compile(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]: + """Compile once and persist immutable compiler inputs and output.""" + if repair_count < 0 or repair_count > self.MAX_REPAIRS: + raise ValueError("repair budget exhausted") + runtime_cdsl, audit = self.runtime.compile_authoring(authoring) + digest = self._digest(authoring) + attempt = repair_count + 1 + runtime_path = f"documents/runtime-cdsl-attempt-{attempt:02d}-{digest[:8]}.json" + audit_path = f"documents/compile-audit-attempt-{attempt:02d}-{digest[:8]}.json" + self.artifacts.write_json_once(task_id, runtime_path, runtime_cdsl) + self.artifacts.write_json_once(task_id, audit_path, audit) + return {"runtime": runtime_cdsl, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": digest} + + def build(self, task_id: str, authoring: dict[str, Any], runtime_cdsl: dict[str, Any], audit: dict[str, Any], *, repair_count: int, digest: str = "") -> dict[str, Any]: + """Build one compiled CDSL document and publish any executable prefix.""" + if repair_count < 0 or repair_count > self.MAX_REPAIRS: + raise ValueError("repair budget exhausted") + digest = digest or self._digest(authoring) + stage = self.artifacts.start_staging_revision(task_id, f"single_{repair_count}_{digest}", { + "schema_version": "cad.single-stage.v1", "authoring": authoring, + "runtime": runtime_cdsl, "compile_audit": audit, + }) + try: + rebuilt, failures = self.runtime.rebuild_best_effort(runtime_cdsl, stage.output_dir, task_id, stage.stage_id) + except (OSError, AdapterUnavailable): + raise + except Exception as error: + self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", { + "schema_version": "cad.build-diagnostics.v1", + "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], + }) + return {"status": "failed", "repair_count": repair_count, "diagnostics": [{"code": "ENGINE_EXECUTION_FAILED", "message": str(error)[:1000]}], "compile_audit": audit} + diagnostics = self._decorate_diagnostics(failures, audit) + self.artifacts.write_stage_json(task_id, stage.stage_id, "build-diagnostics.json", { + "schema_version": "cad.build-diagnostics.v1", + "diagnostics": diagnostics, + "executed_feature_ids": rebuilt.get("executed_feature_ids", []), + }) + if rebuilt: + revision = f"rev_{digest}" + self.artifacts.write_stage_json(task_id, stage.stage_id, "staging-manifest.json", { + "schema_version": "cad.single-stage.staging-manifest.v1", + "stage_id": stage.stage_id, + "revision_id": revision, + "repair_count": repair_count, + "source_sha256": audit.get("source_sha256", ""), + "executed_feature_ids": rebuilt.get("executed_feature_ids", []), + }) + paths = self.artifacts.publish_staging_revision(task_id, stage.stage_id, revision) + else: + paths = {} + return {"status": "published_best_effort" if diagnostics else "completed", "repair_count": repair_count, "paths": paths, "revision_id": revision if rebuilt else "", "executed_feature_ids": rebuilt.get("executed_feature_ids", []), "diagnostics": diagnostics, "compile_audit": audit} + + def execute(self, task_id: str, authoring: dict[str, Any], *, repair_count: int = 0) -> dict[str, Any]: + """Compatibility convenience for direct callers and focused tests.""" + compiled = self.compile(task_id, authoring, repair_count=repair_count) + return { + **self.build(task_id, authoring, compiled["runtime"], compiled["compile_audit"], repair_count=repair_count, digest=compiled["digest"]), + "runtime_path": compiled["runtime_path"], "audit_path": compiled["audit_path"], + } + + @staticmethod + def _digest(authoring: dict[str, Any]) -> str: + encoded = json.dumps(authoring, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8") + return sha256(encoded).hexdigest()[:16] + + @staticmethod + def _decorate_diagnostics(failures: list[dict[str, Any]], audit: dict[str, Any]) -> list[dict[str, Any]]: + names = { + str(feature_id): str(name) + for name, feature_id in (audit.get("feature_ids") or {}).items() + if isinstance(name, str) and isinstance(feature_id, str) + } + result: list[dict[str, Any]] = [] + for failure in failures: + if not isinstance(failure, dict): + continue + feature_id = str(failure.get("feature_id") or "") + result.append({**failure, "feature_name": names.get(feature_id, "")}) + return result diff --git a/backend/app/cad_agent/application/workflow.py b/backend/app/cad_agent/application/workflow.py index 73e9839b..b081fc59 100644 --- a/backend/app/cad_agent/application/workflow.py +++ b/backend/app/cad_agent/application/workflow.py @@ -1,53 +1,26 @@ -"""LLM turn coordinator for protocol v3. +"""Single-stage coordinator for Authoring CDSL generation. -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. +Each task makes one request-analysis call and one full Authoring CDSL call. +Only structured authoring/compile/runtime failures can request a replacement, +and the replacement budget is fixed at two. """ - from __future__ import annotations import json import secrets -from hashlib import sha256 from dataclasses import dataclass -from typing import Any, AsyncIterator, TypeVar +from hashlib import sha256 +from typing import Any, AsyncIterator -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict, Field -from app.cad_agent.application.action_handlers import ActionCommandHandler -from app.cad_agent.application.llm_contracts import ( - CandidateReview, CompiledRequirementsSpec, EmptyCommand, FinalReview, GeometryConclusion, ImageObservation, MarkdownDocument, NextAction, - RollbackCheckpoint, StatelessCandidateReview, - StatelessFinalReview, StatelessGeometryConclusion, StatelessNextAction, StatelessRollbackCheckpoint, - StatelessTopologyRequest, TopologyRequest, - canonical_json_object, canonical_validate, canonical_validate_schema, - sanitize_compiled_requirements_arguments, - stateless_final_review_schema, - stateless_next_action_schema, stateless_rollback_checkpoint_schema, - raw_arguments_hash, validate_one_tool_call, -) -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 -from app.cad_agent.ports import ( - AdapterUnavailable, - ArtifactStore, - AuthorGuidance, - AuthorGuidanceSelection, - CadRuntime, - ModelGateway, - NullAuthorGuidance, - ReviewGateway, - TaskRepository, -) - - -T = TypeVar("T", bound=BaseModel) -_FEATURE_REPLAN_FAILURE_LIMIT = 3 +from app.cad_agent.application.authoring_compiler import AuthoringCompileError +from app.cad_agent.application.authoring_contract import AuthoringDocument, validation_error_code +from app.cad_agent.application.authoring_guidance import load_authoring_guidance +from app.cad_agent.application.single_stage import SingleStageExecutor +from app.cad_agent.domain.errors import ErrorCode +from app.cad_agent.domain.state import TaskPhase, TaskState, transition +from app.cad_agent.ports import AdapterUnavailable, ArtifactStore, CadRuntime, ModelGateway, TaskRepository @dataclass(frozen=True, slots=True) @@ -58,2264 +31,487 @@ class ModelIdentity: @dataclass(frozen=True, slots=True) class WorkflowConfig: - max_turns: int - format_error_limit: int - author_fallbacks: tuple[ModelIdentity, ...] = () - max_author_turns: int | None = None - max_reviewer_turns: int | None = None - max_model_calls: int | None = None + max_repairs: int = 2 -@dataclass(slots=True) -class _ModelCallBudget: - """Task-scoped model-call caps, including calls from a prior resume.""" +class _RequirementsTarget(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + kind: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$") + expected: dict[str, Any] = Field(default_factory=dict) + verification: str = Field(default="manual", pattern=r"^(deterministic|manual)$") - max_author_turns: int | None - max_reviewer_turns: int | None - max_model_calls: int | None - author_calls: int = 0 - reviewer_calls: int = 0 - @classmethod - def from_usage(cls, config: WorkflowConfig, records: list[dict[str, Any]]) -> "_ModelCallBudget": - reviewer_calls = sum(1 for record in records if record.get("role") == "reviewer") - return cls( - max_author_turns=config.max_author_turns, - max_reviewer_turns=config.max_reviewer_turns, - max_model_calls=config.max_model_calls, - author_calls=len(records) - reviewer_calls, - reviewer_calls=reviewer_calls, - ) - - @property - def total_calls(self) -> int: - return self.author_calls + self.reviewer_calls - - def exhausted(self, actor: str) -> bool: - return ( - (actor == "author" and self.max_author_turns is not None and self.author_calls >= self.max_author_turns) - or (actor == "reviewer" and self.max_reviewer_turns is not None and self.reviewer_calls >= self.max_reviewer_turns) - or (self.max_model_calls is not None and self.total_calls >= self.max_model_calls) - ) - - def record_attempt(self, actor: str) -> None: - if actor == "reviewer": - self.reviewer_calls += 1 - else: - self.author_calls += 1 - - def payload(self) -> dict[str, int | None]: - return { - "author_calls": self.author_calls, - "reviewer_calls": self.reviewer_calls, - "total_calls": self.total_calls, - "max_author_turns": self.max_author_turns, - "max_reviewer_turns": self.max_reviewer_turns, - "max_model_calls": self.max_model_calls, - } +class RequirementsAnalysis(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True) + schema_version: str = Field(default="cad.requirements.v1", pattern=r"^cad\.requirements\.v1$") + explicit_requirements: list[str] = Field(min_length=1, max_length=64) + assumptions: list[str] = Field(default_factory=list, max_length=64) + acceptance_targets: list[_RequirementsTarget] = Field(default_factory=list, max_length=128) + manual_targets: list[str] = Field(default_factory=list, max_length=64) + clarification_question: str | None = Field(default=None, min_length=1, max_length=500) class WorkflowCoordinator: - def __init__( - self, - config: WorkflowConfig, - repository: TaskRepository, - artifacts: ArtifactStore, - runtime: CadRuntime, - model_gateway: ModelGateway, - review_gateway: ReviewGateway, - requirements: RequirementsCommandHandler, - actions: ActionCommandHandler, - author_guidance: AuthorGuidance | None = None, - ) -> None: + def __init__(self, config: WorkflowConfig, repository: TaskRepository, artifacts: ArtifactStore, runtime: CadRuntime, model_gateway: ModelGateway, executor: SingleStageExecutor) -> None: self.config = config self.repository = repository self.artifacts = artifacts self.runtime = runtime self.model_gateway = model_gateway - self.review_gateway = review_gateway - self.requirements = requirements - self.actions = actions - self.author_guidance = author_guidance or NullAuthorGuidance() + self.executor = executor - def create_task( - self, - task_id: str, - request: str, - *, - source_blocks: list[dict[str, Any]] | None = None, - image_inputs: list[dict[str, str]] | None = None, - ) -> TaskState: - # The immutable source artifact is safe to create before SQLite state: - # an interrupted creation leaves only an unreferenced directory, never - # a runnable task without its source index. + def create_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> TaskState: self.artifacts.initialize_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) return self.repository.create_task(task_id, request) def resume(self, task_id: str) -> bool: state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.WAITING_RETRY: + if state is None or state.phase != TaskPhase.FAILED or state.retry_from_phase is None: return False - event = retry_resume_event(state) - if not event: - return False - next_state = transition(state, event) - return self.repository.compare_and_swap(next_state, events=[ - { - "event": "workflow_resumed", - "from_phase": state.phase.value, - "retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "", - "to_phase": next_state.phase.value, - }, - ]) + resumed = transition(state, "resume") + return self.repository.compare_and_swap(resumed, events=[{"event": "task_resumed", "from_phase": state.retry_from_phase.value}]) def resume_with_user_clarification(self, task_id: str, clarification: str, *, message_id: str) -> bool: - """Resume a requirements pause on the same task with durable input.""" state = self.repository.get_state(task_id) - if state is None or state.phase != TaskPhase.WAITING_FOR_USER: + if state is None or state.phase != TaskPhase.WAITING_FOR_USER or not clarification.strip(): return False - clarification_request = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None - if not isinstance(clarification_request, dict) or not str(clarification_request.get("question") or "").strip(): - return False - text = clarification.strip() - if not text: - return False - digest = sha256(f"{message_id}:{text}".encode("utf-8")).hexdigest()[:16] - clarification_path = f"documents/user-clarification-{digest}.json" - try: - self.artifacts.write_json_once(task_id, clarification_path, { - "schema_version": "cad.user-clarification.v1", - "task_id": task_id, - "message_id": message_id, - "text": text, - }) - except OSError: - return False - resumed = transition(state, "requirements_clarified", clarification_path="") - return self.repository.compare_and_swap(resumed, events=[{ - "event": "user_clarification_received", - "message_id": message_id, - "clarification_path": clarification_path, - "response_sha256": sha256(text.encode("utf-8")).hexdigest(), - }]) + digest = sha256(f"{message_id}:{clarification}".encode()).hexdigest()[:16] + path = self.artifacts.write_json_once(task_id, f"documents/user-clarification-{digest}.json", {"schema_version": "cad.user-clarification.v1", "message_id": message_id, "text": clarification.strip()}) + resumed = transition(state, "clarification_received", clarification_path=path) + return self.repository.compare_and_swap(resumed, events=[{"event": "clarification_received", "clarification_path": path}]) - async def run(self, *, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> AsyncIterator[tuple[str, dict[str, Any]]]: - feedback: list[dict[str, Any]] = [] - format_errors: dict[str, int] = {} - transport_attempted: set[str] = set() - # Observations are request-scoped, read-only facts. A restarted run - # deliberately has to fetch them again before authoring a selector- - # bound feature. - action_observations: dict[str, set[str]] = {} - active_author = author - max_turns = self.config.max_turns - call_budget = _ModelCallBudget.from_usage( - self.config, - self.repository.usage_summary(task_id).get("records", []), - ) - try: - initial = self.repository.get_state(task_id) - if initial is not None: - referenced = {initial.candidate_stage_id} if initial.candidate_stage_id else set() - # Candidate rejection/build-failure evidence is itself an - # immutable repair input. Once the state transition clears - # ``candidate_stage_id``, retain that stage through restart - # based on its committed ledger reference. - referenced.update( - str(event["stage_id"]) - for event in self.repository.ledger_events(task_id) - if event.get("event") in { - "candidate_rejected", "candidate_build_failed", "candidate_recovery_failed", "candidate_recovered_rejected", - } - and isinstance(event.get("stage_id"), str) - and event["stage_id"] - ) - try: - self.artifacts.recover_staged_candidates(task_id, referenced) - except Exception as error: - yield self._storage_failure(task_id, str(error)) - return - for _turn in range(max_turns): - try: - self._sync_action_ledger(task_id) - except Exception as error: - yield self._storage_failure(task_id, str(error)) - return - state = self.repository.get_state(task_id) - if state is None: - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "V3 task state is unavailable."} - return - if state.phase == TaskPhase.COMPLETED: - yield "task_terminal", self._projected_terminal(task_id, state) - return - if state.phase == TaskPhase.CANCELLED: - yield "task_terminal", { - "taskId": task_id, - "lifecycle": "cancelled", - "revisionId": state.active_revision, - "code": ErrorCode.CANCELLED.value, - } - return + def waiting_for_user_terminal(self, task_id: str, state: TaskState | None = None) -> dict[str, Any]: + state = state or self.repository.get_state(task_id) + details = self.artifacts.read_json(task_id, state.clarification_path) if state and state.clarification_path else {} + question = str((details or {}).get("question") or "CAD generation needs clarification.") + return {"taskId": task_id, "lifecycle": "waiting_for_user", "message": question, "questions": [question], "userActionRequired": True} + + async def run(self, *, task_id: str, author: ModelIdentity) -> AsyncIterator[tuple[str, dict[str, Any]]]: + while True: + state = self.repository.get_state(task_id) + if state is None: + return + if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + yield "task_terminal", self._terminal(state) + return + try: + self.artifacts.sync_event_ledger(task_id, self.repository.ledger_events(task_id)) if state.phase == TaskPhase.WAITING_FOR_USER: yield "task_terminal", self.waiting_for_user_terminal(task_id, state) return - 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 + if state.phase == TaskPhase.ANALYZING_REQUEST: + result = await self._analyze(task_id, state, author) + yield result 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} - if isinstance(recovered, Rejected): - yield self._service_failure(task_id, state, recovered.error) - return + if state.phase == TaskPhase.AUTHORING_CDSL: + result = await self._author(task_id, state, author) + yield result continue - if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: - image_paths = self.artifacts.source_image_paths(task_id) - if image_paths and self.artifacts.read_json(task_id, "documents/image-observation.json") is None: - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - observation = await self._observe_images(task_id, reviewer, image_paths) - if isinstance(observation, WorkflowError): - if observation.code == ErrorCode.AUTHOR_FORMAT_INVALID: - observation = WorkflowError( - ErrorCode.REVIEW_SERVICE_UNAVAILABLE, - "Image observation did not return the required structured format.", - field_errors=observation.field_errors, - retryable=True, - ) - yield self._service_failure(task_id, state, observation) - return - try: - self.artifacts.write_json_once(task_id, "documents/image-observation.json", { - "schema_version": "cad.image-observation.v3", - **observation.model_dump(mode="json"), - }) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - observed_state = transition(state, "image_observed") - self.repository.compare_and_swap(observed_state, events=[{ - "event": "image_observation_ready", - "path": "documents/image-observation.json", - "image_count": len(image_paths), - }]) - yield "image_observation", {"taskId": task_id, "status": "success", "path": "documents/image-observation.json"} - continue - document_schema = self.requirements.document_schema() - tools = [self._tool("write_requirements_document", document_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, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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, MarkdownDocument) - dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - invocation_id = self._invocation_id(task_id) - command = self.requirements.submit_requirements_document(task_id, validation, invocation_id=invocation_id) - if isinstance(command, Rejected): - terminal = self._requirements_rejection(task_id, state, command.error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - event_payload = self._event(task_id, name, self._result_payload(command), "success", usage) - event_payload["markdown"] = validation.markdown - yield "requirements_document_ready", event_payload - feedback = [] + if state.phase == TaskPhase.COMPILING_CDSL: + result = self._compile(task_id, state) + yield result continue - if state.phase in {TaskPhase.DRAFTING_COMPLETION_TARGET, TaskPhase.DRAFTING_MODELING_PLAN}: - document_schema = self.requirements.document_schema() - tool_name = "write_completion_target" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "write_modeling_plan" - tools = [self._tool(tool_name, document_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, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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, MarkdownDocument) - dynamic_error = canonical_validate_schema(raw, document_schema) if not isinstance(validation, WorkflowError) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool=tool_name) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.requirements.submit_completion_target(task_id, validation, invocation_id=self._invocation_id(task_id)) if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else self.requirements.submit_modeling_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=tool_name) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - event_payload = self._event(task_id, name, self._result_payload(command), "success", usage) - event_payload["markdown"] = validation.markdown - yield ("completion_target_ready" if state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET else "modeling_plan_ready"), event_payload - feedback = [] + if state.phase == TaskPhase.BUILDING: + result = self._build(task_id, state) + yield result continue - if state.phase == TaskPhase.COMPILING_REQUIREMENTS: - compiler_schema = self.requirements.compiler_schema(task_id) - tools = [self._tool("compile_requirements_spec", compiler_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, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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 + if state.phase == TaskPhase.REPAIRING: + started = transition(state, "repair_started", repair_count=state.repair_count + 1) + if not self.repository.compare_and_swap(started, events=[{"event": "repair_started", "repair_count": started.repair_count}]): continue - name, raw, usage = result - sanitized_raw = sanitize_compiled_requirements_arguments(raw) - if isinstance(sanitized_raw, WorkflowError): - validation: CompiledRequirementsSpec | WorkflowError = sanitized_raw - else: - validation = canonical_validate(sanitized_raw, CompiledRequirementsSpec) - dynamic_error = canonical_validate_schema(sanitized_raw, compiler_schema) if not isinstance(validation, WorkflowError) and isinstance(sanitized_raw, str) else None - if dynamic_error is not None: - validation = dynamic_error - if isinstance(validation, WorkflowError): - terminal = self._requirements_format_failure(task_id, state, validation, format_errors, feedback, tool="compile_requirements_spec") - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.requirements.submit_compiled_spec(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="compile_requirements_spec") - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "requirements_compiled", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] + yield "repair_started", {"taskId": task_id, "repairCount": started.repair_count, "repairBudget": self.config.max_repairs} 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 = [] + if state.phase == TaskPhase.PUBLISHING_BEST_EFFORT: + result = self._publish(task_id, state) + yield result 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")] - action_schema = stateless_next_action_schema(list(self.actions.available_atomic_ids(task_id, state))) - tools = self._recovery_tools(task_id, state) - if not tools: - if self._can_complete(task_id, state): - tools = [self._tool("complete_task", EmptyCommand)] - elif self.actions.repair_action_ready(task_id, state): - tools = [self._tool("propose_next_action", action_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, "author_turn", result, format_errors, feedback) - yield "tool_call", {"taskId": task_id, "tool": "author_turn", "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 == "complete_task": - validation = canonical_validate(raw, EmptyCommand) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.actions.complete_task(task_id, invocation_id=self._invocation_id(task_id)) - elif name == "record_geometry_conclusion": - validation = canonical_validate(raw, StatelessGeometryConclusion) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = GeometryConclusion( - working_head=state.working_head, - evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)), - root_cause=validation.root_cause, - decision=validation.decision, - corrective_intent=validation.corrective_intent, - ) - command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id)) - elif name == "rollback_checkpoint": - rollback_schema = stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))) - validation = canonical_validate(raw, StatelessRollbackCheckpoint) - dynamic_error = canonical_validate_schema(raw, rollback_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 "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = RollbackCheckpoint(working_head=state.working_head, checkpoint_token=validation.checkpoint_token, reason=validation.reason) - command = self.actions.rollback_checkpoint(task_id, validation, invocation_id=self._invocation_id(task_id)) - else: - validation = canonical_validate(raw, StatelessNextAction) - dynamic_error = canonical_validate_schema(raw, action_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 "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = NextAction( - working_head=state.working_head, - intent=validation.intent, - requirement_ids=requirement_ids, - atomic_id=validation.operation, - expected_change=validation.expected_change, - ) - command = self.actions.propose_next_action(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "action_selection", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "action_selection", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if state.phase == TaskPhase.ACTION_PENDING: - observed = action_observations.setdefault(state.working_head, set()) - tools = self._recovery_tools(task_id, state) - if not tools and self.actions.repair_action_ready(task_id, state): - 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 "tool_call", {"taskId": task_id, "tool": "author_turn", "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 == "record_geometry_conclusion": - validation = canonical_validate(raw, StatelessGeometryConclusion) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - continue - validation = GeometryConclusion( - working_head=state.working_head, - evidence_refs=list(self.actions.diagnostic_evidence_refs(task_id, state)), - root_cause=validation.root_cause, - decision=validation.decision, - corrective_intent=validation.corrective_intent, - ) - command = self.actions.record_geometry_conclusion(task_id, validation, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "tool_call", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if name == "inspect_topology": - validation = canonical_validate(raw, StatelessTopologyRequest) - if isinstance(validation, WorkflowError): - terminal = self._format_failure(task_id, state, name, validation, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation.payload(), "error", usage) - if terminal: - yield terminal - return - 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 = [*feedback, {"role": "tool", "content": json.dumps({"tool": name, "result": payload}, ensure_ascii=False)}][-2:] - continue - fragment = canonical_json_object(raw) - if isinstance(fragment, WorkflowError): - validation_error = fragment - terminal = self._format_failure(task_id, state, name, validation_error, format_errors, feedback) - yield "tool_call", self._event(task_id, name, validation_error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - command = self.actions.submit_cdsl_fragment(task_id, fragment, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - # A candidate build may reject and advance back to - # ACTION_PENDING. That is repair work, not a no-side- - # effect schema/state retry. - after = self.repository.get_state(task_id) - if after is not None and after.version != state.version: - feedback = [self._feedback(command.error)] - yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage) - continue - terminal = self._model_rejection_or_service_failure(task_id, state, name, command.error, format_errors, feedback) - yield "candidate_result", self._event(task_id, name, command.error.payload(), "error", usage) - if terminal: - yield terminal - return - continue - yield "candidate_result", self._event(task_id, name, self._result_payload(command), "success", usage) - feedback = [] - continue - if state.phase == TaskPhase.CANDIDATE_REVIEW: - recovered = self.actions.recover_candidate_review(task_id) - if recovered is not None: - yield "candidate_review", {"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 - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - review = await self._review_candidate(task_id, reviewer, state, feedback) - if isinstance(review, WorkflowError): - if review.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "review_candidate", review, format_errors, feedback, actor="reviewer") - yield "candidate_review", {"taskId": task_id, "status": "error", "result": review.payload()} - if terminal: - yield terminal - return - continue - yield self._service_failure(task_id, state, review) - return - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") or {} - action = state.pending_action - if action is None: - yield self._storage_failure(task_id, "Candidate action is unavailable during review.") - return - coverage = [] - for item in candidate.get("claim_results") or (): - if not isinstance(item, dict): - continue - status = str(item.get("status") or "pending") - coverage.append({ - "claim_id": str(item.get("claim_id") or ""), - "status": status if status in {"pass", "pending", "fail", "not_applicable"} else "fail", - "evidence_refs": [], - }) - review = CandidateReview( - candidate_id=state.candidate_id, - working_head=action.working_head, - verdict=review.verdict, - claim_coverage=coverage, - evidence=review.evidence, - issues=review.issues, - ) - command = self.actions.record_candidate_review(task_id, review, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, "review_candidate", command.error, format_errors, feedback, actor="reviewer") - yield "candidate_review", {"taskId": task_id, "status": "error", "result": command.error.payload()} - if terminal: - yield terminal - return - continue - 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": - completed_state = self.repository.get_state(task_id) - if completed_state is not None: - try: - self._ensure_recovered_completion_result(task_id, completed_state) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} - yield "final_review", {"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 - terminal = self._call_budget_terminal(task_id, state, call_budget, actor="reviewer") - if terminal: - yield terminal - return - call_budget.record_attempt("reviewer") - review = await self._review_final(task_id, reviewer, state, feedback) - if isinstance(review, WorkflowError): - if review.code == ErrorCode.AUTHOR_FORMAT_INVALID: - terminal = self._format_failure(task_id, state, "review_final", review, format_errors, feedback, actor="reviewer") - yield "final_review", {"taskId": task_id, "status": "error", "result": review.payload()} - if terminal: - yield terminal - return - continue - yield self._service_failure(task_id, state, review) - return - facts = self.actions._facts(task_id, state.active_revision) - claim_results = self.actions._evaluate_claims(task_id, facts) - visual_decisions = iter(review.visual_claims) - coverage = [] - for item in claim_results: - if item.get("deterministic"): - status = str(item.get("status") or "fail") - status = status if status in {"pass", "pending", "fail", "not_applicable"} else "fail" - else: - status = next(visual_decisions).status - coverage.append({"claim_id": str(item.get("claim_id") or ""), "status": status, "evidence_refs": []}) - stateless_review = review - review = FinalReview( - working_head=state.working_head, - verdict=review.verdict, - claim_coverage=coverage, - evidence=review.evidence, - issues=review.issues, - ) - will_complete = ( - stateless_review.verdict == "pass" - and all(item.get("status") == "pass" for item in claim_results if item.get("deterministic")) - and all(item.status == "pass" for item in stateless_review.visual_claims) - ) - if will_complete: - try: - self.requirements.write_completion_result( - task_id, - state, - claim_results=claim_results, - review=stateless_review.model_dump(mode="json"), - ) - except OSError as error: - yield self._storage_failure(task_id, str(error)) - return - command = self.actions.record_final_review(task_id, review, invocation_id=self._invocation_id(task_id)) - if isinstance(command, Rejected): - terminal = self._model_rejection_or_service_failure(task_id, state, "review_final", command.error, format_errors, feedback, actor="reviewer") - yield "final_review", {"taskId": task_id, "status": "error", "result": command.error.payload()} - if terminal: - yield terminal - return - continue - if isinstance(command, Accepted) and command.payload.get("status") == "completed": - yield "completion_result_ready", {"taskId": task_id, "status": "success", "path": "completion-result.md"} - 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"} - yield terminal - return - if state: - failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) - self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": "Workflow exceeded its finite turn limit."}]) - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Workflow exceeded its finite turn limit."} - except OSError as error: - 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"} - yield terminal - return - if state 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": "failed_internal", "message": str(error)[:1000]}]) - yield "task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": str(error)[:1000]} - finally: - # The database ledger is authoritative. A failed mirror write is - # retried by the next active workflow pass; do not replace a - # completed/cancelled durable result with a filesystem exception. - try: - self._sync_action_ledger(task_id) - except Exception: - pass + except AdapterUnavailable as error: + self._fail(task_id, state, ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error), retryable=True) + except OSError as error: + self._fail(task_id, state, ErrorCode.STORAGE_FAILURE, str(error), retryable=True) + except Exception as error: # A coordinator failure must have a durable terminal record. + self._fail(task_id, state, ErrorCode.FAILED_INTERNAL, str(error), retryable=False) - def _sync_action_ledger(self, task_id: str) -> None: - self.artifacts.sync_action_ledger(task_id, self.repository.ledger_events(task_id)) - - def _call_budget_terminal( - self, - task_id: str, - state: TaskState, - budget: _ModelCallBudget, - *, - actor: str, - ) -> tuple[str, dict[str, Any]] | None: - if not budget.exhausted(actor): - 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, - ErrorCode.CALL_BUDGET_EXHAUSTED, - "Configured model-call budget is exhausted; publishing the last executable checkpoint.", - budget=details, - ) - if terminal: - return terminal - 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 workflow converged.", - **details, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.CALL_BUDGET_EXHAUSTED.value, - "message": "Configured model-call budget is exhausted before the workflow converged.", - "budget": details, - "blockerType": "call_budget_exhausted", - "userActionRequired": False, - } - - def _best_effort_terminal( - self, - task_id: str, - state: TaskState, - reason: ErrorCode, - message: str, - *, - budget: dict[str, Any] | None = None, - ) -> tuple[str, dict[str, Any]] | None: - if not state.active_revision or state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: - return None - claim_results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision)) - visual_claims = [ - {"status": "not_reviewed", "evidence": "Final review was not reached before bounded completion."} - for item in claim_results - if not item.get("deterministic") - ] + async def _analyze(self, task_id: str, state: TaskState, author: ModelIdentity) -> tuple[str, dict[str, Any]]: + if state.requirements_path: + next_state = transition(state, "analysis_written") + self.repository.compare_and_swap(next_state, events=[{"event": "requirements_reused", "path": state.requirements_path}]) + return "requirements_ready", {"taskId": task_id, "path": state.requirements_path, "reused": True} + source = self.artifacts.read_source_requirements(task_id) try: - self.requirements.write_completion_result( + analysis = RequirementsAnalysis.model_validate(await self._tool_call( + task_id, author, "analyze_requirements", RequirementsAnalysis.model_json_schema(), + "Extract explicit CAD requirements, safe assumptions, and verification targets. Do not invent strict dimensions that the request did not specify.", + source, + )) + except (AuthoringCompileError, ValueError) as error: + diagnostic_path = self.artifacts.write_json_once(task_id, "documents/requirements-analysis-diagnostic.json", { + "schema_version": "cad.requirements-diagnostic.v1", + "diagnostics": [{"code": "AUTHOR_SCHEMA_INVALID", "message": str(error)[:1000]}], + }) + failed = transition(state, "failed", diagnostics_path=diagnostic_path, error=ErrorCode.AUTHOR_SCHEMA_INVALID) + self.repository.compare_and_swap(failed, events=[{"event": "requirements_analysis_failed", "diagnostics_path": diagnostic_path}]) + return "task_terminal", self._terminal(failed) + if analysis.clarification_question: + path = self.artifacts.write_json_once(task_id, "documents/clarification-request.json", {"question": analysis.clarification_question}) + waiting = transition(state, "waiting_for_user", clarification_path=path, requirements_path="") + self.repository.compare_and_swap(waiting, events=[{"event": "requirements_waiting_for_user", "question": analysis.clarification_question}]) + return "task_terminal", self.waiting_for_user_terminal(task_id, waiting) + path = self.artifacts.write_json_once(task_id, "documents/requirements-analysis.json", analysis.model_dump(mode="json")) + next_state = transition(state, "analysis_written", requirements_path=path) + self.repository.compare_and_swap(next_state, events=[{"event": "requirements_analyzed", "path": path, "assumptions": analysis.assumptions}]) + return "requirements_ready", {"taskId": task_id, "path": path, "assumptions": analysis.assumptions} + + async def _author(self, task_id: str, state: TaskState, author: ModelIdentity) -> tuple[str, dict[str, Any]]: + requirements = self.artifacts.read_json(task_id, state.requirements_path) or {} + previous = self.artifacts.read_json(task_id, state.authoring_path) if state.authoring_path else None + diagnostics = self.artifacts.read_json(task_id, state.diagnostics_path) if state.diagnostics_path else None + operation_schemas = { + atomic_id: self._author_operation_contract(self.runtime.operation_contract(atomic_id)) + for atomic_id in self.runtime.supported_atomic_ids() + } + repair_instruction = "" if state.repair_count == 0 else "Return a complete replacement document. Preserve only features confirmed in executed_feature_ids unless the diagnostic identifies that feature. Features that did not execute may be corrected. Keep local names unless the diagnostic identifies a name conflict. Never add IDs, stable selectors, snapshots, or tokens." + content = json.dumps({"requirements": requirements, "supported_operations": operation_schemas, "previous_authoring": previous, "diagnostics": diagnostics}, ensure_ascii=False) + try: + raw = await self._tool_call( + task_id, author, "write_authoring_cdsl", AuthoringDocument.model_json_schema(), + "Write only cad.author.v1. Use local body and feature names. The service creates all runtime IDs. " + repair_instruction + "\n\n" + load_authoring_guidance(), + content, + ) + document = AuthoringDocument.model_validate(raw).model_dump(mode="json") + if previous is not None and state.repair_count: + self._validate_repair_document(previous, document, diagnostics, self.artifacts.read_json(task_id, state.compile_audit_path) if state.compile_audit_path else None) + except (AuthoringCompileError, ValueError) as error: + return self._repair_or_stop( task_id, state, - claim_results=claim_results, - review={"visual_claims": visual_claims}, + validation_error_code(error), + str(error), + self._author_validation_details(error), ) - except OSError: - return None - completed = self.actions.finalize_best_effort(task_id, reason=reason, invocation_id=self._invocation_id(task_id)) - if isinstance(completed, Rejected): - return None - return "task_terminal", { - "taskId": task_id, - "lifecycle": "completed", - "revisionId": state.active_revision, - "code": ErrorCode.BEST_EFFORT_COMPLETED.value, - "message": message, - "issues": completed.payload.get("issues") or [], - "verificationStatus": "completed_with_risks", - "verificationWarnings": completed.payload.get("issues") or [], - "completionResultPath": "completion-result.md", - "budget": budget or {}, - "userActionRequired": False, - } + path = self.artifacts.write_json_once(task_id, f"documents/authoring-cdsl-attempt-{state.repair_count + 1:02d}.json", document) + next_state = transition(state, "authoring_written", authoring_path=path) + self.repository.compare_and_swap(next_state, events=[{"event": "authoring_cdsl_written", "path": path, "repair_count": state.repair_count}]) + return "authoring_cdsl_ready", {"taskId": task_id, "path": path, "repairCount": state.repair_count} - def _storage_failure(self, task_id: str, message: str) -> tuple[str, dict[str, Any]]: - """Park a nonterminal task when a durable artifact operation fails.""" - state = self.repository.get_state(task_id) - if state is not None and state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}: - waiting = transition(state, "waiting_retry", error=ErrorCode.STORAGE_FAILURE) - self.repository.compare_and_swap(waiting, events=[{ - "event": "waiting_retry", - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "waiting_retry", - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - } - lifecycle = state.phase.value.lower() if state is not None else "failed" - return "task_terminal", { - "taskId": task_id, - "lifecycle": lifecycle, - "code": ErrorCode.STORAGE_FAILURE.value, - "message": message[:1000], - } - - async def _author_turn(self, task_id: str, author: ModelIdentity, tools: list[dict[str, Any]], feedback: list[dict[str, Any]]) -> tuple[str, str, dict[str, Any]] | WorkflowError: - if len(tools) != 1: - raise RuntimeError("Workflow state must expose exactly one author tool.") - tool = tools[0] - name = str((tool.get("function") or {}).get("name") or "") - if not name: - raise RuntimeError("Workflow exposed an unnamed author tool.") - messages, guidance = self._author_context(task_id, feedback) + def _compile(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: + authoring = self.artifacts.read_json(task_id, state.authoring_path) + if authoring is None: + raise RuntimeError("Authoring CDSL artifact is unavailable") try: - response = await self.model_gateway.call_tool( - messages=messages, tool=tool, provider_id=author.provider_id, - model_id=author.model_id, required_tool_name=name, - ) - except AdapterUnavailable as error: - self.repository.record_usage(task_id, { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "usage_available": False, - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "retry_reason": "provider_unavailable", - "cache_hit": False, - **guidance.usage_metadata(), - }) - return WorkflowError(ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, str(error)[:1000], retryable=True) - call = validate_one_tool_call(response["tool_calls"], name) - if isinstance(call, WorkflowError): - # A provider response is still a billable author attempt even when - # it violates the one-tool-call protocol. Keep guidance audit - # metadata on that record so A/B reports do not silently omit the - # failures this corpus is intended to reduce. - self.repository.record_usage(task_id, { - **response["usage"], - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "raw_arguments_hash": "", - "retry_reason": "invalid_tool_call", - "cache_hit": False, - **guidance.usage_metadata(), - }) - self._record_rejected_tool_calls( - task_id, - actor="author", - expected_tool=name, - tool_calls=response["tool_calls"], - schema=(tool.get("function") or {}).get("parameters"), - ) - return call - _name, raw = call - self._record_tool_audit( - task_id, - actor="author", - tool=name, - raw_arguments=raw, - schema=(tool.get("function") or {}).get("parameters"), - ) - usage = { - **response["usage"], - "context_chars": len(json.dumps(messages, ensure_ascii=False)), - "tool": name, - "provider_id": author.provider_id, - "model_id": author.model_id, - "raw_arguments_hash": raw_arguments_hash(raw), - "retry_reason": "", - "cache_hit": False, - **guidance.usage_metadata(), - } - self.repository.record_usage(task_id, usage) - return name, raw, usage + compiled = self.executor.compile(task_id, authoring, repair_count=state.repair_count) + except AuthoringCompileError as error: + return self._repair_or_stop(task_id, state, error.code, str(error), {"path": error.path}) + next_state = transition(state, "compiled", runtime_cdsl_path=compiled["runtime_path"], compile_audit_path=compiled["audit_path"]) + self.repository.compare_and_swap(next_state, events=[{"event": "cdsl_compiled", "runtime_path": compiled["runtime_path"], "compile_audit_path": compiled["audit_path"]}]) + return "cdsl_compiled", {"taskId": task_id, "runtimePath": compiled["runtime_path"], "compileAuditPath": compiled["audit_path"]} - async def _review_candidate(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessCandidateReview | WorkflowError: - candidate = self.artifacts.read_stage_json(task_id, state.candidate_stage_id, "candidate.json") - action = state.pending_action - if not isinstance(candidate, dict) or action is None: - return WorkflowError(ErrorCode.STORAGE_FAILURE, "Candidate review facts are unavailable.", retryable=True) - return await self._review_tool(task_id, reviewer, "review_candidate", StatelessCandidateReview, { - "requirements": self._public_requirements(self._requirements_contract(task_id, state)), - "action": {"intent": action.intent, "expected_change": action.expected_change, "operation": action.atomic_id}, - "candidate_facts": self._public_candidate(candidate), - "render_manifest": candidate.get("render_manifest") or {}, - "instruction": "Review only whether the current checkpoint correctly performs the stated action. Deterministic facts are authoritative. Do not return task, action, candidate, requirement, claim, revision, head, or evidence identifiers.", - }, feedback=feedback) + def _build(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: + authoring = self.artifacts.read_json(task_id, state.authoring_path) + runtime_cdsl = self.artifacts.read_json(task_id, state.runtime_cdsl_path) + audit = self.artifacts.read_json(task_id, state.compile_audit_path) + if authoring is None or runtime_cdsl is None or audit is None: + raise RuntimeError("Compiled CDSL artifacts are unavailable") + result = self.executor.build(task_id, authoring, runtime_cdsl, audit, repair_count=state.repair_count) + diagnostics = result.get("diagnostics") if isinstance(result.get("diagnostics"), list) else [] + diagnostic_path = self.artifacts.write_json_once(task_id, f"documents/diagnostics-attempt-{state.repair_count + 1:02d}.json", {"diagnostics": diagnostics, "executed_feature_ids": result.get("executed_feature_ids", [])}) + revision = str(result.get("revision_id") or "") + if diagnostics: + if state.repair_count < self.config.max_repairs: + repairing = transition(state, "repair_required", active_revision=revision or state.active_revision, diagnostics_path=diagnostic_path, error=ErrorCode.ENGINE_EXECUTION_FAILED) + self.repository.compare_and_swap(repairing, events=[{"event": "build_failed", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revision_id": revision}]) + return "build_result", {"taskId": task_id, "status": "repair_required", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revisionId": revision} + publishing = transition(state, "build_completed", active_revision=revision or state.active_revision, diagnostics_path=diagnostic_path, error=ErrorCode.BEST_EFFORT_COMPLETED) + self.repository.compare_and_swap(publishing, events=[{"event": "published_best_effort", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revision_id": revision}]) + return "build_result", {"taskId": task_id, "status": "published_best_effort", "paths": result.get("paths", {}), "diagnostics": diagnostics, "revisionId": revision} + publishing = transition(state, "build_completed", active_revision=revision, diagnostics_path=diagnostic_path) + self.repository.compare_and_swap(publishing, events=[{"event": "build_completed", "paths": result.get("paths", {}), "revision_id": revision, "executed_feature_ids": result.get("executed_feature_ids", [])}]) + return "build_result", {"taskId": task_id, "status": "completed", "paths": result.get("paths", {}), "revisionId": revision} - async def _observe_images(self, task_id: str, reviewer: ModelIdentity, image_paths: list[str]) -> ImageObservation | WorkflowError: - return await self._review_tool(task_id, reviewer, "observe_images", ImageObservation, { - "source_requirements": self.artifacts.read_source_requirements(task_id), - "reference_image_paths": image_paths, - "instruction": ( - "Inspect every supplied reference image once. Describe visible part geometry, view directions, readable dimensions, holes and profiles, confidence, assumptions, and uncertainties. " - "Do not create CAD operations and do not return attachment or runtime identifiers." - ), - }) - - async def _review_final(self, task_id: str, reviewer: ModelIdentity, state: TaskState, feedback: list[dict[str, Any]] | None = None) -> StatelessFinalReview | WorkflowError: - facts = self.actions._facts(task_id, state.active_revision) - results = self.actions._evaluate_claims(task_id, facts) - visual_claims = [item for item in results if not item.get("deterministic")] - return await self._review_tool(task_id, reviewer, "review_final", StatelessFinalReview, { - "source_requirements": self.artifacts.read_source_requirements(task_id), - "requirements": self._public_requirements(self._requirements_contract(task_id, state)), - "deterministic_results": [self._public_claim_result(item) for item in results if item.get("deterministic")], - "visual_claims": [self._public_claim_result(item) for item in visual_claims], - "render_manifest": (facts.get("report") or {}).get("render_manifest") or {}, - "reference_image_paths": self.artifacts.source_image_paths(task_id), - "instruction": "Review the final CAD renders against the original reference images and the ordered visual claims. Return exactly one visual_claims decision for each supplied visual claim, in the same order. Deterministic results are final. Do not return any runtime identifiers.", - }, schema=stateless_final_review_schema(len(visual_claims)), feedback=feedback) - - async def _review_tool(self, task_id: str, reviewer: ModelIdentity, name: str, model_type: type[T], payload: dict[str, Any], *, schema: dict[str, Any] | None = None, feedback: list[dict[str, Any]] | None = None) -> T | WorkflowError: - if feedback: - payload = {**payload, "previous_schema_or_state_error": str(feedback[-1].get("content") or "")[:2_000]} - try: - tool = self._tool(name, schema or model_type) - kind = "image_observation" if name == "observe_images" else "candidate" if name == "review_candidate" else "final" - response = await self.review_gateway.review(kind=kind, payload=payload, tool=tool, provider_id=reviewer.provider_id, model_id=reviewer.model_id) - except AdapterUnavailable as error: - error_code = ( - ErrorCode.RENDER_SERVICE_UNAVAILABLE - if str(error).startswith("RENDER_SERVICE_UNAVAILABLE:") - else ErrorCode.REVIEW_SERVICE_UNAVAILABLE - ) - self.repository.record_usage(task_id, { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - "usage_available": False, - "context_chars": len(json.dumps(payload, ensure_ascii=False)), - "tool": name, - "provider_id": reviewer.provider_id, - "model_id": reviewer.model_id, - "role": "reviewer", - "retry_reason": error_code.value.lower(), - "cache_hit": False, - }) - return WorkflowError(error_code, str(error)[:1000], retryable=True) - check = validate_one_tool_call(response["tool_calls"], name) - if isinstance(check, WorkflowError): - self._record_rejected_tool_calls( - task_id, - actor="reviewer", - expected_tool=name, - tool_calls=response["tool_calls"], - schema=(tool.get("function") or {}).get("parameters"), - ) - return check - _tool_name, raw = check - self._record_tool_audit( - task_id, - actor="reviewer", - tool=name, - raw_arguments=raw, - schema=(tool.get("function") or {}).get("parameters"), - ) - self.repository.record_usage(task_id, { - **response["usage"], - "context_chars": len(json.dumps(payload, ensure_ascii=False)), - "tool": name, - "provider_id": reviewer.provider_id, - "model_id": reviewer.model_id, - "role": "reviewer", - "raw_arguments_hash": raw_arguments_hash(raw), - "retry_reason": "", - "cache_hit": False, - }) - validated = canonical_validate(raw, model_type) - if isinstance(validated, WorkflowError): - return validated - dynamic_error = canonical_validate_schema(raw, schema) if schema is not None else None - return dynamic_error or validated # type: ignore[return-value] - - def _record_tool_audit( - self, - task_id: str, - *, - actor: str, - tool: str, - raw_arguments: str, - schema: Any, - returned_tool: str | None = None, - single_allowed_call: bool = True, - ) -> None: - """Persist the raw-output validation and current state-binding facts. - - Provider parsers are not a trust boundary. This stores a redacted, - diagnostic copy locally while release reports expose only the hash and - validation/binding summary. - """ - state = self.repository.get_state(task_id) - schema_error = canonical_validate_schema(raw_arguments, schema) if isinstance(schema, dict) else WorkflowError( - ErrorCode.RUNTIME_CONTRACT_INVALID, - "The exposed tool has no JSON Schema.", - ) - parsed = canonical_json_object(raw_arguments) - supplied_head = parsed.get("working_head") if isinstance(parsed, dict) else None - topology = self.artifacts.read_topology(task_id, state.active_revision) if state and state.active_revision else None - pending = state.pending_action if state else None - contract_current = True - if pending is not None: - try: - contract_current = self.runtime.operation_contract(pending.atomic_id).get("contract_hash") == pending.contract_hash - except Exception: - contract_current = False - schema_head = self._schema_working_head(schema) - expected_head = schema_head or (state.working_head if state is not None else "") - # Candidate reviews are scoped to the action checkpoint that was used - # to build the candidate. The state has already advanced by the time - # the review runs, so current-state equality would reject a valid, - # schema-bound response. The dynamic schema is server-generated and - # canonical-validated above, making its const the authority here. - working_head_matches = not isinstance(supplied_head, str) or (bool(expected_head) and supplied_head == expected_head) - binding_valid = bool(state) and single_allowed_call and schema_error is None and working_head_matches and contract_current - self.repository.record_tool_audit(task_id, { - "schema_version": "cad.v3.tool-audit.v2", - "actor": actor, - "tool": tool, - "raw_arguments_hash": raw_arguments_hash(raw_arguments), - "redacted_arguments": self._redact_tool_arguments(parsed) if isinstance(parsed, dict) else None, - "canonical_schema_valid": schema_error is None, - "field_errors": list(schema_error.field_errors) if isinstance(schema_error, WorkflowError) else [], - "state_binding": { - "phase": state.phase.value if state else "", - "working_head": state.working_head if state else "", - "expected_working_head": expected_head, - "working_head_binding_source": "schema_const" if schema_head else "current_state", - "supplied_working_head": str(supplied_head or ""), - "working_head_matches": working_head_matches, - "active_revision": state.active_revision if state else "", - "contract_hash": pending.contract_hash if pending else "", - "contract_hash_current": contract_current, - "selector_snapshot_id": str((topology or {}).get("snapshot_id") or ""), - "binding_valid": binding_valid, - }, - "returned_tool": returned_tool if returned_tool is not None else tool, - "single_allowed_call": single_allowed_call, - }) - - @staticmethod - def _schema_working_head(schema: Any) -> str | None: - """Return a server-bound head from a dynamic top-level tool schema.""" - properties = schema.get("properties") if isinstance(schema, dict) else None - working_head = properties.get("working_head") if isinstance(properties, dict) else None - value = working_head.get("const") if isinstance(working_head, dict) else None - return value if isinstance(value, str) else None - - def _record_rejected_tool_calls( - self, - task_id: str, - *, - actor: str, - expected_tool: str, - tool_calls: list[dict[str, Any]], - schema: Any, - ) -> None: - """Audit every returned call even when the one-call gate rejects it.""" - if not tool_calls: - self._record_tool_audit( - task_id, - actor=actor, - tool=expected_tool, - raw_arguments="", - schema=schema, - returned_tool="", - single_allowed_call=False, - ) - return - for call in tool_calls: - function = call.get("function") if isinstance(call, dict) and isinstance(call.get("function"), dict) else {} - name = str(function.get("name") or "") - arguments = function.get("arguments") - raw = arguments if isinstance(arguments, str) else "" - self._record_tool_audit( - task_id, - actor=actor, - tool=expected_tool, - raw_arguments=raw, - schema=schema, - returned_tool=name, - single_allowed_call=False, - ) - - @staticmethod - def _redact_tool_arguments(value: Any) -> Any: - if isinstance(value, list): - return [WorkflowCoordinator._redact_tool_arguments(item) for item in value] - if not isinstance(value, dict): - return value - sensitive = {"api_key", "authorization", "credential", "credentials", "secret", "token", "password"} - return { - str(key): "[REDACTED]" if str(key).casefold() in sensitive else WorkflowCoordinator._redact_tool_arguments(item) - for key, item in value.items() - } - - def _action_tools(self, task_id: str, state: TaskState, observed: set[str] | None = None) -> list[dict[str, Any]]: - action = state.pending_action - if action is None: - return [] - contract = self.runtime.operation_contract(action.atomic_id) - selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") - seen = observed or set() - topology = self.artifacts.read_topology(task_id, state.active_revision) - tokens = self.runtime.selector_tokens(topology) - eligible_tokens = self._selector_tokens_for_contract(contract, tokens) - if selector_shape == "required" and len(eligible_tokens) > 16 and "topology" not in seen: - return [self._tool("inspect_topology", StatelessTopologyRequest)] - references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) - description = "Submit exactly one CDSL feature for the pending action." - if not state.active_revision and action.atomic_id in {"extrude_add_blind", "extrude_add_two_sided"}: - description += " Root extrusion uses the world XY datum: workplane.origin_mm must be [0, 0, Z], normal [0, 0, 1], and x_dir [1, 0, 0]. Profile coordinates are local to that plane." - if action.atomic_id.startswith("hole_"): - description += ( - " Every feature.params.positions[].mm value is an absolute world-space mm point on the selected host face. " - "It is not a host-face-local offset; the server converts the world point to the selected face frame." - ) - if selector_shape == "required": - description += ( - " The root payload shape is {\"feature\":{\"atomic_id\":\"...\"," - "\"selector_tokens\":[\"opaque topology token\"],\"params\":{...}}}. " - "feature.selector_tokens is required author input: copy one of the opaque tokens from " - "inspect_topology; the server resolves it to the host after validation." - ) - fragment = {"type": "function", "function": {"name": "submit_cdsl_fragment", "description": description, "parameters": fragment_schema(contract, selector_tokens=eligible_tokens, reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))}} - return [fragment] - - def _recovery_tools(self, task_id: str, state: TaskState) -> list[dict[str, Any]]: - if not state.repair_required: - return [] - if self.actions.rollback_available(task_id, state): - return [self._tool("rollback_checkpoint", stateless_rollback_checkpoint_schema(list(self.actions.checkpoint_tokens(task_id, state))))] - if self.actions.repair_action_ready(task_id, state): - return [] - evidence_refs = list(self.actions.diagnostic_evidence_refs(task_id, state)) - if not evidence_refs: - return [] - return [self._tool("record_geometry_conclusion", StatelessGeometryConclusion)] - - def _author_context(self, task_id: str, feedback: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], AuthorGuidanceSelection]: - state = self.repository.get_state(task_id) - if state is None: - return [], AuthorGuidanceSelection(fallback_reason="task_state_unavailable") - if state.phase == TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT: - content = { - "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), - "instruction": ( - "Write the frozen engineering-expanded requirements Markdown using every required heading. Clearly separate explicit user facts from engineering defaults. " - "Conventional functional geometry is allowed for underspecified common parts, but never contradict explicit text or the image observation. Do not include runtime identifiers." - ), - } - elif state.phase == TaskPhase.DRAFTING_COMPLETION_TARGET: - content = { - "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.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. 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 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.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": 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": [ - { - "claim_kind": claim.get("claim_kind"), - "expected": claim.get("expected"), - } - for claim in item.get("acceptance_claims") or () - if isinstance(claim, dict) - ], - } for item in contract.get("requirements") or () if isinstance(item, dict)] - 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 = "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)) - selector_summary = [ - {"token": token, "kind": value.get("kind"), "geometry": {key: value.get("geometry", {}).get(key) for key in ("center_mm", "normal", "bbox_mm", "surface_type") if key in value.get("geometry", {})}} - 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." - 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), - }) - guidance = self.author_guidance.select( - phase=state.phase, - atomic_id=state.pending_feature.atomic_id if state.pending_feature is not None else "", - repair_required=state.repair_required, - supported_atomic_ids=self.runtime.supported_atomic_ids(), - ) - system = "You are the autonomous CAD author. Use exactly one offered structured tool call. Never emit Markdown plans or free-form JSON." - if guidance.content: - system += "\n\nThe following is non-authoritative CDSL author guidance. The current tool schema, operation contract, and server facts take precedence.\n\n" + guidance.content - messages: list[dict[str, Any]] = [{"role": "system", "content": system}, {"role": "user", "content": json.dumps(content, ensure_ascii=False)}] - return [*messages, *feedback[-2:]], guidance - - def _user_clarifications(self, task_id: str) -> list[dict[str, str]]: - clarifications: list[dict[str, str]] = [] - for event in self.repository.ledger_events(task_id): - if event.get("event") != "user_clarification_received": - continue - path = str(event.get("clarification_path") or "") - payload = self.artifacts.read_json(task_id, path) if path else None - text = str((payload or {}).get("text") or "").strip() - if text: - clarifications.append({"message_id": str((payload or {}).get("message_id") or ""), "text": text}) - return clarifications - - def _read_markdown(self, task_id: str, relative_path: str) -> str: - if not relative_path: - return "" - try: - path = self.artifacts.artifact_path(task_id, relative_path) - return path.read_text(encoding="utf-8") if path.is_file() else "" - 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. - - The clarification artifact is the durable source of the single human - decision needed to continue this task. - """ - clarification = self.artifacts.read_json(task_id, state.clarification_path) if state.clarification_path else None - question = str((clarification or {}).get("question") or "").strip() - questions = [question] if question else [] - if not questions: - raise RuntimeError("WAITING_FOR_USER requires at least one answerable requirements question") - message = f"Requirements need a user decision. {questions[0]}" - payload: dict[str, Any] = { - "taskId": task_id, - "lifecycle": TaskPhase.WAITING_FOR_USER.value.lower(), - "revisionId": state.active_revision, - "code": state.last_error.value if state.last_error else ErrorCode.WAITING_FOR_USER.value, - "message": message, - "questions": questions, - "clarificationPath": state.clarification_path, - "blockerType": "requirements_ambiguity", - "userActionRequired": True, - } - return payload - - def _requirements_contract(self, task_id: str, state: TaskState | None) -> dict[str, Any] | None: - if state is None or not state.requirements_contract_path: - return None - return self.artifacts.read_requirements_contract(task_id, state.requirements_contract_path) - - def _ensure_recovered_completion_result(self, task_id: str, state: TaskState) -> None: - result_path = self.artifacts.artifact_path(task_id, "completion-result.md") - if result_path.is_file(): - return - facts = self.actions._facts(task_id, state.active_revision) - claim_results = self.actions._evaluate_claims(task_id, facts) - raw_review = self.artifacts.read_json(task_id, f"reviews/final/{state.active_revision}/final-review.json") or {} - coverage = { - str(item.get("claim_id") or ""): item - for item in raw_review.get("claim_coverage") or () - if isinstance(item, dict) - } - visual_claims = [ - { - "status": str(coverage.get(str(item.get("claim_id") or ""), {}).get("status") or "fail"), - "evidence": "; ".join(str(value) for value in raw_review.get("evidence") or ()) or "Recovered final review decision.", - } - for item in claim_results - if not item.get("deterministic") - ] - self.requirements.write_completion_result( - task_id, - state, - claim_results=claim_results, - review={"visual_claims": visual_claims}, - ) - - @staticmethod - def _public_requirements(contract: dict[str, Any] | None) -> list[dict[str, Any]]: - return [ - { - "statement": str(item.get("statement") or ""), - "assumptions": list(item.get("assumptions") or []), - "acceptance_claims": [ - { - "claim_kind": str(claim.get("claim_kind") or ""), - "expected": claim.get("expected") or {}, - "verification_mode": str(claim.get("verification_mode") or ""), - } - for claim in item.get("acceptance_claims") or () - if isinstance(claim, dict) - ], - } - for item in (contract or {}).get("requirements") or () - if isinstance(item, dict) - ] - - @staticmethod - def _public_claim_result(item: dict[str, Any]) -> dict[str, Any]: - return { - key: value - for key, value in item.items() - if key not in {"claim_id", "requirement_id", "evidence_refs"} - } - - @classmethod - def _public_candidate(cls, candidate: dict[str, Any]) -> dict[str, Any]: - return { - "claim_results": [cls._public_claim_result(item) for item in candidate.get("claim_results") or () if isinstance(item, dict)], - "operation_verifier_results": [cls._public_claim_result(item) for item in candidate.get("operation_verifier_results") or () if isinstance(item, dict)], - "health": candidate.get("health") or {}, - "model_summary": candidate.get("model_summary") or {}, - } - - @staticmethod - def _public_pending_context(state: TaskState) -> dict[str, Any] | None: - action = state.pending_action - 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]: - protocol = { - "system": "world_mm_right_handed", - "sketch_mapping": "workplane.origin_mm is the world position of sketch local (0,0); profile points such as circle.center are sketch-local.", - "vectors": "normal is positive extrusion direction; x_dir is sketch local +X expressed in world coordinates.", - "hosted_features": "For existing solids, use the selected max_z/min_z face and its supplied normal; do not infer or hand-copy a world-space offset.", - } + def _publish(self, task_id: str, state: TaskState) -> tuple[str, dict[str, Any]]: if not state.active_revision: - protocol["root_extrusion"] = "Root extrude_add_blind and extrude_add_two_sided are bound to world XY: origin=[0,0,Z], normal=[0,0,1], x_dir=[1,0,0]. Requirements decide Z only; never place a Z offset in Y." - return protocol - - @staticmethod - def _public_operation_payload(payload: dict[str, Any] | None) -> dict[str, Any] | None: - if not isinstance(payload, dict): - return None - return { - "operation": payload.get("atomic_id"), - "contract": payload.get("contract"), - "fragment_schema": payload.get("fragment_schema"), - } - - def _projected_terminal(self, task_id: str, state: TaskState) -> dict[str, Any]: - projection = self.repository.get_task_projection(task_id) or {} - return { - "taskId": task_id, - "lifecycle": str(projection.get("lifecycle") or state.phase.value.lower()), - "revisionId": state.active_revision, - "code": state.last_error.value if state.last_error else "", - "message": str(projection.get("message") or ("CAD generation completed." if state.phase == TaskPhase.COMPLETED else "CAD generation stopped.")), - "questions": projection.get("questions") or [], - "issues": projection.get("issues") or [], - "blockerType": str(projection.get("blocker_type") or ""), - "userActionRequired": bool(projection.get("user_action_required")), - "verificationStatus": str(projection.get("verification_status") or "verified"), - "verificationWarnings": projection.get("verification_warnings") or [], - } - - def _operation_payload(self, task_id: str, state: TaskState) -> dict[str, Any]: - action = state.pending_action - assert action is not None - contract = self.runtime.operation_contract(action.atomic_id) - topology = self.artifacts.read_topology(task_id, state.active_revision) - tokens = self.runtime.selector_tokens(topology) - references = self.runtime.reference_tokens(self.artifacts.read_active_cdsl(task_id, state.active_revision)) - return {"working_head": state.working_head, "atomic_id": action.atomic_id, "contract_hash": contract["contract_hash"], "contract": contract, "fragment_schema": fragment_schema(contract, selector_tokens=self._selector_tokens_for_contract(contract, tokens), reference_tokens=list(references), root_xy_datum=not bool(state.active_revision))} - - 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)): - 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 - 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": 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), - "message": str(event.get("message") or event.get("reason") or "")[:360], - }) - if len(constraints) == 4: - 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.""" - if str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden") != "required": - return [] - policy = contract.get("selector_policy") if isinstance(contract.get("selector_policy"), dict) else {} - kind = str(policy.get("token_kind") or "") - return [token for token, value in tokens.items() if isinstance(value, dict) and value.get("kind") == kind] - - def _topology_payload(self, task_id: str, state: TaskState, kind: str | None, limit: int) -> dict[str, Any]: - tokens = self.runtime.selector_tokens(self.artifacts.read_topology(task_id, state.active_revision)) - values = [{"token": token, "kind": item["kind"], "geometry": {key: item["geometry"].get(key) for key in ("center_mm", "normal", "plane_normal", "bbox_mm", "radius_mm", "surface_type") if key in item["geometry"]}} for token, item in tokens.items() if not kind or item["kind"] == kind] - return { - "working_head": state.working_head, - "coordinate_system": "world_mm", - "tokens": values[:limit], - } - - def _can_complete(self, task_id: str, state: TaskState) -> bool: - if not state.active_revision or state.repair_required: - return False - results = self.actions._evaluate_claims(task_id, self.actions._facts(task_id, state.active_revision)) - return bool(results) and all(item.get("status") == "pass" for item in results if item.get("deterministic")) - - def _transport_or_failure(self, task_id: str, state: TaskState, error: WorkflowError, author: ModelIdentity, attempted: set[str]) -> tuple[ModelIdentity, tuple[str, dict[str, Any]] | None]: - if error.code != ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE: - failed = transition(state, "failed", error=ErrorCode.FAILED_INTERNAL) - self.repository.compare_and_swap(failed, events=[{"event": "failed_internal", "message": error.message}]) - return author, ("task_terminal", {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": error.message}) - attempted.add(author.provider_id) - # The adapter already performs bounded exponential retries for the - # active provider. The workflow permits one, and only one, provider - # failover before parking the durable task for explicit recovery. - fallback = next((candidate for candidate in self.config.author_fallbacks if candidate.provider_id not in attempted), None) if len(attempted) == 1 else None - if fallback is not None: - self.repository.append_outbox(task_id, {"event": "author_provider_failover", "from_provider": author.provider_id, "to_provider": fallback.provider_id}) - return fallback, None - waiting = transition(state, "waiting_retry", error=ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE) - self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message}]) - return author, ("task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE.value, "message": error.message}) - - def _service_failure(self, task_id: str, state: TaskState, error: WorkflowError) -> tuple[str, dict[str, Any]]: - waiting = transition(state, "waiting_retry", error=error.code) - self.repository.compare_and_swap(waiting, events=[{"event": "waiting_retry", "code": error.code.value, "message": error.message}]) - return "task_terminal", {"taskId": task_id, "lifecycle": "waiting_retry", "code": error.code.value, "message": error.message} - - def _model_rejection_or_service_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None: - """Bound no-side-effect model rejections; park real service failures.""" - if error.code == ErrorCode.NO_PROGRESS_LIMIT: - current = self.repository.get_state(task_id) or state - terminal = self._best_effort_terminal( - task_id, - current, - ErrorCode.NO_PROGRESS_LIMIT, - "Further attempts repeated an already failed CAD path; publishing the last executable checkpoint.", - ) - if terminal: - return terminal - feedback[:] = [self._feedback(error)] - return None - if error.code == ErrorCode.RUNTIME_CONTRACT_INVALID: - # Registry integrity is a deployment defect. Retrying a model with - # the same broken contract cannot repair it and must never consume - # the author-format budget. - terminal = self._best_effort_terminal(task_id, state, error.code, error.message) - if terminal: - return terminal - failed = transition(state, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "runtime_contract_invalid", - "tool": name, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - } - if error.code == ErrorCode.REQUIREMENTS_SPEC_INVALID: - # A frozen verifier contract is service-owned input at this stage. - # CAD retries cannot repair it, so preserve the terminal diagnosis - # instead of parking the task or blaming the author fragment. - current = self.repository.get_state(task_id) - if current is not None and current.phase != TaskPhase.FAILED: - failed = transition(current, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "requirements_contract_execution_failed", - "tool": name, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - "blockerType": "requirements_contract_invalid", - "userActionRequired": False, - "issues": [str(error.details.get("diagnostic") or error.message)], - } - if error.code == ErrorCode.RUNTIME_PRECONDITION_FAILED: - # A schema-valid fragment can still be impossible on the current - # geometry. This is not an author-format failure: preserve the - # accepted checkpoint, discard only the pending action and let - # the author make a fresh, evidence-backed choice. - if state.phase == TaskPhase.ACTION_PENDING: - action = state.pending_action - atomic_id = str(error.details.get("atomic_id") or (action.atomic_id if action else "")) - checkpoint_revision = str(error.details.get("active_revision") or state.active_revision) - normalized_code = str(error.details.get("normalized_error_code") or error.code.value) - failure_class_fingerprint = sha256( - f"{checkpoint_revision}|{atomic_id}|{normalized_code}".encode("utf-8") - ).hexdigest() - prior = [ - event for event in self.repository.ledger_events(task_id) - if event.get("failure_class_fingerprint") == failure_class_fingerprint - ] - if len(prior) >= 2: - next_state = transition(state, "runtime_precondition_rejected", pending_action=None, error=ErrorCode.NO_PROGRESS_LIMIT) - self.repository.compare_and_swap(next_state, events=[{ - "event": "no_progress_limit", - "tool": name, - "code": ErrorCode.NO_PROGRESS_LIMIT.value, - "message": error.message, - "checkpoint_revision": checkpoint_revision, - "atomic_id": atomic_id, - "normalized_error_code": normalized_code, - "failure_class_fingerprint": failure_class_fingerprint, - }]) - terminal = self._best_effort_terminal( - task_id, - next_state, - ErrorCode.NO_PROGRESS_LIMIT, - "The same operation failure made no progress; publishing the last executable checkpoint.", - ) - if terminal: - return terminal - feedback[:] = [self._feedback(error)] - return None - next_state = transition( - state, - "runtime_precondition_rejected", - pending_action=None, - error=ErrorCode.RUNTIME_PRECONDITION_FAILED, - ) - self.repository.compare_and_swap(next_state, events=[{ - "event": "runtime_precondition_rejected", - "tool": name, - "code": error.code.value, - "message": error.message, - "checkpoint_revision": checkpoint_revision, - "atomic_id": atomic_id, - "fragment_hash": error.details.get("fragment_hash"), - "failure_exact_fingerprint": error.details.get("failure_exact_fingerprint"), - "normalized_error_code": normalized_code, - "failure_class_fingerprint": failure_class_fingerprint, - "attempt": len(prior) + 1, - }]) - feedback_error = error - if prior: - feedback_error = WorkflowError( - error.code, - error.message + " A different fragment or operation path is required; do not repeat the proven failure class.", - field_errors=error.field_errors, - details={**error.details, "prohibited_failure_class": failure_class_fingerprint}, - ) - feedback[:] = [self._feedback(feedback_error)] - return None - # A precondition result outside fragment submission is an invalid - # workflow implementation state, not a provider/service outage. - terminal = self._best_effort_terminal(task_id, state, error.code, error.message) - if terminal: - return terminal - failed = transition(state, "failed", error=error.code) - self.repository.compare_and_swap(failed, events=[{ - "event": "runtime_precondition_rejected", - "tool": name, - "code": error.code.value, - "message": error.message, - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": error.code.value, - "message": error.message, - } - if error.code in { - ErrorCode.CANDIDATE_BUILD_FAILED, - ErrorCode.CLAIM_VERIFICATION_FAILED, - ErrorCode.CANDIDATE_REVIEW_REJECTED, - }: - # The handler already preserved the last checkpoint and recorded - # the field/runtime diagnostic. Continue with that evidence; a - # planning miss is not an infrastructure terminal condition. - feedback[:] = [self._feedback(error)] - return None - model_rejection_codes = { - ErrorCode.AUTHOR_FORMAT_INVALID, - ErrorCode.AUTHOR_DECISION_REJECTED, - ErrorCode.STALE_WORKING_HEAD, - } - if error.code in model_rejection_codes: - return self._format_failure(task_id, state, name, error, counters, feedback, actor=actor) - return self._service_failure(task_id, state, error) - - def _format_failure(self, task_id: str, state: TaskState, name: str, error: WorkflowError, counters: dict[str, int], feedback: list[dict[str, Any]], *, actor: str = "author") -> tuple[str, dict[str, Any]] | None: - counters[name] = counters.get(name, 0) + 1 - 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( + failed = transition(state, "failed", error=ErrorCode.ENGINE_EXECUTION_FAILED) + self.repository.compare_and_swap(failed, events=[{"event": "no_executable_model", "message": "No executable CDSL prefix could be published."}]) + return "task_terminal", self._terminal(failed) + requirements = self.artifacts.read_json(task_id, state.requirements_path) or {} + diagnostics = self.artifacts.read_json(task_id, state.diagnostics_path) or {} + authoring = self.artifacts.read_json(task_id, state.authoring_path) or {} + claim_report_path = self.artifacts.write_json_once( task_id, - state, - ErrorCode.FAILED_AUTHOR_FORMAT, - f"{actor.capitalize()} repeatedly failed the canonical schema; publishing the last executable checkpoint.", + "documents/claim-report.json", + self._claim_report(requirements), ) - if terminal: - return terminal - 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)}]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.FAILED_AUTHOR_FORMAT.value, - "message": f"{actor.capitalize()} repeatedly failed the same canonical schema or state contract.", - "tool": name, - "field_errors": list(error.field_errors), + report = self._completion_report(state, requirements, authoring, diagnostics) + path = self.artifacts.write_text_once(task_id, "completion-result.md", report) + completed = transition(state, "published", completion_path=path) + self.repository.compare_and_swap(completed, events=[{"event": "task_published", "revision_id": state.active_revision, "completion_path": path, "claim_report_path": claim_report_path, "best_effort": bool(diagnostics.get("diagnostics"))}]) + return "task_terminal", self._terminal(completed) + + def _repair_or_stop(self, task_id: str, state: TaskState, code: str, message: str, details: dict[str, Any]) -> tuple[str, dict[str, Any]]: + diagnostic = { + "code": code, + "message": message, + **details, } + diagnostic.setdefault("repair_hint", self._repair_hint(code, str(diagnostic.get("path") or ""))) + diagnostic_path = self.artifacts.write_json_once(task_id, f"documents/diagnostics-attempt-{state.repair_count + 1:02d}.json", {"diagnostics": [diagnostic]}) + error = self._error_code(code) + if state.repair_count < self.config.max_repairs: + repairing = transition(state, "repair_required", diagnostics_path=diagnostic_path, error=error) + self.repository.compare_and_swap(repairing, events=[{"event": "compile_failed", "code": code, "message": message, "diagnostics_path": diagnostic_path}]) + return "cdsl_compiled", {"taskId": task_id, "status": "repair_required", "code": code, "message": message} + if state.active_revision: + publishing = transition( + state, + "publish_best_effort", + diagnostics_path=diagnostic_path, + error=ErrorCode.BEST_EFFORT_COMPLETED, + ) + self.repository.compare_and_swap(publishing, events=[{ + "event": "repair_budget_exhausted", + "code": code, + "message": message, + "diagnostics_path": diagnostic_path, + "revision_id": state.active_revision, + }]) + return "build_result", { + "taskId": task_id, + "status": "published_best_effort", + "code": code, + "message": message, + "revisionId": state.active_revision, + } + failed = transition(state, "failed", diagnostics_path=diagnostic_path, error=error) + self.repository.compare_and_swap(failed, events=[{"event": "compile_failed", "code": code, "message": message, "diagnostics_path": diagnostic_path}]) + return "task_terminal", self._terminal(failed) - def _requirements_format_failure( - self, - task_id: str, - state: TaskState, - error: WorkflowError, - counters: dict[str, int], - feedback: list[dict[str, Any]], - *, - tool: str = "compile_requirements_spec", - ) -> tuple[str, dict[str, Any]] | None: - key = "requirements_spec" - counters[key] = counters.get(key, 0) + 1 - feedback[:] = [self._feedback(error)] - if counters[key] < 2: - return None - failed = transition(state, "failed", error=ErrorCode.REQUIREMENTS_SPEC_INVALID) - self.repository.compare_and_swap(failed, events=[{ - "event": "requirements_spec_invalid", - "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, - "message": error.message, - "field_errors": list(error.field_errors), - }]) - return "task_terminal", { - "taskId": task_id, - "lifecycle": "failed", - "code": ErrorCode.REQUIREMENTS_SPEC_INVALID.value, - "message": "Requirements specification remained unreadable after one field-level correction.", - "tool": tool, - "field_errors": list(error.field_errors), - "userActionRequired": False, - } - - def _requirements_rejection( - self, - task_id: str, - state: TaskState, - error: WorkflowError, - counters: dict[str, int], - feedback: list[dict[str, Any]], - *, - tool: str = "compile_requirements_spec", - ) -> 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 - def _tool(name: str, model: type[BaseModel] | dict[str, Any]) -> dict[str, Any]: - parameters = model if isinstance(model, dict) else model.model_json_schema() - return {"type": "function", "function": {"name": name, "description": name.replace("_", " "), "parameters": parameters}} - - @staticmethod - def _result_payload(result: Accepted | Rejected | Waiting) -> dict[str, Any]: - if isinstance(result, Accepted): - return result.payload - return result.error.payload() - - @staticmethod - def _feedback(error: WorkflowError) -> dict[str, Any]: - return {"role": "user", "content": json.dumps({"schema_or_state_error": error.payload(), "instruction": "Correct only the reported fields and return one allowed tool call."}, ensure_ascii=False)} - - @staticmethod - def _pending_context(state: TaskState) -> dict[str, Any] | None: - action = state.pending_action - return {"action_id": action.action_id, "working_head": action.working_head, "intent": action.intent, "requirement_ids": list(action.requirement_ids), "atomic_id": action.atomic_id, "expected_change": action.expected_change, "contract_hash": action.contract_hash} if action else None - - @staticmethod - def _tool_feedback(content: str) -> dict[str, Any] | None: + async def _tool_call(self, task_id: str, author: ModelIdentity, name: str, schema: dict[str, Any], system: str, user: str) -> dict[str, Any]: + response = await self.model_gateway.call_tool( + messages=[{"role": "system", "content": system}, {"role": "user", "content": user}], + tool={"type": "function", "function": {"name": name, "description": "Return one schema-valid object.", "parameters": schema}}, + provider_id=author.provider_id, model_id=author.model_id, required_tool_name=name, + ) + usage = response.get("usage") if isinstance(response.get("usage"), dict) else {} + self.repository.record_usage(task_id, {"role": "author", "tool": name, **usage}) + calls = response.get("tool_calls") if isinstance(response.get("tool_calls"), list) else [] + if len(calls) != 1 or not isinstance(calls[0], dict): + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider did not return exactly one tool call") + function = calls[0].get("function") if isinstance(calls[0].get("function"), dict) else {} + if function.get("name") != name: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider returned an unexpected tool") try: - value = json.loads(content) - except json.JSONDecodeError: - return None - return value if isinstance(value, dict) else None + value = json.loads(str(function.get("arguments") or "")) + except json.JSONDecodeError as error: + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider returned invalid JSON") from error + if not isinstance(value, dict): + raise AuthoringCompileError("AUTHOR_SCHEMA_INVALID", "provider did not return an object") + self.repository.record_tool_audit(task_id, {"tool": name, "arguments_sha256": sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()}) + return value @staticmethod - def _event(task_id: str, tool: str, result: dict[str, Any], status: str, usage: dict[str, Any]) -> dict[str, Any]: - return {"taskId": task_id, "tool": tool, "status": status, "result": result, "usage": usage} + def _author_operation_contract(contract: dict[str, Any]) -> dict[str, Any]: + """Expose only author-owned operation facts, including selector shape.""" + selector = contract.get("selector_policy") or {} + fragment = contract.get("fragment_shape") or {} + sketch_mode = fragment.get("sketch") + return { + "params_schema": contract.get("author_params_schema") or {}, + "sketch": sketch_mode, + "authoring_sketch_template": ( + { + "workplane": { + "origin_mm": [0, 0, 0], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1], + }, + "profile": { + "type": "circle", + "diameter_mm": 10, + "center_mm": [0, 0], + }, + } + if sketch_mode == "required" else None + ), + "selector": { + "required": fragment.get("selector_tokens") == "required", + "kind": selector.get("token_kind"), + "min_items": selector.get("min_items"), + "max_items": selector.get("max_items"), + "destination": selector.get("slot"), + "source_syntax": ".", + }, + } @staticmethod - def _invocation_id(task_id: str) -> str: - return f"{task_id}_inv_{secrets.token_hex(8)}" + def _validate_repair_document( + previous: dict[str, Any], replacement: dict[str, Any], diagnostics: dict[str, Any] | None, + compile_audit: dict[str, Any] | None, + ) -> None: + """Keep successful features fixed across complete-document repairs.""" + targeted = { + str(item.get("feature_name") or "") + for item in (diagnostics or {}).get("diagnostics") or () + if isinstance(item, dict) and item.get("feature_name") + } + for item in (diagnostics or {}).get("diagnostics") or (): + if not isinstance(item, dict): + continue + path = str(item.get("path") or "") + if path.startswith("features."): + targeted.add(path.split(".", 2)[1]) + feature_ids = (compile_audit or {}).get("feature_ids") if isinstance(compile_audit, dict) else {} + ids_to_names = { + str(feature_id): str(name) + for name, feature_id in (feature_ids or {}).items() + if isinstance(name, str) and isinstance(feature_id, str) + } + executed = { + ids_to_names[feature_id] + for feature_id in (diagnostics or {}).get("executed_feature_ids") or () + if isinstance(feature_id, str) and feature_id in ids_to_names + } + old_features = { + str(feature.get("name") or ""): feature + for body in previous.get("bodies") or () if isinstance(body, dict) + for feature in body.get("features") or () if isinstance(feature, dict) + } + new_features = { + str(feature.get("name") or ""): feature + for body in replacement.get("bodies") or () if isinstance(body, dict) + for feature in body.get("features") or () if isinstance(feature, dict) + } + for name in executed: + if name in targeted: + continue + old_feature = old_features.get(name) + if new_features.get(name) != old_feature: + raise AuthoringCompileError( + "AUTHOR_SCHEMA_INVALID", + f"repair changed successful feature {name}", + path=f"features.{name}", + ) + + def _fail(self, task_id: str, state: TaskState, code: ErrorCode, message: str, *, retryable: bool) -> None: + current = self.repository.get_state(task_id) + if current is None or current.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + return + failed = transition(current, "failed", error=code) + self.repository.compare_and_swap(failed, events=[{"event": "service_failure", "code": code.value, "message": message[:1000], "retryable": retryable}]) + + @staticmethod + def _error_code(value: str) -> ErrorCode: + try: + return ErrorCode(value) + except ValueError: + return ErrorCode.AUTHOR_SCHEMA_INVALID + + @staticmethod + def _author_validation_details(error: Exception) -> dict[str, Any]: + """Preserve one exact Pydantic location for a complete-document repair.""" + errors = getattr(error, "errors", None) + if not callable(errors): + return {"path": getattr(error, "path", "")} + values = errors() + if not values or not isinstance(values[0], dict): + return {"path": getattr(error, "path", "")} + location = values[0].get("loc") + path = ".".join(str(item) for item in location) if isinstance(location, tuple) else "" + return {"path": path, "schema_error": str(values[0].get("msg") or "")} + + @staticmethod + def _repair_hint(code: str, path: str) -> str: + if ".sketch" in path or "sketch" in path: + return ( + "Use exactly sketch.workplane {origin_mm, x_dir, normal} and " + "sketch.profile. A circle is {type: circle, diameter_mm, center_mm}; " + "do not use profiles, plane, support, radius_mm, or center." + ) + if ".selectors" in path or code.startswith("SELECTOR_"): + return ( + "Use one declarative selector {kind, source: '.', " + "match: 'unique'}. Do not add role, query, host_face, face indexes, or tokens." + ) + if code == "AUTHOR_FORBIDDEN_FIELD": + return "Remove the forbidden runtime identity or server-injected field. Use only local names and declarative selectors." + if code == "AUTHOR_REFERENCE_INVALID": + return "Reference an existing local feature name and exact output role; the compiler records the selector source as a dependency." + return "Return the complete document with the named diagnostic corrected. Preserve executed features unless the diagnostic targets them." + + @staticmethod + def _claim_report(requirements: dict[str, Any]) -> dict[str, Any]: + targets = requirements.get("acceptance_targets") if isinstance(requirements.get("acceptance_targets"), list) else [] + claims = [ + { + "target": str(target.get("kind") or "target"), + "status": "pending", + "verification": str(target.get("verification") or "manual"), + } + for target in targets + if isinstance(target, dict) + ] + manual = requirements.get("manual_targets") if isinstance(requirements.get("manual_targets"), list) else [] + claims.extend({"target": item, "status": "pending", "verification": "manual"} for item in manual if isinstance(item, str)) + if not claims: + claims.append({"target": "no deterministic target declared", "status": "not_applicable", "verification": "manual"}) + return {"schema_version": "cad.requirement-claim-report.v1", "claims": claims} + + @staticmethod + def _completion_report( + state: TaskState, + requirements: dict[str, Any], + authoring: dict[str, Any], + diagnostics: dict[str, Any], + ) -> str: + lines = ["# CAD Generation Result", "", f"Published revision: `{state.active_revision}`", "", "## Generated Model", ""] + bodies = authoring.get("bodies") if isinstance(authoring.get("bodies"), list) else [] + for body in bodies: + if not isinstance(body, dict): + continue + lines.append(f"- body: {body.get('name', 'body')}") + features = body.get("features") if isinstance(body.get("features"), list) else [] + for feature in features: + if isinstance(feature, dict): + lines.append(f"- feature: {feature.get('name', 'feature')} ({feature.get('operation', 'operation')})") + lines.extend(["", "## Requested Requirements", ""]) + lines.extend(f"- {item}" for item in requirements.get("explicit_requirements", []) if isinstance(item, str)) + lines.extend(["", "## Requirement Compliance", ""]) + targets = requirements.get("acceptance_targets") if isinstance(requirements.get("acceptance_targets"), list) else [] + if targets: + lines.extend(f"- pending: {item.get('kind', 'target')}" for item in targets if isinstance(item, dict)) + else: + lines.append("- not_applicable: no deterministic acceptance target was declared") + lines.extend(f"- pending: {item}" for item in requirements.get("manual_targets", []) if isinstance(item, str)) + lines.extend(["", "## Assumptions", ""]) + lines.extend(f"- {item}" for item in requirements.get("assumptions", []) if isinstance(item, str)) + lines.extend(["", "## Execution", ""]) + failures = diagnostics.get("diagnostics") if isinstance(diagnostics.get("diagnostics"), list) else [] + if failures: + lines.append("The executable prefix was published with unresolved operations:") + lines.extend(f"- {item.get('code', 'ENGINE_EXECUTION_FAILED')}: {item.get('feature_name') or item.get('feature_id') or 'document'}: {item.get('message', item)}" for item in failures if isinstance(item, dict)) + else: + lines.append("The complete compiled CDSL executed successfully.") + lines.extend(["", "## Planning Limitations", ""]) + lines.append("Requirement compliance is reported separately from executable publication. Pending targets require deterministic measurement or user review.") + lines.extend(["", "## Delivery Artifacts", ""]) + root = f"revisions/{state.active_revision}" + lines.extend([ + f"- STEP: {root}/model.step", + f"- GLB: {root}/model.glb", + f"- Render bundle: {root}/renders/render-manifest.json", + f"- Runtime CDSL: {root}/model.cdsl.json", + f"- Build diagnostics: {state.diagnostics_path or root + '/build-diagnostics.json'}", + "- Requirement compliance: documents/claim-report.json", + f"- Authoring CDSL: {state.authoring_path}", + f"- Compile audit: {state.compile_audit_path}", + ]) + lines.extend(["", "## Repair Budget", ""]) + reason = "all compiled features executed" if not failures else "published the best executable prefix after unresolved diagnostics" + lines.append(f"- repair calls used: {state.repair_count}/2") + lines.append(f"- stop reason: {reason}") + return "\n".join(lines) + "\n" + + @staticmethod + def _terminal(state: TaskState) -> dict[str, Any]: + lifecycle = "completed" if state.phase == TaskPhase.COMPLETED else "cancelled" if state.phase == TaskPhase.CANCELLED else "failed" + return {"taskId": state.task_id, "lifecycle": lifecycle, "revisionId": state.active_revision, "repairCount": state.repair_count, "completionPath": state.completion_path, "diagnosticsPath": state.diagnostics_path, "code": state.last_error.value if state.last_error else "", "message": "CAD result published." if lifecycle == "completed" else "CAD generation stopped.", "userActionRequired": False} diff --git a/backend/app/cad_agent/composition.py b/backend/app/cad_agent/composition.py index 13287c9c..de88461e 100644 --- a/backend/app/cad_agent/composition.py +++ b/backend/app/cad_agent/composition.py @@ -1,4 +1,4 @@ -"""Protocol v3 composition root. This is the only layer joining adapters.""" +"""Single-stage Authoring CDSL composition root.""" from __future__ import annotations @@ -6,70 +6,58 @@ from dataclasses import dataclass import shutil from app.cad_agent.adapters.artifact_store import FileArtifactStore -from app.cad_agent.adapters.author_guidance import FileAuthorGuidance from app.cad_agent.adapters.event_publisher import IdempotentInProcessPublisher from app.cad_agent.adapters.runtime import ProfileCadRuntime -from app.cad_agent.adapters.review_gateway import RenderedReviewGateway from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository from app.cad_agent.adapters.structured_llm import StructuredModelGateway -from app.cad_agent.adapters.verifier import RegistryVerifierExecutor -from app.cad_agent.application.action_handlers import ActionCommandHandler from app.cad_agent.application.outbox import OutboxDispatcher -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 BACKEND_ROOT, Settings +from app.cad_agent.application.workflow import WorkflowConfig, WorkflowCoordinator +from app.cad_agent.application.single_stage import SingleStageExecutor +from app.settings import Settings from app.services.storage import WorkspaceStore @dataclass(frozen=True, slots=True) -class V3Services: +class CadServices: repository: SqliteTaskRepository artifacts: FileArtifactStore workflow: WorkflowCoordinator models: StructuredModelGateway outbox: OutboxDispatcher + single_stage: SingleStageExecutor -def compose_v3(settings: Settings) -> V3Services: - repository = SqliteTaskRepository(settings.task_root.parent / "autonomous-cad-v3.sqlite3") - if repository.protocol_reset and settings.task_root.exists(): - # Protocol 3.1 has no valid interpretation for structured-only task - # artifacts, so clear that task root together with its old database. +def compose_cad_services(settings: Settings) -> CadServices: + database_root = settings.task_root.parent + legacy_database = database_root / "autonomous-cad-v3.sqlite3" + removed_legacy_database = False + if legacy_database.exists(): + # The removed coordinator persisted incompatible task/action state in + # its own database. Delete it as part of the deliberate destructive + # migration, including SQLite sidecars if a worker stopped mid-write. + for candidate in (legacy_database, *(database_root / f"{legacy_database.name}{suffix}" for suffix in ("-wal", "-shm"))): + if candidate.exists(): + candidate.unlink() + removed_legacy_database = True + repository = SqliteTaskRepository(database_root / "autonomous-cad-single-stage.sqlite3") + protocol_reset = repository.protocol_reset or removed_legacy_database + if protocol_reset and settings.task_root.exists(): + # Old task artifacts have no valid interpretation under the Authoring + # protocol, so clear them together with the task database. shutil.rmtree(settings.task_root) - if repository.protocol_reset: + if 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, atomic_ids=runtime.supported_atomic_ids) - actions = ActionCommandHandler(repository, artifacts, runtime, verifier) - fallbacks = tuple( - ModelIdentity(provider.id, model.id) - for provider in settings.providers - if provider.configured - for model in provider.models[:1] - ) models = StructuredModelGateway(settings) outbox = OutboxDispatcher(repository, IdempotentInProcessPublisher()) + single_stage = SingleStageExecutor(repository, artifacts, runtime) workflow = WorkflowCoordinator( - WorkflowConfig( - max_turns=max(8, settings.agent_tool_calls_per_cycle * 8), - format_error_limit=settings.agent_format_error_repeat_limit, - author_fallbacks=fallbacks, - ), + WorkflowConfig(), repository, artifacts, runtime, models, - RenderedReviewGateway(models), - requirements, - actions, - FileAuthorGuidance( - BACKEND_ROOT / "agent" / "skills" / "cdsl-author-guidance", - enabled=settings.agent_author_guidance_enabled, - max_chars=settings.agent_author_guidance_max_chars, - ), + single_stage, ) - return V3Services(repository, artifacts, workflow, models, outbox) + return CadServices(repository, artifacts, workflow, models, outbox, single_stage) diff --git a/backend/app/cad_agent/domain/__init__.py b/backend/app/cad_agent/domain/__init__.py index abb758e6..9c2c8fcf 100644 --- a/backend/app/cad_agent/domain/__init__.py +++ b/backend/app/cad_agent/domain/__init__.py @@ -1,4 +1,4 @@ -"""Pure domain objects and policies for protocol v3.""" +"""Pure domain objects and policies for the Authoring CDSL protocol.""" from .errors import ErrorCode, WorkflowError from .state import TaskPhase, TaskState, transition diff --git a/backend/app/cad_agent/domain/claim_matching.py b/backend/app/cad_agent/domain/claim_matching.py deleted file mode 100644 index 3a40aab8..00000000 --- a/backend/app/cad_agent/domain/claim_matching.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Canonical partial matching for acceptance-claim expectations.""" - -from __future__ import annotations - -from typing import Any - - -def contains_expected(actual: Any, required: Any) -> bool: - """Allow an actual claim to add provider-chosen optional fields only.""" - if isinstance(required, dict): - return isinstance(actual, dict) and all( - key in actual and contains_expected(actual[key], value) - for key, value in required.items() - ) - if isinstance(required, list): - return isinstance(actual, list) and len(actual) == len(required) and all( - contains_expected(actual_item, required_item) - for actual_item, required_item in zip(actual, required, strict=True) - ) - return actual == required diff --git a/backend/app/cad_agent/domain/errors.py b/backend/app/cad_agent/domain/errors.py index a585b7c7..04159faa 100644 --- a/backend/app/cad_agent/domain/errors.py +++ b/backend/app/cad_agent/domain/errors.py @@ -1,4 +1,4 @@ -"""Typed, serializable failures used across the v3 workflow.""" +"""Typed, serializable failures used across the single-stage workflow.""" from __future__ import annotations @@ -9,29 +9,25 @@ from typing import Any class ErrorCode(StrEnum): CANCELLED = "CANCELLED" - AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID" - AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED" - STALE_WORKING_HEAD = "STALE_WORKING_HEAD" - FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT" - RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED" RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID" - CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED" - CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED" - VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE" - CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED" MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED" - MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING" AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE" - REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE" RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE" STORAGE_FAILURE = "STORAGE_FAILURE" - CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED" - REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID" - NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT" RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE" BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED" FAILED_INTERNAL = "FAILED_INTERNAL" WAITING_FOR_USER = "WAITING_FOR_USER" + AUTHOR_FORBIDDEN_FIELD = "AUTHOR_FORBIDDEN_FIELD" + AUTHOR_SCHEMA_INVALID = "AUTHOR_SCHEMA_INVALID" + AUTHOR_REFERENCE_INVALID = "AUTHOR_REFERENCE_INVALID" + AUTHOR_CYCLE = "AUTHOR_CYCLE" + OPERATION_UNSUPPORTED = "OPERATION_UNSUPPORTED" + SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND" + SELECTOR_AMBIGUOUS = "SELECTOR_AMBIGUOUS" + SELECTOR_DEPENDENCY_UNAVAILABLE = "SELECTOR_DEPENDENCY_UNAVAILABLE" + SELECTOR_KIND_MISMATCH = "SELECTOR_KIND_MISMATCH" + ENGINE_EXECUTION_FAILED = "ENGINE_EXECUTION_FAILED" @dataclass(frozen=True, slots=True) diff --git a/backend/app/cad_agent/domain/feature_plan.py b/backend/app/cad_agent/domain/feature_plan.py deleted file mode 100644 index 494d2f69..00000000 --- a/backend/app/cad_agent/domain/feature_plan.py +++ /dev/null @@ -1,265 +0,0 @@ -"""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/operation_contract.py b/backend/app/cad_agent/domain/operation_contract.py index 3569e4fd..36c41f33 100644 --- a/backend/app/cad_agent/domain/operation_contract.py +++ b/backend/app/cad_agent/domain/operation_contract.py @@ -1,8 +1,7 @@ -"""Versioned runtime operation contracts and dynamic fragment schemas.""" +"""Versioned runtime operation contracts for the Authoring compiler.""" from __future__ import annotations -from copy import deepcopy from hashlib import sha256 import json from typing import Any @@ -15,25 +14,6 @@ class OperationContractError(ValueError): pass -SEMANTIC_PREFLIGHT_NAMES = frozenset({ - "sketch_workplane", - "profile_non_self_intersecting", - "host_face_exists", - "hole_positions_on_host_plane", - "cut_exit_distance", - "requires_active_solid", - "revolve_axis_on_sketch", - "reference_plane_nonzero_normal", - "reference_axis_nonzero_direction", - "selected_edges_exist", - "source_features_exist", - "mirror_plane_exists", - "loft_profiles_exist", - "loft_profiles_closed", - "loft_profiles_single_region", -}) - - def canonical_hash(value: dict[str, Any]) -> str: return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() @@ -58,10 +38,16 @@ def _is_closed(schema: Any) -> bool: return True +def is_authoring_schema_closed(schema: Any) -> bool: + """Whether an operation can safely be exposed in the Authoring whitelist.""" + return _is_closed(schema) + + def validate_operation_contract(contract: dict[str, Any]) -> None: required = { "atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy", "server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers", + "runtime_capability", } if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}: raise OperationContractError("Operation contract has unknown or missing fields") @@ -111,113 +97,43 @@ def validate_operation_contract(contract: dict[str, Any]) -> None: if reference["mode"] == "snapshot_bound": if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}: raise OperationContractError("Snapshot-bound reference policy is invalid") - if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True: - raise OperationContractError("Snapshot-bound reference policy has invalid bounds") - reference_name = reference["slot"].removeprefix("params.") - properties = params.get("properties") if isinstance(params.get("properties"), dict) else {} - reference_schema = properties.get(reference_name) - required_params = params.get("required") if isinstance(params.get("required"), list) else [] + slot = reference["slot"] if ( - not isinstance(reference_schema, dict) - or reference_schema.get("type") != "array" - or not isinstance(reference_schema.get("items"), dict) - or reference_name not in required_params + not isinstance(slot, str) + or (slot != "feature.selectors" and not slot.startswith("params.")) + or reference["token_kind"] not in {"face", "edge", "plane", "axis", "body", "feature"} + or not isinstance(reference["min_items"], int) + or not isinstance(reference["max_items"], int) + or not 0 <= reference["min_items"] <= reference["max_items"] <= 64 + or reference["snapshot_bound"] is not True ): - raise OperationContractError("Snapshot-bound reference slot must be a required author array") + raise OperationContractError("Snapshot-bound reference policy has invalid bounds") + if slot == "feature.selectors": + if shape["selector_tokens"] != "required" or selector["token_kind"] != reference["token_kind"]: + raise OperationContractError("Feature selector reference policy disagrees with selector policy") + else: + reference_name = slot.removeprefix("params.") + properties = params.get("properties") if isinstance(params.get("properties"), dict) else {} + reference_schema = properties.get(reference_name) + if not isinstance(reference_schema, dict) or reference_schema.get("type") not in {"array", "string"}: + raise OperationContractError("Snapshot-bound reference slot is absent from author params schema") + if reference_schema.get("type") == "string" and reference["max_items"] > 1: + raise OperationContractError("Scalar snapshot-bound reference must allow at most one item") if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]): raise OperationContractError("Operation semantic preflight is invalid") - if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES: - raise OperationContractError("Operation semantic preflight names are unknown or duplicated") + if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]): + raise OperationContractError("Operation semantic preflight names are duplicated") if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]): raise OperationContractError("Operation candidate_verifiers are invalid") if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]): raise OperationContractError("Operation candidate verifiers are duplicated") - - -def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> dict[str, Any]: - """Build the one-operation schema exposed for one pending action.""" - validate_operation_contract(contract) - shape = contract["fragment_shape"] - params = deepcopy(contract["author_params_schema"]) - reference = contract["reference_policy"] - if reference["mode"] == "snapshot_bound": - slot = str(reference["slot"]).removeprefix("params.") - items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None - if not isinstance(items, dict): - raise OperationContractError("Snapshot-bound reference slot is absent from author params schema") - items.clear() - items.update({"enum": reference_tokens or []}) - feature_properties: dict[str, Any] = { - "atomic_id": {"const": contract["atomic_id"]}, - "params": params, + runtime_capability = contract["runtime_capability"] + required_capability_flags = { + "body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok", } - feature_required = ["atomic_id", "params"] - if shape["selector_tokens"] == "required": - policy = contract["selector_policy"] - feature_properties["selector_tokens"] = { - "type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"], - "maxItems": policy["max_items"], "uniqueItems": True, - "description": ( - "Required author input. Copy the opaque selector token returned by the current topology " - "snapshot here; do not omit it and do not put a host face in params. The server resolves this " - "token into the host selector after schema validation." - ), - } - feature_required.append("selector_tokens") - feature = { - "type": "object", - "description": ( - "One atomic feature. selector_tokens, when present, is an author-supplied topology token array " - "rather than a server-filled params field." - ), - "properties": feature_properties, - "required": feature_required, - "additionalProperties": False, - } - properties: dict[str, Any] = {"feature": feature} - required = ["feature"] - if shape["sketch"] == "required": - properties["sketch"] = _sketch_schema(root_xy_datum=root_xy_datum and contract["atomic_id"] in {"extrude_add_blind", "extrude_add_two_sided"}) - required.insert(0, "sketch") - schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False} - Draft202012Validator.check_schema(schema) - return schema - - -def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> list[dict[str, str]]: - schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens, root_xy_datum=root_xy_datum) - return [ - {"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message} - for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message)) - ] - - -def _point(size: int) -> dict[str, Any]: - return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size} - - -def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]: - point2 = _point(2) - point3 = _point(3) - workplane = { - "type": "object", - "description": "origin_mm is the world position of sketch local (0,0); profile coordinates are local to this plane. normal is positive extrusion direction and x_dir is local +X in world coordinates.", - "properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)}, - "required": ["origin_mm", "x_dir", "normal"], - "additionalProperties": False, - } - if root_xy_datum: - workplane["description"] += " Root extrusion uses fixed world XY datum: origin X/Y are 0, normal is +Z, x_dir is +X. Only origin Z is task-defined." - workplane["properties"] = { - "origin_mm": {"type": "array", "prefixItems": [{"const": 0}, {"const": 0}, {"type": "number"}], "items": False, "minItems": 3, "maxItems": 3}, - "x_dir": {"const": [1, 0, 0]}, - "normal": {"const": [0, 0, 1]}, - } - profile = { - "oneOf": [ - {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False}, - {"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False}, - {"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False}, - ] - } - return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False} + if ( + not isinstance(runtime_capability, dict) + or set(runtime_capability) != required_capability_flags + or not all(isinstance(value, bool) for value in runtime_capability.values()) + ): + raise OperationContractError("Operation runtime capability is invalid") diff --git a/backend/app/cad_agent/domain/state.py b/backend/app/cad_agent/domain/state.py index d017247e..957e7c1c 100644 --- a/backend/app/cad_agent/domain/state.py +++ b/backend/app/cad_agent/domain/state.py @@ -1,56 +1,24 @@ -"""Finite workflow state machine. This module has no persistence imports.""" +"""Finite state for the single-stage Authoring CDSL protocol.""" from __future__ import annotations from dataclasses import dataclass, replace from enum import StrEnum -from .errors import ErrorCode, WorkflowError +from .errors import ErrorCode 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" - CANDIDATE_BUILDING = "CANDIDATE_BUILDING" - CANDIDATE_REVIEW = "CANDIDATE_REVIEW" - FINAL_VALIDATION = "FINAL_VALIDATION" - WAITING_RETRY = "WAITING_RETRY" + ANALYZING_REQUEST = "ANALYZING_REQUEST" + AUTHORING_CDSL = "AUTHORING_CDSL" + COMPILING_CDSL = "COMPILING_CDSL" + BUILDING = "BUILDING" + REPAIRING = "REPAIRING" + PUBLISHING_BEST_EFFORT = "PUBLISHING_BEST_EFFORT" WAITING_FOR_USER = "WAITING_FOR_USER" - CANCELLED = "CANCELLED" COMPLETED = "COMPLETED" FAILED = "FAILED" - - -@dataclass(frozen=True, slots=True) -class PendingAction: - action_id: str - working_head: str - intent: str - requirement_ids: tuple[str, ...] - atomic_id: str - 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 + CANCELLED = "CANCELLED" @dataclass(frozen=True, slots=True) @@ -59,167 +27,84 @@ class TaskState: phase: TaskPhase version: int active_revision: str = "" - pending_action: PendingAction | None = None - candidate_id: str = "" - candidate_stage_id: str = "" - repair_required: bool = False + repair_count: int = 0 last_error: ErrorCode | None = None retry_from_phase: TaskPhase | None = None - requirements_spec_path: str = "" - requirements_document_path: str = "" - completion_target_path: str = "" - modeling_plan_path: str = "" - feature_plan_path: str = "" - feature_plan_hash: str = "" - feature_stage_id: str = "" + requirements_path: str = "" + authoring_path: str = "" + runtime_cdsl_path: str = "" + compile_audit_path: str = "" + diagnostics_path: str = "" + completion_path: 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}" -# Legal state transitions. Events are intentionally terse persistence-neutral -# names used by command handlers and architecture tests. _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.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 - # so its frozen request remains authoritative - # instead of turning a clarification into a new CAD request. - (TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, - (TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING, - (TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION, - (TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION, - (TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING, - # Runtime semantic preflight happens before candidate staging. It must - # leave the checkpoint untouched and return control to the author for a - # fresh observation/action decision, never masquerade as a format error. - (TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION, - (TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING, - (TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION, - (TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW, - (TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING, - (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, + (TaskPhase.ANALYZING_REQUEST, "analysis_written"): TaskPhase.AUTHORING_CDSL, + (TaskPhase.ANALYZING_REQUEST, "waiting_for_user"): TaskPhase.WAITING_FOR_USER, + (TaskPhase.WAITING_FOR_USER, "clarification_received"): TaskPhase.ANALYZING_REQUEST, + (TaskPhase.AUTHORING_CDSL, "authoring_written"): TaskPhase.COMPILING_CDSL, + (TaskPhase.AUTHORING_CDSL, "repair_required"): TaskPhase.REPAIRING, + (TaskPhase.COMPILING_CDSL, "compiled"): TaskPhase.BUILDING, + (TaskPhase.COMPILING_CDSL, "repair_required"): TaskPhase.REPAIRING, + (TaskPhase.BUILDING, "build_completed"): TaskPhase.PUBLISHING_BEST_EFFORT, + (TaskPhase.BUILDING, "repair_required"): TaskPhase.REPAIRING, + (TaskPhase.REPAIRING, "repair_started"): TaskPhase.AUTHORING_CDSL, + (TaskPhase.PUBLISHING_BEST_EFFORT, "published"): TaskPhase.COMPLETED, + (TaskPhase.PUBLISHING_BEST_EFFORT, "failed"): TaskPhase.FAILED, } -_TRANSITIONS.update({ - (phase, "best_effort_completed"): TaskPhase.COMPLETED - for phase in TaskPhase - if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} -}) -_TRANSITIONS.update({ - (phase, "failed"): TaskPhase.FAILED - for phase in TaskPhase - if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} -}) -_TRANSITIONS.update({ - (phase, "cancelled"): TaskPhase.CANCELLED - for phase in TaskPhase - if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} -}) -_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, - TaskPhase.CANDIDATE_BUILDING, - TaskPhase.CANDIDATE_REVIEW, - TaskPhase.FINAL_VALIDATION, -}) -_TRANSITIONS.update({ - (TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase - for phase in _RETRY_RESUMABLE_PHASES -}) -_TRANSITIONS.update({ - (phase, "waiting_retry"): TaskPhase.WAITING_RETRY - for phase in TaskPhase - if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY} -}) +for _phase in TaskPhase: + if _phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: + _TRANSITIONS[(_phase, "failed")] = TaskPhase.FAILED + _TRANSITIONS[(_phase, "cancelled")] = TaskPhase.CANCELLED + if _phase in { + TaskPhase.AUTHORING_CDSL, TaskPhase.COMPILING_CDSL, + TaskPhase.BUILDING, TaskPhase.REPAIRING, + }: + _TRANSITIONS[(_phase, "publish_best_effort")] = TaskPhase.PUBLISHING_BEST_EFFORT + + def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]: - """Return a copy of the protocol transition table for architecture tests.""" return dict(_TRANSITIONS) -def retry_resume_event(state: TaskState) -> str | None: - """Return the only lossless resume event for a parked service failure.""" - if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES: - return 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, 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)) +def transition( + state: TaskState, + event: str, + *, + active_revision: str | None = None, + repair_count: int | None = None, + error: ErrorCode | None = None, + requirements_path: str | None = None, + authoring_path: str | None = None, + runtime_cdsl_path: str | None = None, + compile_audit_path: str | None = None, + diagnostics_path: str | None = None, + completion_path: str | None = None, + clarification_path: str | None = None, +) -> TaskState: + target = state.retry_from_phase if state.phase == TaskPhase.FAILED and event == "resume" else _TRANSITIONS.get((state.phase, event)) if target is None: - raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?") - 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.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: - next_pending = None + raise ValueError(f"Illegal single-stage transition: {state.phase.value} --{event}--> ?") + next_repairs = state.repair_count if repair_count is None else repair_count + if not 0 <= next_repairs <= 2: + raise ValueError("Single-stage repair count must be within [0, 2]") return replace( state, phase=target, 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.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, + repair_count=next_repairs, last_error=error, - retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None, - requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path, - 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, + retry_from_phase=state.phase if target == TaskPhase.FAILED and error in { + ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, ErrorCode.STORAGE_FAILURE, + ErrorCode.RUNTIME_EXECUTION_FAILURE, + } else None, + requirements_path=state.requirements_path if requirements_path is None else requirements_path, + authoring_path=state.authoring_path if authoring_path is None else authoring_path, + runtime_cdsl_path=state.runtime_cdsl_path if runtime_cdsl_path is None else runtime_cdsl_path, + compile_audit_path=state.compile_audit_path if compile_audit_path is None else compile_audit_path, + diagnostics_path=state.diagnostics_path if diagnostics_path is None else diagnostics_path, + completion_path=state.completion_path if completion_path is None else completion_path, 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, ) - - -def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None: - if supplied_head != state.working_head: - return WorkflowError( - ErrorCode.STALE_WORKING_HEAD, - "The command was bound to an obsolete working head.", - details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head}, - ) - return None diff --git a/backend/app/cad_agent/evals/TOKEN_BASELINE.md b/backend/app/cad_agent/evals/TOKEN_BASELINE.md deleted file mode 100644 index 1eb07d03..00000000 --- a/backend/app/cad_agent/evals/TOKEN_BASELINE.md +++ /dev/null @@ -1,21 +0,0 @@ -# Token Baseline Format - -`python -m app.cad_agent.evals.live --suite release --require-live` requires -`--baseline-report` to point at a measured protocol-2 report. It is not a -fixture or an estimate. Record three independent runs of every release -scenario with the same author request configuration and runtime profile. - -| Field | Requirement | -| --- | --- | -| `schema_version` | `cad.token-baseline.v1` | -| `protocol_version` | `2.0` | -| `author` | Exact `provider`, `model`, `api_style`, `reasoning_effort`, and `sampling` object from the v3 report | -| `runtime_profile_sha256` | Exact v3 `runtime_profile_sha256` | -| `measurement` | `prompt_tokens` when provider usage exists; otherwise `context_chars` | -| `scenarios[]` | One entry for every versioned release scenario, including its request SHA-256 | -| `repetitions[]` | Entries `1`, `2`, and `3`, with non-negative `author_metric` and `plan_review_metric`, plus completion, final-review, and deterministic-claim booleans | - -The comparison uses `author_metric + plan_review_metric` for the v2 median and -the v3 author metric only. It fails closed when provenance differs, any -repetition is absent, completion/review/claim rates drop, plan-review calls -remain, or the median reduction is below 30%. diff --git a/backend/app/cad_agent/evals/__init__.py b/backend/app/cad_agent/evals/__init__.py index 2a0ae0f0..f5e60eb3 100644 --- a/backend/app/cad_agent/evals/__init__.py +++ b/backend/app/cad_agent/evals/__init__.py @@ -1 +1 @@ -"""Live, non-mocked protocol v3 release evaluations.""" +"""Local evaluation helpers for the single-stage Authoring protocol.""" diff --git a/backend/app/cad_agent/evals/create_isolated_task.py b/backend/app/cad_agent/evals/create_isolated_task.py index cf0c236e..8ee80979 100644 --- a/backend/app/cad_agent/evals/create_isolated_task.py +++ b/backend/app/cad_agent/evals/create_isolated_task.py @@ -1,4 +1,4 @@ -"""Create a resumable live-evaluation task without starting its workflow.""" +"""Create an isolated single-stage task without starting its workflow.""" from __future__ import annotations @@ -9,12 +9,12 @@ from pathlib import Path import secrets import sys -from app.cad_agent.composition import compose_v3 +from app.cad_agent.composition import compose_cad_services 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 = argparse.ArgumentParser(description="Create one isolated Authoring CDSL evaluation task.") parser.add_argument("--report-root", required=True, type=Path) parser.add_argument("--prompt", required=True) parser.add_argument("--task-id") @@ -25,7 +25,7 @@ def main() -> int: arguments = _arguments() root = arguments.report_root.resolve() settings = get_settings() - services = compose_v3(replace( + services = compose_cad_services(replace( settings, task_root=root / "artifacts", conversation_root=root / "conversations", diff --git a/backend/app/cad_agent/evals/fixtures/comprehensive.json b/backend/app/cad_agent/evals/fixtures/comprehensive.json deleted file mode 100644 index af596fc2..00000000 --- a/backend/app/cad_agent/evals/fixtures/comprehensive.json +++ /dev/null @@ -1,423 +0,0 @@ -{ - "schema_version": "cad.comprehensive-prompt-fixtures.v1", - "source_document": "docs/cad-agent-v3-comprehensive-prompt-test-target.md", - "source_document_sha256": "79fbfeba41b2bf1fa37d2e90df1c4f5f06460f725f8898ae4238676bfea82d8b", - "scenarios": [ - { - "id": "rectangular_mounting_plate", - "units": "mm", - "request": "生成一个CNC矩形安装板,长100毫米,宽60毫米,厚8毫米,四角圆角R6,四角各有一个直径8毫米的贯穿孔,孔中心距左右边10毫米、上下边10毫米,中心有一个直径30毫米的贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 8, "count": 4, "edge_offset_mm": 10}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "corner_radius", "description": "R6 outer corner radii"} - ], - "max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000 - }, - { - "id": "circular_flange_pcd", - "units": "mm", - "request": "生成一个圆形法兰盘,外径120毫米,厚度12毫米,中心贯穿孔直径40毫米,在直径90毫米的分度圆上均布6个直径8毫米的贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 6}}, - {"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 8, "count": 6, "pitch_radius_mm": 45, "concentric_bore_diameter_mm": 40}} - ], - "required_atomic_ids": [], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind", "pattern_linear"], - "required_any_atomic_id_groups": [["extrude_add_blind", "revolve_add"]], - "validation_capability_gaps": [], - "max_author_turns": 52, "max_reviewer_turns": 20, "max_total_calls": 72, "max_wall_seconds": 1500, "max_total_tokens": 320000 - }, - { - "id": "square_flange", - "units": "mm", - "request": "生成一个方形安装法兰,长100毫米,宽100毫米,厚度12毫米,四角圆角R8,中心贯穿孔直径45毫米,四角各有一个直径10毫米的安装孔,孔中心距相邻两边各15毫米。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "rectangular_corner_through_bore_pattern"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 45, "count": 1}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 10, "count": 4}}, - {"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 10, "count": 4, "edge_offset_mm": 15}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "corner_radius", "description": "R8 outer corner radii"} - ], - "max_author_turns": 56, "max_reviewer_turns": 20, "max_total_calls": 76, "max_wall_seconds": 1500, "max_total_tokens": 220000 - }, - { - "id": "counterbored_mounting_plate", - "units": "mm", - "request": "生成一个矩形安装板,长120毫米,宽80毫米,厚度15毫米,四角各有一个直径9毫米的贯穿孔,每个孔顶部带直径16毫米、深5毫米的圆柱沉孔,孔中心距相邻边各12毫米。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "cylindrical_bore", "cylindrical_bore_depth", "rectangular_corner_through_bore_pattern"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 15}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}}, - {"claim_kind": "cylindrical_bore", "expected": {"diameter_mm": 16, "count": 4}}, - {"claim_kind": "cylindrical_bore_depth", "expected": {"diameter_mm": 16, "count": 4, "depth_mm": 5}}, - {"claim_kind": "rectangular_corner_through_bore_pattern", "expected": {"diameter_mm": 9, "count": 4, "edge_offset_mm": 12}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_counterbore", "hole_wizard"], - "validation_capability_gaps": [], - "max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000 - }, - { - "id": "countersunk_cover_plate", - "units": "mm", - "request": "生成一个盖板,长100毫米,宽70毫米,厚度8毫米,四角圆角R5,四角各有一个直径6.5毫米的贯穿孔,孔顶部带90度沉头,沉头最大直径12毫米。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "conical_bore"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 70}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 6.5, "count": 4}}, - {"claim_kind": "conical_bore", "expected": {"small_diameter_mm": 6.5, "large_diameter_mm": 12, "included_angle_deg": 90, "count": 4}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_countersink", "hole_wizard"], - "validation_capability_gaps": [ - {"id": "corner_radius", "description": "R5 outer corner radii"} - ], - "max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000 - }, - { - "id": "obround_slot_plate", - "units": "mm", - "request": "生成一个连接板,长140毫米,宽50毫米,厚度10毫米,两端各有一个长圆形贯穿槽,槽总长30毫米、宽12毫米,槽中心距板端20毫米,槽的长轴沿板长度方向。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 10}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "slot_dimensions", "description": "two through obround slots with 30 x 12 dimensions"}, - {"id": "slot_position_orientation", "description": "20 mm end offset and lengthwise major-axis orientation"} - ], - "max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000 - }, - { - "id": "t_slot_test_block", - "units": "mm", - "request": "生成一个T形槽试块,长100毫米,宽50毫米,高25毫米,在顶面中心沿长度方向加工一条T形槽,槽口宽10毫米、深8毫米,槽底宽20毫米、总深15毫米,槽贯穿试块两端。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "t_slot_cross_section", "description": "10 x 8 slot mouth, 20 mm lower width and 15 mm total depth"}, - {"id": "t_slot_through_orientation", "description": "top-centred T-slot through both lengthwise ends"} - ], - "max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000 - }, - { - "id": "rounded_rectangular_pocket", - "units": "mm", - "request": "生成一个矩形底板,长120毫米,宽80毫米,厚20毫米,在顶面中心加工一个长80毫米、宽45毫米、深12毫米的矩形口袋,口袋四角圆角R6,底部保留8毫米厚度。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 80}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 20}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "pocket_dimensions", "description": "centred 80 x 45 x 12 pocket with 8 mm remaining floor"}, - {"id": "pocket_corner_radius", "description": "R6 internal pocket corners"} - ], - "max_author_turns": 64, "max_reviewer_turns": 24, "max_total_calls": 88, "max_wall_seconds": 1800, "max_total_tokens": 250000 - }, - { - "id": "two_level_pocket_plate", - "units": "mm", - "request": "生成一个CNC加工板,长140毫米,宽100毫米,厚25毫米。顶面中心先加工一个长100毫米、宽70毫米、深8毫米的矩形口袋,再在第一级口袋中心加工一个长60毫米、宽35毫米、额外深7毫米的第二级口袋,所有内角圆角R5。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 140}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 100}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 25}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "pocket_levels", "description": "100 x 70 x 8 first pocket and centred 60 x 35 x 7 additional-depth second pocket"}, - {"id": "pocket_corner_radii", "description": "R5 on every internal corner"} - ], - "max_author_turns": 80, "max_reviewer_turns": 30, "max_total_calls": 110, "max_wall_seconds": 2400, "max_total_tokens": 320000 - }, - { - "id": "cross_drilled_valve_block", - "units": "mm", - "request": "生成一个阀块试件,长80毫米,宽60毫米,高50毫米。沿长度方向加工一个直径20毫米的贯穿孔,沿宽度方向加工一个直径12毫米的贯穿孔,两个孔的轴线在零件中心相交。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore", "orthogonal_intersecting_through_bores"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 60}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 50}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 1}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 1}}, - {"claim_kind": "orthogonal_intersecting_through_bores", "expected": {"first_diameter_mm": 20, "second_diameter_mm": 12, "first_axis": "x", "second_axis": "y"}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [], - "max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000 - }, - { - "id": "double_hole_linkage_arm", - "units": "mm", - "request": "生成一个机械臂双孔连杆,两个销孔中心距120毫米,连杆厚度12毫米,两端外圆直径40毫米,两个销孔直径16毫米,中间杆身最小宽度24毫米,轮廓平滑相切,中部设置三个直径14毫米的减重贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 12}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 14, "count": 3}}, - {"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 16, "adjacent_distances_mm": [120], "tolerance_mm": 0.1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "linkage_outline", "description": "40 mm end circles, 24 mm minimum web width and smooth tangency"}, - {"id": "lightening_holes", "description": "three 14 mm central through holes"} - ], - "max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000 - }, - { - "id": "three_hole_linkage", - "units": "mm", - "request": "生成一个三孔机械连杆,三个孔的中心位于同一直线上,相邻孔中心距分别为60毫米和80毫米,三个孔直径均为12毫米,连杆厚度10毫米,每个孔周围外圆直径32毫米,各段外轮廓平滑连接。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_rank_dimension_mm", "through_cylindrical_bore", "collinear_through_bore_chain", "visual"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 10}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 12, "count": 3}}, - {"claim_kind": "collinear_through_bore_chain", "expected": {"diameter_mm": 12, "adjacent_distances_mm": [60, 80], "tolerance_mm": 0.1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "linkage_outer_circles", "description": "32 mm outer circles with smoothly connected segments"} - ], - "max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000 - }, - { - "id": "l_bracket", - "units": "mm", - "request": "生成一个整体式L形角码,水平底板长80毫米、宽50毫米、厚8毫米,竖直板高60毫米、宽50毫米、厚8毫米,两板成90度。水平板上有两个直径8毫米贯穿孔,竖直板上有两个直径8毫米贯穿孔,孔左右对称。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "l_bracket_dimensions", "description": "base and upright dimensions, thicknesses and 90 degree relationship"}, - {"id": "l_bracket_hole_symmetry", "description": "two symmetric 8 mm through holes on each plate"} - ], - "max_author_turns": 88, "max_reviewer_turns": 32, "max_total_calls": 120, "max_wall_seconds": 2700, "max_total_tokens": 360000 - }, - { - "id": "ribbed_l_bracket", - "units": "mm", - "request": "生成一个整体式L形机械支架,底板长100毫米、宽60毫米、厚10毫米,竖板高80毫米、宽60毫米、厚10毫米,两板成90度。底板和竖板之间设置两个厚度8毫米的三角加强筋。底板有四个直径9毫米贯穿孔,竖板中心有一个直径30毫米贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 9, "count": 4}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "bracket_dimensions", "description": "100 x 60 x 10 base and 80 x 60 x 10 upright at 90 degrees"}, - {"id": "triangular_ribs", "description": "two triangular 8 mm thick ribs"}, - {"id": "bracket_hole_placement", "description": "four base 9 mm holes and centred upright 30 mm hole"} - ], - "max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000 - }, - { - "id": "u_bearing_support", - "units": "mm", - "request": "生成一个U形轴承支座,底板长100毫米、宽60毫米、厚12毫米,两侧竖耳厚12毫米、高55毫米,两个竖耳内侧间距40毫米。两个竖耳上各有一个直径20毫米的同轴贯穿孔,孔轴线距底板上表面35毫米。底板四角各有一个直径8毫米安装孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 20, "count": 2}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 8, "count": 4}}, - {"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 20, "count": 2}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "u_support_dimensions", "description": "base and ear dimensions, ear spacing and 35 mm axis height"}, - {"id": "base_hole_positions", "description": "four 8 mm base mounting-hole positions"} - ], - "max_author_turns": 96, "max_reviewer_turns": 36, "max_total_calls": 132, "max_wall_seconds": 3000, "max_total_tokens": 400000 - }, - { - "id": "double_lug_mount", - "units": "mm", - "request": "生成一个双耳连接座,底座长90毫米、宽60毫米、厚12毫米,底座上有两个平行耳板,每个耳板厚10毫米、高50毫米,两耳板内侧间距30毫米。两个耳板上各有一个直径16毫米的同轴贯穿销孔,孔中心距底座上表面32毫米。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "through_cylindrical_bore", "coaxial_through_bore_group", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 16, "count": 2}}, - {"claim_kind": "coaxial_through_bore_group", "expected": {"diameter_mm": 16, "count": 2}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "lug_dimensions", "description": "base, lug thickness/height, 30 mm inside spacing and 32 mm bore-axis height"} - ], - "max_author_turns": 92, "max_reviewer_turns": 34, "max_total_calls": 126, "max_wall_seconds": 2700, "max_total_tokens": 380000 - }, - { - "id": "stepped_shaft", - "units": "mm", - "request": "生成一根阶梯轴,总长120毫米。第一段直径30毫米、长40毫米;第二段直径24毫米、长50毫米;第三段直径18毫米、长30毫米。所有轴肩过渡圆角R2,两端倒角1毫米乘45度。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 120}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 24, "count": 1}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 18, "count": 1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["revolve_add"], - "required_any_atomic_ids": ["fillet", "chamfer"], - "validation_capability_gaps": [ - {"id": "shaft_segment_lengths", "description": "the 30 mm, 24 mm and 18 mm shaft sections have axial lengths 40 mm, 50 mm and 30 mm"}, - {"id": "shaft_finish_features", "description": "R2 shoulders and 1 x 45 degree end chamfers"} - ], - "max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000 - }, - { - "id": "keyed_stepped_shaft", - "units": "mm", - "request": "生成一根阶梯传动轴,总长140毫米,中间轴段直径30毫米、长70毫米,两端轴段直径20毫米、各长35毫米。中间轴段沿轴向加工一条平键槽,键槽宽8毫米、深3.3毫米、长50毫米,所有轴肩圆角R2。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "outer_cylindrical_surface", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 140}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 20, "count": 2}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["revolve_add"], - "required_any_atomic_ids": ["extrude_cut_blind", "fillet"], - "validation_capability_gaps": [ - {"id": "shaft_segment_lengths", "description": "the 20 mm end sections are 35 mm each and the 30 mm middle section is 70 mm"}, - {"id": "keyway", "description": "8 x 3.3 x 50 axial keyway on the middle shaft section"}, - {"id": "shoulder_fillet", "description": "R2 on all shaft shoulders"} - ], - "max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000 - }, - { - "id": "flanged_sleeve", - "units": "mm", - "request": "生成一个机械套筒,外径50毫米,内孔直径30毫米,总长60毫米。套筒一端带外径70毫米、厚度10毫米的法兰,法兰上在直径56毫米分度圆上均布4个直径7毫米贯穿孔。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "circular_hole_pattern", "outer_cylindrical_surface"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 60}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 70}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 50, "count": 1, "axial_span_mm": 50}}, - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 70, "count": 1, "axial_span_mm": 10}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 30, "count": 1}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 7, "count": 4}}, - {"claim_kind": "circular_hole_pattern", "expected": {"diameter_mm": 7, "count": 4, "pitch_radius_mm": 28, "concentric_bore_diameter_mm": 30}} - ], - "required_atomic_ids": ["revolve_add"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "validation_capability_gaps": [ - {"id": "flange_at_one_end", "description": "the 70 mm flange occurs at exactly one end of the coaxial sleeve, not at an interior position"} - ], - "max_author_turns": 84, "max_reviewer_turns": 32, "max_total_calls": 116, "max_wall_seconds": 2400, "max_total_tokens": 340000 - }, - { - "id": "chamfered_bushing", - "units": "mm", - "request": "生成一个圆柱轴套,外径40毫米,内孔直径25毫米,长度50毫米,两端外边缘倒角1.5毫米乘45度,两端内孔边缘倒角1毫米乘45度。", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["single_connected_body", "bbox_rank_dimension_mm", "through_cylindrical_bore", "visual"], - "required_claims": [ - {"claim_kind": "single_connected_body", "expected": {}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "minimum", "value": 40}}, - {"claim_kind": "bbox_rank_dimension_mm", "expected": {"rank": "maximum", "value": 50}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 25, "count": 1}}, - {"claim_kind": "visual", "expected": {}} - ], - "required_atomic_ids": ["revolve_add"], - "required_any_atomic_ids": ["chamfer"], - "validation_capability_gaps": [ - {"id": "inside_outside_chamfers", "description": "both 1.5 x 45 degree external and 1 x 45 degree internal chamfers"} - ], - "max_author_turns": 72, "max_reviewer_turns": 28, "max_total_calls": 100, "max_wall_seconds": 2100, "max_total_tokens": 280000 - } - ] -} diff --git a/backend/app/cad_agent/evals/fixtures/release.json b/backend/app/cad_agent/evals/fixtures/release.json deleted file mode 100644 index 23867fba..00000000 --- a/backend/app/cad_agent/evals/fixtures/release.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "schema_version": "cad.live-eval-fixtures.v1", - "scenarios": [ - { - "id": "rectangular_plate", - "units": "mm", - "request": "Create one connected rectangular plate, 80 mm long, 50 mm wide, and 8 mm thick. Use millimetres. Verify the single solid and all three bounding-box dimensions.", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 80, "tolerance_mm": 0.01}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 50, "tolerance_mm": 0.01}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 8, "tolerance_mm": 0.01}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "max_author_turns": 32, - "max_reviewer_turns": 12, - "max_total_calls": 44, - "max_wall_seconds": 900, - "max_total_tokens": 120000 - }, - { - "id": "simple_flange", - "units": "mm", - "request": "Create one connected round flange in millimetres: outer diameter 120 mm, thickness 12 mm, and a centered 40 mm through bore. Verify the single solid, thickness, and through bore topology.", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "bbox_dimension_mm", "through_cylindrical_bore"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "x", "value": 120, "tolerance_mm": 0.01}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "y", "value": 120, "tolerance_mm": 0.01}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "tolerance_mm": 0.01}}, - {"claim_kind": "through_cylindrical_bore", "expected": {"diameter_mm": 40, "count": 1, "tolerance_mm": 0.01}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["hole_blind", "extrude_cut_blind"], - "max_author_turns": 40, - "max_reviewer_turns": 16, - "max_total_calls": 56, - "max_wall_seconds": 1200, - "max_total_tokens": 160000 - }, - { - "id": "ribbed_mounting_plate", - "units": "mm", - "request": "Create one connected mounting plate in millimetres with a rectangular base, a vertical reinforcing rib, and four equally spaced mounting through holes. State reasonable dimensions as assumptions, then verify a single connected solid and the four-hole pattern.", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals", "circular_hole_pattern", "through_cylindrical_bore"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}} - ], - "required_atomic_ids": ["extrude_add_blind", "hole_blind"], - "max_author_turns": 56, - "max_reviewer_turns": 20, - "max_total_calls": 76, - "max_wall_seconds": 1500, - "max_total_tokens": 200000 - }, - { - "id": "selector_finish", - "units": "mm", - "request": "Create one connected rectangular plate with an added rounded or chamfered edge selected from the current topology. Use millimetres, state assumptions, and verify the final single solid.", - "expected_phase": "COMPLETED", - "required_claim_kinds": ["solid_count_equals"], - "required_claims": [ - {"claim_kind": "solid_count_equals", "expected": {"value": 1}} - ], - "required_atomic_ids": ["extrude_add_blind"], - "required_any_atomic_ids": ["fillet", "chamfer"], - "max_author_turns": 40, - "max_reviewer_turns": 16, - "max_total_calls": 56, - "max_wall_seconds": 1200, - "max_total_tokens": 160000 - } - ] -} diff --git a/backend/app/cad_agent/evals/live.py b/backend/app/cad_agent/evals/live.py deleted file mode 100644 index 593767b1..00000000 --- a/backend/app/cad_agent/evals/live.py +++ /dev/null @@ -1,990 +0,0 @@ -"""Run the real provider, engine, reviewer and v3 state machine end to end. - -``--require-live`` deliberately fails closed: missing credentials, blocked -network, a skipped model capability, a timeout, or any scenario failure emits -``LIVE_EVAL_BLOCKED``/failure details and exits non-zero. -""" - -from __future__ import annotations - -import argparse -import asyncio -from dataclasses import replace -from datetime import datetime, timezone -from hashlib import sha256 -import json -from pathlib import Path -import secrets -from statistics import median -import subprocess -import sys -from typing import Any - -from app.cad_agent.application.capabilities import verify_model_capability -from app.cad_agent.application.workflow import ModelIdentity -from app.cad_agent.composition import compose_v3 -from app.cad_agent.domain.claim_matching import contains_expected -from app.cad_agent.domain.operation_contract import canonical_hash -from app.cad_agent.domain.verifier_registry import default_registry -from app.cad_agent.evals.token_baseline import ( - TokenBaselineError, - author_request_identity, - compare_token_baseline, - load_token_baseline, - profile_sha256, - validate_token_baseline_provenance, -) -from app.settings import BACKEND_ROOT, get_settings - - -_LEGACY_CANDIDATE_EVIDENCE_FILES = frozenset({ - "candidate.json", - "candidate-review.json", - "model.cdsl.json", - "model.step", - "model.glb", - "model.topology.json", - "rebuild-report.json", - "renders/render-manifest.json", - "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", - "cdsl_expression", - "engine_execution", - "independent_visual_review", - "configuration_or_network", -}) - -_ERROR_FAILURE_LAYERS = { - "AUTHOR_FORMAT_INVALID": "model_format_or_decision", - "AUTHOR_DECISION_REJECTED": "model_format_or_decision", - "STALE_WORKING_HEAD": "model_format_or_decision", - "FAILED_AUTHOR_FORMAT": "model_format_or_decision", - "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED": "model_format_or_decision", - "RUNTIME_PRECONDITION_FAILED": "v3_contract_or_verifier", - "RUNTIME_CONTRACT_INVALID": "v3_contract_or_verifier", - "VERIFIER_UNAVAILABLE": "v3_contract_or_verifier", - "CLAIM_VERIFICATION_FAILED": "cdsl_expression", - "CANDIDATE_BUILD_FAILED": "engine_execution", - "CANDIDATE_REVIEW_REJECTED": "independent_visual_review", - "AUTHOR_TRANSPORT_UNAVAILABLE": "configuration_or_network", - "REVIEW_SERVICE_UNAVAILABLE": "configuration_or_network", - "RENDER_SERVICE_UNAVAILABLE": "configuration_or_network", - "STORAGE_FAILURE": "configuration_or_network", - "LIVE_EVAL_TIMEOUT": "configuration_or_network", - "FAILED_INTERNAL": "engine_execution", -} - - -def _arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run live protocol-v3 CAD evaluations.") - parser.add_argument("--suite", choices=("smoke", "release", "comprehensive"), default="smoke") - parser.add_argument("--require-live", action="store_true") - parser.add_argument("--allow-skip", action="store_true") - parser.add_argument("--author-provider") - parser.add_argument("--author-model") - parser.add_argument("--review-provider") - parser.add_argument("--review-model") - parser.add_argument("--scenario", action="append", dest="scenarios", help="Run a fixture scenario by stable ID. Repeat this option to select a comparison set.") - parser.add_argument("--repetitions", type=int, help="Run every selected scenario this many times.") - parser.add_argument("--author-guidance", choices=("on", "off"), help="Override CDSL author guidance for this run.") - parser.add_argument("--compare-guidance-reports", nargs=2, type=Path, metavar=("CONTROL", "TREATMENT"), help="Compare matched --author-guidance off/on report.json files without invoking providers.") - parser.add_argument("--baseline-report", type=Path, help="Measured pre-v3 token baseline JSON for a release run.") - return parser.parse_args() - - -def _fixture(suite: str, scenario_ids: list[str] | None = None) -> list[dict[str, Any]]: - fixture_name = "comprehensive.json" if suite == "comprehensive" else "release.json" - value = json.loads((Path(__file__).parent / "fixtures" / fixture_name).read_text(encoding="utf-8")) - if fixture_name == "comprehensive.json": - source_document = str(value.get("source_document") or "") - expected_digest = str(value.get("source_document_sha256") or "") - source_path = (BACKEND_ROOT.parent / source_document).resolve() - workspace_root = BACKEND_ROOT.parent.resolve() - if ( - not source_document - or workspace_root not in source_path.parents - or not source_path.is_file() - or sha256(source_path.read_bytes()).hexdigest() != expected_digest - ): - raise ValueError("Comprehensive fixture is not synchronized with its source document.") - values = [item for item in value.get("scenarios") or () if isinstance(item, dict)] - values = values[:2] if suite == "smoke" else values - if not scenario_ids: - return values - requested = list(dict.fromkeys(scenario_ids)) - available = {str(item.get("id") or "") for item in values} - unknown = [scenario_id for scenario_id in requested if scenario_id not in available] - if unknown: - raise ValueError(f"Unknown scenario {unknown[0]!r} for suite {suite!r}") - selected = [item for item in values if str(item.get("id") or "") in set(requested)] - return selected - - -def _rejection_codes(events: list[dict[str, Any]]) -> list[str]: - codes: list[str] = [] - for event in events: - payload = event.get("payload") if isinstance(event.get("payload"), dict) else {} - result = payload.get("result") if isinstance(payload.get("result"), dict) else payload - code = result.get("code") if isinstance(result, dict) else None - if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "STALE_WORKING_HEAD"}: - codes.append(str(code)) - return codes - - -def _acceptance_coverage( - scenario: dict[str, Any], - requirements_contract: dict[str, Any] | None, -) -> dict[str, Any]: - """Assess whether a fixture's acceptance contract is representable today. - - Comprehensive prompts deliberately include manufacturing relationships that - may not yet have a deterministic verifier. A healthy-looking solid is - not evidence for those relationships, so they remain explicit capability - gaps instead of silently passing a scenario. - """ - actual = [ - { - "claim_kind": str(claim.get("claim_kind") or ""), - "expected": claim.get("expected") if isinstance(claim.get("expected"), dict) else {}, - } - for requirement in (requirements_contract or {}).get("requirements") or () - if isinstance(requirement, dict) - for claim in requirement.get("acceptance_claims") or () - if isinstance(claim, dict) - ] - required = {str(value) for value in scenario.get("required_claim_kinds") or ()} - expected_claims = [ - { - "claim_kind": str(item.get("claim_kind") or ""), - "expected": item.get("expected") if isinstance(item.get("expected"), dict) else {}, - } - for item in scenario.get("required_claims") or () - if isinstance(item, dict) and isinstance(item.get("claim_kind"), str) - ] - expected_kinds = {claim["claim_kind"] for claim in expected_claims} - expected_claims.extend( - {"claim_kind": claim_kind, "expected": {}} - for claim_kind in sorted(required - expected_kinds) - ) - gaps = [ - {"id": str(item.get("id") or ""), "description": str(item.get("description") or "")} - for item in scenario.get("validation_capability_gaps") or () - if isinstance(item, dict) - ] - missing = [ - claim for claim in expected_claims - if not any( - actual_claim["claim_kind"] == claim["claim_kind"] - and _contains_business_expected(actual_claim["expected"], claim["expected"]) - for actual_claim in actual - ) - ] - return { - "required_claim_kinds": sorted(required), - "covered_claim_kinds": sorted(required.intersection({claim["claim_kind"] for claim in actual})), - "required_claims": expected_claims, - "missing_claims": missing, - "validation_capability_gaps": gaps, - "complete": not missing and not gaps, - } - - -def _contains_business_expected(actual: Any, required: Any) -> bool: - """Match fixture business values without coupling to verifier tolerances. - - Tolerances are executable verifier parameters chosen within the schema's - safe range. They are not a separate user requirement and should not make - a valid generated contract fail release evaluation merely because the - author used the registry default instead of the fixture's tighter value. - """ - if isinstance(actual, dict) and isinstance(required, dict): - return all( - key in actual - and _contains_business_expected(actual[key], value) - for key, value in required.items() - if key not in {"tolerance_mm", "tolerance"} - ) - return contains_expected(actual, required) - - -def _contains_expected(actual: Any, required: Any) -> bool: - """Match canonical claim values in release evaluation.""" - return contains_expected(actual, required) - - -def _capability_block_reason(*reports: dict[str, Any]) -> tuple[str, str]: - """Classify a failed conformance probe without confusing outages for gaps. - - A provider transport failure means the probe did not establish either - support or non-support. Only a complete response that violates one of the - exposed tool contracts is evidence of a model structured-output limit. - """ - messages = [ - str(failure.get("message") or "").casefold() - for report in reports - for failure in report.get("failures") or () - if isinstance(failure, dict) - ] - unavailable_markers = ( - "transport unavailable", - "connection", - "network", - "timeout", - "timed out", - "temporarily unavailable", - ) - if any(any(marker in message for marker in unavailable_markers) for message in messages): - return "MODEL_CAPABILITY_PROBE_UNAVAILABLE", "configuration_or_network" - return "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED", "model_format_or_decision" - - -def _artifact_evidence_complete( - artifact_root: Path, - 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 - 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: - return False - report_files = { - str(item.get("path") or "") - for item in (artifact_manifest or {}).get("files") or () - if isinstance(item, dict) - } - required_report_files = { - "requirements-contract.json", - f"reviews/final/{active_revision}/final-review.json", - } - if artifact_manifest is not None and not required_report_files.issubset(report_files): - 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 - 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 - try: - manifest = json.loads(manifest_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return False - declared = manifest.get("files") if isinstance(manifest, dict) else None - if not isinstance(declared, dict) or not evidence_files.issubset(declared): - return False - for relative, digest in declared.items(): - path = revision_root / str(relative) - if not isinstance(digest, str) or len(digest) != 64 or not path.is_file(): - return False - 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 evidence_files} - if not expected_report_paths.issubset(report_files): - return False - return True - - -def _failure_attribution( - *, - checks: dict[str, bool], - outcome: str, - events: list[dict[str, Any]], - projection: dict[str, Any], -) -> dict[str, Any] | None: - """Return one evidence-backed failure layer required by the test target.""" - if outcome == "passed": - return None - codes: list[str] = [] - for event in events: - payload = event.get("payload") if isinstance(event, dict) and isinstance(event.get("payload"), dict) else {} - result = payload.get("result") if isinstance(payload.get("result"), dict) else payload - code = result.get("code") if isinstance(result, dict) else "" - if isinstance(code, str) and code: - codes.append(code) - last_error = projection.get("last_error") if isinstance(projection, dict) else "" - if isinstance(last_error, str) and last_error: - codes.append(last_error) - for code in reversed(codes): - layer = _ERROR_FAILURE_LAYERS.get(code) - if layer: - return { - "layer": layer, - "reason_code": code, - "evidence_codes": list(dict.fromkeys(codes)), - "failed_checks": [name for name, passed in checks.items() if not passed], - } - failed_checks = [name for name, passed in checks.items() if not passed] - if outcome == "validation_capability_gap" or any(name in {"required_claims", "acceptance_contract_coverage"} for name in failed_checks): - layer, reason = "v3_contract_or_verifier", "VALIDATION_CAPABILITY_GAP" - elif any(name in {"deterministic_claims_pass", "required_operations", "required_operation_alternative"} for name in failed_checks): - layer, reason = "cdsl_expression", "CAD_ACCEPTANCE_GATE_FAILED" - elif any(name in {"token_budget", "call_budget", "author_turn_budget", "reviewer_turn_budget"} for name in failed_checks): - layer, reason = "configuration_or_network", "EVALUATION_BUDGET_EXCEEDED" - elif "raw_argument_audit" in failed_checks: - layer, reason = "v3_contract_or_verifier", "RAW_ARGUMENT_AUDIT_FAILED" - elif any(name in {"immutable_artifacts", "artifact_manifest", "required_artifact_evidence", "action_ledger"} for name in failed_checks): - layer, reason = "configuration_or_network", "ARTIFACT_EVIDENCE_INCOMPLETE" - else: - layer, reason = "model_format_or_decision", "WORKFLOW_TERMINAL_STATE_MISMATCH" - return { - "layer": layer, - "reason_code": reason, - "evidence_codes": list(dict.fromkeys(codes)), - "failed_checks": failed_checks, - } - - -def _run_checks( - scenario: dict[str, Any], - projection: dict[str, Any], - usage: dict[str, Any], - requirements_contract: dict[str, Any] | None, - events: list[dict[str, Any]], - artifact_root: Path, - ledger: list[dict[str, Any]] | None = None, - tool_audits: list[dict[str, Any]] | None = None, - artifact_manifest: dict[str, Any] | None = None, -) -> dict[str, bool]: - records = usage.get("records") if isinstance(usage.get("records"), list) else [] - author_calls = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"] - 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)] - 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 () - if isinstance(group, list) - ] - active_revision = str(projection.get("active_revision") or "") - manifest = artifact_root / "revisions" / active_revision / "manifest.json" - jsonl = artifact_root / "actions" / "action-ledger.jsonl" - final_claims = next((item.get("claim_results") for item in reversed(audit_ledger) if isinstance(item, dict) and item.get("event") == "completed" and isinstance(item.get("claim_results"), list)), []) - audit_records = tool_audits if isinstance(tool_audits, list) else [] - audit_valid = bool(audit_records) and all( - isinstance(item, dict) - and isinstance(item.get("raw_arguments_hash"), str) - and len(item["raw_arguments_hash"]) == 64 - and item.get("canonical_schema_valid") is True - and isinstance(item.get("state_binding"), dict) - and bool(item["state_binding"].get("phase")) - and item["state_binding"].get("binding_valid") is True - and item.get("single_allowed_call") is True - for item in audit_records - ) - acceptance = _acceptance_coverage(scenario, requirements_contract) - artifact_evidence = _artifact_evidence_complete( - artifact_root, - revisions, - active_revision, - artifact_manifest, - audit_ledger, - ) - return { - "expected_terminal_phase": projection.get("phase") == scenario.get("expected_phase", "COMPLETED"), - "token_budget": total_tokens <= int(scenario["max_total_tokens"]), - "call_budget": len(records) <= int(scenario["max_total_calls"]), - "author_turn_budget": len(author_calls) <= int(scenario["max_author_turns"]), - "reviewer_turn_budget": len(reviewer_calls) <= int(scenario["max_reviewer_turns"]), - "required_claims": not acceptance["missing_claims"], - "acceptance_contract_coverage": acceptance["complete"], - "required_operations": {str(value) for value in scenario.get("required_atomic_ids") or ()}.issubset(operation_ids), - "required_operation_alternative": ( - (not scenario.get("required_any_atomic_ids") or bool({str(value) for value in scenario["required_any_atomic_ids"]}.intersection(operation_ids))) - and all(group.intersection(operation_ids) for group in required_operation_groups) - ), - "immutable_artifacts": bool(active_revision) and manifest.is_file(), - "required_artifact_evidence": artifact_evidence, - "artifact_manifest": ( - artifact_manifest is None - or ( - isinstance(artifact_manifest.get("files"), list) - and bool(artifact_manifest["files"]) - and all( - isinstance(item, dict) - and isinstance(item.get("path"), str) - and isinstance(item.get("sha256"), str) - and len(item["sha256"]) == 64 - for item in artifact_manifest["files"] - ) - ) - ), - "action_ledger": jsonl.is_file(), - "unique_revisions": len(revisions) == len(set(revisions)), - "deterministic_claims_pass": bool(final_claims) and all( - not isinstance(item, dict) or not item.get("deterministic") or item.get("status") == "pass" - for item in final_claims - ), - "raw_argument_audit": audit_valid if tool_audits is not None else bool(records) and all(isinstance(item, dict) and isinstance(item.get("raw_arguments_hash"), str) and len(item["raw_arguments_hash"]) == 64 for item in records), - "no_schema_or_decision_rejections": not _rejection_codes(events), - } - - -def _git_revision() -> str: - try: - completed = subprocess.run( - ["git", "rev-parse", "HEAD"], cwd=BACKEND_ROOT.parent, capture_output=True, - text=True, check=True, timeout=5, - ) - return completed.stdout.strip() - except (OSError, subprocess.SubprocessError): - return "unknown" - - -def _event_audit(events: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Persist event metadata without prompts, tool arguments, or tool facts.""" - audit: list[dict[str, Any]] = [] - for item in events: - payload = item.get("payload") if isinstance(item.get("payload"), dict) else {} - result = payload.get("result") if isinstance(payload.get("result"), dict) else {} - usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {} - audit.append({ - "name": str(item.get("name") or ""), "tool": str(payload.get("tool") or ""), - "status": str(payload.get("status") or ""), "code": str(result.get("code") or payload.get("code") or ""), - "raw_arguments_hash": str(usage.get("raw_arguments_hash") or ""), - "field_errors": result.get("field_errors") if isinstance(result.get("field_errors"), list) else [], - }) - return audit - - -def _report_tool_audits(audits: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Return report-safe audit metadata without argument values or prompts.""" - return [ - { - "audit_id": item.get("audit_id"), - "at": item.get("at"), - "actor": item.get("actor"), - "tool": item.get("tool"), - "raw_arguments_hash": item.get("raw_arguments_hash"), - "canonical_schema_valid": item.get("canonical_schema_valid"), - "field_errors": item.get("field_errors") if isinstance(item.get("field_errors"), list) else [], - "state_binding": item.get("state_binding") if isinstance(item.get("state_binding"), dict) else {}, - "returned_tool": item.get("returned_tool"), - "single_allowed_call": item.get("single_allowed_call"), - } - for item in audits - if isinstance(item, dict) - ] - - -def _ledger_identifiers(ledger: list[dict[str, Any]]) -> dict[str, list[str]]: - """Expose audit identifiers explicitly without copying provider payloads.""" - candidate_ids = sorted({ - str(item.get("candidate_id")) - for item in ledger - if isinstance(item.get("candidate_id"), str) and item.get("candidate_id") - }) - revision_ids = sorted({ - str(item.get("revision_id")) - for item in ledger - if isinstance(item.get("revision_id"), str) and item.get("revision_id") - }) - return {"candidate_ids": candidate_ids, "revision_ids": revision_ids} - - -def _final_claim_results(ledger: list[dict[str, Any]]) -> list[dict[str, Any]]: - for item in reversed(ledger): - results = item.get("claim_results") - if item.get("event") == "completed" and isinstance(results, list): - return [value for value in results if isinstance(value, dict)] - return [] - - -def _redacted_correlation_ids(task_id: str, invocations: list[dict[str, Any]]) -> list[str]: - """Keep run linkage without publishing task or provider correlation values.""" - return [ - canonical_hash({"task_id": task_id, "invocation_id": str(item.get("invocation_id") or "")})[:20] - for item in invocations - if isinstance(item, dict) and item.get("invocation_id") - ] - - -def _safe_artifact_manifest(artifact_root: Path) -> dict[str, Any]: - """Hash reviewable CAD evidence without copying source or prompt content.""" - allowed_exact = { - "requirements-contract.json", - "requirements.md", - "completion-target.md", - "completion-result.md", - } - allowed_prefixes = ("actions/", "revisions/", "reviews/", "documents/requirements-") - files: list[dict[str, str]] = [] - if artifact_root.is_dir(): - for path in sorted(artifact_root.rglob("*")): - if not path.is_file(): - continue - relative = path.relative_to(artifact_root).as_posix() - if relative not in allowed_exact and not relative.startswith(allowed_prefixes): - continue - files.append({"path": relative, "sha256": sha256(path.read_bytes()).hexdigest()}) - return {"schema_version": "cad.live-eval-artifact-manifest.v1", "artifact_root": str(artifact_root), "files": files} - - -def _guidance_metadata(usage: dict[str, Any]) -> dict[str, Any]: - """Summarize author-only guidance audit metadata without retaining prompts.""" - records = [ - item for item in usage.get("records") or () - if isinstance(item, dict) and item.get("role") != "reviewer" - ] - sections = sorted({ - section_id - for item in records - for section_id in item.get("guidance_section_ids") or () - if isinstance(section_id, str) - }) - versions = sorted({ - str(item.get("guidance_version") or "") - for item in records - if str(item.get("guidance_version") or "") - }) - return { - "enabled": bool(records) and any(item.get("guidance_enabled") is True for item in records), - "versions": versions, - "section_ids": sections, - "chars_total": sum(int(item.get("guidance_chars") or 0) for item in records), - "fallback_reasons": sorted({ - str(item.get("guidance_fallback_reason") or "") - for item in records - if str(item.get("guidance_fallback_reason") or "") - }), - } - - -def _has_unsupported_capability(row: dict[str, Any]) -> bool: - """Recognize engine-declared unsupported capability without hiding model errors.""" - for event in row.get("ledger") or (): - if not isinstance(event, dict): - continue - for failure in event.get("operation_failures") or (): - if isinstance(failure, dict) and "unsupported_" in str(failure.get("message") or ""): - return True - if "unsupported_" in str(event.get("message") or ""): - return True - return False - - -def _guidance_metric_rows(report: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: - results = [item for item in report.get("results") or () if isinstance(item, dict)] - capability_gaps = [ - item for item in results - if item.get("outcome") == "validation_capability_gap" or _has_unsupported_capability(item) - ] - return [item for item in results if item not in capability_gaps], capability_gaps - - -def _guidance_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]: - if not rows: - return {"eligible_runs": 0} - author_calls = [ - sum(1 for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") - for row in rows - ] - context_chars = [ - sum(int(item.get("context_chars") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") - for row in rows - ] - prompt_tokens = [ - sum(int(item.get("prompt_tokens") or 0) for item in (row.get("usage") or {}).get("records") or () if isinstance(item, dict) and item.get("role") != "reviewer") - for row in rows - ] - failure_layers: dict[str, int] = {} - for row in rows: - layer = str((row.get("failure_attribution") or {}).get("layer") or "passed") - failure_layers[layer] = failure_layers.get(layer, 0) + 1 - def rate(predicate: Any) -> float: - return sum(1 for row in rows if predicate(row)) / len(rows) - return { - "eligible_runs": len(rows), - "executable_checkpoint_rate": rate(lambda row: bool(row.get("revision_ids"))), - "completion_rate": rate(lambda row: str((row.get("projection") or {}).get("phase") or "") == "COMPLETED"), - "deterministic_claim_success_rate": rate(lambda row: bool((row.get("checks") or {}).get("deterministic_claims_pass"))), - "schema_or_decision_rejections": sum(int(row.get("schema_rejection_count") or 0) for row in rows), - "cdsl_expression_failures": sum(1 for row in rows if str((row.get("failure_attribution") or {}).get("layer") or "") == "cdsl_expression"), - "median_author_calls": median(author_calls), - "median_author_context_chars": median(context_chars), - "total_author_prompt_tokens": sum(prompt_tokens), - "median_author_prompt_tokens": median(prompt_tokens), - "failure_layers": dict(sorted(failure_layers.items())), - } - - -def compare_guidance_reports(control: dict[str, Any], treatment: dict[str, Any]) -> dict[str, Any]: - """Compare paired guidance-off/on runs without treating engine gaps as prompt results.""" - control_rows, control_gaps = _guidance_metric_rows(control) - treatment_rows, treatment_gaps = _guidance_metric_rows(treatment) - control_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in control_rows} - treatment_by_key = {(str(row.get("scenario") or ""), int(row.get("repetition") or 0)): row for row in treatment_rows} - paired = sorted(set(control_by_key).intersection(treatment_by_key)) - control_only = sorted(set(control_by_key).difference(treatment_by_key)) - treatment_only = sorted(set(treatment_by_key).difference(control_by_key)) - control_pairs = [control_by_key[key] for key in paired] - treatment_pairs = [treatment_by_key[key] for key in paired] - control_metrics = _guidance_metrics(control_pairs) - treatment_metrics = _guidance_metrics(treatment_pairs) - same_runtime = ( - control.get("author") == treatment.get("author") - and control.get("reviewer") == treatment.get("reviewer") - and control.get("runtime_profile_sha256") == treatment.get("runtime_profile_sha256") - and control.get("operation_contracts") == treatment.get("operation_contracts") - ) - control_guidance = bool((control.get("author_guidance") or {}).get("enabled")) - treatment_guidance = bool((treatment.get("author_guidance") or {}).get("enabled")) - same_budgets = all( - control_by_key[key].get("scenario_budget") == treatment_by_key[key].get("scenario_budget") - for key in paired - ) - calls_control = control_metrics.get("median_author_calls") - calls_treatment = treatment_metrics.get("median_author_calls") - calls_within_limit = ( - isinstance(calls_control, (int, float)) - and isinstance(calls_treatment, (int, float)) - and calls_treatment <= calls_control * 1.10 - ) - improved = ( - treatment_metrics.get("schema_or_decision_rejections", 0) < control_metrics.get("schema_or_decision_rejections", 0) - or treatment_metrics.get("cdsl_expression_failures", 0) < control_metrics.get("cdsl_expression_failures", 0) - ) - gates = { - "complete_pairing": bool(paired) and not control_only and not treatment_only, - "control_off_treatment_on": not control_guidance and treatment_guidance, - "same_author_reviewer_runtime_and_contracts": same_runtime, - "same_per_scenario_budgets": same_budgets, - "checkpoint_rate_not_lower": treatment_metrics.get("executable_checkpoint_rate", -1) >= control_metrics.get("executable_checkpoint_rate", 0), - "completion_rate_not_lower": treatment_metrics.get("completion_rate", -1) >= control_metrics.get("completion_rate", 0), - "median_author_calls_within_ten_percent": calls_within_limit, - "model_or_cdsl_failure_improved": improved, - } - return { - "schema_version": "cad.author-guidance-comparison.v1", - "status": "passed" if all(gates.values()) else "failed", - "gates": gates, - "paired_runs": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in paired], - "unpaired_runs": { - "control_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in control_only], - "treatment_only": [{"scenario": scenario, "repetition": repetition} for scenario, repetition in treatment_only], - }, - "control": control_metrics, - "treatment": treatment_metrics, - "excluded_capability_gaps": { - "control": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in control_gaps], - "treatment": [{"scenario": item.get("scenario"), "repetition": item.get("repetition")} for item in treatment_gaps], - }, - } - - -def compare_guidance_report_paths(control_path: Path, treatment_path: Path) -> dict[str, Any]: - try: - control = json.loads(control_path.read_text(encoding="utf-8")) - treatment = json.loads(treatment_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - return {"status": "failed", "error": f"GUIDANCE_COMPARISON_INPUT_INVALID: {type(error).__name__}"} - if not isinstance(control, dict) or not isinstance(treatment, dict): - return {"status": "failed", "error": "GUIDANCE_COMPARISON_INPUT_INVALID: report must be an object"} - return compare_guidance_reports(control, treatment) - - -async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]: - try: - scenarios = _fixture(arguments.suite, arguments.scenarios) - except ValueError as error: - return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)} - repetitions = arguments.repetitions if arguments.repetitions is not None else 3 if arguments.suite == "release" else 1 - if repetitions < 1: - return {"status": "LIVE_EVAL_BLOCKED", "error": "--repetitions must be at least 1"} - settings = get_settings() - if arguments.author_guidance is not None: - settings = replace(settings, agent_author_guidance_enabled=arguments.author_guidance == "on") - try: - 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) - if review_provider.id == author_provider.id and review_model.id == author_model.id: - raise ValueError("Reviewer must differ from author") - else: - review_provider, review_model = settings.resolve_independent_review_model(author_provider, author_model) - except ValueError as error: - return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)} - baseline_path = arguments.baseline_report.resolve() if arguments.baseline_report else None - if arguments.suite == "release" and baseline_path is None: - return {"status": "LIVE_EVAL_BLOCKED", "error": "TOKEN_BASELINE_REQUIRED: release requires a provenance-checked pre-v3 token baseline report."} - try: - baseline = load_token_baseline(baseline_path) if baseline_path else None - current_author_identity = author_request_identity(author_provider, author_model) - runtime_profile_hash = profile_sha256(settings.engine_root / "profile_schema.json") - except TokenBaselineError as error: - return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {error}"} - if baseline is not None: - baseline_errors = validate_token_baseline_provenance( - baseline, - scenarios=scenarios, - author_identity=current_author_identity, - runtime_profile_hash=runtime_profile_hash, - ) - if baseline_errors: - return {"status": "LIVE_EVAL_BLOCKED", "error": f"TOKEN_BASELINE_INVALID: {baseline_errors[0]}", "baseline_errors": baseline_errors} - isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations") - services = compose_v3(isolated) - contracts = [ - { - "atomic_id": atomic_id, "contract_hash": services.workflow.runtime.operation_contract(atomic_id)["contract_hash"], - "contract_version": services.workflow.runtime.operation_contract(atomic_id)["contract_version"], - "registry_revision": services.workflow.runtime.operation_contract(atomic_id)["registry_revision"], - } - for atomic_id in services.workflow.runtime.supported_atomic_ids() - ] - verifier_schema_hash = canonical_hash(default_registry().expected_one_of_schema()) - try: - author_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=author_provider.id, model_id=author_model.id, role="author", force=True) - reviewer_capability = await verify_model_capability(services.repository, services.workflow.runtime, services.models, provider_id=review_provider.id, model_id=review_model.id, role="reviewer", force=True) - except Exception as error: - return {"status": "LIVE_EVAL_BLOCKED", "error": str(error)[:1000]} - if not author_capability.get("supported") or not reviewer_capability.get("supported"): - error, failure_layer = _capability_block_reason(author_capability, reviewer_capability) - return { - "status": "LIVE_EVAL_BLOCKED", - "error": error, - "failure_layer": failure_layer, - "author_capability": author_capability, - "reviewer_capability": reviewer_capability, - } - results: list[dict[str, Any]] = [] - for scenario in scenarios: - for repetition in range(1, repetitions + 1): - services.workflow.config = replace( - services.workflow.config, - # State transitions include local candidate recovery, so the - # loop guard is deliberately independent from the externally - # measured model-call budgets below. - max_turns=max(8, int(scenario["max_total_calls"]) * 3), - max_author_turns=int(scenario["max_author_turns"]), - max_reviewer_turns=int(scenario["max_reviewer_turns"]), - max_model_calls=int(scenario["max_total_calls"]), - ) - task_id = f"cad_{secrets.token_hex(6)}" - oracle_claims = [item for item in scenario.get("required_claims") or () if isinstance(item, dict)] - if oracle_claims: - services.workflow.requirements.register_evaluation_contract_oracle( - task_id, - oracle_claims, - validation_capability_gaps=[item for item in scenario.get("validation_capability_gaps") or () if isinstance(item, dict)], - ) - services.workflow.create_task(task_id, str(scenario["request"])) - events: list[dict[str, Any]] = [] - started = datetime.now(timezone.utc) - try: - async with asyncio.timeout(int(scenario["max_wall_seconds"])): - async for name, payload in services.workflow.run( - task_id=task_id, - author=ModelIdentity(author_provider.id, author_model.id), - reviewer=ModelIdentity(review_provider.id, review_model.id), - ): - events.append({"name": name, "payload": payload}) - except TimeoutError: - events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT"}}) - projection = services.repository.get_task_projection(task_id) or {} - usage = services.repository.usage_summary(task_id) - ledger = services.repository.ledger_events(task_id) - invocations = services.repository.invocation_records(task_id) - tool_audits = services.repository.tool_audits(task_id) - terminal = next((item["payload"] for item in reversed(events) if item["name"] == "task_terminal"), {}) - artifact_root = (isolated.task_root / task_id).resolve() - artifact_manifest = _safe_artifact_manifest(artifact_root) - finished = datetime.now(timezone.utc) - checks = _run_checks( - scenario, - projection, - usage, - services.artifacts.read_requirements_contract( - task_id, - services.repository.get_state(task_id).requirements_contract_path - if services.repository.get_state(task_id) is not None - else "", - ), - events, - artifact_root, - ledger, - tool_audits, - artifact_manifest, - ) - success = all(checks.values()) - acceptance = _acceptance_coverage( - scenario, - services.artifacts.read_requirements_contract( - task_id, - services.repository.get_state(task_id).requirements_contract_path - if services.repository.get_state(task_id) is not None - else "", - ), - ) - outcome = ( - "passed" - if success - else "validation_capability_gap" - if not acceptance["complete"] - and all(value for key, value in checks.items() if key != "acceptance_contract_coverage") - else "failed" - ) - failure_attribution = _failure_attribution( - checks=checks, - outcome=outcome, - events=events, - projection=projection, - ) - results.append({ - "scenario": scenario["id"], "repetition": repetition, "success": success, "outcome": outcome, - "started_at": started.isoformat(), "finished_at": finished.isoformat(), - "duration_ms": round((finished - started).total_seconds() * 1000), - "terminal": terminal, "projection": projection, "usage": usage, "checks": checks, - "acceptance_coverage": acceptance, - "failure_attribution": failure_attribution, - "guidance": _guidance_metadata(usage), - "scenario_budget": { - "max_author_turns": int(scenario["max_author_turns"]), - "max_reviewer_turns": int(scenario["max_reviewer_turns"]), - "max_total_calls": int(scenario["max_total_calls"]), - "max_total_tokens": int(scenario["max_total_tokens"]), - }, - "rejection_codes": _rejection_codes(events), - "schema_rejection_count": len(_rejection_codes(events)), - "retry_count": sum(1 for event in events if (event.get("payload") or {}).get("status") == "error"), - "rollback_count": sum(1 for item in ledger if item.get("event") == "rollback"), - **_ledger_identifiers(ledger), - "final_claim_results": _final_claim_results(ledger), - "redacted_correlation_ids": _redacted_correlation_ids(task_id, invocations), - "tool_audits": _report_tool_audits(tool_audits), - "artifact_root": str(artifact_root), "action_ledger_path": str(artifact_root / "actions" / "action-ledger.jsonl"), - "artifact_manifest": artifact_manifest, - "ledger": ledger, "invocations": invocations, "event_audit": _event_audit(events), - }) - token_comparison = ( - compare_token_baseline( - baseline, - scenarios=scenarios, - v3_results=results, - author_identity=current_author_identity, - runtime_profile_hash=runtime_profile_hash, - ) - if baseline is not None - else {"schema_version": "cad.token-comparison.v1", "status": "not_required"} - ) - token_gate = all(token_comparison.get("checks", {}).values()) if baseline is not None else True - return { - "status": "passed" if results and all(item["success"] for item in results) and token_gate else "failed", - "author": {"provider": author_provider.id, "model": author_model.id}, - "author_request_identity": current_author_identity, - "reviewer": {"provider": review_provider.id, "model": review_model.id}, - "author_guidance": { - "enabled": settings.agent_author_guidance_enabled, - "max_chars": settings.agent_author_guidance_max_chars, - }, - "author_capability": author_capability, - "reviewer_capability": reviewer_capability, - "structured_output_mode": { - "author": str(author_capability.get("mode") or ""), - "reviewer": str(reviewer_capability.get("mode") or ""), - }, - "git_revision": _git_revision(), - "protocol_version": "3.2", - "runtime_profile_sha256": runtime_profile_hash, - "operation_contracts": contracts, - "verifier_registry_version": "cad.verifier-registry.v1", - "verifier_schema_hash": verifier_schema_hash, - "token_comparison": token_comparison, - "baseline_report": str(baseline_path) if baseline_path else "", - "repetitions": repetitions, - "results": results, - } - - -def main() -> int: - arguments = _arguments() - report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - report_root.mkdir(parents=True, exist_ok=True) - try: - result = ( - compare_guidance_report_paths(*arguments.compare_guidance_reports) - if arguments.compare_guidance_reports - else asyncio.run(_run(arguments, report_root)) - ) - except KeyboardInterrupt: - # Let an explicit operator interruption retain its normal CLI - # semantics. An external kill cannot be reported reliably either. - raise - except BaseException as error: - # A live evaluation may fail before it creates a task (for example - # during conformance). Its report is still the release gate's audit - # artifact, so an unexpected evaluator failure must not disappear. - result = { - "status": "LIVE_EVAL_BLOCKED", - "error": f"UNEXPECTED_LIVE_EVAL_ERROR: {type(error).__name__}: {str(error)[:900]}", - "failure_layer": "configuration_or_network", - } - if result["status"] == "LIVE_EVAL_BLOCKED" and arguments.allow_skip and not arguments.require_live: - result["skip_reason"] = result.get("error", "live provider access is unavailable") - result["status"] = "skipped" - result.update({"suite": arguments.suite, "require_live": arguments.require_live, "report_root": str(report_root.resolve())}) - (report_root / "report.json").write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") - print(json.dumps({"status": result["status"], "report": str((report_root / "report.json").resolve())}, ensure_ascii=False)) - if result["status"] == "passed": - return 0 - if result["status"] == "skipped": - return 0 - return 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/app/cad_agent/evals/resume_one_step.py b/backend/app/cad_agent/evals/resume_one_step.py deleted file mode 100644 index 72456d84..00000000 --- a/backend/app/cad_agent/evals/resume_one_step.py +++ /dev/null @@ -1,90 +0,0 @@ -"""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/evals/single_stage.py b/backend/app/cad_agent/evals/single_stage.py new file mode 100644 index 00000000..a002032d --- /dev/null +++ b/backend/app/cad_agent/evals/single_stage.py @@ -0,0 +1,55 @@ +"""Aggregate local results for the `cad.single-stage.v1` evaluation suite. + +The evaluator accepts already-recorded task projections and never calls a +model. This keeps rate reporting reproducible and separates it from live +provider experiments. +""" +from __future__ import annotations + +from typing import Any, Iterable + + +def summarize(records: Iterable[dict[str, Any]]) -> dict[str, Any]: + """Return first-pass, final, compliance, and cost counters. + + A record may contain ``attempts`` with ``schema_valid`` and + ``executable`` booleans, final lifecycle/revision information, requirement + target states, usage records, and an elapsed duration in milliseconds. + Missing fields are reported as zero rather than guessed. + """ + values = [item for item in records if isinstance(item, dict)] + first_schema = first_executable = final_executable = 0 + targets: dict[str, int] = {key: 0 for key in ("pass", "fail", "pending", "not_applicable")} + calls = prompt_tokens = completion_tokens = duration_ms = 0 + for record in values: + attempts = record.get("attempts") if isinstance(record.get("attempts"), list) else [] + first = attempts[0] if attempts and isinstance(attempts[0], dict) else {} + first_schema += int(bool(first.get("schema_valid"))) + first_executable += int(bool(first.get("executable"))) + final_executable += int(bool(record.get("published_revision") or record.get("active_revision"))) + for target in record.get("requirement_targets") or (): + state = str(target.get("status") or "pending") if isinstance(target, dict) else "pending" + targets[state if state in targets else "pending"] += 1 + usage = record.get("usage") if isinstance(record.get("usage"), dict) else {} + usage_records = usage.get("records") if isinstance(usage.get("records"), list) else [] + calls += len(usage_records) + for item in usage_records: + if not isinstance(item, dict): + continue + prompt_tokens += int(item.get("prompt_tokens") or 0) + completion_tokens += int(item.get("completion_tokens") or 0) + duration_ms += int(record.get("duration_ms") or 0) + total = len(values) + rate = lambda count: count / total if total else 0.0 + return { + "schema_version": "cad.single-stage.eval-summary.v1", + "tasks": total, + "first_pass_schema_rate": rate(first_schema), + "first_pass_executable_rate": rate(first_executable), + "final_executable_rate": rate(final_executable), + "requirement_targets": targets, + "calls": calls, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "duration_ms": duration_ms, + } diff --git a/backend/app/cad_agent/evals/token_baseline.py b/backend/app/cad_agent/evals/token_baseline.py deleted file mode 100644 index b9f2a47b..00000000 --- a/backend/app/cad_agent/evals/token_baseline.py +++ /dev/null @@ -1,297 +0,0 @@ -"""Provenance-checked token baseline comparison for the v3 release gate. - -The target intentionally does not accept estimated tokens or a hand-written -percentage. A v2 run must record the same fixed requests and author request -configuration before it can be compared with a v3 release report. -""" - -from __future__ import annotations - -from hashlib import sha256 -import json -from pathlib import Path -from statistics import median -from typing import Any - -from app.settings import ProviderConfig, ProviderModel - - -BASELINE_SCHEMA_VERSION = "cad.token-baseline.v1" -BASELINE_PROTOCOL_VERSION = "2.0" -MINIMUM_REPETITIONS = 3 -TOKEN_REDUCTION_TARGET = 0.30 -MEASUREMENTS = {"prompt_tokens", "context_chars"} - - -class TokenBaselineError(ValueError): - """A baseline cannot prove the release token target.""" - - -def request_sha256(request: str) -> str: - return sha256(request.encode("utf-8")).hexdigest() - - -def profile_sha256(profile_path: Path) -> str: - try: - return sha256(profile_path.read_bytes()).hexdigest() - except OSError as error: - raise TokenBaselineError("Runtime profile is unavailable for token-baseline provenance.") from error - - -def author_request_identity(provider: ProviderConfig, model: ProviderModel) -> dict[str, Any]: - """Capture every author setting that can affect prompt-token comparison.""" - return { - "provider": provider.id, - "model": model.id, - "api_style": provider.api_style, - "reasoning_effort": provider.reasoning_effort, - # Chat Completions is explicitly deterministic in StructuredModelGateway. - # Responses models use their configured/provider-default sampling mode. - "sampling": {"temperature": 0 if provider.api_style == "chat_completions" else None}, - } - - -def load_token_baseline(path: Path) -> dict[str, Any]: - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise TokenBaselineError("Token baseline report is missing or is not valid JSON.") from error - if not isinstance(payload, dict): - raise TokenBaselineError("Token baseline report must be a JSON object.") - if payload.get("schema_version") != BASELINE_SCHEMA_VERSION: - raise TokenBaselineError(f"Token baseline must use {BASELINE_SCHEMA_VERSION}.") - if payload.get("protocol_version") != BASELINE_PROTOCOL_VERSION: - raise TokenBaselineError("Token baseline must be a measured pre-v3 (2.0) report.") - if payload.get("measurement") not in MEASUREMENTS: - raise TokenBaselineError("Token baseline measurement must be prompt_tokens or context_chars.") - return payload - - -def validate_token_baseline_provenance( - baseline: dict[str, Any], - *, - scenarios: list[dict[str, Any]], - author_identity: dict[str, Any], - runtime_profile_hash: str, -) -> list[str]: - """Reject an incomparable baseline before any billable v3 API call.""" - errors: list[str] = [] - if baseline.get("schema_version") != BASELINE_SCHEMA_VERSION: - errors.append(f"schema_version must equal {BASELINE_SCHEMA_VERSION}") - if baseline.get("protocol_version") != BASELINE_PROTOCOL_VERSION: - errors.append("protocol_version must equal 2.0") - if baseline.get("author") != author_identity: - errors.append("author provider/model/API mode/reasoning/sampling does not match the v3 release") - if baseline.get("runtime_profile_sha256") != runtime_profile_hash: - errors.append("runtime profile hash does not match the v3 release") - measurement = str(baseline.get("measurement") or "") - if measurement not in MEASUREMENTS: - errors.append("measurement must be prompt_tokens or context_chars") - baseline_scenarios = baseline.get("scenarios") - if not isinstance(baseline_scenarios, list): - return [*errors, "scenarios must be an array"] - by_scenario = { - str(item.get("scenario") or ""): item - for item in baseline_scenarios - if isinstance(item, dict) and item.get("scenario") - } - for scenario in scenarios: - scenario_id = str(scenario.get("id") or "") - expected_request_hash = request_sha256(str(scenario.get("request") or "")) - baseline_scenario = by_scenario.get(scenario_id) - if not isinstance(baseline_scenario, dict): - errors.append(f"missing baseline scenario {scenario_id}") - continue - if baseline_scenario.get("request_sha256") != expected_request_hash: - errors.append(f"baseline request hash differs for {scenario_id}") - repetitions = baseline_scenario.get("repetitions") - if not isinstance(repetitions, list): - errors.append(f"baseline {scenario_id} repetitions must be an array") - continue - by_repetition = { - int(item.get("repetition")): item - for item in repetitions - if isinstance(item, dict) and isinstance(item.get("repetition"), int) - } - expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1)) - if not expected_repetitions.issubset(by_repetition): - errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}") - continue - for repetition in expected_repetitions: - run = by_repetition[repetition] - if _non_negative_int(run.get("author_metric")) is None or _non_negative_int(run.get("plan_review_metric")) is None: - errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics") - for field in ("completed", "final_review_passed", "deterministic_claims_pass"): - if not isinstance(run.get(field), bool): - errors.append(f"baseline {scenario_id} repetition {repetition} has non-boolean {field}") - return errors - - -def compare_token_baseline( - baseline: dict[str, Any], - *, - scenarios: list[dict[str, Any]], - v3_results: list[dict[str, Any]], - author_identity: dict[str, Any], - runtime_profile_hash: str, -) -> dict[str, Any]: - """Return a complete, fail-closed comparison for the release report.""" - errors = validate_token_baseline_provenance( - baseline, scenarios=scenarios, author_identity=author_identity, - runtime_profile_hash=runtime_profile_hash, - ) - measurement = str(baseline.get("measurement") or "") - - baseline_scenarios = baseline.get("scenarios") - if not isinstance(baseline_scenarios, list): - errors.append("scenarios must be an array") - baseline_scenarios = [] - by_scenario = { - str(item.get("scenario") or ""): item - for item in baseline_scenarios - if isinstance(item, dict) and item.get("scenario") - } - current_by_scenario: dict[str, list[dict[str, Any]]] = {} - for result in v3_results: - if isinstance(result, dict) and isinstance(result.get("scenario"), str): - current_by_scenario.setdefault(result["scenario"], []).append(result) - - baseline_tokens: list[int] = [] - current_tokens: list[int] = [] - baseline_completed: list[bool] = [] - current_completed: list[bool] = [] - baseline_final_review: list[bool] = [] - current_final_review: list[bool] = [] - baseline_deterministic: list[bool] = [] - current_deterministic: list[bool] = [] - per_scenario: list[dict[str, Any]] = [] - - for scenario in scenarios: - scenario_id = str(scenario.get("id") or "") - expected_request_hash = request_sha256(str(scenario.get("request") or "")) - baseline_scenario = by_scenario.get(scenario_id) - if not isinstance(baseline_scenario, dict): - errors.append(f"missing baseline scenario {scenario_id}") - continue - if baseline_scenario.get("request_sha256") != expected_request_hash: - errors.append(f"baseline request hash differs for {scenario_id}") - continue - repetitions = baseline_scenario.get("repetitions") - if not isinstance(repetitions, list) or len(repetitions) < MINIMUM_REPETITIONS: - errors.append(f"baseline {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions") - continue - current = current_by_scenario.get(scenario_id, []) - if len(current) < MINIMUM_REPETITIONS: - errors.append(f"v3 {scenario_id} needs at least {MINIMUM_REPETITIONS} repetitions") - continue - baseline_by_repetition = { - int(item.get("repetition")) : item - for item in repetitions - if isinstance(item, dict) and isinstance(item.get("repetition"), int) - } - current_by_repetition = { - int(item.get("repetition")): item - for item in current - if isinstance(item.get("repetition"), int) - } - expected_repetitions = set(range(1, MINIMUM_REPETITIONS + 1)) - if not expected_repetitions.issubset(baseline_by_repetition): - errors.append(f"baseline {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}") - continue - if not expected_repetitions.issubset(current_by_repetition): - errors.append(f"v3 {scenario_id} is missing repetitions 1..{MINIMUM_REPETITIONS}") - continue - - scenario_baseline_tokens: list[int] = [] - scenario_current_tokens: list[int] = [] - for repetition in sorted(expected_repetitions): - baseline_run = baseline_by_repetition[repetition] - author_tokens = _non_negative_int(baseline_run.get("author_metric")) - plan_review_tokens = _non_negative_int(baseline_run.get("plan_review_metric")) - if author_tokens is None or plan_review_tokens is None: - errors.append(f"baseline {scenario_id} repetition {repetition} has invalid {measurement} metrics") - continue - current_run = current_by_repetition[repetition] - usage = current_run.get("usage") if isinstance(current_run.get("usage"), dict) else {} - v3_author_tokens = _v3_author_metric(usage, measurement) - if v3_author_tokens is None: - errors.append(f"v3 {scenario_id} repetition {repetition} has invalid author {measurement} usage") - continue - scenario_baseline_tokens.append(author_tokens + plan_review_tokens) - scenario_current_tokens.append(v3_author_tokens) - baseline_completed.append(bool(baseline_run.get("completed"))) - current_completed.append(str((current_run.get("projection") or {}).get("phase") or "") == "COMPLETED") - baseline_final_review.append(bool(baseline_run.get("final_review_passed"))) - current_final_review.append(str((current_run.get("terminal") or {}).get("lifecycle") or "") == "completed") - baseline_deterministic.append(bool(baseline_run.get("deterministic_claims_pass"))) - checks = current_run.get("checks") if isinstance(current_run.get("checks"), dict) else {} - current_deterministic.append(bool(checks.get("deterministic_claims_pass"))) - if len(scenario_baseline_tokens) == MINIMUM_REPETITIONS and len(scenario_current_tokens) == MINIMUM_REPETITIONS: - baseline_tokens.extend(scenario_baseline_tokens) - current_tokens.extend(scenario_current_tokens) - per_scenario.append({ - "scenario": scenario_id, - "baseline_median_metric": median(scenario_baseline_tokens), - "v3_median_author_metric": median(scenario_current_tokens), - }) - - baseline_median = median(baseline_tokens) if baseline_tokens else None - current_median = median(current_tokens) if current_tokens else None - reduction = (1 - current_median / baseline_median) if baseline_median and current_median is not None else None - baseline_valid = not errors - checks = { - "baseline_valid": baseline_valid, - "median_metric_reduction": baseline_valid and reduction is not None and reduction >= TOKEN_REDUCTION_TARGET, - "completion_rate_not_lower": baseline_valid and _rate(current_completed) >= _rate(baseline_completed) if baseline_completed and current_completed else False, - "final_review_rate_not_lower": baseline_valid and _rate(current_final_review) >= _rate(baseline_final_review) if baseline_final_review and current_final_review else False, - "deterministic_claim_rate_not_lower": baseline_valid and _rate(current_deterministic) >= _rate(baseline_deterministic) if baseline_deterministic and current_deterministic else False, - "plan_review_calls_removed": baseline_valid and all( - not str(record.get("tool") or "").startswith("modeling_plan") - for result in v3_results if isinstance(result, dict) - for record in ((result.get("usage") or {}).get("records") or []) - if isinstance(record, dict) - ), - } - return { - "schema_version": "cad.token-comparison.v1", - "baseline_protocol_version": baseline.get("protocol_version"), - "measurement": measurement, - "baseline_median_metric": baseline_median, - "v3_median_author_metric": current_median, - "median_metric_reduction": reduction, - "target_median_metric_reduction": TOKEN_REDUCTION_TARGET, - "baseline_completion_rate": _rate(baseline_completed), - "v3_completion_rate": _rate(current_completed), - "baseline_final_review_rate": _rate(baseline_final_review), - "v3_final_review_rate": _rate(current_final_review), - "baseline_deterministic_claim_rate": _rate(baseline_deterministic), - "v3_deterministic_claim_rate": _rate(current_deterministic), - "per_scenario": per_scenario, - "errors": errors, - "checks": checks, - } - - -def _non_negative_int(value: Any) -> int | None: - return value if isinstance(value, int) and value >= 0 else None - - -def _v3_author_metric(usage: dict[str, Any], measurement: str) -> int | None: - records = usage.get("records") - if not isinstance(records, list): - return None - author_records = [item for item in records if isinstance(item, dict) and item.get("role") != "reviewer"] - if not author_records: - return None - if measurement == "prompt_tokens" and not all(item.get("usage_available") is True for item in author_records): - return None - field = "prompt_tokens" if measurement == "prompt_tokens" else "context_chars" - values = [ - _non_negative_int(item.get(field)) - for item in author_records - ] - return sum(value for value in values if value is not None) if all(value is not None for value in values) else None - - -def _rate(values: list[bool]) -> float | None: - return sum(values) / len(values) if values else None diff --git a/backend/app/cad_agent/evals/usable_smoke.py b/backend/app/cad_agent/evals/usable_smoke.py deleted file mode 100644 index 39b9bda4..00000000 --- a/backend/app/cad_agent/evals/usable_smoke.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Run real-provider CAD tasks with a usable-model success criterion. - -This evaluator is intentionally narrower than the release gate in ``live.py``. -It answers one operational question: can the current workflow reliably finish -ordinary prompts with a downloadable/previewable checkpoint, even if some -acceptance claims remain best-effort warnings. -""" - -from __future__ import annotations - -import argparse -import asyncio -from dataclasses import replace -from datetime import datetime, timezone -import json -from pathlib import Path -import secrets -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 BACKEND_ROOT, get_settings - - -DEFAULT_PROMPTS = ( - "生成一个 80 mm x 50 mm x 8 mm 的简单矩形板,使用毫米,输出一个单一实体。", - "生成一个简单法兰,外径 100 mm,厚度 10 mm,中间有 30 mm 通孔,使用毫米。", - "生成一个圆柱垫块,直径 60 mm,高度 20 mm,中间有 20 mm 通孔,使用毫米。", -) - - -def _arguments() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Run real CAD generation prompts and require usable model artifacts.") - parser.add_argument("--author-provider") - parser.add_argument("--author-model") - parser.add_argument("--review-provider") - parser.add_argument("--review-model") - parser.add_argument("--prompt", action="append", help="Prompt to run. Repeat for multiple prompts. Defaults to three simple CAD prompts.") - parser.add_argument("--max-wall-seconds", type=int, default=1200) - parser.add_argument("--max-model-calls", type=int, default=60) - return parser.parse_args() - - -def _result_code(events: list[dict[str, Any]], projection: dict[str, Any]) -> str: - terminal = next((item["payload"] for item in reversed(events) if item.get("name") == "task_terminal"), {}) - for source in (terminal, projection): - code = source.get("code") or source.get("last_error") if isinstance(source, dict) else "" - if isinstance(code, str) and code: - return code - return "" - - -def _artifact_ok(artifact_root: Path, revision_id: str) -> bool: - if not revision_id: - return False - revision_root = artifact_root / "revisions" / revision_id - return all((revision_root / name).is_file() for name in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json")) - - -def _classify_failure(code: str, projection: dict[str, Any]) -> str: - if str(projection.get("lifecycle") or "") == "waiting_retry": - return "configuration_or_service" - if code in {"FAILED_INTERNAL", "RUNTIME_CONTRACT_INVALID", "REQUIREMENTS_SPEC_INVALID", "STORAGE_FAILURE", "RENDER_SERVICE_UNAVAILABLE"}: - return "code_or_flow" - if code in {"AUTHOR_TRANSPORT_UNAVAILABLE", "REVIEW_SERVICE_UNAVAILABLE", "MODEL_PROTOCOL_CHECK_PENDING"}: - return "configuration_or_service" - if code in {"AUTHOR_FORMAT_INVALID", "AUTHOR_DECISION_REJECTED", "CANDIDATE_BUILD_FAILED", "CLAIM_VERIFICATION_FAILED", "CANDIDATE_REVIEW_REJECTED", "NO_PROGRESS_LIMIT", "BEST_EFFORT_COMPLETED"}: - return "model_output_or_best_effort" - return "unknown" - - -async def _run(arguments: argparse.Namespace, report_root: Path) -> dict[str, Any]: - settings = get_settings() - try: - 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) - except ValueError as error: - return {"status": "blocked", "error": str(error), "results": []} - isolated = replace(settings, task_root=report_root / "artifacts", conversation_root=report_root / "conversations") - services = compose_v3(isolated) - services.workflow.config = replace( - services.workflow.config, - max_turns=max(8, arguments.max_model_calls * 3), - max_model_calls=arguments.max_model_calls, - ) - prompts = tuple(arguments.prompt or DEFAULT_PROMPTS) - results: list[dict[str, Any]] = [] - for index, prompt in enumerate(prompts, start=1): - task_id = f"cad_{secrets.token_hex(6)}" - services.workflow.create_task(task_id, prompt) - events: list[dict[str, Any]] = [] - started = datetime.now(timezone.utc) - try: - async with asyncio.timeout(arguments.max_wall_seconds): - async for name, payload in services.workflow.run( - task_id=task_id, - author=ModelIdentity(author_provider.id, author_model.id), - reviewer=ModelIdentity(review_provider.id, review_model.id), - ): - events.append({"name": name, "payload": payload}) - except TimeoutError: - events.append({"name": "timeout", "payload": {"code": "LIVE_EVAL_TIMEOUT", "message": "Task timed out."}}) - projection = services.repository.get_task_projection(task_id) or {} - artifact_root = (isolated.task_root / task_id).resolve() - revision_id = str(projection.get("active_revision") or projection.get("current_revision") or "") - code = _result_code(events, projection) - usable = str(projection.get("lifecycle") or "") == "completed" and _artifact_ok(artifact_root, revision_id) - results.append({ - "index": index, - "task_id": task_id, - "prompt": prompt, - "success": usable, - "failure_layer": "" if usable else _classify_failure(code, projection), - "code": code, - "phase": projection.get("phase"), - "lifecycle": projection.get("lifecycle"), - "active_revision": revision_id, - "verification_status": projection.get("verification_status"), - "artifact_root": str(artifact_root), - "duration_ms": round((datetime.now(timezone.utc) - started).total_seconds() * 1000), - "usage": services.repository.usage_summary(task_id), - "event_audit": [ - { - "name": item.get("name"), - "status": (item.get("payload") or {}).get("status"), - "lifecycle": (item.get("payload") or {}).get("lifecycle"), - "code": ((item.get("payload") or {}).get("result") or {}).get("code") if isinstance((item.get("payload") or {}).get("result"), dict) else (item.get("payload") or {}).get("code"), - "tool": (item.get("payload") or {}).get("tool"), - } - for item in events - ], - }) - return { - "status": "passed" if results and all(item["success"] for item in results) else "failed", - "schema_version": "cad.usable-smoke.v1", - "author": {"provider": author_provider.id, "model": author_model.id}, - "reviewer": {"provider": review_provider.id, "model": review_model.id}, - "results": results, - } - - -def main() -> int: - arguments = _arguments() - report_root = BACKEND_ROOT / "live-evals" / datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") - report_root.mkdir(parents=True, exist_ok=True) - try: - result = asyncio.run(_run(arguments, report_root)) - except KeyboardInterrupt: - raise - except BaseException as error: - result = {"status": "blocked", "error": f"UNEXPECTED_USABLE_SMOKE_ERROR: {type(error).__name__}: {str(error)[:1000]}", "results": []} - result.update({"report_root": str(report_root.resolve())}) - report_path = report_root / "usable-smoke-report.json" - report_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") - print(json.dumps({"status": result["status"], "report": str(report_path.resolve())}, ensure_ascii=False)) - return 0 if result["status"] == "passed" else 2 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/backend/app/cad_agent/ports.py b/backend/app/cad_agent/ports.py index dc88cff5..9672943b 100644 --- a/backend/app/cad_agent/ports.py +++ b/backend/app/cad_agent/ports.py @@ -5,60 +5,13 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any, Protocol -from .domain.state import TaskPhase, TaskState +from .domain.state import TaskState class AdapterUnavailable(RuntimeError): """A bounded external-service outage; handlers must preserve checkpoints.""" -@dataclass(frozen=True, slots=True) -class AuthorGuidanceSelection: - """Non-authoritative author context selected from the local guidance corpus.""" - - version: str = "" - section_ids: tuple[str, ...] = () - content: str = "" - enabled: bool = False - fallback_reason: str = "" - - def usage_metadata(self) -> dict[str, object]: - return { - "guidance_version": self.version, - "guidance_section_ids": list(self.section_ids), - "guidance_chars": len(self.content), - "guidance_enabled": self.enabled, - "guidance_fallback_reason": self.fallback_reason, - } - - -class AuthorGuidance(Protocol): - """Select bounded local author guidance without interpreting user intent.""" - - def select( - self, - *, - phase: TaskPhase, - atomic_id: str, - repair_required: bool, - supported_atomic_ids: tuple[str, ...], - ) -> AuthorGuidanceSelection: ... - - -class NullAuthorGuidance: - """Compatibility default that retains the pre-guidance author prompt.""" - - def select( - self, - *, - phase: TaskPhase, - atomic_id: str, - repair_required: bool, - supported_atomic_ids: tuple[str, ...], - ) -> AuthorGuidanceSelection: - return AuthorGuidanceSelection(fallback_reason="guidance_not_configured") - - @dataclass(frozen=True, slots=True) class InvocationRecord: invocation_id: str @@ -68,7 +21,7 @@ class InvocationRecord: @dataclass(frozen=True, slots=True) -class CandidateStage: +class StagingRevision: stage_id: str output_dir: str @@ -106,38 +59,21 @@ class TaskRepository(Protocol): class ArtifactStore(Protocol): def initialize_task(self, task_id: str, request: str, *, source_blocks: list[dict[str, Any]] | None = None, image_inputs: list[dict[str, str]] | None = None) -> None: ... - def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ... - def write_source_index(self, task_id: str, request: str) -> dict[str, str]: ... - def read_source_index(self, task_id: str) -> dict[str, str]: ... + def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str: ... def read_source_requirements(self, task_id: str) -> str: ... def source_image_paths(self, task_id: str) -> list[str]: ... - def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ... - def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None: ... - def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str: ... def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None: ... def write_json_once(self, task_id: str, relative_path: str, payload: dict[str, Any]) -> str: ... def write_text_once(self, task_id: str, relative_path: str, text: str) -> str: ... - def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ... - def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None: ... - def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage: ... - def stage_output_dir(self, task_id: str, stage_id: str) -> str: ... + def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision: ... def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str: ... - def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None: ... - def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ... - def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None: ... - def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None: ... + def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]: ... class CadRuntime(Protocol): def supported_atomic_ids(self) -> tuple[str, ...]: ... 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, 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 compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], 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]]]: ... @@ -146,13 +82,5 @@ class ModelGateway(Protocol): async def conformance(self, *, provider_id: str, model_id: str, tools: list[dict[str, Any]]) -> dict[str, Any]: ... -class ReviewGateway(Protocol): - async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]: ... - - -class VerifierExecutor(Protocol): - def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]: ... - - class EventPublisher(Protocol): async def publish(self, event: dict[str, Any]) -> None: ... diff --git a/backend/app/main.py b/backend/app/main.py index d9e025d0..b1321793 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,13 +7,11 @@ 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 from app.services.attachments import attachment_record, classify_upload, extract_document_text from app.services.image_processing import image_metadata -from app.services.review_renderer import renderer_status from app.settings import get_settings @@ -57,12 +55,6 @@ async def config() -> dict[str, Any]: for model in provider.models ], }) - try: - settings.resolve_review_model() - renderer_ready, renderer_detail = renderer_status() - review_error = "" if renderer_ready else renderer_detail - except ValueError as error: - review_error = str(error) return { "default_provider": settings.default_provider_id, "default_model": settings.llm_model, @@ -71,8 +63,6 @@ async def config() -> dict[str, Any]: "configured": settings.llm_configured, "library_samples": library.count(), "autonomous_generation": settings.autonomous_generation, - "review_configured": not review_error, - "review_error": review_error, } @@ -123,7 +113,7 @@ async def upload_conversation_attachment( if current is None: raise HTTPException(status_code=404, detail="Conversation not found") active_task_id = str(current.get("current_task_id") or "") - active_task = agent.v3.repository.get_task_projection(active_task_id) if active_task_id else None + active_task = agent.cad.repository.get_task_projection(active_task_id) if active_task_id else None if str((active_task or {}).get("lifecycle") or "") == "running": raise HTTPException(status_code=409, detail="CAD task is running; attachments are locked until it reaches a terminal state") kind = classify_upload(filename, file.content_type or "", len(data)) @@ -145,101 +135,25 @@ async def upload_conversation_attachment( async def read_task(task_id: str) -> JSONResponse: try: safe_id = safe_task_id(task_id) - task = agent.v3.repository.get_task_projection(safe_id) + task = agent.cad.repository.get_task_projection(safe_id) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error if task is None: raise HTTPException(status_code=404, detail="Task not found") - task["preview_revision"] = str(task.get("active_revision") or task.get("current_revision") or "") - state = agent.v3.repository.get_state(safe_id) - task["requirements_spec"] = agent.v3.artifacts.read_requirements_spec(safe_id, state.requirements_spec_path) if state is not None else None - task["requirements_contract"] = agent.v3.artifacts.read_requirements_contract(safe_id, state.requirements_contract_path) if state is not None else None - task["claim_summary"] = _claim_summary(task["requirements_contract"], task.get("action_ledger_summary")) - task["checklist_progress"] = _checklist_progress(task["requirements_contract"], task["claim_summary"]) - 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.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["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) + task["preview_revision"] = str(task.get("active_revision") or "") + state = agent.cad.repository.get_state(safe_id) + task["requirements_analysis"] = agent.cad.artifacts.read_json(safe_id, state.requirements_path) if state and state.requirements_path else None + task["authoring_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.authoring_path) if state and state.authoring_path else None + task["runtime_cdsl"] = agent.cad.artifacts.read_json(safe_id, state.runtime_cdsl_path) if state and state.runtime_cdsl_path else None + task["compile_audit"] = agent.cad.artifacts.read_json(safe_id, state.compile_audit_path) if state and state.compile_audit_path else None + task["diagnostics"] = agent.cad.artifacts.read_json(safe_id, state.diagnostics_path) if state and state.diagnostics_path else None + task["claim_report"] = agent.cad.artifacts.read_json(safe_id, "documents/claim-report.json") + result_path = agent.cad.artifacts.artifact_path(safe_id, state.completion_path) if state and state.completion_path else None + task["completion_result_markdown"] = result_path.read_text(encoding="utf-8") if result_path and result_path.is_file() else None + task["usage"] = agent.cad.repository.usage_summary(safe_id) return JSONResponse(task) -def _claim_summary(contract: dict[str, Any] | None, ledger: Any) -> list[dict[str, Any]]: - """Project frozen claims with the newest committed verification evidence. - - This is API-only data derived from SQLite-backed ledger entries and the - state-referenced frozen contract. It never parses Markdown or exposes a - staged candidate as a task checkpoint. - """ - 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", "feature_node_verified", "final_visual_reviewed"}: - continue - values = event.get("claim_results") - if not isinstance(values, list): - values = event.get("coverage") - if not isinstance(values, list): - continue - for value in values: - if isinstance(value, dict) and isinstance(value.get("claim_id"), str) and value["claim_id"] not in latest: - latest[value["claim_id"]] = value - result: list[dict[str, Any]] = [] - for requirement in (contract or {}).get("requirements") or (): - if not isinstance(requirement, dict): - continue - for claim in requirement.get("acceptance_claims") or (): - if not isinstance(claim, dict) or not isinstance(claim.get("claim_id"), str): - continue - evidence = latest.get(claim["claim_id"], {}) - result.append({ - "requirement_id": str(requirement.get("requirement_id") or ""), - "claim_id": claim["claim_id"], - "claim_kind": str(claim.get("claim_kind") or ""), - "deterministic": claim.get("verification_mode") == "deterministic", - "status": str(evidence.get("status") or "pending"), - "evidence": evidence.get("evidence") if isinstance(evidence.get("evidence"), dict) else {}, - }) - return result - - -def _checklist_progress(contract: dict[str, Any] | None, claims: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Expose server-evaluated checklist progress without parsing Markdown.""" - by_claim = {str(item.get("claim_id") or ""): str(item.get("status") or "pending") for item in claims if isinstance(item, dict)} - progress: list[dict[str, Any]] = [] - for requirement in (contract or {}).get("requirements") or (): - if not isinstance(requirement, dict): - continue - statuses = [by_claim.get(str(claim.get("claim_id") or ""), "pending") for claim in requirement.get("acceptance_claims") or () if isinstance(claim, dict)] - progress.append({ - "requirement_id": str(requirement.get("requirement_id") or ""), - "statement": str(requirement.get("statement") or ""), - "status": "pass" if statuses and all(status == "pass" for status in statuses) else "fail" if "fail" in statuses or "unavailable" in statuses else "pending", - }) - return progress - - @app.delete("/v1/tasks/{task_id}") async def cancel_task(task_id: str) -> JSONResponse: try: @@ -281,19 +195,19 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: try: safe_id = safe_task_id(task_id) - path = agent.v3.artifacts.artifact_path(safe_id, artifact_path) + path = agent.cad.artifacts.artifact_path(safe_id, artifact_path) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error if not path.is_file(): raise HTTPException(status_code=404, detail="Artifact not found") - task = agent.v3.repository.get_task_projection(safe_id) or {} + task = agent.cad.repository.get_task_projection(safe_id) or {} parts = artifact_path.split("/") revision_id = parts[1] if len(parts) >= 3 and parts[0] == "revisions" else "" - published_revision = str(task.get("active_revision") or "") if str(task.get("lifecycle") or "") == "completed" else "" + published_revision = str(task.get("published_revision") or "") active_revision = str(task.get("active_revision") or task.get("current_revision") or "") if not revision_id: - # The task directory also contains candidate staging, agent audit and - # frozen-input files. None of those are a public artifact surface. + if artifact_path == "completion-result.md" and str(task.get("completion_path") or "") == artifact_path: + return FileResponse(path, filename=path.name) raise HTTPException(status_code=403, detail="This task artifact is not public") if revision_id == published_revision: @@ -302,30 +216,15 @@ async def read_artifact(task_id: str, artifact_path: str) -> StreamingResponse: f"revisions/{revision_id}/model.step", f"revisions/{revision_id}/model.glb", f"revisions/{revision_id}/rebuild-report.json", + f"revisions/{revision_id}/build-diagnostics.json", + f"revisions/{revision_id}/renders/render-manifest.json", } if artifact_path not in published_paths: raise HTTPException(status_code=403, detail="Only final delivery artifacts are downloadable") return FileResponse(path, filename=path.name) - 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. + # A running task can render its active executable prefix, but its staging + # inputs and diagnostics remain private until publication. if ( str(task.get("lifecycle") or "") != "running" or revision_id != active_revision diff --git a/backend/app/services/agent_service.py b/backend/app/services/agent_service.py index 3d8b80c6..eeff302d 100644 --- a/backend/app/services/agent_service.py +++ b/backend/app/services/agent_service.py @@ -1,5 +1,4 @@ -"""HTTP/SSE delivery adapter for the protocol v3 workflow.""" - +"""HTTP/SSE delivery adapter for the single-stage CAD protocol.""" from __future__ import annotations import asyncio @@ -10,8 +9,7 @@ import secrets from typing import Any from app.cad_agent.application.workflow import ModelIdentity -from app.cad_agent.application.capabilities import cached_model_capability, verify_model_capability -from app.cad_agent.composition import V3Services, compose_v3 +from app.cad_agent.composition import CadServices, compose_cad_services from app.cad_agent.domain.errors import ErrorCode from app.cad_agent.domain.state import TaskPhase, transition from app.models.contracts import ChatMessage @@ -25,480 +23,174 @@ def text_from_message(message: ChatMessage) -> str: return "\n".join(part.text or "" for part in message.parts if part.type == "text").strip() -def _response_language(text: str) -> str: - cjk = sum(1 for char in text if "\u4e00" <= char <= "\u9fff") - latin = sum(1 for char in text if char.isascii() and char.isalpha()) - return "Chinese" if cjk >= 2 and cjk >= latin * 0.15 else "English" - - _EVENT_LABELS = { - "image_observation": "参考图片观察", - "requirements_document_ready": "需求文档已冻结", - "completion_target_ready": "完成目标已冻结", - "requirements_compiled": "需求合同已编译", - "modeling_plan_ready": "建模计划已冻结", - "completion_result_ready": "完成结果已就绪", - "model_protocol_check": "模型协议检查", - "action_selection": "动作选择", - "tool_call": "建模工具", - "candidate_result": "候选构建", - "candidate_review": "候选独立复核", - "final_review": "最终独立复核", + "requirements_ready": "需求分析", + "authoring_cdsl_ready": "完整 CDSL", + "cdsl_compiled": "CDSL 编译", + "build_result": "CAD 构建", + "repair_started": "CDSL 修复", "task_terminal": "生成任务", - "state_changed": "任务状态", } -def _visible_progress(name: str, payload: dict[str, Any]) -> dict[str, Any]: +def _progress(name: str, payload: dict[str, Any]) -> dict[str, Any]: lifecycle = str(payload.get("lifecycle") or "") - result = payload.get("result") if isinstance(payload.get("result"), dict) else {} - status = "error" if lifecycle == "failed" or str(payload.get("status") or "") == "error" or str(result.get("status") or "") in {"rejected", "repair"} else "waiting" if lifecycle in {"waiting_for_user", "waiting_retry"} else "success" if lifecycle == "completed" else str(payload.get("status") or "running") + status = "error" if lifecycle == "failed" or payload.get("status") in {"failed", "repair_required"} else "waiting" if lifecycle == "waiting_for_user" else "success" if lifecycle == "completed" else "running" return {**payload, "step": name, "label": _EVENT_LABELS.get(name, name), "status": status} -def _upsert_part(parts: list[dict[str, Any]], part: dict[str, Any]) -> None: - part_id = str(part.get("id") or "") - if part_id: - for index, existing in enumerate(parts): - if str(existing.get("id") or "") == part_id: - parts[index] = part - return - parts.append(part) - - class AgentService: - """Delivery boundary: no CAD state transitions or provider calls live here.""" - def __init__(self, settings: Settings, store: WorkspaceStore, library: CdslLibrary) -> None: - self.settings = settings - self.store = store # Conversation/attachment store, not v3 CAD state. - self.library = library - self.v3: V3Services = compose_v3(settings) + self.settings, self.store, self.library = settings, store, library + self.cad: CadServices = compose_cad_services(settings) self._autonomous_runs: dict[str, asyncio.Task[None]] = {} async def resume_running_tasks(self) -> None: - task_ids = self.v3.repository.running_task_ids() - try: - author_provider, author_model = self.settings.resolve_model(None, None) - review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) - except ValueError as error: - await self._park_startup_tasks( - task_ids, - ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED, - str(error), - retryable=False, - ) - return - try: - author_capability, reviewer_capability = await asyncio.gather( - verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=author_provider.id, - model_id=author_model.id, - role="author", - ), - verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=review_provider.id, - model_id=review_model.id, - role="reviewer", - ), - ) - except Exception as error: - # Startup recovery must never bypass a production capability gate. - # Connectivity failures are recoverable, but they may not leave a - # task falsely marked running without a worker. - await self._park_startup_tasks( - task_ids, - ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, - str(error), - retryable=True, - ) - return - if author_capability.get("probe_unavailable") or reviewer_capability.get("probe_unavailable"): - await self._park_startup_tasks( - task_ids, - ErrorCode.MODEL_PROTOCOL_CHECK_PENDING, - "Model protocol check is temporarily unavailable.", - retryable=True, - ) - return - if not author_capability.get("supported") or not reviewer_capability.get("supported"): - await self._park_startup_tasks( - task_ids, - ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED, - "Selected author or reviewer did not pass the v3 conformance suite.", - retryable=False, - ) - return if not self.settings.resume_running_tasks_on_startup: return - for task_id in task_ids: - if task_id in self._autonomous_runs: - continue - self._autonomous_runs[task_id] = asyncio.create_task( - self._consume_discarding(task_id, ModelIdentity(author_provider.id, author_model.id), ModelIdentity(review_provider.id, review_model.id)), - name=f"resume-autonomous-cad-v3-{task_id}", - ) - - async def _park_startup_tasks( - self, - task_ids: list[str], - error: ErrorCode, - message: str, - *, - retryable: bool, - ) -> None: - """Make a failed startup gate durable instead of leaving phantom runs.""" - event_name = "waiting_retry" if retryable else "failed_model_capability" - for task_id in task_ids: - state = self.v3.repository.get_state(task_id) - if state is None or task_id in self._autonomous_runs: - continue - try: - next_state = transition( - state, - "waiting_retry" if retryable else "failed", - error=error, - ) - except ValueError: - continue - if self.v3.repository.compare_and_swap(next_state, events=[{ - "event": event_name, - "code": error.value, - "message": message[:1000], - "startup_recovery": True, - }]): - await self.v3.outbox.dispatch_pending(task_id=task_id) - - async def _consume_discarding(self, task_id: str, author: ModelIdentity, reviewer: ModelIdentity) -> None: try: - async for _name, _payload in self.v3.workflow.run(task_id=task_id, author=author, reviewer=reviewer): - # A resumed task has no active SSE client, but its durable - # state events must still leave the transactional outbox. - await self.v3.outbox.dispatch_pending(task_id=task_id) + provider, model = self.settings.resolve_model(None, None) + except ValueError: + return + author = ModelIdentity(provider.id, model.id) + for task_id in self.cad.repository.running_task_ids(): + if task_id not in self._autonomous_runs: + self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, author), name=f"resume-cad-single-stage-{task_id}") + + async def _consume_discarding(self, task_id: str, author: ModelIdentity) -> None: + try: + async for _name, _payload in self.cad.workflow.run(task_id=task_id, author=author): + await self.cad.outbox.dispatch_pending(task_id=task_id) finally: - # Flush a final transition emitted immediately before the worker - # exits, such as WAITING_RETRY or FAILED_INTERNAL. - await self.v3.outbox.dispatch_pending(task_id=task_id) + await self.cad.outbox.dispatch_pending(task_id=task_id) self._autonomous_runs.pop(task_id, None) async def cancel(self, task_id: str) -> dict[str, Any] | None: - state = self.v3.repository.get_state(task_id) + state = self.cad.repository.get_state(task_id) if state is None: return None if state.phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}: cancelled = transition(state, "cancelled", error=ErrorCode.CANCELLED) - if self.v3.repository.compare_and_swap(cancelled, events=[{ - "event": "task_cancelled", - "from_phase": state.phase.value, - "active_revision": state.active_revision, - "checkpoint_preserved": bool(state.active_revision), - }]): - await self.v3.outbox.dispatch_pending(task_id=task_id) - # Persist the terminal transition before interrupting the coroutine. - # A concurrent worker will fail its optimistic CAS instead of reviving - # the task after the caller has requested cancellation. + self.cad.repository.compare_and_swap(cancelled, events=[{"event": "task_cancelled", "active_revision": state.active_revision}]) + await self.cad.outbox.dispatch_pending(task_id=task_id) running = self._autonomous_runs.pop(task_id, None) if running and not running.done(): running.cancel() - return self.v3.repository.get_task_projection(task_id) + return self.cad.repository.get_task_projection(task_id) async def resume_retry(self, task_id: str) -> dict[str, Any] | None: - """Explicitly restart one durably parked infrastructure retry. - - ``WAITING_FOR_USER`` is deliberately excluded: it requires new user - input, while this endpoint is only the controlled recovery route for - bounded provider/render/storage failures. - """ - state = self.v3.repository.get_state(task_id) + state = self.cad.repository.get_state(task_id) if state is None: return None - if state.phase.value != "WAITING_RETRY": - raise ValueError("Only a WAITING_RETRY CAD task can be resumed through this endpoint") - running = self._autonomous_runs.get(task_id) - if running is not None and not running.done(): + if state.phase != TaskPhase.FAILED or state.retry_from_phase is None: + raise ValueError("Only a retryable failed CAD task can be resumed") + if task_id in self._autonomous_runs and not self._autonomous_runs[task_id].done(): raise ValueError("The CAD task is already running") - author_provider, author_model = self.settings.resolve_model(None, None) - review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) - author_capability = await verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=author_provider.id, - model_id=author_model.id, - role="author", - ) - reviewer_capability = await verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=review_provider.id, - model_id=review_model.id, - role="reviewer", - ) - if not author_capability.get("supported") or not reviewer_capability.get("supported"): - raise ValueError("MODEL_STRUCTURED_OUTPUT_UNSUPPORTED: selected author or reviewer did not pass the v3 conformance suite") - if not self.v3.workflow.resume(task_id): - raise ValueError("The CAD task no longer has a recoverable retry checkpoint") - self._autonomous_runs[task_id] = asyncio.create_task( - self._consume_discarding( - task_id, - ModelIdentity(author_provider.id, author_model.id), - ModelIdentity(review_provider.id, review_model.id), - ), - name=f"resume-autonomous-cad-v3-{task_id}", - ) - return self.v3.repository.get_task_projection(task_id) + provider, model = self.settings.resolve_model(None, None) + if not self.cad.workflow.resume(task_id): + raise ValueError("The CAD task no longer has a retry checkpoint") + self._autonomous_runs[task_id] = asyncio.create_task(self._consume_discarding(task_id, ModelIdentity(provider.id, model.id)), name=f"resume-cad-single-stage-{task_id}") + return self.cad.repository.get_task_projection(task_id) - async def stream( - self, - messages: list[ChatMessage], - conversation_id: str | None, - selected_task_id: str | None, - provider_id: str | None = None, - model_id: str | None = None, - viewer_context: list[dict[str, Any]] | None = None, - ) -> AsyncIterator[bytes]: + async def stream(self, messages: list[ChatMessage], conversation_id: str | None, selected_task_id: str | None, provider_id: str | None = None, model_id: str | None = None, viewer_context: list[dict[str, Any]] | None = None) -> AsyncIterator[bytes]: del viewer_context - latest_user = next((message for message in reversed(messages) if message.role == "user"), None) - if latest_user is None or not text_from_message(latest_user): + latest = next((item for item in reversed(messages) if item.role == "user"), None) + if latest is None or not text_from_message(latest): yield event("cad_error", {"stage": "request", "message": "A non-empty user request is required."}) yield event("done", {}) return - request = text_from_message(latest_user) + request = text_from_message(latest) conversation = self.store.ensure_conversation(conversation_id) selected = str(selected_task_id or conversation.get("current_task_id") or "") - current = self.v3.repository.get_task_projection(selected) if selected else None - resumed_task_id = "" - if current and str(current.get("lifecycle") or "") == "waiting_for_user": - state = self.v3.repository.get_state(selected) - if state is not None: - terminal = self.v3.workflow.waiting_for_user_terminal(selected, state) - fields = [ - {"path": "/requirements/clarification", "message": str(question)} - for question in terminal.get("questions") or () - if str(question).strip() - ] - fields.extend( - {"path": "/requirements", "message": str(issue)} - for issue in terminal.get("issues") or () - if str(issue).strip() - ) - if not self.v3.workflow.resume_with_user_clarification(selected, request, message_id=latest_user.id): - yield event("cad_error", { - "stage": "request", - "message": "This parked CAD task cannot apply the supplied clarification. " + str(terminal["message"]), - "fieldErrors": fields, - "taskId": selected, - "blockerType": terminal.get("blockerType"), - "userActionRequired": terminal.get("userActionRequired"), - }) - yield event("done", {}) - return - resumed_task_id = selected - if current and str(current.get("lifecycle") or "") == "running": - yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成或失败前不能继续对话。"}) + current = self.cad.repository.get_task_projection(selected) if selected else None + resumed = False + if current and current.get("lifecycle") == "waiting_for_user": + resumed = self.cad.workflow.resume_with_user_clarification(selected, request, message_id=latest.id) + if not resumed: + yield event("cad_error", {"stage": "request", "message": "The CAD task cannot apply this clarification."}) + yield event("done", {}) + return + if current and current.get("lifecycle") == "running": + yield event("cad_error", {"stage": "request", "message": "该 CAD 任务正在生成,完成后才能继续。"}) yield event("done", {}) return - # A terminal task is immutable; follow-up text creates a new task. - task_id = resumed_task_id or f"cad_{secrets.token_hex(6)}" - if not resumed_task_id: + task_id = selected if resumed else f"cad_{secrets.token_hex(6)}" + if not resumed: try: source_blocks, image_inputs = self._task_inputs(conversation, request) - self.v3.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) + self.cad.workflow.create_task(task_id, request, source_blocks=source_blocks, image_inputs=image_inputs) except ValueError as error: yield event("cad_error", {"stage": "request", "message": str(error)}) yield event("done", {}) return - conversation = self.store.append_conversation_message(conversation["conversation_id"], latest_user.model_dump(), task_id) - yield event("progress", {"taskId": task_id, "step": "task_started", "label": "Agent", "status": "running", "message": "已应用补充说明并恢复 CAD 任务。" if resumed_task_id else "CAD 任务已启动。" if _response_language(request) == "Chinese" else "CAD task started."}) - + self.store.append_conversation_message(conversation["conversation_id"], latest.model_dump(), task_id) + yield event("progress", _progress("task_started", {"taskId": task_id, "message": "CAD task started."})) try: - author_provider, author_model = self.settings.resolve_model(provider_id, model_id) - review_provider, review_model = self.settings.resolve_independent_review_model(author_provider, author_model) + provider, model = self.settings.resolve_model(provider_id, model_id) except ValueError as error: - state = self.v3.repository.get_state(task_id) - if state is not None: + state = self.cad.repository.get_state(task_id) + if state: failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) - self.v3.repository.compare_and_swap(failed, events=[{ - "event": "model_configuration_invalid", - "message": str(error)[:1000], - "issues": [str(error)[:1000]], - }]) - terminal = {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": str(error), "userActionRequired": False} - yield event("task_terminal", terminal) + self.cad.repository.compare_and_swap(failed, events=[{"event": "model_configuration_invalid", "message": str(error)}]) yield event("cad_error", {"stage": "configuration", "message": str(error)}) yield event("done", {}) return - + author = ModelIdentity(provider.id, model.id) queue: asyncio.Queue[tuple[str, dict[str, Any]] | None] = asyncio.Queue() parts: list[dict[str, Any]] = [] - sequence = 0 async def consume() -> None: - nonlocal sequence - - async def dispatch_state_events() -> None: - """Publish state notifications only through the outbox. - - The dispatcher preserves at-least-once semantics. The SSE - event ID derives from the durable outbox row, so reconnecting - clients can safely de-duplicate a delivery replay. - """ - nonlocal sequence - for outbox_event in await self.v3.outbox.dispatch_pending(task_id=task_id): - sequence += 1 - event_name = str(outbox_event.get("event") or "state_changed") - payload = { - "taskId": task_id, - "eventId": f"outbox_{outbox_event['event_id']}", - "sequence": sequence, - "timestamp": now_iso(), - "outboxEvent": event_name, - "message": event_name.replace("_", " "), - } - await queue.put(("progress", _visible_progress("state_changed", payload))) - try: - capability_terminal = await self._ensure_task_capabilities( - task_id, - ModelIdentity(author_provider.id, author_model.id), - ModelIdentity(review_provider.id, review_model.id), - queue, - ) - if capability_terminal is not None: - sequence += 1 - capability_terminal = {**capability_terminal, "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso()} - _upsert_part(parts, {"type": "data-cad-progress", "id": capability_terminal["eventId"], "data": _visible_progress("task_terminal", capability_terminal)}) - await queue.put(("task_terminal", capability_terminal)) - return - async for name, payload in self.v3.workflow.run( - task_id=task_id, - author=ModelIdentity(author_provider.id, author_model.id), - reviewer=ModelIdentity(review_provider.id, review_model.id), - ): - sequence += 1 - decorated = {**payload, "taskId": task_id, "eventId": str(payload.get("eventId") or f"{task_id}_{sequence}_{name}"), "sequence": sequence, "timestamp": now_iso()} - _upsert_part(parts, {"type": "data-cad-progress", "id": decorated["eventId"], "data": _visible_progress(name, decorated)}) - if name == "task_terminal" and str(decorated.get("lifecycle") or "") == "failed": - _upsert_part(parts, {"type": "data-cad-error", "id": f"{decorated['eventId']}_error", "data": { - "stage": "generation", - "message": str(decorated.get("message") or "CAD autonomous generation failed."), - "tool": str(decorated.get("tool") or ""), - "fieldErrors": decorated.get("field_errors") if isinstance(decorated.get("field_errors"), list) else [], - }}) + async for name, payload in self.cad.workflow.run(task_id=task_id, author=author): + decorated = {**payload, "taskId": task_id, "eventId": f"{task_id}_{secrets.token_hex(4)}", "timestamp": now_iso()} + parts.append({"type": "data-cad-progress", "id": decorated["eventId"], "data": _progress(name, decorated)}) await queue.put((name, decorated)) - await dispatch_state_events() - except Exception as error: - sequence += 1 - terminal = {"taskId": task_id, "lifecycle": "failed", "code": "FAILED_INTERNAL", "message": str(error)[:1000], "eventId": f"{task_id}_{sequence}_task_terminal", "sequence": sequence, "timestamp": now_iso(), "userActionRequired": False} - _upsert_part(parts, {"type": "data-cad-progress", "id": terminal["eventId"], "data": _visible_progress("task_terminal", terminal)}) - _upsert_part(parts, {"type": "data-cad-error", "id": f"{terminal['eventId']}_error", "data": {"stage": "generation", "message": terminal["message"], "fieldErrors": []}}) - await queue.put(("task_terminal", terminal)) + await self.cad.outbox.dispatch_pending(task_id=task_id) + if name == "task_terminal" and decorated.get("lifecycle") == "completed": + projection = self.cad.repository.get_task_projection(task_id) or {} + result = self._result_payload(task_id, projection) + if result is not None: + await queue.put(("cad_result", result)) finally: self.store.append_conversation_message(conversation["conversation_id"], {"id": f"assistant_{secrets.token_hex(8)}", "role": "assistant", "parts": parts}, task_id) self._autonomous_runs.pop(task_id, None) await queue.put(None) - self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"autonomous-cad-v3-{task_id}") + self._autonomous_runs[task_id] = asyncio.create_task(consume(), name=f"cad-single-stage-{task_id}") while True: - try: - item = await asyncio.wait_for(queue.get(), timeout=15) - except asyncio.TimeoutError: - yield event("heartbeat", {"taskId": task_id, "timestamp": now_iso()}) - continue + item = await queue.get() if item is None: break name, payload = item - if name == "task_terminal" and str(payload.get("lifecycle") or "") == "failed": - yield event("cad_error", { - "stage": "generation", - "message": str(payload.get("message") or "CAD autonomous generation failed."), - "tool": str(payload.get("tool") or ""), - "fieldErrors": payload.get("field_errors") if isinstance(payload.get("field_errors"), list) else [], - }) + if name == "task_terminal" and payload.get("lifecycle") == "failed": + yield event("cad_error", {"stage": "generation", "message": str(payload.get("message") or "CAD generation failed."), "code": payload.get("code")}) yield event(name, payload) yield event("done", {}) - async def _ensure_task_capabilities( - self, - task_id: str, - author: ModelIdentity, - reviewer: ModelIdentity, - queue: asyncio.Queue[tuple[str, dict[str, Any]] | None], - ) -> dict[str, Any] | None: - roles = (("author", author), ("reviewer", reviewer)) - cached = { - role: cached_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - provider_id=model.provider_id, - model_id=model.model_id, - role=role, - ) - for role, model in roles - } - unsupported = [role for role, result in cached.items() if result is not None and not result.get("supported")] - if unsupported: - return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}") - missing = [(role, model) for role, model in roles if cached[role] is None] - if not missing: + @staticmethod + def _result_payload(task_id: str, projection: dict[str, Any]) -> dict[str, Any] | None: + revision_id = str(projection.get("published_revision") or projection.get("active_revision") or "") + revisions = projection.get("revisions") if isinstance(projection.get("revisions"), list) else [] + revision = next((item for item in revisions if isinstance(item, dict) and item.get("revision_id") == revision_id), None) + if not isinstance(revision, dict): return None - - state = self.v3.repository.get_state(task_id) - if state is None: - return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STORAGE_FAILURE.value, "message": "Task state is unavailable.", "userActionRequired": False} - waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) - if not self.v3.repository.compare_and_swap(waiting, events=[{ - "event": "model_protocol_check_pending", - "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, - "message": "模型协议检查中,完成后将自动继续。", - }]): - return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.STALE_WORKING_HEAD.value, "message": "Task state changed before the model protocol check started.", "userActionRequired": False} - await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "waiting", "lifecycle": "waiting_retry", "message": "模型协议检查中,完成后将自动继续。"})) - try: - results = await asyncio.gather(*( - verify_model_capability( - self.v3.repository, - self.v3.workflow.runtime, - self.v3.models, - provider_id=model.provider_id, - model_id=model.model_id, - role=role, - ) - for role, model in missing - )) - except Exception as error: - return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用:{str(error)[:500]}", "userActionRequired": False} - unavailable = [role for (role, _model), result in zip(missing, results, strict=True) if result.get("probe_unavailable")] - if unavailable: - return {"taskId": task_id, "lifecycle": "waiting_retry", "code": ErrorCode.MODEL_PROTOCOL_CHECK_PENDING.value, "message": f"模型协议检查暂时不可用({', '.join(unavailable)}),可稍后重试。", "userActionRequired": False} - unsupported = [role for (role, _model), result in zip(missing, results, strict=True) if not result.get("supported")] - if unsupported: - return self._fail_capability(task_id, f"Model protocol is unsupported for role(s): {', '.join(unsupported)}") - if not self.v3.workflow.resume(task_id): - return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.FAILED_INTERNAL.value, "message": "Model protocol check completed but the task could not resume.", "userActionRequired": False} - await queue.put(("progress", {"taskId": task_id, "step": "model_protocol_check", "label": _EVENT_LABELS["model_protocol_check"], "status": "success", "lifecycle": "running", "message": "模型协议检查完成,继续生成。"})) - return None - - def _fail_capability(self, task_id: str, message: str) -> dict[str, Any]: - state = self.v3.repository.get_state(task_id) - if state is not None and state.phase not in {TaskPhase.FAILED, TaskPhase.COMPLETED, TaskPhase.CANCELLED}: - failed = transition(state, "failed", error=ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED) - self.v3.repository.compare_and_swap(failed, events=[{ - "event": "model_protocol_unsupported", - "message": message, - "issues": [message], - }]) - return {"taskId": task_id, "lifecycle": "failed", "code": ErrorCode.MODEL_STRUCTURED_OUTPUT_UNSUPPORTED.value, "message": message, "userActionRequired": False} + required = ("cdsl_path", "step_path", "glb_path", "report_path") + if not all(isinstance(revision.get(path), str) and revision[path] for path in required): + return None + return { + "taskId": task_id, + "revisionId": revision_id, + "cdslPath": revision["cdsl_path"], + "stepPath": revision["step_path"], + "glbPath": revision["glb_path"], + "reportPath": revision["report_path"], + "summary": str(revision.get("summary") or "CDSL CAD model"), + "referenceIds": list(revision.get("reference_ids") or []), + "engine": str(revision.get("engine") or "cdsl_only"), + "lifecycle": str(projection.get("lifecycle") or "completed"), + } def _task_inputs(self, conversation: dict[str, Any], request: str) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: - """Freeze message paragraphs and attachment blocks before task creation.""" blocks = [{"text": paragraph} for paragraph in re.split(r"\n\s*\n", request) if paragraph.strip()] image_inputs: list[dict[str, str]] = [] conversation_id = str(conversation.get("conversation_id") or "") @@ -509,51 +201,24 @@ class AgentService: continue if str(attachment.get("conversation_id") or "") != conversation_id: raise ValueError("Attachment does not belong to this conversation") - attachment_id = str(attachment.get("id") or "") - relative_path = str(attachment.get("path") or "") - expected_digest = str(attachment.get("sha256") or "") - if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", expected_digest): + attachment_id, relative_path, digest = str(attachment.get("id") or ""), str(attachment.get("path") or ""), str(attachment.get("sha256") or "") + if not attachment_id or not relative_path or not re.fullmatch(r"[a-f0-9]{64}", digest): raise ValueError("Attachment metadata is incomplete") - binary_path = self.store.conversation_attachment_path(conversation_id, relative_path) - if not binary_path.is_file(): - raise ValueError(f"Attachment is missing: {attachment.get('name') or attachment_id}") - if sha256(binary_path.read_bytes()).hexdigest() != expected_digest: - raise ValueError(f"Attachment checksum mismatch: {attachment.get('name') or attachment_id}") + binary = self.store.conversation_attachment_path(conversation_id, relative_path) + if not binary.is_file() or sha256(binary.read_bytes()).hexdigest() != digest: + raise ValueError(f"Attachment is unavailable: {attachment.get('name') or attachment_id}") kind = str(attachment.get("kind") or "") if kind == "document": - extracted_path = str(attachment.get("extracted_path") or "") - if not extracted_path: - raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}") - text_path = self.store.conversation_attachment_path(conversation_id, extracted_path) + text_path = self.store.conversation_attachment_path(conversation_id, str(attachment.get("extracted_path") or "")) if not text_path.is_file(): - raise ValueError(f"Attachment text is missing: {attachment.get('name') or attachment_id}") + raise ValueError(f"Attachment text is unavailable: {attachment.get('name') or attachment_id}") text = text_path.read_text(encoding="utf-8", errors="replace").strip() elif kind == "image": - # The binary is integrity-checked above. Do not claim that a - # text-only author has interpreted visual content; the source - # block is still a stable, reviewable attachment reference. - text = ( - f"Visual attachment {attachment.get('name') or attachment_id} " - f"(SHA-256 {expected_digest}, MIME {attachment.get('mime') or 'image/*'}). " - "It is a visual reference and requires explicit visual verification." - ) - image_inputs.append({ - "path": str(binary_path), - "mime": str(attachment.get("mime") or "image/*"), - "sha256": expected_digest, - }) + text = f"Visual attachment {attachment.get('name') or attachment_id} (SHA-256 {digest})." + image_inputs.append({"path": str(binary), "mime": str(attachment.get("mime") or "image/*"), "sha256": digest}) else: raise ValueError(f"Unsupported attachment kind: {kind or 'unknown'}") if not text: raise ValueError(f"Attachment source is empty: {attachment.get('name') or attachment_id}") - blocks.append({ - "text": text, - "attachment": { - "attachment_id": attachment_id, - "name": str(attachment.get("name") or attachment_id), - "kind": kind, - "mime": str(attachment.get("mime") or "application/octet-stream"), - "sha256": expected_digest, - }, - }) + blocks.append({"text": text, "attachment": {"attachment_id": attachment_id, "name": str(attachment.get("name") or attachment_id), "kind": kind, "mime": str(attachment.get("mime") or "application/octet-stream"), "sha256": digest}}) return blocks, image_inputs diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py index 2ea2d5a1..78f9a1fd 100644 --- a/backend/app/services/engine_service.py +++ b/backend/app/services/engine_service.py @@ -47,7 +47,7 @@ def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]: except (OSError, json.JSONDecodeError) as error: raise RuntimeError("The local engine schema document is unavailable or invalid") from error if not isinstance(schema, dict) or not isinstance(schema.get("operation_contracts"), dict): - raise RuntimeError("The local engine schema has no v3 operation contract registry") + raise RuntimeError("The local engine schema has no operation contract registry") return schema @@ -108,10 +108,11 @@ def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None: raise ValueError(f"CDSL schema violation at {location}: {error.message}") -def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: +def validate_cdsl_shape(cdsl: dict[str, Any], engine: Any) -> None: + """Validate the static Runtime CDSL contract without resolving topology.""" if not isinstance(cdsl, dict): raise ValueError("CDSL must be a JSON object") - if cdsl.get("schema") != "cad.cdsl.llm.v1": + if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}: raise ValueError("Unsupported CDSL schema") _validate_cdsl_json_schema(cdsl, engine) part_id = str(cdsl.get("part_id") or "") @@ -125,6 +126,14 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: sketches = cdsl.get("geometry", {}).get("sketches") if not isinstance(features, list) or not features or not isinstance(sketches, list): raise ValueError("CDSL requires a feature list and a geometry.sketches array") + if cdsl.get("schema") == "cad.runtime.v1": + bodies = cdsl.get("bodies") + if not isinstance(bodies, list) or not bodies: + raise ValueError("Runtime CDSL requires server-assigned bodies") + body_ids = [str(body.get("id") or "") for body in bodies if isinstance(body, dict)] + body_names = [str(body.get("name") or "") for body in bodies if isinstance(body, dict)] + if len(body_ids) != len(bodies) or len(body_ids) != len(set(body_ids)) or len(body_names) != len(set(body_names)): + raise ValueError("Runtime CDSL bodies must have unique server IDs and local names") sketch_ids = {str(sketch.get("id")) for sketch in sketches} semantic_contract = _engine_schema(engine) try: @@ -185,6 +194,11 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: raise ValueError("Polygon profiles require vertices") elif profile_type not in engine.SHAPE_GENERATORS: raise ValueError(f"Unsupported CDSL profile: {profile_type}") + + +def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None: + """Validate static CDSL and the current executable topology semantics.""" + validate_cdsl_shape(cdsl, engine) try: analysis = engine.analyze_cdsl(copy.deepcopy(cdsl)) except Exception as error: diff --git a/backend/app/services/review_renderer.py b/backend/app/services/render_bundle.py similarity index 91% rename from backend/app/services/review_renderer.py rename to backend/app/services/render_bundle.py index 1c57d6cc..47eb8415 100644 --- a/backend/app/services/review_renderer.py +++ b/backend/app/services/render_bundle.py @@ -1,8 +1,8 @@ -"""Deterministic, CPU-only CAD technical renders for visual review. +"""Deterministic, CPU-only CAD technical render bundles. OpenCascade computes exact visible/hidden edges from the revision STEP file. Pillow rasterizes the resulting technical drawings. Neither stage needs a web -browser, OpenGL, a desktop session, nor a GPU, which keeps review evidence +browser, OpenGL, a desktop session, nor a GPU, which keeps published artifacts consistent on macOS, Linux, and Windows workers. """ @@ -26,7 +26,7 @@ VISIBLE_EDGE_RGB = (34, 54, 69) HIDDEN_EDGE_RGB = (142, 157, 170) -class ReviewRenderError(RuntimeError): +class RenderBundleError(RuntimeError): """The fixed-view renderer was unavailable or produced incomplete evidence.""" @@ -34,7 +34,7 @@ def renderer_status() -> tuple[bool, str]: """Verify that the pure-Python/OCC renderer dependencies are importable.""" try: _render_modules() - except ReviewRenderError as error: + except RenderBundleError as error: return False, str(error) return True, "" @@ -45,7 +45,7 @@ def _render_modules() -> tuple[Any, Any, Any]: pillow_draw = importlib.import_module("PIL.ImageDraw") import_step = importlib.import_module("build123d").import_step except (ImportError, AttributeError) as error: - raise ReviewRenderError( + raise RenderBundleError( "Python technical renderer is unavailable; install backend requirements (build123d and Pillow)" ) from error return pillow_image, pillow_draw, import_step @@ -73,7 +73,7 @@ def _shape_bounds(shape: Any) -> list[float]: box = shape.bounding_box() bounds = [float(box.min.X), float(box.max.X), float(box.min.Y), float(box.max.Y), float(box.min.Z), float(box.max.Z)] if not all(math.isfinite(value) for value in bounds): - raise ReviewRenderError("STEP review source has invalid bounds") + raise RenderBundleError("STEP render source has invalid bounds") return bounds @@ -128,7 +128,7 @@ def _edge_points(edge: Any, spacing: float) -> list[tuple[float, float]]: def _projected_bounds(edges: list[Any]) -> tuple[float, float, float, float]: points = [point for edge in edges for point in _edge_points(edge, 0.5)] if not points: - raise ReviewRenderError("Hidden-line projection produced no drawable edges") + raise RenderBundleError("Hidden-line projection produced no drawable edges") xs, ys = zip(*points) return min(xs), max(xs), min(ys), max(ys) @@ -242,7 +242,7 @@ def _render_view( camera["position"], viewport_up=camera["view_up"], look_at=camera["focal_point"] ) except Exception as error: - raise ReviewRenderError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error + raise RenderBundleError(f"OpenCascade hidden-line projection failed for {view_id}: {error}") from error visible_edges, hidden_edges = list(visible), list(hidden) frame = _frame_bounds([*visible_edges, *hidden_edges], target_extent) rendered = _rasterize( @@ -254,12 +254,12 @@ def _render_view( intentional_crop=target_extent > 0, ) if not rendered["diagnostics"]["valid"]: - raise ReviewRenderError(f"Review render quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}") + raise RenderBundleError(f"Render bundle quality check failed for {view_id}: {json.dumps(rendered['diagnostics'], ensure_ascii=False)}") return {"id": view_id, "camera": {**camera, "view": projection_id, "frame_mm": list(frame)}, "target": target, **rendered} def _contact_sheet(views: list[dict[str, Any]], output_dir: Path) -> str: - """Create compact whole-model evidence for routine reviewer calls.""" + """Create compact whole-model images for published CAD artifacts.""" pillow_image, pillow_draw, _ = _render_modules() canonical = [item for item in views if item["id"] in CANONICAL_VIEWS] if not canonical: @@ -283,27 +283,27 @@ def render_checkpoint( *, step_path: Path, output_dir: Path, - review_targets: list[dict[str, Any]] | None = None, + detail_targets: list[dict[str, Any]] | None = None, include_canonical: bool = True, ) -> dict[str, Any]: """Render STEP geometry into stable canonical and bounded node-detail views.""" del settings ready, detail = renderer_status() if not ready: - raise ReviewRenderError(detail) + raise RenderBundleError(detail) if not step_path.is_file(): - raise ReviewRenderError(f"STEP review source is missing: {step_path.name}") + raise RenderBundleError(f"STEP render source is missing: {step_path.name}") _, _, import_step = _render_modules() try: shape = import_step(str(step_path)) except Exception as error: - raise ReviewRenderError(f"Unable to read STEP review source: {error}") from error + raise RenderBundleError(f"Unable to read STEP render source: {error}") from error bounds = _shape_bounds(shape) output_dir.mkdir(parents=True, exist_ok=True) jobs: list[tuple[str, dict[str, Any] | None]] = [] if include_canonical: jobs.extend((view_id, None) for view_id in CANONICAL_VIEWS) - jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((review_targets or [])[:3])) + jobs.extend((f"detail-{index + 1}", target) for index, target in enumerate((detail_targets or [])[:3])) views = [ _render_view( shape=shape, @@ -317,14 +317,14 @@ def render_checkpoint( ] canonical = {item["id"] for item in views if not str(item["id"]).startswith("detail-")} if include_canonical and canonical != set(CANONICAL_VIEWS): - raise ReviewRenderError("Python review renderer did not produce every canonical view") + raise RenderBundleError("Python render bundle generator did not produce every canonical view") contact_sheet_path = _contact_sheet(views, output_dir) if include_canonical else "" manifest = { "schema_version": "cad.render-manifest.v2", "renderer": "python-occ-hlr-pillow", "source": {"type": "step", "path": str(step_path), "bounds_mm": bounds}, "high_resolution": {"width": RENDER_SIZE, "height": RENDER_SIZE, "method": "occ_hidden_line"}, - "review_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"}, + "render_resolution": {"width": REVIEW_SIZE, "height": REVIEW_SIZE, "resample": "lanczos"}, "contact_sheet_path": contact_sheet_path, "views": views, } @@ -343,22 +343,22 @@ def render_section( """Create an actual OpenCascade section drawing, not a clipped viewport. It intentionally uses the same deterministic Pillow raster path as the - seven canonical review views. The output contains compact contour evidence - suitable for a multimodal author without sending a STEP file or full B-rep. + seven canonical render views. The output contains compact contour evidence + suitable for inspection without sending a STEP file or full B-rep. """ del settings ready, detail = renderer_status() if not ready: - raise ReviewRenderError(detail) + raise RenderBundleError(detail) if not step_path.is_file(): - raise ReviewRenderError(f"STEP section source is missing: {step_path.name}") + raise RenderBundleError(f"STEP section source is missing: {step_path.name}") origin = _number_list(origin_mm, size=3) direction = _number_list(normal, size=3) if origin is None or direction is None: - raise ReviewRenderError("Section origin_mm and normal must each contain three finite numbers") + raise RenderBundleError("Section origin_mm and normal must each contain three finite numbers") length = math.sqrt(sum(value * value for value in direction)) if length <= 1e-9: - raise ReviewRenderError("Section normal must not be zero") + raise RenderBundleError("Section normal must not be zero") normal_unit = [value / length for value in direction] _, _, import_step = _render_modules() try: @@ -372,9 +372,9 @@ def render_section( section = b3d.section(shape, section_by=plane) edges = list(section.edges()) except Exception as error: - raise ReviewRenderError(f"OpenCascade section operation failed: {error}") from error + raise RenderBundleError(f"OpenCascade section operation failed: {error}") from error if not edges: - raise ReviewRenderError("Section plane does not intersect the model") + raise RenderBundleError("Section plane does not intersect the model") # Choose a deterministic right-handed in-plane frame. Projecting exact # OCC section edges into this frame preserves holes and internal contours. @@ -406,7 +406,7 @@ def render_section( if len(line) >= 2: projected.append(line) if not projected: - raise ReviewRenderError("Section operation produced no drawable contours") + raise RenderBundleError("Section operation produced no drawable contours") xs = [point[0] for line in projected for point in line] ys = [point[1] for line in projected for point in line] minimum_x, maximum_x, minimum_y, maximum_y = min(xs), max(xs), min(ys), max(ys) diff --git a/backend/app/services/storage.py b/backend/app/services/storage.py index 51f1f5b1..05b849df 100644 --- a/backend/app/services/storage.py +++ b/backend/app/services/storage.py @@ -1,6 +1,6 @@ -"""Conversation and attachment storage for the v3 delivery boundary. +"""Conversation and attachment storage for the CAD delivery boundary. -CAD task state deliberately does not live here. Protocol v3 owns mutable task +CAD task state deliberately does not live here. The Authoring protocol owns mutable task state in ``SqliteTaskRepository`` and immutable task artifacts in ``FileArtifactStore``. """ diff --git a/backend/app/settings.py b/backend/app/settings.py index 49a6c07d..80273c85 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -65,15 +65,6 @@ class Settings: llm_timeout_s: float default_provider_id: str providers: tuple[ProviderConfig, ...] - review_provider_id: str = "" - review_model_id: str = "" - agent_tool_calls_per_cycle: int = 12 - agent_consecutive_no_progress_limit: int = 6 - agent_format_error_repeat_limit: int = 3 - agent_context_char_limit: int = 14000 - agent_author_guidance_enabled: bool = True - agent_author_guidance_max_chars: int = 3600 - agent_render_cache: bool = True autonomous_generation: bool = True resume_running_tasks_on_startup: bool = True @@ -102,29 +93,6 @@ class Settings: raise ValueError("The selected model is not enabled for this provider") return provider, model - def resolve_review_model(self) -> tuple[ProviderConfig, ProviderModel]: - """Return the independently configured visual reviewer, never an author fallback.""" - provider_id = self.review_provider_id - if not provider_id: - raise ValueError("CDSL_REVIEW_PROVIDER must identify a configured vision provider") - provider = self.provider_for(provider_id) - if provider is None: - raise ValueError("The configured visual review provider is unavailable") - model_id = self.review_model_id or "" - if not model_id: - raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision model") - model = provider.model(model_id) - if model is None or not model.vision: - raise ValueError("CDSL_REVIEW_MODEL must identify a configured vision-capable model") - return provider, model - - def resolve_independent_review_model(self, author_provider: ProviderConfig, author_model: ProviderModel) -> tuple[ProviderConfig, ProviderModel]: - """Require the candidate judge to be a separately configured model.""" - provider, model = self.resolve_review_model() - if provider.id == author_provider.id and model.id == author_model.id: - raise ValueError("CDSL_REVIEW_PROVIDER/CDSL_REVIEW_MODEL must differ from the autonomous author model") - return provider, model - def _reasoning_effort(value: str) -> str: effort = value.strip().lower() @@ -207,15 +175,6 @@ def get_settings() -> Settings: llm_timeout_s=llm_timeout_s, default_provider_id=default_provider_id, providers=providers, - review_provider_id=os.getenv("CDSL_REVIEW_PROVIDER", "").strip().lower(), - review_model_id=os.getenv("CDSL_REVIEW_MODEL", "").strip(), - agent_tool_calls_per_cycle=max(1, int(os.getenv("CDSL_AGENT_TOOL_CALLS_PER_CYCLE", "12"))), - agent_consecutive_no_progress_limit=max(1, int(os.getenv("CDSL_AGENT_CONSECUTIVE_NO_PROGRESS_LIMIT", "6"))), - agent_format_error_repeat_limit=max(1, int(os.getenv("CDSL_AGENT_FORMAT_ERROR_REPEAT_LIMIT", "3"))), - agent_context_char_limit=max(4000, int(os.getenv("CDSL_AGENT_CONTEXT_CHAR_LIMIT", "14000"))), - agent_author_guidance_enabled=_env_flag("CDSL_AGENT_AUTHOR_GUIDANCE_ENABLED", True), - agent_author_guidance_max_chars=min(6000, max(1200, int(os.getenv("CDSL_AGENT_AUTHOR_GUIDANCE_MAX_CHARS", "3600")))), - agent_render_cache=_env_flag("CDSL_AGENT_RENDER_CACHE", True), autonomous_generation=True, # Production instances recover durable runs by default. Test workers # can disable this before startup to guarantee they touch only tasks diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py index c633da3a..7d3d8086 100644 --- a/backend/engine/cdsl_engine/build123d_adapter.py +++ b/backend/engine/cdsl_engine/build123d_adapter.py @@ -14,7 +14,7 @@ from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFille from OCP.BRepOffset import BRepOffset_Skin from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform -from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol +from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol from OCP.Geom import Geom_SurfaceOfRevolution from OCP.GeomAbs import GeomAbs_Arc from OCP.LocOpe import LocOpe_DPrism @@ -23,7 +23,7 @@ from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL from OCP.TopExp import TopExp_Explorer from OCP.TopTools import TopTools_ListOfShape from OCP.TopoDS import TopoDS -from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec +from OCP.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec from .parametric_bend import build_bend_solid from .parametric_gears import build_gear_solid, build_rack_solid @@ -1136,6 +1136,37 @@ class Build123dGeometryAdapter: ) return Solid.make_cylinder(radius_mm, height_mm, build_plane) + @staticmethod + def cylinder_with_topology_delta( + radius_mm: float, + height_mm: float, + axis: AxisSpec | None = None, + ) -> tuple[Solid, TopologyDelta]: + """Build a cylinder with exact OCC witnesses for its two cap faces.""" + origin = axis.origin_mm if axis is not None else (0.0, 0.0, 0.0) + direction = axis.direction if axis is not None else (0.0, 0.0, 1.0) + placement = gp_Ax2( + gp_Pnt(float(origin[0]), float(origin[1]), float(origin[2])), + gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])), + ) + builder = BRepPrimAPI_MakeCylinder(placement, float(radius_mm), float(height_mm)) + builder.Build() + if not builder.IsDone(): + raise ValueError("OCC cylinder operation did not complete") + result = Solid(builder.Solid()) + if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9: + raise ValueError("OCC cylinder operation did not produce a valid solid") + primitive = builder.Cylinder() + relations = tuple( + TopologyDeltaRelation("generated", "face", result.wrapped, (face,), output_role=role) + for face, role in ( + (primitive.BottomFace(), "cylinder.start"), + (primitive.TopFace(), "cylinder.end"), + ) + if not face.IsNull() + ) + return result, TopologyDelta(operation="cylinder", relations=relations) + @staticmethod def intersect(left: Any, right: Any) -> Any: # 布尔交:取两实体公共部分。结果可能为空(不相交或仅边界接触), @@ -1242,7 +1273,21 @@ class Build123dGeometryAdapter: relations.append(TopologyDeltaRelation("preserved", kind, source_value, (source_value,))) if generated: relations.append(TopologyDeltaRelation("generated", kind, source_value, generated)) - return TopologyDelta(operation=operation_name, relations=tuple(relations)) + section_values: tuple[Any, ...] = () + section_edges = getattr(operation, "SectionEdges", None) + if callable(section_edges): + try: + # BRepAlgoAPI boolean builders expose the exact intersection + # edges. Builders without that API simply carry no section + # evidence; callers must not infer it from result geometry. + section_values = tuple(section_edges()) + except (AttributeError, TypeError, ValueError): + section_values = () + return TopologyDelta( + operation=operation_name, + relations=tuple(relations), + section_values=section_values, + ) @staticmethod def _shell_topology_delta( diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py index d40f10a2..67c664f1 100644 --- a/backend/engine/cdsl_engine/capabilities.py +++ b/backend/engine/cdsl_engine/capabilities.py @@ -84,6 +84,19 @@ def _mappings(value: Any): yield from _mappings(child) +def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None) -> list[dict[str, Any]]: + """Read only the selector slot declared by the operation contract.""" + slot = str((contract or {}).get("selector_slot") or "") + if slot == "feature.selectors": + values = node.selectors + elif slot.startswith("params.") and slot.count(".") == 1: + value = node.params.get(slot.removeprefix("params.")) + values = value if isinstance(value, list) else [value] + else: + values = [] + return [value for value in values if isinstance(value, dict)] + + def _transform_member_sources(params: dict[str, Any]) -> set[str]: """Return internal body-member keys named by a transform contract.""" source_ids = {str(value) for value in params.get("source_feature_ids") or ()} @@ -545,18 +558,20 @@ class CapabilityAnalyzer: "Loft currently requires exactly one outer profile without holes", sketch_id=sketch_id, )) + contract_selectors = _contract_selectors(node, contract) + contract_selector_ids = {id(selector) for selector in contract_selectors} for selector in _mappings(params): - if selector.get("output_role") is not None: + if selector.get("output_role") is not None and id(selector) not in contract_selector_ids: blockers.append(self._blocker( node.feature_id, "unsupported_output_role_selector_context", - "Feature output role selectors are only supported in feature.selectors", + "Feature output role selector is outside the operation contract slot", )) - for selector_index, selector in enumerate(node.selectors): + for selector_index, selector in enumerate(contract_selectors): if selector.get("output_role") is None: continue required.append("selector:feature_output_role") - if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face": + if contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face": blockers.append(self._blocker( node.feature_id, "unsupported_output_role_selector", diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index cbab8ca2..fc597dba 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -5,14 +5,16 @@ "description": "Complete self-contained CDSL. Runtime-supported operations can be rebuilt by the local CDSL-only engine; deferred operations are retained for future engine implementations.", "type": "object", "properties": { - "schema": {"const": "cad.cdsl.llm.v1"}, + "schema": {"enum": ["cad.cdsl.llm.v1", "cad.runtime.v1"]}, "schema_version": {"type": "string"}, "kind": {"type": "string", "minLength": 1}, "part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"}, "meta": {"type": "object"}, + "bodies": {"type": "array", "items": {"$ref": "#/$defs/runtimeBody"}}, "geometry": { "type": "object", "properties": { + "selector_intent_version": {"const": "1.0"}, "sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}} }, "required": ["sketches"], @@ -26,6 +28,15 @@ "number": {"type": "number"}, "positive": {"type": "number", "exclusiveMinimum": 0}, "positiveInteger": {"type": "integer", "minimum": 1}, + "runtimeBody": { + "type": "object", + "properties": { + "id": {"type": "string", "pattern": "^body_[0-9]{3}$"}, + "name": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"} + }, + "required": ["id", "name"], + "additionalProperties": false + }, "point2": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 2, "maxItems": 2}, "point3": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 3, "maxItems": 3}, "circleItem": { @@ -541,7 +552,7 @@ }, "featureOutputRole": { "enum": [ - "extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", + "extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end", "shell.offset_face", "shell.closing_descendant", "shell.body_face" ] }, @@ -554,6 +565,48 @@ "required": ["owner_feature_id", "output_role"], "additionalProperties": false }, + "selectorIntentSourceQuery": { + "type": "object", + "properties": { + "ast": {}, + "featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"}, + "standard_library": {"type": "string", "minLength": 1} + }, + "required": ["ast", "featurescript_version"], + "additionalProperties": false + }, + "selectorIntent": { + "type": "object", + "properties": { + "version": {"const": "1.0"}, + "kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]}, + "query_family": {"enum": ["CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY", "GEOMETRIC"]}, + "source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"}, + "source_entity": { + "type": "object", + "properties": { + "sketch_id": {"type": "string", "minLength": 1}, + "entity_id": {"type": "string", "minLength": 1} + }, + "required": ["sketch_id", "entity_id"], + "additionalProperties": false + }, + "output_role": {"$ref": "#/$defs/featureOutputRole"}, + "derivation_policy": { + "type": "object", + "properties": { + "allowed": {"type": "array", "minItems": 1, "items": {"enum": ["continuation", "fragment", "merge", "intersection", "boundary", "replacement"]}}, + "multiplicity": {"enum": ["one", "all_fragments", "source_qualified", "none"]} + }, + "required": ["allowed", "multiplicity"], + "additionalProperties": false + }, + "evidence": {"enum": ["kernel_history", "operation_role", "feature_script_query", "explicit_datum", "geometry_hint"]}, + "disambiguation": {"type": "object"} + }, + "required": ["version", "query_family", "source_query", "derivation_policy", "evidence"], + "additionalProperties": false + }, "selectorRef": { "type": "object", "properties": { @@ -570,6 +623,8 @@ "match_mode": {"enum": ["unique", "all"]}, "matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}}, "intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}}, + "selector_intent_version": {"const": "1.0"}, + "selector_intent": {"$ref": "#/$defs/selectorIntent"}, "confidence": {"type": "number", "minimum": 0, "maximum": 1} }, "required": ["kind", "source", "confidence"], diff --git a/backend/engine/cdsl_engine/executors/common.py b/backend/engine/cdsl_engine/executors/common.py index e3a6c4f2..677c7759 100644 --- a/backend/engine/cdsl_engine/executors/common.py +++ b/backend/engine/cdsl_engine/executors/common.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Any, Callable from ..extents import _extent_vectors_from_normal, _normal_from_sketch from ..runtime_base import ExtentVector, FeatureExecutionError from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit -from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyRecord if TYPE_CHECKING: # pragma: no cover - import for type checkers only from ..session import ExecutionSession @@ -343,6 +343,8 @@ def _register_added_solid( session: "ExecutionSession", node: FeaturePlanNode, solid: Any, + *, + topology_delta: TopologyDelta | None = None, ) -> None: """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). @@ -356,10 +358,56 @@ def _register_added_solid( if node.params.get("result_mode") == "new_body": combined = session.adapter.combine(session.body, solid) members = {**session.body_members, node.feature_id: solid} - session.register_body(node.feature_id, combined, replay_node=node, body_members=members) + session.register_body( + node.feature_id, combined, replay_node=node, body_members=members, + topology_delta=topology_delta, + ) return - fused = session.adapter.fuse(session.body, solid) - session.register_body(node.feature_id, fused, replay_node=node) + if session.body is None: + session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta) + return + if topology_delta is None: + session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) + return + # Preserve primitive output roles only through the exact OCC fuse history. + # The transient records provide source handles, not selectable snapshots. + role_records = _direct_output_role_records(session, node, solid, topology_delta) + fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid) + session.register_body( + node.feature_id, fused, replay_node=node, topology_delta=fuse_delta, + topology_predecessors=role_records, + ) + + +def _direct_output_role_records( + session: "ExecutionSession", + node: FeaturePlanNode, + solid: Any, + topology_delta: TopologyDelta, +) -> list[TopologyRecord]: + """Attach only builder-proven output roles to a transient primitive snapshot.""" + records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}") + result: list[TopologyRecord] = [] + for record in records: + roles = { + relation.output_role + for relation in topology_delta.relations + if relation.output_role is not None + and relation.kind == record.kind + and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values) + } + if roles: + result.append(TopologyRecord( + record_id=record.record_id, + kind=record.kind, + feature_id=record.feature_id, + body_id=record.body_id, + geometry=record.geometry, + value=record.value, + owner_feature_ids=(node.feature_id,), + output_roles=tuple(sorted(roles)), + )) + return result def _host_plane(resolution: SelectorResolution) -> PlaneSpec: @@ -415,10 +463,12 @@ def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tange edges: list[Any] = [] for item in resolved: - if item.record.kind == "edge": - edges.append(item.record.value) - elif item.record.kind == "face": - edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge)) + records = item.records or ((item.record,) if item.record is not None else ()) + for record in records: + if record.kind == "edge": + edges.append(record.value) + elif record.kind == "face": + edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge)) if not edges: raise ValueError("selectors did not resolve any edges") return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges @@ -432,7 +482,11 @@ def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[A failed = next((item for item in resolved if item.status != "resolved"), None) if failed: raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") - records = [item.record for item in resolved if item.record is not None] + records = [ + record + for item in resolved + for record in (item.records or ((item.record,) if item.record is not None else ())) + ] if not records or any(record.kind != "face" for record in records): raise ValueError("shell selectors must resolve to faces") target_ids = {record.body_id for record in records} diff --git a/backend/engine/cdsl_engine/executors/primitives.py b/backend/engine/cdsl_engine/executors/primitives.py index 54b9d3b1..24949bba 100644 --- a/backend/engine/cdsl_engine/executors/primitives.py +++ b/backend/engine/cdsl_engine/executors/primitives.py @@ -73,9 +73,9 @@ def _execute_cylinder(node: FeaturePlanNode, session: "ExecutionSession") -> Fea raise ValueError("cylinder_add axis must define origin_mm and direction") axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 - solid = session.adapter.cylinder(radius, height, axis) + solid, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis) # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 - _register_added_solid(session, node, solid) + _register_added_solid(session, node, solid, topology_delta=topology_delta) return session.result(node) diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py index e9fe27fb..ffdc6b16 100644 --- a/backend/engine/cdsl_engine/runtime_types.py +++ b/backend/engine/cdsl_engine/runtime_types.py @@ -43,6 +43,7 @@ from .topology import ( SelectorResolution, TopologyDelta, TopologyDeltaRelation, + TopologyLineage, TopologyRecord, TopologyRegistry, ) @@ -63,6 +64,7 @@ __all__ = [ "ThreadSpec", "TopologyDelta", "TopologyDeltaRelation", + "TopologyLineage", "TopologyRecord", "TopologyRegistry", "Vector3", diff --git a/backend/engine/cdsl_engine/semantic_validation.py b/backend/engine/cdsl_engine/semantic_validation.py index 6b300977..3223a6db 100644 --- a/backend/engine/cdsl_engine/semantic_validation.py +++ b/backend/engine/cdsl_engine/semantic_validation.py @@ -32,6 +32,66 @@ def _mappings(value: Any): yield from _mappings(child) +def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> list[dict[str, Any]]: + """Return the selectors at the operation contract's declared slot.""" + slot = str(contract.get("selector_slot") or "") + if slot == "feature.selectors": + values = feature.get("selectors") or [] + elif slot.startswith("params.") and slot.count(".") == 1: + value = (feature.get("params") or {}).get(slot.removeprefix("params.")) + values = value if isinstance(value, list) else [value] + else: + values = [] + return [value for value in values if isinstance(value, dict)] + + +def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: int) -> None: + """Enforce the provenance boundary before the runtime can bind a selector.""" + intent = selector.get("selector_intent") + if intent is None: + if selector.get("selector_intent_version") is not None: + raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent") + return + if selector.get("selector_intent_version") not in {None, "1.0"}: + raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent version") + if not isinstance(intent, dict) or intent.get("version") != "1.0": + raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent") + if intent.get("kind") not in {None, selector.get("kind")}: + raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind") + family = intent.get("query_family") + derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY"} + if family in derived and not selector.get("owner_feature_id"): + raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id") + policy = intent.get("derivation_policy") or {} + allowed = policy.get("allowed") or [] + if not allowed or any(value not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} for value in allowed): + raise ValueError(f"Feature {feature_id} selector {index} has an invalid lineage derivation policy") + if policy.get("multiplicity") not in {"one", "all_fragments", "source_qualified", "none"}: + raise ValueError(f"Feature {feature_id} selector {index} has an invalid lineage multiplicity") + if policy.get("multiplicity") == "all_fragments" and "fragment" not in allowed: + raise ValueError(f"Feature {feature_id} selector {index} all_fragments policy requires fragment lineage") + if family in derived and intent.get("evidence") == "geometry_hint": + raise ValueError(f"Feature {feature_id} selector {index} cannot use geometry_hint for FeatureScript provenance") + if selector.get("owner_match_required") and intent.get("evidence") == "geometry_hint": + raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback") + source_query = intent.get("source_query") or {} + version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None + if not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version): + raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version") + if intent.get("output_role") is not None and intent.get("output_role") != selector.get("output_role"): + raise ValueError(f"Feature {feature_id} selector {index} intent output role differs from selector output role") + forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"} + stack = [intent] + while stack: + value = stack.pop() + if isinstance(value, dict): + if forbidden.intersection(value): + raise ValueError(f"Feature {feature_id} selector {index} intent contains a runtime identifier") + stack.extend(value.values()) + elif isinstance(value, list): + stack.extend(value) + + @lru_cache(maxsize=1) def _schema() -> dict[str, Any]: path = Path(__file__).with_name("cdsl_schema.json") @@ -69,7 +129,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: """ if not isinstance(cdsl, dict): raise ValueError("CDSL must be a JSON object") - if cdsl.get("schema") != "cad.cdsl.llm.v1": + if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}: raise ValueError("Unsupported CDSL schema") schema_error = _schema_error(cdsl) if schema_error: @@ -124,15 +184,21 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: if feature.get("execution_status") == "deferred": deferred.append(fid) contract = contracts.get(str(feature.get("atomic_id") or "")) or {} - feature_selectors = feature.get("selectors") or [] + feature_selectors = _contract_selectors(feature, contract) output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)} + selector_intent_ids = { + id(selector.get("selector_intent")) + for selector in feature_selectors + if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict) + } for index, selector in enumerate(feature_selectors): + _validate_selector_intent(selector, fid, index) owner = selector.get("owner_feature_id") binding_owner = selector.get("binding_feature_id") if owner is not None and owner not in feature_ids and binding_owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id") if selector.get("output_role") is not None: - if contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face": + if not contract.get("selector_slot") or contract.get("selector_token_kind") != "face": raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role") if selector.get("kind") != "face" or not owner or owner not in feature_ids: raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id") @@ -174,8 +240,13 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]: selector.get("output_role") is not None and selector.get("kind") is not None and id(selector) not in output_role_selector_ids + and id(selector) not in selector_intent_ids ): - raise ValueError(f"Feature {fid} output role selectors are only supported in feature.selectors") + if contract.get("selector_slot") == "feature.selectors": + raise ValueError( + f"Feature {fid} output role selectors are only supported in feature.selectors" + ) + raise ValueError(f"Feature {fid} output role selector is outside its operation contract slot") if feature.get("atomic_id") == "shell": target_feature_id = (feature.get("params") or {}).get("target_feature_id") if target_feature_id is not None and target_feature_id not in feature_ids: diff --git a/backend/engine/cdsl_engine/session.py b/backend/engine/cdsl_engine/session.py index 9dc99bd4..7b8c1775 100644 --- a/backend/engine/cdsl_engine/session.py +++ b/backend/engine/cdsl_engine/session.py @@ -66,6 +66,7 @@ class GeometryAdapter(Protocol): def cut(self, body: Any, tool: Any) -> Any: ... def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ... def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ... + def cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ... def thread_solid(self, spec: ThreadSpec) -> Any: ... def bend_solid(self, spec: BendSpec) -> Any: ... def gear_solid(self, spec: GearSpec) -> Any: ... @@ -184,11 +185,15 @@ class ExecutionSession: binding_feature_id = selector.get("binding_feature_id") active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))] - failures = [item for item in resolved if item.status != "resolved" or item.record is None] + failures = [item for item in resolved if item.status != "resolved" or (item.record is None and not item.records)] if failures: detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved" raise FeatureExecutionError("intersection_selector_component_unresolved", detail) - return [item.record for item in resolved if item.record is not None] + return [ + record + for item in resolved + for record in (item.records or ((item.record,) if item.record is not None else ())) + ] def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution: components = selector.get("intersection_of") diff --git a/backend/engine/cdsl_engine/topology.py b/backend/engine/cdsl_engine/topology.py index 6b73e11f..a3e39147 100644 --- a/backend/engine/cdsl_engine/topology.py +++ b/backend/engine/cdsl_engine/topology.py @@ -163,6 +163,13 @@ class TopologyDeltaRelation: source_value: Any result_values: tuple[Any, ...] = () output_role: str | None = None + derivation: str | None = None + source_role: str | None = None + result_role: str | None = None + source_slot: str | None = None + result_slot: str | None = None + coverage: str = "complete" + status: str = "proven" def __post_init__(self) -> None: if self.event not in {"preserved", "modified", "generated", "deleted"}: @@ -173,6 +180,21 @@ class TopologyDeltaRelation: raise ValueError("deleted topology delta relations cannot have result values or an output role") if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role): raise ValueError("topology delta output_role must be a non-empty string when provided") + derivation = self.derivation + if derivation is None: + derivation = { + "preserved": "continuation", + "modified": "fragment" if len(self.result_values) > 1 else "continuation", + "generated": "boundary", + "deleted": "replacement", + }[self.event] + object.__setattr__(self, "derivation", derivation) + if derivation not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"}: + raise ValueError(f"unsupported topology derivation {derivation!r}") + if self.coverage not in {"complete", "partial", "none"}: + raise ValueError(f"unsupported topology coverage {self.coverage!r}") + if self.status not in {"proven", "unknown", "rejected"}: + raise ValueError(f"unsupported topology relation status {self.status!r}") @dataclass(frozen=True) @@ -186,6 +208,37 @@ class TopologyDelta: operation: str relations: tuple[TopologyDeltaRelation, ...] = () + # Boolean section edges are distinct from source continuations. INTERSECT + # selectors may only rely on this exact builder evidence. + section_values: tuple[Any, ...] = () + + +@dataclass(frozen=True) +class TopologyLineage: + """An N:M semantic edge backed by an adapter history relation.""" + + source_record_ids: tuple[str, ...] + result_record_ids: tuple[str, ...] + derivation: str + evidence: str + coverage: str + status: str + operation: str + output_role: str | None = None + + def as_dict(self) -> dict[str, Any]: + result = { + "source_record_ids": list(self.source_record_ids), + "result_record_ids": list(self.result_record_ids), + "derivation": self.derivation, + "evidence": self.evidence, + "coverage": self.coverage, + "status": self.status, + "operation": self.operation, + } + if self.output_role is not None: + result["output_role"] = self.output_role + return result @dataclass(frozen=True) @@ -193,13 +246,29 @@ class SelectorResolution: selector: dict[str, Any] status: str record: TopologyRecord | None = None + # ``records`` carries policy-authorized 1:N selector results. ``record`` + # remains the compatibility field for a unique selection and context use. + records: tuple[TopologyRecord, ...] = () candidates: tuple[dict[str, Any], ...] = () diagnostic: RuntimeDiagnostic | None = None + @property + def resolution_mode(self) -> str: + if self.status == "resolved": + if self.records: + return "kernel_lineage" + if self.selector.get("output_role"): + return "operation_role" + if self.selector.get("selector_intent"): + return "kernel_lineage" + return "geometry" + return "unresolved" + def as_dict(self) -> dict[str, Any]: output = { "selector": self.selector, "status": self.status, + "resolution_mode": self.resolution_mode, "candidates": list(self.candidates), } if self.record is not None: @@ -211,8 +280,15 @@ class SelectorResolution: ) if score is not None: output["score"] = score + if self.records: + output["records"] = [record.public_dict() for record in self.records] if self.diagnostic is not None: output["diagnostic"] = self.diagnostic.as_dict() + if self.status == "resolved": + output["evidence"] = { + "source_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))], + "result_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))], + } return output @@ -224,6 +300,7 @@ class TopologyRegistry: self._by_feature: dict[str, list[TopologyRecord]] = {} self._active_body_id: str | None = None self._topology_deltas: list[dict[str, Any]] = [] + self._lineage: list[TopologyLineage] = [] # #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer # 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何 # 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的 @@ -244,6 +321,10 @@ class TopologyRegistry: """Return serializable evidence derived from exact adapter history.""" return tuple(self._topology_deltas) + def lineage(self) -> tuple[TopologyLineage, ...]: + """Return kernel-backed N:M lineage without heuristic successors.""" + return tuple(self._lineage) + def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord: kind = "plane" if isinstance(context, PlaneSpec) else "axis" record = TopologyRecord( @@ -378,10 +459,25 @@ class TopologyRegistry: if successor_id not in known: known.append(successor_id) if delta_evidence is not None: + operation_lineage = [ + TopologyLineage( + source_record_ids=tuple(item["source_record_ids"]), + result_record_ids=tuple(item["result_record_ids"]), + derivation=str(item["derivation"]), + evidence="kernel_history", + coverage=str(item["coverage"]), + status=str(item["lineage_status"]), + operation=topology_delta.operation, + output_role=item.get("output_role"), + ) + for item in delta_evidence + ] + self._lineage.extend(operation_lineage) self._topology_deltas.append({ "feature_id": feature_id, "operation": topology_delta.operation, "relations": delta_evidence, + "lineage": [lineage.as_dict() for lineage in operation_lineage], }) # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续 # selector 的 stable_id 引用解析到 active body 内的新形态。多条演化 @@ -471,7 +567,14 @@ class TopologyRegistry: "source_record_ids": [record.record_id for record in sources], "result_record_ids": [record.record_id for record in outputs], "proof": "kernel_history", + "derivation": relation.derivation, + "coverage": relation.coverage if len(outputs) == len(relation.result_values) else "partial", + "lineage_status": relation.status, } + for field_name in ("source_role", "result_role", "source_slot", "result_slot"): + value = getattr(relation, field_name) + if value is not None: + item[field_name] = value if relation.output_role is not None: item["output_role"] = relation.output_role role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1 @@ -501,6 +604,24 @@ class TopologyRegistry: else: relation_links.append(None) evidence.append(item) + if topology_delta.section_values: + section_outputs = [ + record for record in current + if record.kind == "edge" + and any(cls._same_topology_value(record.value, value) for value in topology_delta.section_values) + ] + evidence.append({ + "event": "generated", + "kind": "edge", + "source_record_ids": [], + "result_record_ids": [record.record_id for record in section_outputs], + "proof": "kernel_history", + "derivation": "intersection", + "coverage": "complete" if len(section_outputs) == len(topology_delta.section_values) else "partial", + "lineage_status": "proven" if len(section_outputs) == len(topology_delta.section_values) else "unknown", + "section_edge": True, + "status": "recorded_section_edge", + }) predecessors = { result_id: next(iter(source_ids)) for result_id, source_ids in candidate_sources.items() @@ -520,6 +641,8 @@ class TopologyRegistry: item["status"] = "non_unique_or_incomplete" else: item["status"] = "recorded_without_owner_transfer" + if item["coverage"] != "complete" or relation.status != "proven": + item["lineage_status"] = "unknown" return ( predecessors, successors, @@ -529,6 +652,45 @@ class TopologyRegistry: evidence, ) + def _intent_lineage_successors( + self, + source_record_id: str, + *, + allowed: set[str], + active_body_id: str | None, + ) -> list[TopologyRecord]: + """Follow only complete, proven CDSL lineage edges to active records.""" + pending = [source_record_id] + visited = {source_record_id} + result_ids: set[str] = set() + while pending: + current = pending.pop() + for edge in self._lineage: + if ( + current not in edge.source_record_ids + or edge.derivation not in allowed + or edge.evidence != "kernel_history" + or edge.coverage != "complete" + or edge.status != "proven" + ): + continue + for record_id in edge.result_record_ids: + if record_id not in visited: + visited.add(record_id) + pending.append(record_id) + result_ids.add(record_id) + active: list[TopologyRecord] = [] + for record in self._records: + if record.record_id not in result_ids: + continue + if active_body_id is not None and not ( + record.body_id == active_body_id + or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) + ): + continue + active.append(record) + return active + @staticmethod def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: try: @@ -725,6 +887,8 @@ class TopologyRegistry: ) -> SelectorResolution: kind = selector.get("kind") owner = selector.get("owner_feature_id") + intent = selector.get("selector_intent") + provenance_intent = isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC" candidates = [record for record in self._records if record.kind == kind] if active_body_id and kind in {"face", "edge", "vertex", "body"}: # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体 @@ -895,6 +1059,49 @@ class TopologyRegistry: record.body_id == active_body_id or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:")) ) + if not is_active and provenance_intent: + policy = intent.get("derivation_policy") or {} + allowed = { + value for value in policy.get("allowed") or () + if value in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} + } + successors = self._intent_lineage_successors( + record.record_id, + allowed=allowed, + active_body_id=active_body_id, + ) + if len(successors) == 1: + record = successors[0] + is_active = True + elif len(successors) > 1: + if policy.get("multiplicity") == "all_fragments" and "fragment" in allowed: + return SelectorResolution( + selector=selector, + status="resolved", + records=tuple(successors), + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + ) + return SelectorResolution( + selector=selector, + status="ambiguous", + candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors), + diagnostic=RuntimeDiagnostic( + code="selector_relation_non_unique", + message="The proven topology lineage has more than one active result", + detail={"stable_id": stable_id, "candidate_count": len(successors), "multiplicity": policy.get("multiplicity")}, + ), + ) + else: + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code="selector_kernel_history_missing", + message="No complete proven lineage reaches an active topology record", + detail={"stable_id": stable_id, "allowed": sorted(allowed)}, + ), + ) if not is_active: successors = [ candidate for candidate in stable_records @@ -974,6 +1181,24 @@ class TopologyRegistry: detail={"minimum_score": minimum_score}, ), ) + if provenance_intent: + evidence = intent.get("evidence") + code = "selector_geometry_only" if evidence == "geometry_hint" else "selector_kernel_history_missing" + message = ( + "A FeatureScript provenance selector cannot resolve from geometry alone" + if code == "selector_geometry_only" + else "The FeatureScript selector has no stable active record or complete kernel history" + ) + return SelectorResolution( + selector=selector, + status="not_found", + candidates=(), + diagnostic=RuntimeDiagnostic( + code=code, + message=message, + detail={"query_family": intent.get("query_family")}, + ), + ) scored: list[tuple[float, TopologyRecord]] = [] for candidate in candidates: # An owner-qualified context selector is deterministic when it has diff --git a/backend/tests/test_agent_service.py b/backend/tests/test_agent_service.py new file mode 100644 index 00000000..f3ee0bbe --- /dev/null +++ b/backend/tests/test_agent_service.py @@ -0,0 +1,80 @@ +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository +from app.cad_agent.domain.state import transition +from app.services.agent_service import AgentService + + +def test_result_payload_contains_all_published_artifacts() -> None: + payload = AgentService._result_payload("cad_abcdefghijkl", { + "lifecycle": "completed", + "published_revision": "rev_prefix", + "revisions": [{ + "revision_id": "rev_prefix", + "cdsl_path": "revisions/rev_prefix/model.cdsl.json", + "step_path": "revisions/rev_prefix/model.step", + "glb_path": "revisions/rev_prefix/model.glb", + "report_path": "revisions/rev_prefix/rebuild-report.json", + }], + }) + + assert payload == { + "taskId": "cad_abcdefghijkl", + "revisionId": "rev_prefix", + "cdslPath": "revisions/rev_prefix/model.cdsl.json", + "stepPath": "revisions/rev_prefix/model.step", + "glbPath": "revisions/rev_prefix/model.glb", + "reportPath": "revisions/rev_prefix/rebuild-report.json", + "summary": "CDSL CAD model", + "referenceIds": [], + "engine": "cdsl_only", + "lifecycle": "completed", + } + + +def test_best_effort_projection_retains_prefix_artifact_paths() -> None: + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + state = repository.create_task("cad_abcdefghijkl", "make a plate") + state = transition(state, "analysis_written", requirements_path="documents/requirements-analysis.json") + assert repository.compare_and_swap(state) + state = transition(state, "authoring_written", authoring_path="documents/authoring-cdsl-attempt-01.json") + assert repository.compare_and_swap(state) + state = transition(state, "compiled", runtime_cdsl_path="documents/runtime-cdsl-attempt-01.json") + assert repository.compare_and_swap(state) + state = transition(state, "repair_required", active_revision="rev_prefix") + assert repository.compare_and_swap(state, events=[{ + "event": "build_failed", + "revision_id": "rev_prefix", + "paths": { + "model.cdsl.json": "revisions/rev_prefix/model.cdsl.json", + "model.step": "revisions/rev_prefix/model.step", + "model.glb": "revisions/rev_prefix/model.glb", + "rebuild-report.json": "revisions/rev_prefix/rebuild-report.json", + }, + }]) + state = transition(state, "repair_started", repair_count=1) + assert repository.compare_and_swap(state) + state = transition(state, "repair_required") + assert repository.compare_and_swap(state) + state = transition(state, "repair_started", repair_count=2) + assert repository.compare_and_swap(state) + state = transition(state, "publish_best_effort") + assert repository.compare_and_swap(state, events=[{"event": "repair_budget_exhausted"}]) + state = transition(state, "published") + assert repository.compare_and_swap(state) + + projection = repository.get_task_projection("cad_abcdefghijkl") + assert projection is not None + result = AgentService._result_payload("cad_abcdefghijkl", projection) + assert result is not None + assert result["revisionId"] == "rev_prefix" + assert result["stepPath"] == "revisions/rev_prefix/model.step" + + +def test_result_payload_rejects_incomplete_artifact_sets() -> None: + assert AgentService._result_payload("cad_abcdefghijkl", { + "published_revision": "rev_prefix", + "revisions": [{"revision_id": "rev_prefix", "step_path": "revisions/rev_prefix/model.step"}], + }) is None diff --git a/backend/tests/test_author_guidance.py b/backend/tests/test_author_guidance.py deleted file mode 100644 index fe734dfd..00000000 --- a/backend/tests/test_author_guidance.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -import asyncio -import json -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.adapters.author_guidance import FileAuthorGuidance # noqa: E402 -from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator # noqa: E402 -from app.cad_agent.domain.errors import ErrorCode, WorkflowError # noqa: E402 -from app.cad_agent.domain.state import TaskPhase, TaskState # noqa: E402 - - -GUIDANCE_ROOT = ROOT / "backend" / "agent" / "skills" / "cdsl-author-guidance" -PROFILE = ROOT / "backend" / "engine" / "cdsl_engine" / "profile_schema.json" - - -def atomic_ids() -> tuple[str, ...]: - return tuple(json.loads(PROFILE.read_text(encoding="utf-8"))["operation_contracts"]) - - -class _Repository: - def __init__(self, state: TaskState) -> None: - self.state = state - self.usage_records: list[dict] = [] - - def get_state(self, _task_id: str) -> TaskState: - return self.state - - def ledger_events(self, _task_id: str) -> list[dict]: - return [] - - def record_usage(self, _task_id: str, payload: dict) -> None: - self.usage_records.append(payload) - - def record_tool_audit(self, _task_id: str, _payload: dict) -> None: - pass - - -class _Artifacts: - def read_source_requirements(self, _task_id: str) -> str: - return "Create a symmetric mounting plate." - - def read_json(self, *_args: object) -> None: - return None - - -class _Runtime: - def supported_atomic_ids(self) -> tuple[str, ...]: - return atomic_ids() - - -class AuthorGuidanceTests(unittest.TestCase): - def test_manifest_covers_every_runtime_atomic_and_keeps_coordinate_core_at_minimum_budget(self) -> None: - guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=1_200) - covered: set[str] = set() - for atomic_id in atomic_ids(): - selection = guidance.select( - phase=TaskPhase.FEATURE_PENDING, - atomic_id=atomic_id, - repair_required=False, - supported_atomic_ids=atomic_ids(), - ) - self.assertTrue(selection.enabled, selection.fallback_reason) - self.assertIn("00-author-contract", selection.section_ids) - self.assertIn("03-coordinate-system-and-datums", selection.section_ids) - self.assertLessEqual(len(selection.content), 1_200) - self.assertIn("世界坐标", selection.content) - covered.update(section_id for section_id in selection.section_ids if section_id.startswith("op-")) - self.assertEqual(covered, {"op-extrude-add", "op-extrude-cut", "op-loft", "op-revolve", "op-hole", "op-reference", "op-pattern", "op-finish", "op-sphere", "op-primitives", "op-thread", "op-bend", "op-gear"}) - - def test_phase_repair_and_budget_selection_are_stable(self) -> None: - guidance = FileAuthorGuidance(GUIDANCE_ROOT, max_chars=3_600) - planning = guidance.select( - phase=TaskPhase.COMPILING_FEATURE_PLAN, - atomic_id="", - repair_required=False, - supported_atomic_ids=atomic_ids(), - ) - repair = guidance.select( - phase=TaskPhase.AWAITING_ACTION, - atomic_id="fillet", - repair_required=True, - supported_atomic_ids=atomic_ids(), - ) - self.assertEqual(planning.section_ids[:2], ("00-author-contract", "03-coordinate-system-and-datums")) - self.assertIn("02-parameters-and-derived-dimensions", planning.section_ids) - self.assertEqual(repair.section_ids[:3], ("00-author-contract", "03-coordinate-system-and-datums", "op-finish")) - self.assertIn("10-repair-and-best-effort", repair.section_ids) - - def test_disabled_missing_and_invalid_corpus_fall_back_without_authoring_failure(self) -> None: - common = { - "phase": TaskPhase.FEATURE_PENDING, - "atomic_id": "extrude_add_blind", - "repair_required": False, - "supported_atomic_ids": atomic_ids(), - } - self.assertEqual(FileAuthorGuidance(GUIDANCE_ROOT, enabled=False).select(**common).fallback_reason, "guidance_disabled") - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:FileNotFoundError") - (root / "manifest.json").write_text("{}", encoding="utf-8") - self.assertEqual(FileAuthorGuidance(root).select(**common).fallback_reason, "guidance_load_failed:ValueError") - - def test_author_context_receives_guidance_but_keeps_the_existing_tool_instruction(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), - _Repository(state), - _Artifacts(), - _Runtime(), - object(), - object(), - object(), - object(), - FileAuthorGuidance(GUIDANCE_ROOT), - ) - messages, selection = workflow._author_context(state.task_id, []) - system = str(messages[0]["content"]) - self.assertTrue(selection.enabled) - self.assertIn("Use exactly one offered structured tool call", system) - self.assertIn("Coordinate System And Datums", system) - self.assertIn("世界坐标", system) - - def test_invalid_author_tool_call_retains_guidance_usage_metadata(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 1) - repository = _Repository(state) - class _Models: - async def call_tool(self, **_kwargs: object) -> dict: - return {"tool_calls": [], "usage": {"prompt_tokens": 3, "completion_tokens": 1, "total_tokens": 4}} - workflow = WorkflowCoordinator( - WorkflowConfig(max_turns=8, format_error_limit=2), - repository, - _Artifacts(), - _Runtime(), - _Models(), - object(), - object(), - object(), - FileAuthorGuidance(GUIDANCE_ROOT), - ) - tool = {"type": "function", "function": {"name": "write_requirements_document", "parameters": {"type": "object"}}} - result = asyncio.run(workflow._author_turn(state.task_id, ModelIdentity("provider", "model"), [tool], [])) - self.assertIsInstance(result, WorkflowError) - self.assertEqual(result.code, ErrorCode.AUTHOR_FORMAT_INVALID) - self.assertEqual(repository.usage_records[0]["guidance_enabled"], True) - self.assertIn("03-coordinate-system-and-datums", repository.usage_records[0]["guidance_section_ids"]) - self.assertEqual(repository.usage_records[0]["retry_reason"], "invalid_tool_call") - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_authoring_contract.py b/backend/tests/test_authoring_contract.py new file mode 100644 index 00000000..742d69a8 --- /dev/null +++ b/backend/tests/test_authoring_contract.py @@ -0,0 +1,81 @@ +import math + +import pytest + +from app.cad_agent.application.authoring_contract import AuthoringDocument +from app.cad_agent.application.authoring_compiler import AuthoringCompiler, AuthoringCompileError + + +def test_model_cannot_submit_runtime_identity(): + with pytest.raises(ValueError, match="AUTHOR_FORBIDDEN_FIELD"): + AuthoringDocument.model_validate({"feature_id": "feature_001", "bodies": []}) + + +@pytest.mark.parametrize("field", ["task_id", "revision_id", "stable_id", "selector_tokens", "selector token", "host_face", "mirror_plane"]) +def test_model_cannot_submit_any_internal_identity_variant(field): + with pytest.raises(ValueError, match="AUTHOR_FORBIDDEN_FIELD"): + AuthoringDocument.model_validate({field: "internal", "bodies": []}) + + +def test_authoring_rejects_non_mm_and_non_finite_values(): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({"units": "in", "bodies": []}) + with pytest.raises(ValueError, match="non-finite"): + AuthoringDocument.model_validate({"bodies": [{"name": "main", "features": [{"name": "base", "operation": "box_add", "params": {"length_mm": math.nan}}]}]}) + + +@pytest.mark.parametrize("sketch", [ + { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profiles": [{"type": "circle", "diameter_mm": 20}], + }, + { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "radius_mm": 10}, + }, +]) +def test_authoring_rejects_runtime_or_ambiguous_sketch_shapes(sketch): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({ + "bodies": [{"name": "main", "features": [{ + "name": "base", "operation": "extrude_add_blind", "params": {"distance_mm": 8}, "sketch": sketch, + }]}], + }) + + +def test_authoring_rejects_selector_fields_that_are_not_declarative_source_intent(): + with pytest.raises(ValueError): + AuthoringDocument.model_validate({ + "bodies": [{"name": "main", "features": [{ + "name": "base", "operation": "cylinder_add", "params": {}, + "selectors": [{"kind": "face", "source": "other.top_planar_face", "role": "host_face"}], + }]}], + }) + + +def test_compiler_assigns_deterministic_ids_and_orders_dependencies(): + document = {"bodies": [{"name": "main", "features": [ + {"name": "hole", "operation": "hole_blind", "depends_on": ["base"]}, + {"name": "base", "operation": "extrude_add_blind"}, + ]}]} + runtime, audit = AuthoringCompiler(lambda operation: {"atomic_id": operation}).compile(document) + assert [item["id"] for item in runtime["features"]] == ["feature_002", "feature_001"] + assert runtime["features"][1]["depends_on"] == ["feature_002"] + assert audit["feature_ids"] == {"hole": "feature_001", "base": "feature_002"} + + +def test_compiler_rejects_unknown_operation(): + with pytest.raises(AuthoringCompileError) as error: + AuthoringCompiler(lambda _: (_ for _ in ()).throw(ValueError("unknown"))).compile({ + "bodies": [{"name": "main", "features": [{"name": "base", "operation": "bad"}]}] + }) + assert error.value.code == "OPERATION_UNSUPPORTED" + + +def test_compiler_preserves_the_forbidden_field_error_code(): + with pytest.raises(AuthoringCompileError) as error: + AuthoringCompiler(lambda operation: {"atomic_id": operation}).compile({ + "bodies": [{"name": "main", "features": [{"name": "base", "operation": "box_add", "params": {}}]}], + "feature_id": "feature_001", + }) + assert error.value.code == "AUTHOR_FORBIDDEN_FIELD" diff --git a/backend/tests/test_authoring_runtime.py b/backend/tests/test_authoring_runtime.py new file mode 100644 index 00000000..a07736f9 --- /dev/null +++ b/backend/tests/test_authoring_runtime.py @@ -0,0 +1,219 @@ +import math +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.artifact_store import FileArtifactStore +from app.cad_agent.adapters.runtime import ProfileCadRuntime +from app.cad_agent.ports import AdapterUnavailable +from app.cad_agent.application.single_stage import SingleStageExecutor +from app.services.engine_service import validate_cdsl, validate_cdsl_shape +from app.settings import get_settings + + +def _base_feature() -> dict: + return { + "name": "base", + "operation": "extrude_add_blind", + "params": {"distance_mm": 8, "result_mode": "new_body"}, + "sketch": { + "workplane": { + "origin_mm": [0, 0, 0], + "x_dir": [1, 0, 0], + "normal": [0, 0, 1], + }, + "profile": { + "type": "polygon", + "vertices": [[-40, -25], [40, -25], [40, 25], [-40, 25]], + }, + }, + } + + +def _authoring(*features: dict) -> dict: + return { + "schema_version": "cad.author.v1", + "units": "mm", + "bodies": [{"name": "main", "features": list(features)}], + } + + +def test_authoring_compiles_to_runtime_cdsl_and_publishes_one_revision() -> None: + runtime = ProfileCadRuntime(get_settings()) + authoring = _authoring(_base_feature()) + runtime_cdsl, audit = runtime.compile_authoring(authoring) + + assert runtime_cdsl["schema"] == "cad.runtime.v1" + assert runtime_cdsl["bodies"] == [{"id": "body_001", "name": "main"}] + assert runtime_cdsl["features"][0]["id"] == "feature_001" + assert audit["feature_ids"] == {"base": "feature_001"} + validate_cdsl_shape(runtime_cdsl, runtime.engine) + validate_cdsl(runtime_cdsl, runtime.engine) + + with TemporaryDirectory() as temporary: + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + task_id = "cad_abcdefghijkl" + artifacts.initialize_task(task_id, "make a rectangular plate") + result = SingleStageExecutor(None, artifacts, runtime).execute(task_id, authoring) + + assert result["status"] == "completed" + revision = str(result["revision_id"]) + assert result["executed_feature_ids"] == ["feature_001"] + for relative in ("model.step", "model.glb", "model.cdsl.json", "rebuild-report.json", "renders/render-manifest.json"): + assert artifacts.artifact_path(task_id, f"revisions/{revision}/{relative}").is_file() + + +def test_selector_failure_keeps_the_successful_prefix() -> None: + runtime = ProfileCadRuntime(get_settings()) + invalid_fillet = { + "name": "bad_fillet", + "operation": "fillet", + "depends_on": ["base"], + "params": {"radius_mm": 1}, + # extrude.end is a face output. Using it for an edge-only operation + # is a deliberate selector-kind failure, not a geometry fallback. + "selectors": [{"kind": "edge", "source": "base.top_planar_face"}], + } + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature(), invalid_fillet)) + + with TemporaryDirectory() as temporary: + built, diagnostics = runtime.rebuild_best_effort( + runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_selector_failure", + ) + + assert built["executed_feature_ids"] == ["feature_001"] + assert diagnostics[0]["feature_id"] == "feature_002" + assert diagnostics[0]["code"] == "SELECTOR_KIND_MISMATCH" + + +def test_cylinder_cap_selector_is_compiled_into_hole_host_face_and_executes() -> None: + runtime = ProfileCadRuntime(get_settings()) + base = { + "name": "base_flange", + "operation": "cylinder_add", + "params": { + "radius_mm": 30, + "height_mm": 10, + "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}, + }, + } + bore = { + "name": "center_bore", + "operation": "hole_wizard", + "params": { + "hole_type": "simple", + "diameter_mm": 12, + "depth_mm": 10, + "end_condition": {"type": "through_all", "solidworks_code": 1}, + "positions": [{"mm": [0, 0, 10]}], + }, + "selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}], + } + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(base, bore)) + compiled_bore = runtime_cdsl["features"][1] + assert compiled_bore["depends_on"] == ["feature_001"] + assert "selectors" not in compiled_bore + assert compiled_bore["params"]["host_face"] == { + "kind": "face", + "output_role": "cylinder.end", + "owner_feature_id": "feature_001", + "source": "runtime_snapshot", + "confidence": 1.0, + "match_mode": "unique", + } + + with TemporaryDirectory() as temporary: + built, diagnostics = runtime.rebuild_best_effort( + runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_cylinder_host", + ) + + assert diagnostics == [] + assert built["executed_feature_ids"] == ["feature_001", "feature_002"] + + +def test_authoring_circle_diameter_is_lowered_to_runtime_radius() -> None: + runtime = ProfileCadRuntime(get_settings()) + authoring = _authoring({ + "name": "round_base", + "operation": "extrude_add_blind", + "params": {"distance_mm": 8, "result_mode": "new_body"}, + "sketch": { + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": {"type": "circle", "diameter_mm": 20, "center_mm": [0, 0]}, + }, + }) + compiled, _audit = runtime.compile_authoring(authoring) + assert compiled["geometry"]["sketches"][0]["profile"] == { + "type": "circle", "center": [0.0, 0.0], "radius_mm": 10.0, + } + + +def test_flange_bolt_host_is_built_before_source_topology_is_replaced() -> None: + """An exposed base cap remains a valid bolt host before later fusions/cuts.""" + runtime = ProfileCadRuntime(get_settings()) + circle_sketch = lambda z, normal, diameter: { + "workplane": {"origin_mm": [0, 0, z], "x_dir": [1, 0, 0], "normal": normal}, + "profile": {"type": "circle", "diameter_mm": diameter, "center_mm": [0, 0]}, + } + bolt_positions = [ + {"mm": [43 * math.cos(math.radians(angle)), 43 * math.sin(math.radians(angle)), 12]} + for angle in range(0, 360, 45) + ] + authoring = _authoring( + { + "name": "base_flange", "operation": "cylinder_add", + "params": {"radius_mm": 60, "height_mm": 12, "axis": {"origin_mm": [0, 0, 0], "direction": [0, 0, 1]}}, + }, + { + "name": "rear_shallow_pad", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"], + "params": {"distance_mm": 4, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(0, [0, 0, -1], 105), + }, + { + "name": "rear_guide_boss", "operation": "extrude_add_blind", "depends_on": ["rear_shallow_pad"], + "params": {"distance_mm": 12, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(0, [0, 0, -1], 38), + }, + { + "name": "bolt_holes", "operation": "hole_wizard", + "params": { + "hole_type": "counterbore", "diameter_mm": 8, "depth_mm": 12, + "end_condition": {"type": "through_all_both", "solidworks_code": 7}, + "counterbore": {"diameter_mm": 10, "depth_mm": 4}, "positions": bolt_positions, + }, + "selectors": [{"kind": "face", "source": "base_flange.top_planar_face", "match": "unique"}], + }, + { + "name": "front_hub_boss", "operation": "extrude_add_blind", "depends_on": ["bolt_holes"], + "params": {"distance_mm": 14, "result_mode": "fuse", "reverse": False}, + "sketch": circle_sketch(12, [0, 0, 1], 56), + }, + { + "name": "center_through_cut", "operation": "extrude_cut_through", "depends_on": ["front_hub_boss", "rear_guide_boss"], + "params": {"end_condition": {"type": "through_all_both", "solidworks_code": 7}}, + "sketch": circle_sketch(0, [0, 0, 1], 32), + }, + ) + compiled, audit = runtime.compile_authoring(authoring) + assert audit["implicit_selector_dependencies"] == {"bolt_holes": ["base_flange"]} + + with TemporaryDirectory() as temporary: + rebuilt = runtime.rebuild(compiled, temporary, "cad_abcdefghijkl", "flange_source_order") + + assert rebuilt["health"]["feature_count"] == 6 + + +def test_service_failure_during_prefix_export_is_not_reclassified_as_a_model_error(monkeypatch) -> None: + runtime = ProfileCadRuntime(get_settings()) + runtime_cdsl, _audit = runtime.compile_authoring(_authoring(_base_feature())) + + def unavailable(*_args, **_kwargs): + raise AdapterUnavailable("renderer unavailable") + + monkeypatch.setattr(runtime, "rebuild", unavailable) + with TemporaryDirectory() as temporary: + try: + runtime.rebuild_best_effort(runtime_cdsl, temporary, "cad_abcdefghijkl", "stage_service_failure") + except AdapterUnavailable as error: + assert "renderer unavailable" in str(error) + else: + raise AssertionError("service failure was incorrectly converted into a model repair diagnostic") diff --git a/backend/tests/test_cad_agent_v3.py b/backend/tests/test_cad_agent_v3.py deleted file mode 100644 index e5138dc5..00000000 --- a/backend/tests/test_cad_agent_v3.py +++ /dev/null @@ -1,1117 +0,0 @@ -from __future__ import annotations - -import asyncio -import base64 -from hashlib import sha256 -import json -from pathlib import Path -import sqlite3 -import sys -import tempfile -import unittest -from unittest.mock import AsyncMock, patch - - -ROOT = Path(__file__).resolve().parents[2] -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, 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, - NextAction, - StatelessCandidateReview, - StatelessGeometryConclusion, - canonical_validate, - canonical_validate_schema, - compiled_requirements_schema, - sanitize_compiled_requirements_arguments, - stateless_final_review_schema, - stateless_next_action_schema, - stateless_rollback_checkpoint_schema, -) -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, WorkflowError -from app.cad_agent.domain.operation_contract import fragment_schema, validate_fragment -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 -from app.services.library import CdslLibrary -from app.services.storage import WorkspaceStore -from app.settings import ProviderConfig, ProviderModel, Settings - - -def settings(root: Path) -> Settings: - author = ProviderConfig("author", "Author", "https://author.invalid/v1", "author-key", (ProviderModel("author-model"),)) - reviewer = ProviderConfig("reviewer", "Reviewer", "https://reviewer.invalid/v1", "reviewer-key", (ProviderModel("reviewer-model", vision=True),)) - 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=author.base_url, - llm_api_key=author.api_key, - llm_model="author-model", - llm_timeout_s=1, - default_provider_id="author", - providers=(author, reviewer), - review_provider_id="reviewer", - review_model_id="reviewer-model", - ) - - -def requirements_document() -> MarkdownDocument: - return MarkdownDocument(markdown="""# Design Understanding - -Simple functional flange. - -# Explicit User Requirements - -- Create a simple flange. - -# Engineering Defaults and Assumptions - -- Use a circular body, central through bore, and four equally spaced mounting holes. - -# Dimensions and Coordinate Convention - -- Units are mm. The body is diameter 100 and thickness 10; bore diameter 30; four holes diameter 10 on radius 35. - -# Open Uncertainties - -- None. -""") - - -def completion_target() -> MarkdownDocument: - return MarkdownDocument(markdown="""# Completion Target - -- [ ] One connected cylindrical flange body, 100 mm outer diameter and 10 mm thickness. -- [ ] Centered 30 mm through bore. -- [ ] Four 10 mm mounting holes on a circular pattern of 35 mm pitch radius. -""") - - -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}}, - {"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}}]}, - ]}) - - -def modeling_plan() -> MarkdownDocument: - return MarkdownDocument(markdown="# Modeling Plan\n\n1. Create the circular flange body.\n2. Cut the centered bore.\n3. Add the circular mounting-hole pattern.\n") - - -def walk_keys(value: object) -> set[str]: - keys: set[str] = set() - if isinstance(value, dict): - keys.update(str(key) for key in value) - for item in value.values(): - keys.update(walk_keys(item)) - elif isinstance(value, list): - for item in value: - keys.update(walk_keys(item)) - return keys - - -class CadV3ProtocolTests(unittest.TestCase): - def test_state_machine_has_no_requirements_review_phase(self) -> None: - self.assertNotIn("REVIEWING_REQUIREMENTS", {phase.value for phase in TaskPhase}) - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0) - document = transition(state, "requirements_document_written", requirements_document_path="requirements.md") - target = transition(document, "completion_target_written", completion_target_path="completion-target.md") - compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json") - approved = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md") - self.assertEqual(approved.phase, TaskPhase.AWAITING_ACTION) - self.assertNotIn("requirements_finalized", {event for _phase, event in legal_transitions()}) - - def test_waiting_retry_resumes_exact_source_phase(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, 0) - waiting = transition(state, "waiting_retry", error=ErrorCode.MODEL_PROTOCOL_CHECK_PENDING) - self.assertEqual(retry_resume_event(waiting), "resume_drafting_requirements_document") - resumed = transition(waiting, retry_resume_event(waiting) or "") - self.assertEqual(resumed.phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT) - self.assertIsNone(resumed.retry_from_phase) - - def test_llm_schemas_exclude_server_owned_runtime_ids(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - schemas = [ - MarkdownDocument.model_json_schema(), - compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 2), - stateless_next_action_schema(list(runtime.supported_atomic_ids())), - StatelessCandidateReview.model_json_schema(), - StatelessGeometryConclusion.model_json_schema(), - stateless_final_review_schema(2), - ] - forbidden = { - "task_id", "working_head", "requirement_id", "requirement_ids", "claim_id", - "candidate_id", "action_id", "evidence_id", "evidence_refs", "source_id", "source_ids", - "draft_id", "attachment_id", "record_ids", - } - for schema in schemas: - self.assertFalse(walk_keys(schema) & forbidden, walk_keys(schema) & forbidden) - - def test_compiled_requirements_ignores_non_executable_extra_fields(self) -> None: - schema = compiled_requirements_schema(default_registry().expected_one_of_schema(exclude_claim_kinds=frozenset({"coaxial", "coplanar"})), 1) - raw = json.dumps({ - "assumptions": ["top-level notes from the compiler are not executable"], - "requirements": [{ - "statement": "model-added copy of the checklist text", - "assumptions": [], - "acceptance_claims": [{ - "claim_kind": "single_connected_body", - "expected": {}, - "evidence": "not part of the compiler contract", - }], - }], - }) - sanitized = sanitize_compiled_requirements_arguments(raw) - self.assertIsInstance(sanitized, str) - self.assertIsNone(canonical_validate_schema(sanitized, schema)) - parsed = canonical_validate(sanitized, CompiledRequirementsSpec) - self.assertIsInstance(parsed, CompiledRequirementsSpec) - self.assertEqual(parsed.requirements[0].acceptance_claims[0].claim_kind, "single_connected_body") - - def test_dynamic_tokens_are_enum_constrained(self) -> None: - rollback = stateless_rollback_checkpoint_schema(["checkpoint_one"]) - self.assertEqual(rollback["properties"]["checkpoint_token"], {"enum": ["checkpoint_one"]}) - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - schema = fragment_schema(runtime.operation_contract("hole_blind"), selector_tokens=["selector_one"], reference_tokens=[]) - 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))) - contract = runtime.operation_contract("extrude_add_blind") - fragment = { - "sketch": {"workplane": {"origin_mm": [0, -6, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, "profile": {"type": "circle", "center": [0, 0], "radius_mm": 60}}, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 12}}, - } - self.assertTrue(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True)) - fragment["sketch"]["workplane"]["origin_mm"] = [0, 0, -6] - self.assertEqual(validate_fragment(contract, fragment, selector_tokens=[], root_xy_datum=True), []) - - def test_runtime_keeps_executable_feature_prefix_when_later_feature_is_invalid(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.1.0", - "kind": "part", - "part_id": "partial_rebuild", - "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": "not_an_engine_operation", "params": {}, "depends_on": ["feature_001"]}, - ], - } - rebuilt, failures = runtime.rebuild_best_effort(cdsl, str(Path(temporary) / "candidate"), "partial_rebuild", "candidate") - self.assertEqual(rebuilt["executed_feature_ids"], ["feature_001"]) - self.assertEqual(len(failures), 1) - self.assertEqual(failures[0]["feature_id"], "feature_002") - - def test_action_submission_keeps_partial_feature_batch_for_review(self) -> None: - class PassingVerifier: - 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": "pass", - "evidence": {}, - } - for claim in claims - ] - - 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, PassingVerifier()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - proposal = NextAction( - working_head=state.working_head, - intent="Create a two-feature batch.", - requirement_ids=["req_001"], - atomic_id="extrude_add_blind", - expected_change="Keep the executable part of the batch.", - ) - self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted) - fragment = { - "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}}, - } - cdsl = { - "schema": "cad.cdsl.llm.v1", - "schema_version": "1.1.0", - "kind": "part", - "part_id": "partial_batch", - "geometry": {"sketches": []}, - "features": [ - {"id": "feature_001", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": []}, - {"id": "feature_002", "atomic_id": "extrude_add_blind", "params": {"distance_mm": 5}, "depends_on": ["feature_001"]}, - ], - } - audit = { - "schema_version": "cad.v3.fragment-audit.v1", - "atomic_id": "extrude_add_blind", - "fragment_hash": "hash", - "contract_hash": runtime.operation_contract("extrude_add_blind")["contract_hash"], - "assigned_feature_ids": ["feature_001", "feature_002"], - "assigned_sketch_ids": [], - "selector_snapshot_id": "", - "selector_tokens": [], - "reference_snapshot_id": "", - "reference_tokens": [], - } - rebuilt = { - "executed_feature_ids": ["feature_001"], - "health": {"solid_count": 1}, - "topology": {"records": []}, - "report": {}, - "render_manifest": {}, - "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json"}, - } - operation_failures = [{"feature_index": 1, "feature_id": "feature_002", "message": "failed after feature_001"}] - with patch.object(runtime, "materialize_fragment", return_value=(cdsl, audit)), patch.object(runtime, "rebuild_best_effort", return_value=(rebuilt, operation_failures)): - result = actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment") - self.assertIsInstance(result, Accepted) - reviewing = repository.get_state(task_id) - self.assertEqual(reviewing.phase, TaskPhase.CANDIDATE_REVIEW) - candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {} - self.assertEqual(candidate["executed_feature_ids"], ["feature_001"]) - self.assertEqual(candidate["operation_failures"], operation_failures) - - def test_best_effort_transition_completes_from_an_executable_checkpoint(self) -> None: - state = TaskState("cad_123456abcdef", TaskPhase.AWAITING_ACTION, 7, active_revision="rev_001", repair_required=True) - completed = transition(state, "best_effort_completed", error=ErrorCode.BEST_EFFORT_COMPLETED, repair_required=False) - self.assertEqual(completed.phase, TaskPhase.COMPLETED) - self.assertFalse(completed.repair_required) - - def test_review_rejection_publishes_the_executable_checkpoint_for_repair(self) -> None: - 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())) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - requirements.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - requirements.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - requirements.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - requirements.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - proposal = NextAction( - working_head=state.working_head, - intent="Create the base body.", - requirement_ids=["req_001"], - atomic_id="extrude_add_blind", - expected_change="Create the circular body.", - ) - self.assertIsInstance(actions.propose_next_action(task_id, proposal, invocation_id="action"), Accepted) - fragment = { - "sketch": { - "workplane": {"origin_mm": [0, 0, 0], "normal": [0, 0, 1], "x_dir": [1, 0, 0]}, - "profile": {"type": "circle", "center": [0, 0], "radius_mm": 50}, - }, - "feature": {"atomic_id": "extrude_add_blind", "params": {"distance_mm": 10}}, - } - self.assertIsInstance(actions.submit_cdsl_fragment(task_id, fragment, invocation_id="fragment"), Accepted) - reviewing = repository.get_state(task_id) - candidate = artifacts.read_stage_json(task_id, reviewing.candidate_stage_id, "candidate.json") or {} - review = CandidateReview( - candidate_id=reviewing.candidate_id, - working_head=reviewing.pending_action.working_head, - verdict="reject", - claim_coverage=[ - {"claim_id": str(item["claim_id"]), "status": str(item["status"]), "evidence_refs": []} - for item in candidate["claim_results"] - ], - evidence=["Base body is executable."], - issues=["The bore and bolt holes remain to be added."], - ) - result = actions.record_candidate_review(task_id, review, invocation_id="review") - self.assertIsInstance(result, Accepted) - self.assertEqual(result.payload["status"], "accepted_with_issues") - published = repository.get_state(task_id) - self.assertEqual(published.phase, TaskPhase.AWAITING_ACTION) - self.assertEqual(published.active_revision, "rev_001") - self.assertTrue(published.repair_required) - completed = actions.finalize_best_effort(task_id, reason=ErrorCode.NO_PROGRESS_LIMIT, invocation_id="best_effort") - self.assertIsInstance(completed, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.COMPLETED) - - def test_root_checkpoint_is_not_offered_as_a_rollback_target(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - runtime = ProfileCadRuntime(configured) - initial = repository.create_task("cad_123456abcdef", "Create a flange.") - document = transition(initial, "requirements_document_written", requirements_document_path="requirements.md") - target = transition(document, "completion_target_written", completion_target_path="completion-target.md") - compiled = transition(target, "requirements_compiled", requirements_contract_path="requirements-contract.json") - awaiting = transition(compiled, "modeling_plan_written", modeling_plan_path="modeling-plan.md", repair_required=True) - self.assertTrue(repository.compare_and_swap(document)) - self.assertTrue(repository.compare_and_swap(target)) - self.assertTrue(repository.compare_and_swap(compiled)) - self.assertTrue(repository.compare_and_swap(awaiting, events=[{ - "event": "geometry_conclusion", - "decision": "rollback", - "working_head": awaiting.working_head, - }])) - actions = ActionCommandHandler(repository, artifacts, runtime, default_registry()) - self.assertFalse(actions.rollback_available(awaiting.task_id)) - - def test_geometry_conclusion_is_stateless_for_the_author(self) -> None: - schema = StatelessGeometryConclusion.model_json_schema() - self.assertFalse({"working_head", "evidence_refs"} & walk_keys(schema)) - - def test_outer_cylinder_span_merges_oppositely_oriented_two_sided_faces(self) -> None: - def outer_face(record_id: str, direction: list[float], bbox: list[float]) -> dict[str, object]: - return { - "record_id": record_id, - "geometry": { - "surface_type": "cylinder", - "cylinder_role": "outer", - "radius_mm": 60.0, - "axis_origin_mm": [0.0, 0.0, 0.0], - "axis_direction": direction, - "bbox_mm": bbox, - }, - } - - facts = {"topology": {"records": [ - outer_face("upper", [0.0, 0.0, -1.0], [-60.0, -60.0, 0.0, 60.0, 60.0, 6.0]), - outer_face("lower", [0.0, 0.0, 1.0], [-60.0, -60.0, -6.0, 60.0, 60.0, 0.0]), - ]}} - result = default_registry().evaluate( - "outer_cylindrical_surface", - {"diameter_mm": 120.0, "count": 1, "axial_span_mm": 12.0}, - facts, - ) - self.assertEqual(result["status"], "pass") - self.assertEqual(result["evidence"]["axial_spans_mm"], [12.0]) - self.assertEqual(result["evidence"]["tolerance_mm"], 0.1) - - def test_compiler_persists_default_tolerance_for_axial_outer_cylinder(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - 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}}, - {"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) - state = repository.get_state(task_id) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - first_claim = contract["requirements"][0]["acceptance_claims"][0] - self.assertEqual(first_claim["expected"]["tolerance_mm"], 0.1) - - def test_record_bound_compiler_claims_are_visualized_before_contract_freeze(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - 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}}, - {"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", "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) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a stepped hub adapter.") - artifacts.initialize_task(task_id, "Create a stepped hub adapter.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, MarkdownDocument(markdown="""# Completion Target - -- [ ] A centered solid cylindrical flange body is present with 120 mm outer diameter and 12 mm thickness. -"""), invocation_id="completion_target") - compiled = CompiledRequirementsSpec.model_validate({"requirements": [{ - "assumptions": [], - "acceptance_claims": [ - {"claim_kind": "outer_cylindrical_surface", "expected": {"diameter_mm": 120, "count": 1, "tolerance_mm": 0.1}}, - {"claim_kind": "bbox_dimension_mm", "expected": {"axis": "z", "value": 12, "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 = contract["requirements"][0]["acceptance_claims"] - self.assertEqual([claim["claim_kind"] for claim in claims], ["outer_cylindrical_surface"]) - self.assertEqual(claims[0]["expected"]["axial_span_mm"], 12) - self.assertTrue(any("Global bbox Z verifier" in warning for warning in contract["verification_warnings"])) - - def test_requirements_markdown_is_not_rejected_for_missing_headings(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a flange.") - artifacts.initialize_task(task_id, "Create a flange.") - result = handler.submit_requirements_document(task_id, MarkdownDocument(markdown="A simple circular flange with a bore."), invocation_id="plain_markdown") - self.assertIsInstance(result, Accepted) - self.assertEqual(repository.get_state(task_id).phase, TaskPhase.DRAFTING_COMPLETION_TARGET) - - def test_server_bound_action_accepts_more_than_five_checklist_targets(self) -> None: - action = NextAction( - working_head="cad_123456abcdef:root:v4", - intent="Create the flange body.", - requirement_ids=[f"req_{position:03d}" for position in range(1, 8)], - atomic_id="extrude_add_blind", - expected_change="Add the first solid body.", - ) - self.assertEqual(len(action.requirement_ids), 7) - - def test_markdown_documents_freeze_before_compiled_flange_contract(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a simple flange.") - artifacts.initialize_task(task_id, "Create a simple flange.") - self.assertIsInstance(handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document"), Accepted) - self.assertIsInstance(handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target"), Accepted) - self.assertIsInstance(handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile"), Accepted) - self.assertIsInstance(handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan"), Accepted) - state = repository.get_state(task_id) - self.assertEqual(state.phase, TaskPhase.AWAITING_ACTION) - contract = artifacts.read_requirements_contract(task_id, state.requirements_contract_path) or {} - self.assertEqual(len(contract["requirements"]), 3) - claim_kinds = {claim["claim_kind"] for item in contract["requirements"] for claim in item["acceptance_claims"]} - self.assertTrue({"single_connected_body", "through_cylindrical_bore", "circular_hole_pattern"}.issubset(claim_kinds)) - self.assertTrue((artifacts.task_dir(task_id) / "requirements.md").is_file()) - self.assertTrue((artifacts.task_dir(task_id) / "completion-target.md").is_file()) - self.assertTrue((artifacts.task_dir(task_id) / "modeling-plan.md").is_file()) - - def test_invalid_verifier_contract_is_rejected_without_state_change(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()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create one solid.") - artifacts.initialize_task(task_id, "Create one solid.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - invalid = CompiledRequirementsSpec.model_validate({"requirements": [{"assumptions": [], "acceptance_claims": [{"claim_kind": "solid_count_equals", "expected": {"value": 0}}]}] * 3}) - before = repository.get_state(task_id) - result = handler.submit_compiled_spec(task_id, invalid, invocation_id="invalid_spec") - self.assertIsInstance(result, Rejected) - 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) - repository = SqliteTaskRepository(root / "state.sqlite3") - artifacts = FileArtifactStore(root / "tasks") - handler = RequirementsCommandHandler(repository, artifacts, default_registry()) - task_id = "cad_123456abcdef" - repository.create_task(task_id, "Create a coherent flange.") - artifacts.initialize_task(task_id, "Create a coherent flange.") - handler.submit_requirements_document(task_id, requirements_document(), invocation_id="requirements_document") - handler.submit_completion_target(task_id, completion_target(), invocation_id="completion_target") - handler.submit_compiled_spec(task_id, compiled_flange(), invocation_id="requirements_compile") - handler.submit_modeling_plan(task_id, modeling_plan(), invocation_id="modeling_plan") - state = repository.get_state(task_id) - path = handler.write_completion_result( - task_id, state, - claim_results=[{"claim_id": f"claim_{position:03d}", "status": "pass", "evidence": {"measured": True}} for position in range(1, 6)], - review={"visual_claims": []}, - ) - self.assertEqual(path, "completion-result.md") - result = (artifacts.task_dir(task_id) / path).read_text(encoding="utf-8") - target = (artifacts.task_dir(task_id) / "completion-target.md").read_text(encoding="utf-8") - requirements = (artifacts.task_dir(task_id) / "requirements.md").read_text(encoding="utf-8") - self.assertIn("Engineering Defaults", requirements) - self.assertIn("Centered 30 mm through bore", target) - self.assertIn("Centered 30 mm through bore.: pass", result) - - def test_sqlite_schema_contains_only_current_requirement_paths(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - database = Path(temporary) / "state.sqlite3" - SqliteTaskRepository(database) - with sqlite3.connect(database) as connection: - columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")} - self.assertIn("requirements_spec_path", columns) - self.assertIn("requirements_document_path", columns) - self.assertIn("completion_target_path", columns) - self.assertIn("modeling_plan_path", columns) - self.assertIn("clarification_path", columns) - self.assertNotIn("requirements_draft_path", columns) - self.assertNotIn("requirements_review_path", columns) - - def test_role_specific_capability_tools_have_no_review_loop(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - runtime = ProfileCadRuntime(settings(Path(temporary))) - author = conformance_tools(runtime, role="author") - reviewer = conformance_tools(runtime, role="reviewer") - author_names = {item["function"]["name"] for item in author} - reviewer_names = {item["function"]["name"] for item in reviewer} - self.assertTrue({"write_requirements_document", "write_completion_target", "compile_requirements_spec", "write_modeling_plan"}.issubset(author_names)) - self.assertNotIn("review_requirements", author_names | reviewer_names) - self.assertNotIn("get_cdsl_operation_contract", author_names) - self.assertEqual(reviewer_names, {"observe_images", "review_candidate", "review_final"}) - - def test_capability_cache_is_role_scoped(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - runtime = ProfileCadRuntime(settings(root)) - class Models: - def __init__(self) -> None: - self.calls = 0 - async def conformance(self, **_kwargs: object) -> dict[str, object]: - self.calls += 1 - return {"supported": True, "failures": [], "probe_unavailable": False} - models = Models() - first = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="author")) - second = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="author")) - reviewer = asyncio.run(verify_model_capability(repository, runtime, models, provider_id="p", model_id="m", role="reviewer")) - self.assertFalse(first.get("cached", False)) - self.assertTrue(second["cached"]) - self.assertNotEqual(first["schema_hash"], reviewer["schema_hash"]) - self.assertEqual(models.calls, 2) - self.assertIsNotNone(cached_model_capability(repository, runtime, provider_id="p", model_id="m", role="author")) - - def test_unsupported_capability_is_cached_but_transport_failure_is_not(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - repository = SqliteTaskRepository(root / "state.sqlite3") - runtime = ProfileCadRuntime(settings(root)) - schema_hash = conformance_hash(conformance_tools(runtime, role="reviewer"), role="reviewer") - class Unsupported: - async def conformance(self, **_kwargs: object) -> dict[str, object]: - return {"supported": False, "failures": [{"message": "schema"}], "probe_unavailable": False} - asyncio.run(verify_model_capability(repository, runtime, Unsupported(), provider_id="p", model_id="m", role="reviewer")) - self.assertIsNotNone(repository.model_capability("p", "m", schema_hash)) - class Unavailable: - async def conformance(self, **_kwargs: object) -> dict[str, object]: - return {"supported": False, "failures": [{"message": "network"}], "probe_unavailable": True} - asyncio.run(verify_model_capability(repository, runtime, Unavailable(), provider_id="p2", model_id="m", role="reviewer")) - self.assertIsNone(repository.model_capability("p2", "m", schema_hash)) - - def test_task_started_is_emitted_before_capability_work(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - message = ChatMessage.model_validate({"id": "user_1", "role": "user", "parts": [{"type": "text", "text": "Create a plate."}]}) - async def first_chunk() -> bytes: - stream = service.stream([message], None, None) - chunk = await anext(stream) - await stream.aclose() - return chunk - with patch("app.services.agent_service.verify_model_capability", AsyncMock()) as capability: - chunk = asyncio.run(first_chunk()).decode("utf-8") - self.assertIn("task_started", chunk) - capability.assert_not_awaited() - self.assertEqual(len(service.v3.repository.running_task_ids()), 1) - - def test_cached_capabilities_skip_normal_request_probe(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - service.v3.workflow.create_task(task_id, "Create a plate.") - queue: asyncio.Queue = asyncio.Queue() - with patch("app.services.agent_service.cached_model_capability", return_value={"supported": True}), patch( - "app.services.agent_service.verify_model_capability", AsyncMock() - ) as verify: - result = asyncio.run(service._ensure_task_capabilities( - task_id, ModelIdentity("author", "author-model"), ModelIdentity("reviewer", "reviewer-model"), queue, - )) - self.assertIsNone(result) - verify.assert_not_awaited() - self.assertTrue(queue.empty()) - - def test_missing_cache_is_visible_and_auto_resumes_same_task(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - configured = settings(root) - service = AgentService(configured, WorkspaceStore(configured), CdslLibrary(configured)) - task_id = "cad_123456abcdef" - service.v3.workflow.create_task(task_id, "Create a plate.") - queue: asyncio.Queue = asyncio.Queue() - with patch("app.services.agent_service.cached_model_capability", return_value=None), patch( - "app.services.agent_service.verify_model_capability", AsyncMock(return_value={"supported": True, "probe_unavailable": False}) - ) as verify: - result = asyncio.run(service._ensure_task_capabilities( - task_id, ModelIdentity("author", "author-model"), ModelIdentity("reviewer", "reviewer-model"), queue, - )) - self.assertIsNone(result) - self.assertEqual(verify.await_count, 2) - self.assertEqual(service.v3.repository.get_state(task_id).phase, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT) - events = [queue.get_nowait(), queue.get_nowait()] - self.assertEqual([item[1]["status"] for item in events], ["waiting", "success"]) - - def test_image_bytes_are_frozen_and_sent_to_vision(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - root = Path(temporary) - artifacts = FileArtifactStore(root / "tasks") - task_id = "cad_123456abcdef" - source = root / "reference.png" - png = b"\x89PNG\r\n\x1a\nreference-bytes" - source.write_bytes(png) - artifacts.initialize_task(task_id, "Match the image.", image_inputs=[{ - "path": str(source), "mime": "image/png", "sha256": sha256(png).hexdigest(), - }]) - frozen = Path(artifacts.source_image_paths(task_id)[0]) - class Models: - def __init__(self) -> None: - self.messages: list[list[dict[str, object]]] = [] - async def call_tool(self, *, messages: list[dict[str, object]], **_kwargs: object) -> dict[str, object]: - self.messages.append(messages) - return {"tool_calls": [], "usage": {}} - models = Models() - gateway = RenderedReviewGateway(models) - tool = {"type": "function", "function": {"name": "observe_images", "parameters": {"type": "object"}}} - asyncio.run(gateway.review(kind="image_observation", payload={"reference_image_paths": [str(frozen)]}, tool=tool, provider_id="p", model_id="m")) - image_part = models.messages[0][1]["content"][1] - encoded = image_part["image_url"]["url"].split(",", 1)[1] - self.assertEqual(base64.b64decode(encoded), png) - - def test_final_review_schema_has_ordered_visual_decision_count(self) -> None: - visual = stateless_final_review_schema(2)["properties"]["visual_claims"] - self.assertEqual((visual["minItems"], visual["maxItems"]), (2, 2)) - - def test_sqlite_cas_and_outbox_are_atomic(self) -> None: - with tempfile.TemporaryDirectory() as temporary: - repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") - initial = repository.create_task("cad_123456abcdef", "Create a plate.") - changed = transition(initial, "image_observed") - self.assertTrue(repository.compare_and_swap(changed, events=[{"event": "image_observation_ready"}])) - self.assertFalse(repository.compare_and_swap(changed, events=[{"event": "duplicate"}])) - self.assertEqual(len(repository.pending_outbox()), 1) - delivered = asyncio.run(OutboxDispatcher(repository, IdempotentInProcessPublisher()).dispatch_pending()) - self.assertEqual(len(delivered), 1) - self.assertEqual(repository.pending_outbox(), []) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_engine_extrude_draft_contract.py b/backend/tests/test_engine_extrude_draft_contract.py index 62e8da92..606f90cb 100644 --- a/backend/tests/test_engine_extrude_draft_contract.py +++ b/backend/tests/test_engine_extrude_draft_contract.py @@ -1,41 +1,17 @@ -"""#2 draft 假接受陷阱:extrudeParams.draft 必须被显式拒绝,而不是静默忽略。 +"""Extrude draft contracts distinguish executable and unsupported extents. 中文说明 -------- 这个文件在测试什么(issue #2「draft 被 schema 接受但 runtime 未执行」的回归测试): - 1. 背景:三方合同错位—— - - 机器契约 cdsl_schema.json(extrudeParams,约 101 行)允许 - "draft": {"type": "object"},且是空 object(无任何子字段约束), - 即"文档格式假接受"; - - 人读契约 profile_schema.json 的 extrude_add_blind / - extrude_add_two_sided / extrude_cut_blind 均**未**声明 draft - 为 optional_params; - - runtime(build123d_adapter.extrude 仅调 Solid.extrude,无锥形 - 拉伸)也完全没有 draft 实现,遇到 draft 就静默忽略。 - - 隐患:importer(translator.py 已解析 SolidWorks 的 - draft_angle_rad / reverse_draft_angle_rad)一旦把拔模角写进 - CDSL params,runtime 会静默产出**无拔模角的直壁实体**—— - 注塑件/压铸件丢失脱模斜度,脱模卡死、分型面配合错误,且全程 - 无警告(与 #1 y_dir / #3 revolve.reverse 同族的静默错误)。 + CADFS lowering now maps a single-sided blind draft to + ``Build123dGeometryAdapter.extrude_taper``. The CDSL machine schema uses + ``angle_deg`` plus ``pull_direction`` for that executable contract. - 2. 修复策略:因为 build123d 内核没有锥形拉伸能力、且人读契约未声明 - draft,正确的合同是"显式拒绝"而不是"实现拔模"—— - capabilities.py 对携带 draft 的 extrude 特征报 unsupported_draft - blocker(与 unsupported_extent 同模式)。schema 字段保留(文档格式 - 契约,importer 未来可能产出),能力层明确划界。 - - 3. 本测试套件把"draft 必须显式拒绝"固定下来: - - 主契约:带 draft 的 extrude 特征 → analyze 报 unsupported_draft - blocker,runtime_eligible=False; - - 回归护栏:不带 draft 的 extrude 特征 → 仍 runtime_eligible; - - 文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json(拒绝 - 发生在能力层,不是 schema 层); - - 覆盖:extrude_add_blind / extrude_add_two_sided / extrude_cut_blind - 三种原子都报同一 blocker(同一检查全类生效); - - 端到端:rebuild_cdsl(strict)带 draft → 抛 ValueError(大声失败), - analyze_document → runtime_eligible=False 且不 built(批量重建 - 不被静默污染)。 + A two-sided or non-blind draft has no defined neutral-plane semantics in + the current CDSL runtime. It must remain in the input document but stop + with ``unsupported_draft_extent`` before execution. Non-canonical draft + fields are protocol errors and are rejected by the machine schema. 4. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import cdsl_engine 包做端到端测试(与 test_engine_revolve_reverse.py 风格 @@ -97,15 +73,16 @@ def _rectangle(minimum: list[float], maximum: list[float]) -> dict: def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = True) -> dict: """构造最小 extrude 文档。 - - with_draft=True 时 params 携带 draft 对象(任意非空 object 即可, - 因为 cdsl_schema.json 对 draft 没有子字段约束); + - with_draft=True 时 params 携带可执行的单侧盲向 draft 对象; - with_draft=False 时完全不写 draft 字段(回归护栏用)。 - atomic_id 可切换 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind 三种原子,验证同一 blocker 检查对全类生效。 """ params: dict = {"distance_mm": 10.0} + if atomic_id == "extrude_add_two_sided": + params["reverse_distance_mm"] = 10.0 if with_draft: - params["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"} + params["draft"] = {"angle_deg": 5.0, "pull_direction": True} return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "draft-contract", "meta": {"unit": "mm"}, @@ -116,7 +93,7 @@ def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = Tr }]}, "features": [{ "id": "base_add", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "base", - "params": params, + "params": params, "execution_status": "supported", }], } @@ -134,20 +111,13 @@ def _validate_against_cdsl_schema(doc: dict) -> None: class ExtrudeDraftContractTests(unittest.TestCase): """draft 假接受陷阱「文档格式-能力边界-运行时」三方合同的回归测试。""" - def test_draft_extrude_is_explicitly_blocked(self) -> None: - """主契约:带 draft 的 extrude 特征必须被显式拒绝,而不是静默通过。 - - 修复前(当前):capabilities 对 extrude 只检查 end_condition, - draft 字段完全无人过问 → analyze 报 runtime_eligible=True, - rebuild 静默产出直壁实体 → 本测试红灯。 - 修复后:capabilities 报 unsupported_draft blocker → - runtime_eligible=False → 绿灯。 - """ + def test_blind_draft_extrude_is_runtime_eligible(self) -> None: + """A canonical single-sided blind draft reaches the taper executor.""" analysis = analyze_cdsl(_extrude_cdsl(with_draft=True)) - self.assertFalse(analysis.runtime_eligible) + self.assertTrue(analysis.runtime_eligible) result = next(item for item in analysis.feature_results if item.feature_id == "base_add") - self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers]) + self.assertNotIn("unsupported_draft_extent", [blocker.code for blocker in result.blockers]) def test_draft_free_extrude_stays_eligible(self) -> None: """回归护栏:不带 draft 的 extrude 特征仍必须 runtime_eligible。 @@ -160,52 +130,38 @@ class ExtrudeDraftContractTests(unittest.TestCase): self.assertTrue(analysis.runtime_eligible) def test_draft_passes_machine_schema(self) -> None: - """文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json 校验。 - - cdsl_schema.json(extrudeParams)保留 draft 字段,拒绝发生在 - 能力层(capabilities),不是 schema 层。这条测试锁死"schema 允许 - + 能力拒绝"的分层职责,防止未来把 schema 改过头(删掉字段后 - importer 未来产出 draft 会直接被 schema 打回,失去可诊断性)。 - """ + """Canonical draft input is a valid CDSL document for each extrude form.""" _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_blind", with_draft=True)) _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True)) _validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_cut_blind", with_draft=True)) - def test_draft_blocks_every_extrude_atomic(self) -> None: - """覆盖:三种 extrude 原子都报同一个 unsupported_draft blocker。 + def test_two_sided_draft_is_explicitly_blocked(self) -> None: + analysis = analyze_cdsl(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True)) - draft 检查挂在 _SKETCH_ATOM_PREFIXES(extrude_/revolve_)公共入口, - 必须对 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind - 同时生效,而不是只修了某一个。 - """ - for atomic_id in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"): - with self.subTest(atomic_id=atomic_id): - analysis = analyze_cdsl(_extrude_cdsl(atomic_id=atomic_id, with_draft=True)) - result = next(item for item in analysis.feature_results if item.feature_id == "base_add") - self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers]) + self.assertFalse(analysis.runtime_eligible) + result = next(item for item in analysis.feature_results if item.feature_id == "base_add") + self.assertIn("unsupported_draft_extent", [blocker.code for blocker in result.blockers]) - def test_draft_rebuild_fails_loudly_not_silently(self) -> None: - """端到端:draft 文档的重建必须大声失败,而不是静默产出直壁实体。 + def test_blind_draft_rebuilds_and_batch_reports_an_artifact(self) -> None: + """The supported branch builds a STEP artifact through both entry points.""" + with tempfile.TemporaryDirectory() as directory: + out_step = Path(directory) / "part.step" + rebuild_cdsl(_extrude_cdsl(with_draft=True), out_step) + self.assertTrue(out_step.exists()) - 修复前:rebuild_cdsl(strict)对 draft 视而不见 → 正常返回实体, - volume > 0,但几何是**没有拔模角的直壁**——静默错误。 - 修复后:rebuild_cdsl(strict)因 runtime_eligible=False 抛 - ValueError(feature is not runtime eligible: unsupported_draft); - analyze_document 报告 runtime_eligible=False 且不 built—— - 批量重建不会被静默污染。 - """ - # 1) strict 重建直接抛错(大声失败)。 - with self.assertRaisesRegex(ValueError, "unsupported_draft"): - with tempfile.TemporaryDirectory() as directory: - rebuild_cdsl(_extrude_cdsl(with_draft=True), Path(directory) / "part.step") - - # 2) 批量层 analyze_document 报告不可执行且不产出 STEP。 cdsl = _extrude_cdsl(with_draft=True) with tempfile.TemporaryDirectory() as directory: out_step = Path(directory) / "part.step" report = _analyze_inline(cdsl, out_step) - self.assertFalse(report["runtime_eligible"]) - self.assertFalse(report.get("built", False)) + self.assertTrue(out_step.exists()) + self.assertTrue(report["runtime_eligible"]) + self.assertTrue(report["built"]) + + def test_noncanonical_draft_is_rejected_by_machine_schema(self) -> None: + cdsl = _extrude_cdsl(with_draft=True) + cdsl["features"][0]["params"]["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"} + with self.assertRaises(jsonschema.ValidationError): + _validate_against_cdsl_schema(cdsl) def _analyze_inline(cdsl: dict, out_step: Path) -> dict: diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py index 250d9118..e9f4bb51 100644 --- a/backend/tests/test_engine_runtime_foundation.py +++ b/backend/tests/test_engine_runtime_foundation.py @@ -916,7 +916,6 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(resolved.record.record_id, "sweep:end") self.assertEqual(resolved.record.output_roles, ("sweep.end",)) self.assertEqual(evidence["output_role_status"], "unique_result_snapshot") - registry.replace_body_topology("later", "body:later", [ TopologyRecord("later:end", "face", "later", "body:later", {"center_mm": [0, 0, 10]}, object()), ]) @@ -926,6 +925,103 @@ class EngineRuntimeFoundationTests(unittest.TestCase): self.assertEqual(stale.status, "not_found") self.assertEqual(stale.diagnostic.code, "selector_output_role_not_found") + def test_provenance_selector_uses_exact_lineage_not_geometry_successors(self) -> None: + registry = TopologyRegistry() + source = object() + exact_result = object() + geometrically_similar = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source), + ]) + registry.replace_body_topology("later", "body:later", [ + TopologyRecord("later:exact", "face", "later", "body:later", {"center_mm": [10, 0, 0]}, exact_result), + TopologyRecord("later:similar", "face", "later", "body:later", {"center_mm": [0, 0, 0]}, geometrically_similar), + ], topology_delta=TopologyDelta("transform", ( + TopologyDeltaRelation("modified", "face", source, (exact_result,)), + ))) + selector = { + "kind": "face", "owner_feature_id": "base", "stable_id": "base:face", + "source": "runtime_snapshot", "confidence": 1.0, + "geometry": {"center_mm": [0, 0, 0]}, + "selector_intent": { + "version": "1.0", "kind": "face", "query_family": "SWEPT_FACE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["continuation"], "multiplicity": "one"}, + "evidence": "kernel_history", + }, + } + resolution = registry.resolve(selector, active_body_id="body:later") + self.assertEqual(resolution.status, "resolved") + self.assertEqual(resolution.record.record_id, "later:exact") + self.assertEqual(registry.lineage()[0].derivation, "continuation") + + def test_provenance_selector_rejects_non_unique_fragment(self) -> None: + registry = TopologyRegistry() + source = object() + first = object() + second = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}, source), + ]) + registry.replace_body_topology("fillet", "body:fillet", [ + TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {"center_mm": [0, 0, 0]}, first), + TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {"center_mm": [1, 0, 0]}, second), + ], topology_delta=TopologyDelta("fillet", ( + TopologyDeltaRelation("modified", "edge", source, (first, second)), + ))) + resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["fragment"], "multiplicity": "one"}, + "evidence": "kernel_history", + }, + }, active_body_id="body:fillet") + self.assertEqual(resolution.status, "ambiguous") + self.assertEqual(resolution.diagnostic.code, "selector_relation_non_unique") + + def test_provenance_selector_returns_all_proven_fragments(self) -> None: + registry = TopologyRegistry() + source = object() + first = object() + second = object() + registry.replace_body_topology("base", "body:base", [ + TopologyRecord("base:edge", "edge", "base", "body:base", {}, source), + ]) + registry.replace_body_topology("fillet", "body:fillet", [ + TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first), + TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second), + ], topology_delta=TopologyDelta("fillet", ( + TopologyDeltaRelation("modified", "edge", source, (first, second)), + ))) + resolution = registry.resolve({ + "kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge", + "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": { + "version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE", + "source_query": {"ast": {}, "featurescript_version": "1511"}, + "derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"}, + "evidence": "kernel_history", + }, + }, active_body_id="body:fillet") + self.assertEqual(resolution.status, "resolved") + self.assertIsNone(resolution.record) + self.assertEqual([record.record_id for record in resolution.records], ["fillet:first", "fillet:second"]) + + def test_boolean_section_edges_are_recorded_as_intersection_lineage(self) -> None: + registry = TopologyRegistry() + section_edge = object() + registry.replace_body_topology("boolean", "body:boolean", [ + TopologyRecord("boolean:section", "edge", "boolean", "body:boolean", {}, section_edge), + ], topology_delta=TopologyDelta("intersect", section_values=(section_edge,))) + delta = registry.topology_deltas()[0] + lineage = delta["lineage"][0] + self.assertEqual(lineage["derivation"], "intersection") + self.assertEqual(lineage["result_record_ids"], ["boolean:section"]) + self.assertTrue(delta["relations"][0]["section_edge"]) + def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None: registry = TopologyRegistry() extrude_start = object() diff --git a/backend/tests/test_feature_plan.py b/backend/tests/test_feature_plan.py deleted file mode 100644 index f9d895a0..00000000 --- a/backend/tests/test_feature_plan.py +++ /dev/null @@ -1,357 +0,0 @@ -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/backend/tests/test_live_guidance_comparison.py b/backend/tests/test_live_guidance_comparison.py deleted file mode 100644 index eb1224e0..00000000 --- a/backend/tests/test_live_guidance_comparison.py +++ /dev/null @@ -1,71 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -import sys -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -sys.path.insert(0, str(ROOT / "backend")) - -from app.cad_agent.evals.live import _fixture, compare_guidance_reports # noqa: E402 - - -def _result(*, scenario: str, repetition: int, author_calls: int, context_chars: int, schema_rejections: int, failure_layer: str = "") -> dict: - return { - "scenario": scenario, - "repetition": repetition, - "outcome": "passed", - "revision_ids": ["revision_001"], - "projection": {"phase": "COMPLETED"}, - "checks": {"deterministic_claims_pass": True}, - "schema_rejection_count": schema_rejections, - "failure_attribution": {"layer": failure_layer} if failure_layer else None, - "scenario_budget": {"max_author_turns": 8, "max_reviewer_turns": 2, "max_total_calls": 10, "max_total_tokens": 1000}, - "usage": {"records": [{"context_chars": context_chars} for _ in range(author_calls)]}, - } - - -def _report(results: list[dict]) -> dict: - return { - "author": {"provider": "author", "model": "model"}, - "reviewer": {"provider": "reviewer", "model": "review"}, - "runtime_profile_sha256": "a" * 64, - "operation_contracts": [{"atomic_id": "extrude_add_blind", "contract_hash": "b" * 64}], - "author_guidance": {"enabled": False, "max_chars": 3600}, - "results": results, - } - - -class LiveGuidanceComparisonTests(unittest.TestCase): - def test_fixture_accepts_multiple_stable_scenarios_in_fixture_order(self) -> None: - selected = _fixture("comprehensive", ["l_bracket", "circular_flange_pcd"]) - self.assertEqual([item["id"] for item in selected], ["circular_flange_pcd", "l_bracket"]) - - def test_comparison_enforces_matched_budget_and_quality_gates(self) -> None: - control = _report([ - _result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=2, failure_layer="cdsl_expression"), - _result(scenario="part_a", repetition=2, author_calls=10, context_chars=1000, schema_rejections=1), - ]) - treatment = _report([ - _result(scenario="part_a", repetition=1, author_calls=11, context_chars=1300, schema_rejections=0), - _result(scenario="part_a", repetition=2, author_calls=11, context_chars=1300, schema_rejections=0), - ]) - treatment["author_guidance"]["enabled"] = True - comparison = compare_guidance_reports(control, treatment) - self.assertEqual(comparison["status"], "passed") - self.assertTrue(comparison["gates"]["median_author_calls_within_ten_percent"]) - self.assertTrue(comparison["gates"]["model_or_cdsl_failure_improved"]) - - def test_comparison_excludes_explicit_unsupported_runtime_capability(self) -> None: - control = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1000, schema_rejections=1)]) - treatment = _report([_result(scenario="part_a", repetition=1, author_calls=10, context_chars=1200, schema_rejections=0)]) - treatment["author_guidance"]["enabled"] = True - treatment["results"][0]["ledger"] = [{"operation_failures": [{"message": "unsupported_draft"}]}] - comparison = compare_guidance_reports(control, treatment) - self.assertEqual(comparison["treatment"]["eligible_runs"], 0) - self.assertEqual(comparison["excluded_capability_gaps"]["treatment"], [{"scenario": "part_a", "repetition": 1}]) - - -if __name__ == "__main__": - unittest.main() diff --git a/backend/tests/test_review_renderer.py b/backend/tests/test_render_bundle.py similarity index 93% rename from backend/tests/test_review_renderer.py rename to backend/tests/test_render_bundle.py index 02c6394c..9d1313ad 100644 --- a/backend/tests/test_review_renderer.py +++ b/backend/tests/test_render_bundle.py @@ -9,7 +9,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) -from app.services.review_renderer import render_section # noqa: E402 +from app.services.render_bundle import render_section # noqa: E402 from app.settings import ProviderConfig, ProviderModel, Settings # noqa: E402 @@ -32,7 +32,7 @@ def settings(root: Path) -> Settings: ) -class ReviewRendererTests(unittest.TestCase): +class RenderBundleTests(unittest.TestCase): def test_section_uses_build123d_part_operation_and_emits_a_png(self) -> None: import build123d as b3d diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index b3a8e9c5..22a2847e 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -58,24 +58,3 @@ class SettingsModelSelectionTests(unittest.TestCase): self.assertEqual(resolved_provider.id, "alternate") self.assertEqual(resolved_model.id, "alternate-first") - - def test_reviewer_cannot_be_the_same_author_model(self) -> None: - provider = ProviderConfig("openai", "OpenAI", "https://example.invalid/v1", "key", (ProviderModel("gpt-5.5", vision=True),)) - settings = Settings( - task_root=ROOT / "tmp-tasks", - conversation_root=ROOT / "tmp-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="gpt-5.5", - llm_timeout_s=1, - default_provider_id="openai", - providers=(provider,), - review_provider_id="openai", - review_model_id="gpt-5.5", - ) - - author_provider, author_model = settings.resolve_model(None, None) - with self.assertRaisesRegex(ValueError, "must differ"): - settings.resolve_independent_review_model(author_provider, author_model) diff --git a/backend/tests/test_single_stage.py b/backend/tests/test_single_stage.py new file mode 100644 index 00000000..d0c99867 --- /dev/null +++ b/backend/tests/test_single_stage.py @@ -0,0 +1,21 @@ +from app.cad_agent.application.single_stage import SingleStageExecutor + + +class Artifacts: + def __init__(self): self.writes = [] + def write_json_once(self, *_args): self.writes.append(_args); return "x" + def start_staging_revision(self, *_args): + return type("Stage", (), {"stage_id": "stage", "output_dir": "/tmp"})() + def write_stage_json(self, *_args): return "x" + def publish_staging_revision(self, *_args): return {"model.step": "x.step"} + + +class Runtime: + def compile_authoring(self, value): return ({"features": [{"id": "feature_001"}]}, {"feature_ids": {"base": "feature_001"}}) + def rebuild_best_effort(self, *_args): return ({"executed_feature_ids": ["feature_001"]}, []) + + +def test_single_stage_publishes_compiled_document(): + result = SingleStageExecutor(None, Artifacts(), Runtime()).execute("cad_abcdefghijkl", {"bodies": []}) + assert result["status"] == "completed" + assert result["executed_feature_ids"] == ["feature_001"] diff --git a/backend/tests/test_single_stage_evals.py b/backend/tests/test_single_stage_evals.py new file mode 100644 index 00000000..a8b9d31f --- /dev/null +++ b/backend/tests/test_single_stage_evals.py @@ -0,0 +1,27 @@ +from app.cad_agent.evals.single_stage import summarize + + +def test_single_stage_summary_reports_protocol_quality_and_cost() -> None: + report = summarize([ + { + "attempts": [{"schema_valid": True, "executable": False}], + "published_revision": "rev_1", + "requirement_targets": [{"status": "pass"}, {"status": "pending"}], + "usage": {"records": [{"prompt_tokens": 10, "completion_tokens": 2}]}, + "duration_ms": 15, + }, + { + "attempts": [{"schema_valid": False, "executable": False}], + "requirement_targets": [{"status": "fail"}], + "usage": {"records": [{"prompt_tokens": 3, "completion_tokens": 1}]}, + "duration_ms": 5, + }, + ]) + assert report["first_pass_schema_rate"] == 0.5 + assert report["first_pass_executable_rate"] == 0.0 + assert report["final_executable_rate"] == 0.5 + assert report["requirement_targets"] == {"pass": 1, "fail": 1, "pending": 1, "not_applicable": 0} + assert report["calls"] == 2 + assert report["prompt_tokens"] == 13 + assert report["completion_tokens"] == 3 + assert report["duration_ms"] == 20 diff --git a/backend/tests/test_single_stage_workflow.py b/backend/tests/test_single_stage_workflow.py new file mode 100644 index 00000000..fee6faa3 --- /dev/null +++ b/backend/tests/test_single_stage_workflow.py @@ -0,0 +1,180 @@ +import asyncio +from pathlib import Path +from tempfile import TemporaryDirectory + +from app.cad_agent.adapters.artifact_store import FileArtifactStore +from app.cad_agent.adapters.sqlite_repository import SqliteTaskRepository +from app.cad_agent.application.workflow import ModelIdentity, WorkflowConfig, WorkflowCoordinator +from app.cad_agent.application.authoring_compiler import AuthoringCompileError +from app.cad_agent.domain.errors import ErrorCode +from app.cad_agent.domain.state import TaskPhase, transition + + +class Runtime: + def supported_atomic_ids(self): return ("extrude_add_blind",) + def operation_contract(self, _operation): + return {"atomic_id": "extrude_add_blind", "fragment_shape": {"sketch": "required", "selector_tokens": "forbidden"}, "selector_policy": {"slot": None, "token_kind": None, "min_items": 0, "max_items": 0}, "reference_policy": {"mode": "none"}, "author_params_schema": {"type": "object", "properties": {"distance_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["distance_mm"], "additionalProperties": False}} + + +class Executor: + def __init__(self, artifacts): self.artifacts = artifacts + def compile(self, _task, _authoring, *, repair_count): + runtime, audit = {"features": []}, {} + runtime_path, audit_path = "documents/runtime.json", "documents/audit.json" + self.artifacts.write_json_once(_task, runtime_path, runtime) + self.artifacts.write_json_once(_task, audit_path, audit) + return {"runtime": runtime, "compile_audit": audit, "runtime_path": runtime_path, "audit_path": audit_path, "digest": "x"} + def build(self, _task, _authoring, _runtime, _audit, *, repair_count, digest=""): + return {"status": "completed", "revision_id": "rev_1", "paths": {"model.step": "revisions/rev_1/model.step"}, "executed_feature_ids": ["feature_001"], "diagnostics": []} + + +class Models: + def __init__(self): self.calls = 0 + async def call_tool(self, **kwargs): + self.calls += 1 + if kwargs["required_tool_name"] == "analyze_requirements": + payload = {"explicit_requirements": ["plate"], "assumptions": [], "acceptance_targets": [], "manual_targets": []} + else: + payload = {"bodies": [{"name": "main", "features": [{"name": "base", "operation": "extrude_add_blind", "params": {"distance_mm": 8}, "sketch": {"workplane": {"origin_mm": [0,0,0], "x_dir": [1,0,0], "normal": [0,0,1]}, "profile": {"type": "circle", "diameter_mm": 20}}}]}]} + import json + return {"tool_calls": [{"function": {"name": kwargs["required_tool_name"], "arguments": json.dumps(payload)}}], "usage": {}} + + +class InvalidAuthorModels(Models): + def __init__(self): + super().__init__() + self.author_calls = 0 + + async def call_tool(self, **kwargs): + if kwargs["required_tool_name"] == "analyze_requirements": + return await super().call_tool(**kwargs) + self.calls += 1 + self.author_calls += 1 + import json + return { + "tool_calls": [{"function": { + "name": "write_authoring_cdsl", + "arguments": json.dumps({"feature_id": "feature_001", "bodies": []}), + }}], + "usage": {}, + } + + +def test_workflow_uses_two_author_calls_and_publishes(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + models = Models() + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), models, Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + events = asyncio.run(_collect(workflow)) + assert models.calls == 2 + assert events[-1][0] == "task_terminal" + assert repository.get_state("cad_abcdefghijkl").phase.value == "COMPLETED" + + +def test_service_retry_returns_to_the_persisted_stage_without_new_authoring_call(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), Models(), Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + created = repository.get_state("cad_abcdefghijkl") + assert created is not None + authoring = transition(created, "analysis_written", requirements_path="documents/requirements-analysis.json") + assert repository.compare_and_swap(authoring) + failed = transition(authoring, "failed", error=ErrorCode.STORAGE_FAILURE) + assert failed.phase == TaskPhase.FAILED + assert failed.retry_from_phase == TaskPhase.AUTHORING_CDSL + assert repository.compare_and_swap(failed) + + assert workflow.resume("cad_abcdefghijkl") + resumed = repository.get_state("cad_abcdefghijkl") + assert resumed is not None + assert resumed.phase == TaskPhase.AUTHORING_CDSL + assert resumed.retry_from_phase is None + + +def test_authoring_schema_failures_use_at_most_two_repairs(): + with TemporaryDirectory() as temporary: + repository = SqliteTaskRepository(Path(temporary) / "state.sqlite3") + artifacts = FileArtifactStore(Path(temporary) / "artifacts") + models = InvalidAuthorModels() + workflow = WorkflowCoordinator(WorkflowConfig(), repository, artifacts, Runtime(), models, Executor(artifacts)) + workflow.create_task("cad_abcdefghijkl", "make a plate") + + asyncio.run(_collect(workflow)) + + state = repository.get_state("cad_abcdefghijkl") + assert state is not None + assert state.phase == TaskPhase.FAILED + assert state.repair_count == 2 + assert models.author_calls == 3 + + +def test_author_operation_context_exposes_server_injected_selector_contract(): + context = WorkflowCoordinator._author_operation_contract({ + "fragment_shape": {"sketch": "forbidden", "selector_tokens": "required"}, + "selector_policy": { + "slot": "params.host_face", "token_kind": "face", "min_items": 1, "max_items": 1, + }, + "author_params_schema": {"type": "object", "properties": {}, "additionalProperties": False}, + }) + + assert context["sketch"] == "forbidden" + assert context["selector"] == { + "required": True, + "kind": "face", + "min_items": 1, + "max_items": 1, + "destination": "params.host_face", + "source_syntax": ".", + } + assert context["authoring_sketch_template"] is None + + +def test_schema_repair_hint_contains_the_exact_authoring_sketch_form(): + assert "diameter_mm" in WorkflowCoordinator._repair_hint( + "AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile", + ) + + +def test_repair_can_change_unexecuted_features_after_compile_failure(): + previous = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {}}, + {"name": "bad_hole", "operation": "hole_wizard", "params": {}}, + ]}]} + replacement = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {}}, + {"name": "bad_hole", "operation": "hole_wizard", "params": {"diameter_mm": 8}}, + ]}]} + WorkflowCoordinator._validate_repair_document( + previous, + replacement, + {"diagnostics": [{"code": "SELECTOR_NOT_FOUND", "path": "features.bad_hole.selectors"}]}, + {"feature_ids": {"base": "feature_001", "bad_hole": "feature_002"}}, + ) + + +def test_repair_cannot_change_executed_feature_without_targeted_diagnostic(): + previous = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {"height_mm": 8}}, + ]}]} + replacement = {"bodies": [{"name": "main", "features": [ + {"name": "base", "operation": "box_add", "params": {"height_mm": 9}}, + ]}]} + try: + WorkflowCoordinator._validate_repair_document( + previous, + replacement, + {"diagnostics": [], "executed_feature_ids": ["feature_001"]}, + {"feature_ids": {"base": "feature_001"}}, + ) + except AuthoringCompileError as error: + assert error.code == "AUTHOR_SCHEMA_INVALID" + else: + raise AssertionError("an executed feature was changed without a diagnostic target") + + +async def _collect(workflow): + return [item async for item in workflow.run(task_id="cad_abcdefghijkl", author=ModelIdentity("p", "m"))] diff --git a/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md b/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md new file mode 100644 index 00000000..2a9c00ca --- /dev/null +++ b/cadfs_to_cdsl/CADFS_CAPABILITY_SNAPSHOT_COMPARISON.md @@ -0,0 +1,139 @@ +# CADFS 全量能力快照对比 + +## 范围 + +本文档对比第一次归档的 CADFS 全量运行与最新归档的全量运行。两份快照均包含 +9,347 个源样本。 + +| 快照 | 报告生成时间 | 证据目录 | +| --- | --- | --- | +| 第一次全量运行 | 2026-09-02 20:40:56 | `cadfs_to_cdsl/output-history/20260907-185128/` | +| 最新全量运行 | 2026-09-08 21:59:59 | `cadfs_to_cdsl/output-history/20260908-215959/` | + +一个 executor 已注册,仅表示运行时能识别该原子操作;它本身不表示全部 CADFS +参数变体、拓扑 selector、body 生命周期或源模型都能正确重建。下文的全量比较与 +能力缺口表才是实际覆盖范围的证据。 + +## 已注册的原子操作 + +| 原子操作组 | 第一次全量运行 | 最新全量运行 | 变化 | +| --- | --- | --- | --- | +| 基础实体 | `sphere_add` | `box_add`, `cylinder_add`, `sphere_add` | 新增方体与圆柱体 | +| 盲拉伸/加料拉伸 | `extrude_add_blind`, `extrude_add_two_sided` | 保留原有操作,新增 `extrude_add_blind_with_hole` | 新增 selector 绑定的端盖孔拉伸 | +| 切除拉伸 | `extrude_cut_blind` | 保留原有操作,新增 `extrude_cut_through`、`extrude_cut_two_sided` | 新增贯穿与双向切除 | +| 曲面拉伸 | 未注册 | `extrude_surface` | 新增仅生成曲面的拉伸路径 | +| 回转 | `revolve_add`, `revolve_cut` | 保留原有操作,新增 `revolve_surface` | 新增曲面回转路径 | +| 放样 | 未注册 | `loft_add`, `loft_add_with_cap_face` | 新增实体放样与 `CAP_FACE` 驱动放样 | +| 扫掠 | 未注册 | `sweep_add` | 新增实体扫掠路径 | +| 抽壳 | 未注册 | `shell` | 新增抽壳路径 | +| 多 body 布尔 | 未注册 | `boolean_bodies` | 新增 body 的 union/subtract/intersect | +| 孔 | `hole_blind`, `hole_counterbore`, `hole_countersink`, `hole_wizard` | 相同 | 无新原子操作 | +| 修饰特征 | `fillet`, `chamfer` | 相同 | 无新原子操作;覆盖和 selector 处理有改进 | +| 阵列 | `pattern_linear`, `pattern_mirror` | 保留原有操作,新增 `pattern_circular` | 新增环形阵列;replay/body 处理也有改进 | +| 基准几何 | `reference_plane`, `reference_axis` | 相同 | 无新原子操作;CADFS 基准面变体覆盖增加 | +| 螺纹 | 未注册 | `thread_add`, `thread_cut` | 新增加料/切除螺纹 | +| 钣金折弯 | 未注册 | `bend_add` | 已注册;这两份 CADFS 全量快照未单独证明其 CADFS 映射与几何接受率 | + +## 全量能力覆盖 + +计数是被记录为能力缺口的样本数。减少表示更多样本进入支持的转换/运行时路径, +但不表示每个新增可执行模型都已通过几何验收。 + +| 类别 | 能力 | 第一次缺口 | 最新缺口 | 变化 | 当前解释 | +| --- | --- | ---: | ---: | ---: | --- | +| 新覆盖路径 | `revolve_surface` | 367 | 0 | -367 | 已观察到的样本均进入支持的运行时路径 | +| 新覆盖路径 | `extrude_cut_through_all` | 301 | 0 | -301 | 不再因贯穿切除产生此能力缺口 | +| 新覆盖路径 | `extrude_cut_two_sided` | 260 | 0 | -260 | 不再因双向切除产生此能力缺口 | +| 新覆盖路径 | `extrude_add_through_all` | 3 | 0 | -3 | 不再因贯穿加料产生此能力缺口 | +| 新覆盖路径 | `extrude_extent:up_to_next` | 48 | 0 | -48 | 不再因 up-to-next 产生此能力缺口 | +| 新覆盖路径 | `reference_plane:line_angle` | 121 | 0 | -121 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:plane_point` | 46 | 0 | -46 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:three_point` | 34 | 0 | -34 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:mid_plane` | 24 | 0 | -24 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:line_point` | 22 | 0 | -22 | 不再因该基准面变体产生此能力缺口 | +| 新覆盖路径 | `reference_plane:curve_point` | 12 | 0 | -12 | 不再因该基准面变体产生此能力缺口 | +| 主要覆盖提升 | `extrude` | 3,861 | 1,197 | -2,664 | 最大降幅;派生 profile 与 selector 变体仍待完成 | +| 主要覆盖提升 | `shell` | 696 | 57 | -639 | 主路径已存在;面选择器变体仍待完成 | +| 主要覆盖提升 | `fillet` | 1,914 | 1,417 | -497 | 覆盖增加;OCC 可行性和后继选择仍阻塞大量模型 | +| 主要覆盖提升 | `revolve` | 741 | 344 | -397 | 实体回转覆盖增加 | +| 主要覆盖提升 | `loft` | 308 | 68 | -240 | 主放样路径和部分 cap-face 形式已覆盖 | +| 主要覆盖提升 | `chamfer` | 589 | 381 | -208 | 覆盖增加;selector 与内核失败仍存在 | +| 主要覆盖提升 | `hole` | 623 | 386 | -237 | 更多孔变体进入运行时 | +| 主要覆盖提升 | `sweep` | 326 | 133 | -193 | 实体扫掠覆盖增加;路径变体仍待完成 | +| 主要覆盖提升 | `booleanBodies` | 187 | 92 | -95 | 多 body 路径覆盖增加 | +| 主要覆盖提升 | `cPlane` | 147 | 84 | -63 | 更多基准面路径成功 lower | +| 主要覆盖提升 | `mirror` | 262 | 158 | -104 | 阵列/body replay 覆盖增加 | +| 主要覆盖提升 | `circularPattern` | 228 | 74 | -154 | 阵列/body replay 覆盖增加 | +| 主要覆盖提升 | `transform` | 1 | 230 | +229 | 更多 transform 语义被识别并诊断;不代表完整支持 | +| 派生拓扑 | `extrude_profile_topology:cap_edge` | 133 | 91 | -42 | 部分 `CAP_EDGE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:cap_face` | 200 | 183 | -17 | 部分 `CAP_FACE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:intersect` | 382 | 362 | -20 | 部分 `INTERSECT` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:offset_face` | 18 | 9 | -9 | 部分 `OFFSET_FACE` 覆盖 | +| 派生拓扑 | `extrude_profile_topology:swept_face` | 99 | 94 | -5 | 部分 `SWEPT_FACE` 覆盖 | + +## 最新快照中新分类的未完成边界 + +这些条目在最新全量报告中被新分类或单独拆出。它们表示已知未完成语义,不能视为 +已完成能力。 + +| 能力缺口 | 最新受影响样本 | 含义 | +| --- | ---: | --- | +| `shell_face_selector` | 221 | 这些 source history 的 shell remove-face selector 尚不能解析 | +| `delete_bodies` | 121 | body 生命周期中的删除语义不完整 | +| `sweep_path` | 68 | 扫掠路径变体尚不能表达或执行 | +| `shell_outward` | 19 | 向外抽壳方向尚未完成 | +| `extrude_draft_extent` | 15 | 带拔模拉伸的终止语义尚未完成 | +| `sweep_remove` | 6 | 扫掠切除语义尚未完成 | +| `circular_pattern_remove_source` | 1 | 环形阵列移除源 body 的生命周期尚未完成 | +| `extrude_cap_edge_profile` | 1 | `CAP_EDGE` 派生拉伸 profile 尚未完成 | + +## 端到端结果 + +结果表区分可执行工件和几何验收。`RP` 是工程验收口径;`strict` 是更严格的诊断, +不能隐藏已 RP 通过的工件。 + +| 指标 | 第一次全量运行 | 最新全量运行 | 变化 | +| --- | ---: | ---: | ---: | +| 候选 CDSL 文件 | 8,108 | 8,633 | +525 | +| 完整转换 | 2,427 | 5,367 | +2,940 | +| 无可执行特征的 deferred | 2,602 | 693 | -1,909 | +| 重建 STEP 文件 | 1,812 | 6,134 | +4,322 | +| 比较报告 | 1,719 | 6,090 | +4,371 | +| RP 通过模型 | 1,159 (12.40%) | 2,031 (21.73%) | +872 | +| Strict 通过模型 | 578 (6.18%) | 905 (9.68%) | +327 | +| 比较超时或 worker 失败 | 56 | 44 | -12 | +| 已重建但几何拒绝 | 568 | 4,059 | +3,491 | +| 重建失败 | 617 | 2,144 | +1,527 | +| 解析/lowering 失败 | 3 | 21 | +18 | + +当更多 partial 或原先不支持的 history 能进入 engine 后,被拒绝的 STEP 明显增加。 +这些 STEP 工件和诊断必须保留;它们是 converter/runtime 的证据,不是验收通过的重建。 + +## 当前失败优先级 + +最新全量运行记录了以下主要重建失败类别。 + +| 失败类别 | 受影响重建 | 优先工作 | +| --- | ---: | --- | +| Selector 找不到 | 982 | 保留拓扑 provenance,实现精确的后继/output-role 绑定 | +| Fillet 内核失败 | 347 | 改善通用可行性处理,并保留有界 OCC 诊断 | +| Chamfer 内核失败 | 174 | 改善通用可行性处理,并保留有界 OCC 诊断 | +| BRep API 失败 | 139 | 调查操作特定的内核前置条件与拓扑输入 | +| Compound 拉伸不支持 | 83 | 完成显式多 body/Compound 的拉伸语义 | +| Selector 歧义 | 82 | 改善 selector provenance,确定性拒绝不唯一候选 | +| 终止目标不可达或空 | 71 | 完成以目标为基础的 extent/trim 语义 | +| Boolean 操作失败 | 98 | 完成通用多 body boolean 输入和结果生命周期 | +| Shell 操作失败 | 46 | 完成 remove-face 及向内/向外抽壳语义 | + +## 状态 + +最新快照显示转换和可执行 STEP 覆盖已有明显扩展,但尚未完成全量重建。只有当一项 +能力同时具备 FeatureScript lowering、CDSL contract、runtime/adapter 行为、selector/body +生命周期、单元覆盖、多个真实语料回归与 RP 比较证据时,才能标记为完成。 + +## 证据 + +- `cadfs_to_cdsl/output-history/20260907-185128/summary.json` +- `cadfs_to_cdsl/output-history/20260907-185128/full_run_report.md` +- `cadfs_to_cdsl/output-history/20260908-215959/summary.json` +- `cadfs_to_cdsl/output-history/20260908-215959/full_run_report.md` diff --git a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md index 2a63c5a8..23edad0c 100644 --- a/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md +++ b/cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md @@ -35,6 +35,11 @@ feature history 生成工程相似的 STEP。 - 若已有实现反复依赖样本化补丁、不能表达已出现的通用语义或受内核 API 结构性限制, 必须评估替代方案,不得沿错误方向继续累积补丁。替换需要可复现根因、成熟实现或最小 原型的对照、contract/迁移影响评估和回归计划;单个样本或偶发内核失败不足以触发重写。 +- 每项 FeatureScript operation、query 或枚举语义的 lowering,必须对照源文件声明的 + FeatureScript/standard library 版本对应的官方 API/query contract,并在能力矩阵记录 + 文档或标准库来源、已采用的语义与未覆盖边界。当前官网或其它版本的文档不得替代源版本 + 的默认值和行为。文档只用于确定源语义,不能替代 CDSL provenance、OCC builder history + 或 selector 的唯一性证据。 - 每项能力必须同时具备:FeatureScript lowering、CDSL schema/semantic validation、 runtime/adapter 实现、selector/body 语义、单元测试、多个语料回归和比较工件;缺少 任一层只能标记为“部分完成”。 @@ -495,8 +500,9 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和 和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为 部分完成。 3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。 -4. 原子语义矩阵包含正向、边界和拒绝测试;至少多个真实语料样本覆盖不同几何和 - 生命周期组合。 +4. 能力矩阵记录对应 FeatureScript API/query 的源版本文档或标准库依据、采用的语义和 + 未覆盖边界;原子语义矩阵包含正向、边界和拒绝测试,且至少多个真实语料样本覆盖 + 不同几何和生命周期组合。 5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余 exception 均更新到本地台账。 6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为 diff --git a/cadfs_to_cdsl/featurescript_parser.py b/cadfs_to_cdsl/featurescript_parser.py index c2ca377d..a2c1ecdc 100644 --- a/cadfs_to_cdsl/featurescript_parser.py +++ b/cadfs_to_cdsl/featurescript_parser.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import Any from .featurescript_lexer import Token, lex from .ir import Call, FeatureIR, ModelIR, SketchIR @@ -130,7 +131,18 @@ def _arg_map(call: Call) -> dict[str, Any]: def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR: - parser = Parser(source); calls = parser.statements(); model = ModelIR(sample_id, raw_source=source) + parser = Parser(source); calls = parser.statements() + version = re.search(r"\bFeatureScript\s+(\d+(?:\.\d+)*)\s*;", source) + standard_library = re.search( + r"\bimport\s*\(\s*path\s*:\s*[\"']([^\"']*onshape/std/[^\"']*)[\"']", + source, + ) + model = ModelIR( + sample_id, + raw_source=source, + featurescript_version=version.group(1) if version else None, + standard_library=standard_library.group(1) if standard_library else None, + ) for call in calls: if call.name == "newSketch": definition = _arg_map(call) diff --git a/cadfs_to_cdsl/ir.py b/cadfs_to_cdsl/ir.py index d4abb10c..40568e76 100644 --- a/cadfs_to_cdsl/ir.py +++ b/cadfs_to_cdsl/ir.py @@ -37,3 +37,7 @@ class ModelIR: sketches: list[SketchIR] = field(default_factory=list) steps: list[Any] = field(default_factory=list) raw_source: str = "" + # Source metadata is retained independently from the lowered CDSL so a + # selector can be audited against the FeatureScript API contract it used. + featurescript_version: str | None = None + standard_library: str | None = None diff --git a/cadfs_to_cdsl/lowering.py b/cadfs_to_cdsl/lowering.py index 45ba9b16..dacf8fac 100644 --- a/cadfs_to_cdsl/lowering.py +++ b/cadfs_to_cdsl/lowering.py @@ -49,6 +49,55 @@ def plain(value: Any) -> Any: return value +def _selector_intent( + value: Any, + *, + query_family: str, + kind: str, + evidence: str, + allowed: tuple[str, ...] = ("continuation",), + multiplicity: str = "one", + output_role: str | None = None, + disambiguation: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Keep the source query as semantics, separate from runtime topology IDs.""" + query = parse_query(value) + intent: dict[str, Any] = { + "version": "1.0", + "kind": kind, + "query_family": query_family, + # This placeholder is populated from the enclosing ModelIR once the + # FeatureScript source header has been parsed. + "source_query": {"ast": query.ast, "featurescript_version": "0"}, + "derivation_policy": {"allowed": list(allowed), "multiplicity": multiplicity}, + "evidence": evidence, + } + if query.source_sketch and query.source_entity: + intent["source_entity"] = {"sketch_id": query.source_sketch, "entity_id": query.source_entity} + if output_role is not None: + intent["output_role"] = output_role + if disambiguation is not None: + intent["disambiguation"] = disambiguation + return intent + + +def _finalize_selector_intents(cdsl: dict[str, Any], model: ModelIR) -> None: + """Bind source-version metadata after all FeatureScript selectors lower.""" + source_version = model.featurescript_version or "0" + for feature in cdsl.get("features") or []: + for selector in feature.get("selectors") or []: + intent = selector.get("selector_intent") if isinstance(selector, dict) else None + if not isinstance(intent, dict): + continue + selector["selector_intent_version"] = "1.0" + source_query = intent.get("source_query") + if not isinstance(source_query, dict): + continue + source_query["featurescript_version"] = source_version + if model.standard_library: + source_query["standard_library"] = model.standard_library + + def _bool(value: Any) -> bool: return value is True or (isinstance(value, str) and value.lower() == "true") @@ -573,6 +622,17 @@ def _shell_offset_face_output_role_selector( }, "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="OFFSET_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary", "replacement"), + output_role="shell.offset_face", + disambiguation={"type": "true_dependency", "sources": [ + {"owner_feature_id": source["owner_feature_id"], "output_role": source["output_role"]}, + ]}, + ), } @@ -1792,6 +1852,14 @@ def _cap_face_output_role_selector( "output_role": f"{role_prefix}.{'start' if query.is_start else 'end'}", "source": "runtime_snapshot", "confidence": 1.0, + "selector_intent": _selector_intent( + value, + query_family="CAP_FACE", + kind="face", + evidence="operation_role", + allowed=("boundary", "continuation"), + output_role=f"{role_prefix}.{'start' if query.is_start else 'end'}", + ), } @@ -4800,6 +4868,8 @@ def lower_model(model: ModelIR, provenance: dict[str, Any]) -> LoweringResult: 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) cdsl = {"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": model.sample_id, - "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")})}, + "meta": {"unit": "mm", "source": "CADFS", "provenance": provenance, "capability_gaps": sorted({d.get("operation") for d in diagnostics if d.get("operation")}), + "source_featurescript": {"version": model.featurescript_version or "0", **({"standard_library": model.standard_library} if model.standard_library else {})}}, "geometry": {"sketches": sketches}, "features": features} + _finalize_selector_intents(cdsl, model) return LoweringResult(cdsl, "converted_complete" if complete else "converted_partial", diagnostics, history) diff --git a/cadfs_to_cdsl/query_parser.py b/cadfs_to_cdsl/query_parser.py index c0f9c13c..c7b4fa72 100644 --- a/cadfs_to_cdsl/query_parser.py +++ b/cadfs_to_cdsl/query_parser.py @@ -15,6 +15,18 @@ class QueryInfo: source_entity: str | None = None is_start: bool | None = None calls: list[str] = field(default_factory=list) + # The AST is intentionally lossless for the parser's value model. Query + # aliases may be resolved for lowering, but their nested query semantics + # must remain auditable and cannot be collapsed into a geometry hint. + ast: dict[str, Any] | list[Any] | str | float | bool | None = None + query_combinators: list[str] = field(default_factory=list) + filters: list[str] = field(default_factory=list) + body_scope: list[str] = field(default_factory=list) + disambiguation: list[str] = field(default_factory=list) + + def __post_init__(self) -> None: + if self.ast is None: + self.ast = {} def as_dict(self) -> dict[str, Any]: return asdict(self) @@ -29,10 +41,31 @@ def walk_calls(value: Any): for item in value.values(): yield from walk_calls(item) +def query_ast(value: Any) -> dict[str, Any] | list[Any] | str | float | bool | None: + """Serialize parsed FeatureScript query syntax without evaluating it.""" + if isinstance(value, Call): + return {"call": value.name, "args": [query_ast(arg) for arg in value.args], "line": value.line} + if isinstance(value, list): + return [query_ast(item) for item in value] + if isinstance(value, dict): + return {str(key): query_ast(item) for key, item in value.items()} + if value is None or isinstance(value, (str, float, bool, int)): + return value + return str(value) + + def parse_query(value: Any) -> QueryInfo: - info = QueryInfo() + info = QueryInfo(ast=query_ast(value)) for call in walk_calls(value): info.calls.append(call.name) + if call.name in {"qUnion", "qIntersection", "qSubtraction", "qAdjacent"}: + info.query_combinators.append(call.name) + if call.name in {"qBodyType", "qOwnerBody"}: + info.body_scope.append(call.name) + if call.name in {"TDD", "trueDependencyDisambiguation"}: + info.disambiguation.append(call.name) + if call.name in {"qBodyType", "qOwnerBody", "qAdjacent"}: + info.filters.append(call.name) if call.name in {"makeQuery", "qCreatedBy"} and call.args: owner = symbolic_string(call.args[0]) if "F" in owner: diff --git a/cadfs_to_cdsl/selector_binding.py b/cadfs_to_cdsl/selector_binding.py index 1f7b2145..938f09bc 100644 --- a/cadfs_to_cdsl/selector_binding.py +++ b/cadfs_to_cdsl/selector_binding.py @@ -152,6 +152,18 @@ def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list for placeholder in targets: records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id")) output_role = str(placeholder.get("output_role") or "").strip() + intent = placeholder.get("selector_intent") + if ( + isinstance(intent, dict) + and intent.get("query_family") != "GEOMETRIC" + and not output_role + ): + # Prefix rebuilding is retained as a diagnostic adapter. + # It must not turn an unproven FeatureScript provenance + # query into a geometry-scored stable selector. + raise ValueError( + f"{feature['id']}: selector_kernel_history_missing after prefix rebuild" + ) if output_role: # Builder output roles are not geometry placeholders. They # remain in the bound CDSL so runtime can resolve the diff --git a/cadfs_to_cdsl/tests/test_lowering.py b/cadfs_to_cdsl/tests/test_lowering.py index 3c9b9950..69075369 100644 --- a/cadfs_to_cdsl/tests/test_lowering.py +++ b/cadfs_to_cdsl/tests/test_lowering.py @@ -1041,10 +1041,15 @@ class LoweringTests(unittest.TestCase): cap_extrude = features["f_F5"] self.assertEqual(cap_extrude["atomic_id"], "extrude_from_face") self.assertNotIn("sketch_id", cap_extrude) - self.assertEqual(cap_extrude["selectors"], [{ + selector = cap_extrude["selectors"][0] + self.assertEqual({key: selector[key] for key in ( + "kind", "owner_feature_id", "output_role", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F3", "output_role": "extrude.start", "source": "runtime_snapshot", "confidence": 1.0, - }]) + }) + self.assertEqual(selector["selector_intent_version"], "1.0") + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") from engine.cdsl_engine.runtime import rebuild_cdsl with tempfile.TemporaryDirectory() as directory: @@ -1691,6 +1696,21 @@ class LoweringTests(unittest.TestCase): self.assertEqual(result.diagnostics[0]["code"], "unsupported_engine_capability") self.assertIn("extrude_profile_topology:cap_face", result.diagnostics[0]["capability"]) + def test_cap_face_selector_retains_versioned_provenance_intent(self): + source = SOURCE.replace( + '\n});\n', + '\n extrude(context, id + "F2", {"entities":makeQuery(id + "F1.opExtrude", "CAP_FACE", FACE, {"isStart":false}), "depth":10 * mm});\n});\n', + ) + result = lower_model(parse_featurescript(source, "cap-intent"), {}) + self.assertEqual(result.status, "converted_complete") + selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")["selectors"][0] + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(selector["selector_intent_version"], "1.0") + self.assertEqual(selector["selector_intent"]["source_query"]["featurescript_version"], "1511") + self.assertEqual(selector["selector_intent"]["evidence"], "operation_role") + from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl + self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"]) + 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/cadfs_to_cdsl/tests/test_parser.py b/cadfs_to_cdsl/tests/test_parser.py index 0b388338..4d9a9952 100644 --- a/cadfs_to_cdsl/tests/test_parser.py +++ b/cadfs_to_cdsl/tests/test_parser.py @@ -52,6 +52,16 @@ class ParserTests(unittest.TestCase): value = parse_query(query) self.assertEqual((value.owner_feature, value.source_sketch, value.source_entity), ("F1", "F0", "E0")) self.assertFalse(value.is_start) + self.assertEqual(value.ast["call"], "makeQuery") + self.assertEqual(value.ast["args"][0]["call"], "__binary__") + + def test_source_version_and_standard_library_are_retained(self): + source = '''FeatureScript 1511; + import(path : "onshape/std/geometry.fs", version : "1511.0"); + export const f = defineFeature(function(context, id, definition) {});''' + model = parse_featurescript(source, "versioned") + self.assertEqual(model.featurescript_version, "1511") + self.assertEqual(model.standard_library, "onshape/std/geometry.fs") def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self): source = r''' diff --git a/cadfs_to_cdsl/tests/test_selector_binding.py b/cadfs_to_cdsl/tests/test_selector_binding.py index 32dc3ec4..ef0130c7 100644 --- a/cadfs_to_cdsl/tests/test_selector_binding.py +++ b/cadfs_to_cdsl/tests/test_selector_binding.py @@ -123,10 +123,14 @@ class SelectorBindingTests(unittest.TestCase): bound, evidence = bind_candidate_selectors(candidate.cdsl) selector = next(item for item in bound["features"] if item["id"] == "f_F3")["selectors"][0] - self.assertEqual(selector, { + self.assertEqual({key: selector[key] for key in ( + "kind", "owner_feature_id", "output_role", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F1", "output_role": "extrude.end", "source": "runtime_snapshot", "confidence": 1.0, }) + self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE") + self.assertEqual(selector["selector_intent"]["evidence"], "operation_role") self.assertNotIn("stable_id", selector) self.assertNotIn("snapshot_id", selector) self.assertNotIn("geometry", selector) @@ -155,11 +159,15 @@ class SelectorBindingTests(unittest.TestCase): self.assertEqual(first["snapshot_id"], "body:f_F2:face:1") self.assertEqual(first["source"], "runtime_snapshot") self.assertEqual(first["confidence"], 1.0) - self.assertEqual(offset, { + self.assertEqual({key: offset[key] for key in ( + "kind", "owner_feature_id", "output_role", "output_role_source", "source", "confidence", + )}, { "kind": "face", "owner_feature_id": "f_F2", "output_role": "shell.offset_face", "output_role_source": {"owner_feature_id": "f_F1", "output_role": "extrude.start"}, "source": "runtime_snapshot", "confidence": 1.0, }) + self.assertEqual(offset["selector_intent"]["query_family"], "OFFSET_FACE") + self.assertEqual(offset["selector_intent"]["disambiguation"]["type"], "true_dependency") binding = next(item for item in evidence if item["feature_id"] == "f_F3") self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1") self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5") diff --git a/frontend/src/components/agent-studio.tsx b/frontend/src/components/agent-studio.tsx index 22a6d798..40049575 100644 --- a/frontend/src/components/agent-studio.tsx +++ b/frontend/src/components/agent-studio.tsx @@ -21,7 +21,7 @@ import type { } from "@/lib/cad-types"; import { AgentThread } from "./agent-thread"; import { CadViewerPreview } from "./cad-viewer-preview"; -import { MarkdownDocument } from "./rich-content"; +import { JsonTree, MarkdownDocument } from "./rich-content"; import type { AssistantRuntime } from "@assistant-ui/react"; type LoadState = "loading" | "ready" | "error"; @@ -557,27 +557,10 @@ function StudioShell({
{!config?.configured ?
未配置模型环境变量,聊天会保留诊断但不会生成虚假模型。
: null} - {config?.autonomous_generation && !config.review_configured ?
最终视觉复核未配置,任务在最终发布前会停止:{config.review_error || "请配置独立视觉模型。"}
: null}
@@ -587,27 +570,18 @@ function StudioShell({ ); } -function TaskDocuments({ task, onSelectRevision }: { task: TaskRecord | null; onSelectRevision: (revisionId: string) => void }) { - const documents = [ - ["需求文档", task?.requirements_markdown], - ["完成目标", task?.completion_target_markdown], +function TaskDocuments({ task }: { task: TaskRecord | null }) { + const structured = [ + ["需求分析", task?.requirements_analysis], + ["Authoring CDSL", task?.authoring_cdsl], + ["编译审计", task?.compile_audit], + ["构建诊断", task?.diagnostics], ] as const; - if (!documents.some(([, markdown]) => markdown) && !task?.feature_nodes?.length) return null; + if (!structured.some(([, value]) => value) && !task?.completion_result_markdown) 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)} + {task?.repair_count !== undefined ?
修复
{task.repair_count} / {task.repair_budget ?? 2}
: null} + {structured.map(([title, value]) => value ?
{title}
: null)} + {task?.completion_result_markdown ?
完成报告{task.completion_result_markdown}
: null}
; } diff --git a/frontend/src/components/cad-message-parts.tsx b/frontend/src/components/cad-message-parts.tsx index 1735a15b..f4a9afff 100644 --- a/frontend/src/components/cad-message-parts.tsx +++ b/frontend/src/components/cad-message-parts.tsx @@ -1,9 +1,9 @@ "use client"; -import { AlertTriangle, Box, Check, Download, Eye, FileCheck, Loader2, RotateCcw, Search, Wrench } from "lucide-react"; +import { AlertTriangle, Box, Check, Download, FileCheck, Loader2, RotateCcw, Search } from "lucide-react"; import { encodeArtifactUrl } from "@/lib/cad-artifacts"; import type { CadError, CadProgress, CadResult } from "@/lib/cad-types"; -import { JsonTree, MarkdownDocument } from "./rich-content"; +import { MarkdownDocument } from "./rich-content"; export function TextPart({ text }: { text: string }) { if (!text.trim()) return null; @@ -15,26 +15,20 @@ export function CadProgressPart({ data }: { data: CadProgress }) { const isRunning = status === "running"; const isError = status === "error"; const isWaiting = status === "waiting"; - const statusLabel = isRunning ? "进行中" : isWaiting ? data.lifecycle === "waiting_retry" ? "等待重试" : "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status; - const Icon = data.step === "tool_call" ? Wrench : data.step.includes("review") || data.step === "final_review" ? Eye : data.step === "rollback" ? RotateCcw : data.step.includes("requirements") || data.step.includes("checklist") ? FileCheck : data.step.includes("diagnostic") ? Search : isError || isWaiting ? AlertTriangle : Check; - const evidence = data.evidence || (Array.isArray(data.review?.evidence) ? data.review.evidence.map(String) : []); + const statusLabel = isRunning ? "进行中" : isWaiting ? "等待确认" : isError ? "失败" : status === "success" ? "完成" : data.status; + const Icon = data.step === "repair_started" ? RotateCcw : data.step === "build_result" ? Box : data.step === "cdsl_compiled" ? Search : data.step === "requirements_ready" || data.step === "authoring_cdsl_ready" ? FileCheck : isError || isWaiting ? AlertTriangle : Check; const documentMarkdown = data.markdown || ""; return (
{isRunning ?
{data.message ?
{data.message}
: null} {data.questions?.length ?
    {data.questions.map((question, index) =>
  • {question}
  • )}
: null} {data.issues?.length ?
    {data.issues.map((issue, index) =>
  • {issue}
  • )}
: null} - {data.verificationWarnings?.length ?
验证风险 ({data.verificationWarnings.length})
    {data.verificationWarnings.map((warning, index) =>
  • {warning}
  • )}
: null} {documentMarkdown ?
文档内容{documentMarkdown}
: null} - {data.arguments ?
调用参数
: null} - {data.result !== undefined ?
执行结果
: null} - {evidence.length ?
证据 ({evidence.length})
    {evidence.map((item, index) =>
  • {item}
  • )}
: null}
); } @@ -59,7 +53,6 @@ export function CadResultPart({ data }: { data: CadResult }) { {data.referenceIds.length} 个参考
{data.referenceIds.length ?
参考{data.referenceIds.join(";")}
: null} - {data.verificationWarnings?.length ?
验证风险{data.verificationWarnings.join(";")}
: null} {downloads.length ?
{downloads.map(([label, path]) => ( @@ -84,7 +77,6 @@ export function CadErrorPart({ data }: { data: CadError }) {
{data.message}
- {data.tool ? {data.tool} : null} {data.fieldErrors?.length ?
字段错误 ({data.fieldErrors.length})
    {data.fieldErrors.map((error, index) =>
  • {error.path || "/"}{error.message ? `: ${error.message}` : ""}
  • )}
: null}
); diff --git a/frontend/src/lib/cad-artifacts.ts b/frontend/src/lib/cad-artifacts.ts index 0c3c2256..3ec3a491 100644 --- a/frontend/src/lib/cad-artifacts.ts +++ b/frontend/src/lib/cad-artifacts.ts @@ -30,8 +30,6 @@ function resultForRevision(task: TaskRecord, revisionId: string, checkpoint: boo engine: current.engine || "cdsl_only", checkpoint, lifecycle: task.lifecycle || "completed", - verificationStatus: task.verification_status, - verificationWarnings: task.verification_warnings || [], }; } @@ -44,10 +42,7 @@ export function activeCheckpointPreview(task: TaskRecord | null): CadResult | nu export function latestSuccessfulResult(task: TaskRecord | null): CadResult | null { if (!task) 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; + if (task.lifecycle === "failed" && !task.published_revision) 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-messages.ts b/frontend/src/lib/cad-messages.ts index a19be29e..b10cd1a0 100644 --- a/frontend/src/lib/cad-messages.ts +++ b/frontend/src/lib/cad-messages.ts @@ -62,7 +62,7 @@ export function restoreTaskProjection(messages: CadUIMessage[], task: TaskRecord )); if (alreadyVisible) return messages; const status = task.lifecycle === "failed" ? "error" - : task.lifecycle === "waiting_for_user" || task.lifecycle === "waiting_retry" ? "waiting" + : task.lifecycle === "waiting_for_user" ? "waiting" : task.lifecycle === "completed" ? "success" : "running"; const progress: CadProgress = { step, @@ -73,10 +73,7 @@ export function restoreTaskProjection(messages: CadUIMessage[], task: TaskRecord message: task.message || (terminal ? "CAD 任务已停止。" : "CAD 任务正在运行。"), questions: task.questions || [], issues: task.issues || [], - blockerType: task.blocker_type, userActionRequired: Boolean(task.user_action_required), - verificationStatus: task.verification_status, - verificationWarnings: task.verification_warnings || [], }; return [...messages, { id: `projection_${task.task_id}_${task.state_version || 0}`, diff --git a/frontend/src/lib/cad-stream.test.ts b/frontend/src/lib/cad-stream.test.ts index c1618a1e..01e2dd75 100644 --- a/frontend/src/lib/cad-stream.test.ts +++ b/frontend/src/lib/cad-stream.test.ts @@ -9,10 +9,18 @@ import type { CadUIMessage } from "./cad-types"; test("maps backend cad_result SSE into an AI SDK data part", () => { const chunk = backendEventToUiChunk({ event: "cad_result", - data: { taskId: "cad_abc", revisionId: "rev_001" }, + data: { + taskId: "cad_abc", revisionId: "rev_001", cdslPath: "model.cdsl.json", + stepPath: "model.step", glbPath: "model.glb", reportPath: "rebuild-report.json", + summary: "plate", referenceIds: [], engine: "cdsl_only", + }, }, "text_1"); assert.equal(chunk?.type, "data-cad-result"); - assert.deepEqual("data" in chunk! ? chunk.data : null, { taskId: "cad_abc", revisionId: "rev_001" }); + assert.deepEqual("data" in chunk! ? chunk.data : null, { + taskId: "cad_abc", revisionId: "rev_001", cdslPath: "model.cdsl.json", + stepPath: "model.step", glbPath: "model.glb", reportPath: "rebuild-report.json", + summary: "plate", referenceIds: [], engine: "cdsl_only", + }); }); test("keeps progressive revisions as separate data parts", () => { @@ -21,23 +29,13 @@ test("keeps progressive revisions as separate data parts", () => { assert.notEqual(first?.id, second?.id); }); -test("maps final repair review into a blocking progress state", () => { +test("maps a single-stage build repair into a blocking progress state", () => { const chunk = backendEventToUiChunk({ - event: "final_review", data: { taskId: "cad_abc", result: { status: "repair", evidence: ["missing round"] } }, + event: "build_result", data: { taskId: "cad_abc", status: "repair_required", message: "host face is ambiguous" }, }, "text_1"); assert.equal(chunk?.type, "data-cad-progress"); assert.deepEqual("data" in chunk! ? chunk.data : null, { - step: "final_review", label: "最终独立复核", status: "error", message: "", taskId: "cad_abc", result: { status: "repair", evidence: ["missing round"] }, - }); -}); - -test("maps a rejected independent candidate review into a blocking progress state", () => { - const chunk = backendEventToUiChunk({ - event: "candidate_review", data: { taskId: "cad_abc", result: { status: "rejected", evidence: ["base is disconnected"] } }, - }, "text_1"); - assert.equal(chunk?.type, "data-cad-progress"); - assert.deepEqual("data" in chunk! ? chunk.data : null, { - step: "candidate_review", label: "候选独立复核", status: "error", message: "", taskId: "cad_abc", result: { status: "rejected", evidence: ["base is disconnected"] }, + step: "build_result", label: "CAD 构建", status: "error", message: "host face is ambiguous", taskId: "cad_abc", }); }); @@ -46,7 +44,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { event: "cad_error", data: { stage: "generation", - tool: "compile_requirements_spec", + tool: "write_authoring_cdsl", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }, @@ -54,7 +52,7 @@ test("keeps terminal schema field errors visible to the CAD error part", () => { assert.equal(chunk?.type, "data-cad-error"); assert.deepEqual("data" in chunk! ? chunk.data : null, { stage: "generation", - tool: "compile_requirements_spec", + tool: "write_authoring_cdsl", message: "Author repeatedly failed the schema.", fieldErrors: [{ path: "/patches", message: "Field required" }], }); @@ -124,17 +122,17 @@ test("keeps explicit runtime issues visible when generation stops", () => { }); }); -test("gives repeated tool events unique ordered parts", () => { - const first = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "running" } }, "text_1", 4); - const second = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", tool: "inspect_model", status: "success" } }, "text_1", 5); +test("gives repeated build events unique ordered parts", () => { + const first = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", status: "repair_required" } }, "text_1", 4); + const second = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", status: "completed" } }, "text_1", 5); assert.notEqual(first?.id, second?.id); assert.equal("data" in first! ? (first.data as { sequence?: number }).sequence : null, 4); assert.equal("data" in second! ? (second.data as { sequence?: number }).sequence : null, 5); }); -test("reuses an invocation id so tool completion updates its running card", () => { - const running = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "running" } }, "text_1", 4); - const complete = backendEventToUiChunk({ event: "tool_call", data: { taskId: "cad_abc", eventId: "call_1", invocationId: "call_1", tool: "submit_cdsl_fragment", status: "success" } }, "text_1", 5); +test("reuses an event id for build card updates", () => { + const running = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", eventId: "build_1", status: "repair_required" } }, "text_1", 4); + const complete = backendEventToUiChunk({ event: "build_result", data: { taskId: "cad_abc", eventId: "build_1", status: "completed" } }, "text_1", 5); assert.equal(running?.id, complete?.id); }); @@ -180,21 +178,6 @@ 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-stream.ts b/frontend/src/lib/cad-stream.ts index ef330a5d..faa72884 100644 --- a/frontend/src/lib/cad-stream.ts +++ b/frontend/src/lib/cad-stream.ts @@ -20,15 +20,11 @@ export function backendEventToUiChunk( data: { ...item.data, sequence }, }; } - if (["image_observation", "requirements_document_ready", "completion_target_ready", "requirements_compiled", "modeling_plan_ready", "completion_result_ready", "action_selection", "tool_call", "candidate_result", "candidate_review", "final_review", "task_terminal"].includes(item.event)) { - const review = item.data.review && typeof item.data.review === "object" - ? item.data.review as Record - : null; + if (["requirements_ready", "authoring_cdsl_ready", "cdsl_compiled", "build_result", "repair_started", "task_terminal"].includes(item.event)) { const lifecycle = String(item.data.lifecycle || ""); const status = item.event === "task_terminal" - ? (lifecycle === "failed" ? "error" : lifecycle === "waiting_for_user" || lifecycle === "waiting_retry" ? "waiting" : "success") - : String((item.data.result as Record | undefined)?.status || "") === "rejected" - || String((item.data.result as Record | undefined)?.status || "") === "repair" + ? (lifecycle === "failed" ? "error" : lifecycle === "waiting_for_user" ? "waiting" : "success") + : String(item.data.status || "") === "repair_required" ? "error" : String(item.data.status || "running"); const taskId = String(item.data.taskId || "task"); @@ -37,37 +33,25 @@ export function backendEventToUiChunk( ? { eventId, sequence, - ...(review ? { review } : {}), } : {}; return { type: "data-cad-progress", id: `event_${eventId}`, data: { step: item.event, label: ({ - image_observation: "参考图片观察", requirements_document_ready: "需求文档已冻结", completion_target_ready: "完成目标已冻结", requirements_compiled: "需求合同已编译", modeling_plan_ready: "建模计划已冻结", completion_result_ready: "完成结果已就绪", action_selection: "动作选择", tool_call: "建模工具", candidate_result: "候选构建", candidate_review: "候选独立复核", final_review: "最终独立复核", task_terminal: "生成任务", + requirements_ready: "需求分析", authoring_cdsl_ready: "完整 CDSL", cdsl_compiled: "CDSL 编译", build_result: "CAD 构建", repair_started: "CDSL 修复", task_terminal: "生成任务", } as Record)[item.event], status, ...metadata, message: String( item.data.message || item.data.reason - || (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : "") - || (review?.evidence instanceof Array ? review.evidence.join(";") : "") - || (review?.issues instanceof Array ? review.issues.map((issue) => typeof issue === "object" && issue ? String((issue as Record).message || "") : String(issue)).filter(Boolean).join(";") : ""), + || (Array.isArray(item.data.questions) ? item.data.questions.map(String).filter(Boolean).join(";") : ""), ), ...(item.data.taskId ? { taskId } : {}), - ...(item.data.nodeId ? { nodeId: String(item.data.nodeId) } : {}), ...(item.data.lifecycle ? { lifecycle: String(item.data.lifecycle) } : {}), ...(Array.isArray(item.data.questions) ? { questions: item.data.questions.map(String).filter(Boolean) } : {}), ...(Array.isArray(item.data.issues) ? { issues: item.data.issues.map(String).filter(Boolean) } : {}), - ...(item.data.blockerType ? { blockerType: String(item.data.blockerType) } : {}), ...(typeof item.data.userActionRequired === "boolean" ? { userActionRequired: item.data.userActionRequired } : {}), - ...(item.data.verificationStatus ? { verificationStatus: String(item.data.verificationStatus) } : {}), - ...(Array.isArray(item.data.verificationWarnings) ? { verificationWarnings: item.data.verificationWarnings.map(String).filter(Boolean) } : {}), ...(item.data.clarificationPath ? { clarificationPath: String(item.data.clarificationPath) } : {}), ...(item.data.timestamp ? { timestamp: String(item.data.timestamp) } : {}), ...(item.data.markdown ? { markdown: String(item.data.markdown) } : {}), - ...(item.data.tool ? { tool: String(item.data.tool) } : {}), - ...(item.data.invocationId ? { invocationId: String(item.data.invocationId) } : {}), - ...(item.data.arguments && typeof item.data.arguments === "object" ? { arguments: item.data.arguments as Record } : {}), - ...(item.data.result !== undefined ? { result: item.data.result } : {}), - ...(Array.isArray(item.data.evidence) ? { evidence: item.data.evidence.map(String) } : {}), }, }; } diff --git a/frontend/src/lib/cad-types.ts b/frontend/src/lib/cad-types.ts index 7dc98db1..7f0be71b 100644 --- a/frontend/src/lib/cad-types.ts +++ b/frontend/src/lib/cad-types.ts @@ -10,25 +10,12 @@ export type CadProgress = { message?: string; markdown?: string; taskId?: string; - nodeId?: string; - tool?: string; - invocationId?: string; - arguments?: Record; - result?: unknown; - evidence?: string[]; questions?: string[]; issues?: string[]; - blockerType?: string; userActionRequired?: boolean; - verificationStatus?: "verified" | "completed_with_risks" | string; - verificationWarnings?: string[]; - review?: Record; clarificationPath?: string; - lifecycle?: "running" | "completed" | "failed" | "waiting_retry" | "waiting_for_user" | string; - attempt?: number; - maxAttempts?: number; + lifecycle?: "running" | "completed" | "failed" | "waiting_for_user" | string; path?: string; - contractHash?: string; }; export type CadResult = { @@ -43,8 +30,6 @@ export type CadResult = { engine: string; checkpoint?: boolean; lifecycle?: "running" | "completed" | "failed" | string; - verificationStatus?: "verified" | "completed_with_risks" | string; - verificationWarnings?: string[]; }; export type CadError = { @@ -97,12 +82,6 @@ export type TaskRevision = { engine?: string; error?: string; visibility?: "checkpoint" | "final" | "superseded" | string; - parent_revision_id?: string; - branch_id?: string; - step_review_path?: string; - candidate_review_path?: string; - render_manifest_path?: string; - visual_review_path?: string; }; export type TaskRecord = { @@ -111,71 +90,34 @@ export type TaskRecord = { current_revision: string; active_revision?: string; published_revision?: string; - lifecycle?: "running" | "completed" | "failed" | "cancelled" | "waiting_retry" | "waiting_for_user" | string; + lifecycle?: "running" | "completed" | "failed" | "cancelled" | "waiting_for_user" | string; phase?: string; state_version?: number; - active_candidate_id?: string; - requirements_spec?: Record | null; - requirements_spec_path?: string; + repair_count?: number; + repair_budget?: number; + requirements_path?: string; + authoring_path?: string; + runtime_cdsl_path?: string; + compile_audit_path?: string; + diagnostics_path?: string; + completion_path?: string; clarification_path?: string; - requirements_contract?: Record | null; - requirements_contract_path?: string; - requirements_markdown?: string | null; - requirements_document_path?: string; - completion_target_markdown?: string | null; - completion_target_path?: string; - feature_plan?: { + requirements_analysis?: Record | null; + authoring_cdsl?: Record | null; + runtime_cdsl?: Record | null; + compile_audit?: Record | null; + diagnostics?: Record | null; + claim_report?: { schema_version?: string; - parent_plan_hash?: string; - replaces_node_ids?: string[]; - nodes?: Array>; - final_claim_ids?: string[]; + claims?: Array<{ target?: string; status?: "pass" | "fail" | "pending" | "not_applicable" | string; verification?: 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<{ - requirement_id: string; - claim_id: string; - claim_kind: string; - deterministic: boolean; - status: "pass" | "pending" | "fail" | "unavailable" | string; - evidence?: Record; - }>; - checklist_progress?: Array<{ - requirement_id: string; - statement: string; - status: "pass" | "pending" | "fail" | string; - }>; - pending_action?: { action_id: string; working_head: string; intent: string; requirement_ids: string[]; atomic_id: string; expected_change: string; contract_hash: string } | null; - action_ledger_summary?: Array>; - usage?: { calls: number; prompt_tokens: number; completion_tokens: number; context_chars: number }; + usage?: { calls: number; records: Array> }; preview_revision?: string; message?: string; questions?: string[]; issues?: string[]; - blocker_type?: string; user_action_required?: boolean; - verification_status?: "verified" | "completed_with_risks" | string; - verification_warnings?: string[]; revisions: TaskRevision[]; }; @@ -191,6 +133,4 @@ export type BackendConfig = { configured: boolean; library_samples: number; autonomous_generation?: boolean; - review_configured?: boolean; - review_error?: string; }; -- 2.52.0