"""Append-only CDSL materialisation for the autonomous authoring loop.""" from __future__ import annotations from copy import deepcopy from hashlib import sha256 import json import math from typing import Any class CdslFragmentError(ValueError): """A fragment cannot safely be applied to the current CDSL state.""" class AutonomousFragmentError(CdslFragmentError): """A free-form candidate violates the autonomous append-only boundary.""" def cdsl_sha256(cdsl: dict[str, Any] | None) -> str: value = cdsl or {"geometry": {"sketches": []}, "features": []} return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]: """Create the complete CDSL document that will be rebuilt from scratch.""" if base_cdsl is None: document: dict[str, Any] = { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "agent_preflight", "geometry": {"sketches": []}, "features": [], } else: document = deepcopy(base_cdsl) 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 CdslFragmentError("Base CDSL has invalid geometry collections") sketches.extend(deepcopy(fragment["add_sketches"])) features.extend(deepcopy(fragment["add_features"])) return document def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]: """Persist runtime selector choices as build evidence.""" values = [] for resolution in engine_result.get("selector_resolution") or (): if not isinstance(resolution, dict): continue selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {} values.append({ "consumer_node_id": node_id, "consumer_feature_id": str(resolution.get("feature_id") or ""), "source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id), "kind": str(selector.get("kind") or ""), "owner_feature_id": str(selector.get("owner_feature_id") or ""), "stable_id": str(selector.get("stable_id") or ""), "geometry": deepcopy(selector.get("geometry") or {}), "status": str(resolution.get("status") or ""), "candidates": deepcopy(list(resolution.get("candidates") or [])), "score": resolution.get("score"), "selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}), }) return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values} def _autonomous_id(prefix: str, used: set[str]) -> str: index = 1 while True: candidate = f"{prefix}_{index:03d}" if candidate not in used: used.add(candidate) return candidate index += 1 def autonomous_selector_tokens(snapshot: dict[str, Any] | None) -> dict[str, dict[str, Any]]: """Make opaque, revision-scoped selector tokens from executable topology.""" if not isinstance(snapshot, dict): return {} snapshot_id = str(snapshot.get("snapshot_id") or "") if not snapshot_id: return {} values: dict[str, dict[str, Any]] = {} for record in snapshot.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", "vertex", "plane", "axis", "body"}: 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 ""] values[token] = { "token": token, "kind": kind, "geometry": geometry, "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, }, } return values def _compact_prompt_geometry(geometry: dict[str, Any]) -> dict[str, Any]: """Keep only selector-choice facts useful to an author model. Runtime snapshots also carry adjacency signatures, curve endpoints and other diagnostic detail. Those fields are required for deterministic engine work, but repeatedly placing them in an LLM prompt is expensive and does not help choose an opaque token. Full records remain available on disk and through targeted measurement tools. """ useful = ( "bbox_mm", "center_mm", "normal", "plane_normal", "plane_offset_mm", "surface_type", "curve_type", "radius_mm", "length_mm", "area_mm2", "volume_mm3", "solid_count", ) return {key: deepcopy(geometry[key]) for key in useful if key in geometry} def autonomous_candidate_prompt_tokens(tokens: dict[str, dict[str, Any]], *, kind: str = "") -> list[dict[str, Any]]: """Return compact safe token fields, never persistent selector IDs.""" return [ {"token": token, "kind": value["kind"], "geometry": _compact_prompt_geometry(value["geometry"])} for token, value in sorted(tokens.items()) if not kind or value["kind"] == kind ] def _fragment_lists(fragment: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: if not isinstance(fragment, dict): raise AutonomousFragmentError("fragment_json must decode to a JSON object") # Ordinary tool-call providers occasionally lift the unambiguous selector # array one level out of a single-feature payload. Accept that shorthand # only when it can be moved to exactly one feature without choosing or # changing any selector ourselves. allowed = {"sketch", "feature", "sketches", "features", "add_sketches", "add_features", "selector_tokens", "revolve_axis"} unknown = sorted(set(fragment) - allowed) if unknown: raise AutonomousFragmentError("fragment_json may contain only sketch(es) and feature(s); unexpected: " + ", ".join(unknown)) sketches_raw = fragment.get("sketches", fragment.get("add_sketches", [])) features_raw = fragment.get("features", fragment.get("add_features", [])) if "sketch" in fragment: if sketches_raw: raise AutonomousFragmentError("Use either sketch or sketches, not both") sketches_raw = [fragment["sketch"]] if "feature" in fragment: if features_raw: raise AutonomousFragmentError("Use either feature or features, not both") features_raw = [fragment["feature"]] if not isinstance(sketches_raw, list) or not all(isinstance(item, dict) for item in sketches_raw): raise AutonomousFragmentError("fragment sketches must be an array of objects") if not isinstance(features_raw, list) or not features_raw or not all(isinstance(item, dict) for item in features_raw): raise AutonomousFragmentError("fragment features must be a non-empty array of objects") sketches = deepcopy(sketches_raw) features = deepcopy(features_raw) top_level_tokens = fragment.get("selector_tokens") if top_level_tokens is not None: if len(features) != 1: raise AutonomousFragmentError("top-level selector_tokens are allowed only with exactly one feature") if "selector_tokens" in features[0]: raise AutonomousFragmentError("selector_tokens must appear either at fragment top level or feature level, not both") features[0]["selector_tokens"] = deepcopy(top_level_tokens) return sketches, features def _move_equivalent_field( value: dict[str, Any], *, source: str, target: str, location: str, fixes: list[dict[str, str]], ) -> None: """Move a lossless spelling alias without choosing any CAD geometry.""" if source not in value: return if target in value: if value[source] != value[target]: raise AutonomousFragmentError( f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and {target} were supplied with different values" ) value.pop(source) fixes.append({"path": location, "from": source, "to": target, "action": "deduplicated_equivalent"}) return value[target] = value.pop(source) fixes.append({"path": location, "from": source, "to": target, "action": "renamed_equivalent"}) def _move_axis_component( params: dict[str, Any], axis: dict[str, Any], *, source: str, target: str, location: str, fixes: list[dict[str, str]], ) -> None: """Move an explicit top-level axis alias into the canonical axis object.""" if source not in params: return value = params.pop(source) if target in axis and axis[target] != value: raise AutonomousFragmentError( f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and axis.{target} were supplied with different values" ) if target in axis: fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "deduplicated_equivalent"}) return axis[target] = value fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "renamed_equivalent"}) def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None: """Normalize only lossless aliases used inside an already explicit axis.""" for source in ("origin", "point_mm", "axis_origin_mm", "axis_point_mm"): _move_equivalent_field(axis, source=source, target="origin_mm", location=location, fixes=fixes) for source in ("axis_dir", "axis_direction", "dir"): _move_equivalent_field(axis, source=source, target="direction", location=location, fixes=fixes) def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None: """Accept common feature-local sketch spellings without choosing geometry. The public fragment grammar owns one ordered sketch list and one ordered feature list. Tool-call models commonly emit either a direct feature with a local ``sketch`` or a wrapper shaped as ``{sketch, feature}``. Both are losslessly transformable when the fragment has no root sketch collection. Sketchless features such as ``sphere_add`` and ``chamfer`` may be mixed in the same batch; the materializer pairs only sketch-requiring features with the lifted sketches. """ if any(key in fragment for key in ("sketch", "sketches", "add_sketches")): return features = fragment.get("features", fragment.get("add_features")) if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features): return lifted: list[dict[str, Any]] = [] normalized_features: list[dict[str, Any]] = [] for index, item in enumerate(features): wrapped = item.get("feature") if wrapped is not None: if not isinstance(wrapped, dict): return feature = deepcopy(wrapped) if "selector_tokens" in item: if "selector_tokens" in feature: raise AutonomousFragmentError( f"features[{index}] supplies selector_tokens both on the wrapper and feature" ) feature["selector_tokens"] = deepcopy(item["selector_tokens"]) nested = item.get("sketches", item.get("sketch")) fixes.append({"path": f"features[{index}]", "from": "{sketch,feature}", "to": "feature", "action": "unwrapped_equivalent"}) else: feature = deepcopy(item) nested = feature.get("sketches", feature.get("sketch")) if isinstance(nested, dict): sketches = [nested] elif isinstance(nested, list): sketches = nested else: normalized_features.append(feature) continue if len(sketches) != 1 or not isinstance(sketches[0], dict): return feature.pop("sketch", None) feature.pop("sketches", None) lifted.append(sketches[0]) normalized_features.append(feature) fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"}) fragment["features"] = normalized_features if lifted: fragment["sketches"] = lifted def _lift_param_embedded_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None: """Lift an exact legacy ``params.workplane/profile`` sketch spelling. Some authors place an extrusion's complete sketch inside its params object. The workplane and profile retain their meaning verbatim, so extracting them is safe. Partial shapes remain invalid instead of being guessed. """ if any(key in fragment for key in ("sketch", "sketches", "add_sketches")): return features = fragment.get("features", fragment.get("add_features")) if not isinstance(features, list) or not all(isinstance(item, dict) for item in features): return lifted: list[dict[str, Any]] = [] for index, feature in enumerate(features): atomic_id = str(feature.get("atomic_id") or "") params = feature.get("params") if not atomic_id.startswith(("extrude_", "revolve_")) or not isinstance(params, dict): continue workplane = params.get("workplane") profile = params.get("profile") if workplane is None and profile is None: continue if not isinstance(workplane, dict) or not isinstance(profile, dict): return params.pop("workplane") params.pop("profile") lifted.append({"workplane": workplane, "profile": profile}) fixes.append({"path": f"features[{index}].params", "from": "workplane/profile", "to": "sketches[]", "action": "lifted_equivalent"}) if lifted: fragment["sketches"] = lifted def _set_reverse_from_direction(params: dict[str, Any], *, reverse: bool, location: str, fixes: list[dict[str, str]]) -> None: if "reverse" in params and params["reverse"] is not reverse: raise AutonomousFragmentError( f"CONFLICTING_PARAMETER_ALIASES at {location}: direction conflicts with reverse" ) params["reverse"] = reverse params.pop("direction", None) fixes.append({"path": location, "from": "direction", "to": "reverse", "action": "normalized_equivalent"}) def _normalize_extrude_direction( params: dict[str, Any], sketch: dict[str, Any] | None, *, location: str, fixes: list[dict[str, str]], ) -> None: """Accept an extrusion direction only when it exactly restates the sketch.""" direction = params.get("direction") if direction is None: return if isinstance(direction, str): normalized = direction.strip().lower() if normalized in {"negative", "reverse", "-normal"}: _set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes) elif normalized in {"positive", "forward", "+normal"}: _set_reverse_from_direction(params, reverse=False, location=location, fixes=fixes) return workplane = sketch.get("workplane") if isinstance(sketch, dict) else None normal = workplane.get("normal") if isinstance(workplane, dict) else None if ( not isinstance(direction, list) or not isinstance(normal, list) or len(direction) != 3 or len(normal) != 3 or not all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in [*direction, *normal]) ): return direction_norm = math.sqrt(sum(float(value) ** 2 for value in direction)) normal_norm = math.sqrt(sum(float(value) ** 2 for value in normal)) if direction_norm == 0 or normal_norm == 0: return cosine = sum(float(direction[index]) * float(normal[index]) for index in range(3)) / (direction_norm * normal_norm) if math.isclose(cosine, 1.0, abs_tol=1e-9): params.pop("direction") fixes.append({"path": location, "from": "direction", "to": "workplane.normal", "action": "deduplicated_equivalent"}) elif math.isclose(cosine, -1.0, abs_tol=1e-9): _set_reverse_from_direction(params, reverse=True, location=location, fixes=fixes) def _normalize_angle_radians(params: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None: """Convert the explicitly unit-labelled angle_rad alias to angle_deg.""" if "angle_rad" not in params: return radians = params["angle_rad"] if not isinstance(radians, (int, float)) or isinstance(radians, bool) or not math.isfinite(float(radians)): return degrees = float(radians) * 180.0 / math.pi if "angle_deg" in params: supplied = params["angle_deg"] if not isinstance(supplied, (int, float)) or isinstance(supplied, bool) or not math.isclose(float(supplied), degrees, rel_tol=0.0, abs_tol=1e-9): raise AutonomousFragmentError( f"CONFLICTING_PARAMETER_ALIASES at {location}: angle_rad conflicts with angle_deg" ) params.pop("angle_rad") fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "deduplicated_equivalent"}) return params["angle_deg"] = degrees params.pop("angle_rad") fixes.append({"path": location, "from": "angle_rad", "to": "angle_deg", "action": "converted_unit"}) def _normalize_concentric_circle_contours(profile: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None: """Expand a common two-circle annulus shorthand into analytic contours.""" if profile.get("type") != "analytic_contours": return contours = profile.get("contours") if not isinstance(contours, list) or len(contours) != 2 or not all(isinstance(item, dict) for item in contours): return if not all(item.get("type") == "circle" and isinstance(item.get("center"), list) and len(item["center"]) == 2 for item in contours): return if contours[0]["center"] != contours[1]["center"]: return try: ordered = sorted(contours, key=lambda item: float(item["radius_mm"]), reverse=True) except (KeyError, TypeError, ValueError): return if float(ordered[0]["radius_mm"]) <= float(ordered[1]["radius_mm"]): return profile["contours"] = [ {"role": role, "closed": True, "segments": [{"type": "circle", "center": item["center"], "radius_mm": item["radius_mm"]}]} for role, item in zip(("outer", "inner"), ordered) ] fixes.append({"path": location, "from": "circle contour shorthand", "to": "analytic_contours.segments", "action": "expanded_equivalent"}) def _finite_vector3(value: Any) -> tuple[float, float, float] | None: """Return a finite numeric vector when the author supplied one.""" if ( not isinstance(value, list) or len(value) != 3 or not all(isinstance(component, (int, float)) and not isinstance(component, bool) for component in value) ): return None result = tuple(float(component) for component in value) return result if all(math.isfinite(component) for component in result) else None def _validate_revolve_axis_in_sketch_plane( atomic_id: str, params: dict[str, Any], sketch: dict[str, Any], ) -> None: """Reject a revolve axis that cannot be a construction line of its sketch. A solid revolve is defined around an axis in the source sketch plane. Letting an out-of-plane axis reach OCC can produce degenerate BReps that fail much later during tessellation, so enforce this geometric invariant before candidate staging. Malformed vectors are left to CDSL schema validation, which can report their field-level shape. """ axis = params.get("axis") workplane = sketch.get("workplane") if isinstance(sketch.get("workplane"), dict) else None if not isinstance(axis, dict) or not isinstance(workplane, dict): return axis_origin = _finite_vector3(axis.get("origin_mm")) axis_direction = _finite_vector3(axis.get("direction")) plane_origin = _finite_vector3(workplane.get("origin_mm")) plane_normal = _finite_vector3(workplane.get("normal")) if None in {axis_origin, axis_direction, plane_origin, plane_normal}: return assert axis_origin is not None and axis_direction is not None and plane_origin is not None and plane_normal is not None direction_length = math.sqrt(sum(component * component for component in axis_direction)) normal_length = math.sqrt(sum(component * component for component in plane_normal)) if direction_length == 0 or normal_length == 0: return direction_normal_dot = abs(sum(axis_direction[index] * plane_normal[index] for index in range(3)) / (direction_length * normal_length)) if direction_normal_dot > 1e-7: raise AutonomousFragmentError( "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: " f"{atomic_id} params.axis.direction must be parallel to sketch.workplane; " f"abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" ) origin_plane_offset = abs(sum((axis_origin[index] - plane_origin[index]) * plane_normal[index] for index in range(3)) / normal_length) if origin_plane_offset > 1e-6: raise AutonomousFragmentError( "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: " f"{atomic_id} params.axis.origin_mm must lie in sketch.workplane; " f"plane_offset_mm={origin_plane_offset:.3g}" ) def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]: """Normalize only explicitly equivalent author spellings. Normalization is deliberately narrow. It accepts common CAD vocabulary where the target runtime field has identical units and meaning, while refusing inputs that would need a guessed profile, direction, selector, coordinate system, or topology decision. The original fragment and every applied fix are retained in the candidate audit record. """ if not isinstance(fragment, dict): return fragment, [] normalized = deepcopy(fragment) fixes: list[dict[str, str]] = [] _lift_feature_local_sketches(normalized, fixes=fixes) _lift_param_embedded_sketches(normalized, fixes=fixes) feature_values: list[tuple[dict[str, Any], str]] = [] feature = normalized.get("feature") if isinstance(feature, dict): feature_values.append((feature, "feature")) features = normalized.get("features", normalized.get("add_features")) if isinstance(features, list): feature_values.extend( (item, f"features[{index}]") for index, item in enumerate(features) if isinstance(item, dict) ) for current_feature, location in feature_values: atomic_id = str(current_feature.get("atomic_id") or "") params = current_feature.get("params") if not isinstance(params, dict): continue if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"}: # CAD systems commonly call a blind extrusion's travel "depth". # CDSL calls the exact same signed scalar ``distance_mm``. _move_equivalent_field( params, source="depth_mm", target="distance_mm", location=f"{location}.params", fixes=fixes, ) if atomic_id in {"revolve_add", "revolve_cut"}: _move_equivalent_field( params, source="angle_degrees", target="angle_deg", location=f"{location}.params", fixes=fixes, ) _normalize_angle_radians(params, location=f"{location}.params", fixes=fixes) axis = params.get("axis") if axis is None: axis = {} params["axis"] = axis if isinstance(axis, dict): _move_axis_component(params, axis, source="axis_origin_mm", target="origin_mm", location=f"{location}.params", fixes=fixes) _move_axis_component(params, axis, source="axis_point_mm", target="origin_mm", location=f"{location}.params", fixes=fixes) _move_axis_component(params, axis, source="axis_dir", target="direction", location=f"{location}.params", fixes=fixes) _move_axis_component(params, axis, source="axis_direction", target="direction", location=f"{location}.params", fixes=fixes) axis = params.get("axis") if isinstance(axis, dict): _normalize_axis_mapping(axis, location=f"{location}.params.axis", fixes=fixes) shared_axis = normalized.get("revolve_axis") if shared_axis is not None: if not isinstance(shared_axis, dict): raise AutonomousFragmentError("revolve_axis must be an explicit {origin_mm, direction} object") _normalize_axis_mapping(shared_axis, location="revolve_axis", fixes=fixes) revolved = [feature for feature, _ in feature_values if str(feature.get("atomic_id") or "").startswith("revolve_")] if not revolved: raise AutonomousFragmentError("revolve_axis is valid only in a fragment containing revolve_add or revolve_cut") for feature, location in feature_values: if not str(feature.get("atomic_id") or "").startswith("revolve_"): continue params = feature.get("params") if not isinstance(params, dict): continue axis = params.get("axis") if not isinstance(axis, dict) or not axis: params["axis"] = deepcopy(shared_axis) fixes.append({"path": f"{location}.params", "from": "revolve_axis", "to": "axis", "action": "copied_explicit_batch_axis"}) sketch_values: list[tuple[dict[str, Any], str]] = [] sketch = normalized.get("sketch") if isinstance(sketch, dict): sketch_values.append((sketch, "sketch")) sketches = normalized.get("sketches", normalized.get("add_sketches")) if isinstance(sketches, list): sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict)) sketch_feature_index = 0 for current_feature, feature_location in feature_values: atomic_id = str(current_feature.get("atomic_id") or "") if not atomic_id.startswith(("extrude_", "revolve_")): continue current_sketch = sketch_values[sketch_feature_index][0] if sketch_feature_index < len(sketch_values) else None sketch_feature_index += 1 if atomic_id.startswith("extrude_"): params = current_feature.get("params") if isinstance(params, dict): _normalize_extrude_direction( params, sketch=current_sketch, location=f"{feature_location}.params", fixes=fixes, ) for current_sketch, location in sketch_values: profile = current_sketch.get("profile") if isinstance(profile, dict) and profile.get("type") == "polygon": _move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes) if isinstance(profile, dict): _normalize_concentric_circle_contours(profile, location=f"{location}.profile", fixes=fixes) # Earlier versions advertised sphere_add as sketch-backed even though its # executor has always used only radius_mm and center_mm. Preserve that # single-feature spelling without keeping an unused locator sketch in the # immutable CDSL document. if ( len(feature_values) == 1 and str(feature_values[0][0].get("atomic_id") or "") == "sphere_add" and len(sketch_values) == 1 ): normalized.pop("sketch", None) normalized.pop("sketches", None) normalized.pop("add_sketches", None) fixes.append({"path": "sketch", "from": "sphere locator sketch", "to": "none", "action": "dropped_unused_legacy_locator"}) return normalized, fixes def materialize_autonomous_fragment( base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], *, engine: Any, selector_tokens: dict[str, dict[str, Any]], max_features: int, source: str = "legacy_restore", allow_legacy_aliases: bool = True, expected_atomic_id: str = "", ) -> tuple[dict[str, Any], dict[str, Any]]: """Append authored geometry while assigning only server-owned metadata. Workplanes, profiles, feature parameters, directions and boolean meaning pass through exactly as the author supplied them. Local CDSL validation and full engine rebuild happen after this function, before a checkpoint exists. """ # Delayed import avoids engine_service -> selector_bindings -> this module # becoming an import cycle. from app.services.engine_service import feature_atomic_contract from app.services.cdsl_authoring_schema import CanonicalFragmentError, validate_canonical_fragment normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment) if not allow_legacy_aliases: if compatibility_fixes: first = compatibility_fixes[0] location = str(first.get("path") or "fragment") legacy = str(first.get("from") or "legacy field") canonical = str(first.get("to") or "canonical field") raise AutonomousFragmentError( f"CDSL_CANONICAL_FORMAT_REQUIRED at fragment.{location}: " f"{legacy} is a legacy spelling; use {canonical}" ) try: validate_canonical_fragment(engine, fragment, expected_atomic_id=expected_atomic_id) except CanonicalFragmentError as error: raise AutonomousFragmentError(str(error)) from error normalized_fragment = deepcopy(fragment) compatibility_fixes = [] sketches, features = _fragment_lists(normalized_fragment) if len(features) > max_features: raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)") document = materialize_fragment(base_cdsl, {"add_sketches": [], "add_features": []}) geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {} existing_sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else [] existing_features = document.get("features") if isinstance(document.get("features"), list) else [] used_sketch_ids = {str(item.get("id") or "") for item in existing_sketches if isinstance(item, dict)} used_feature_ids = {str(item.get("id") or "") for item in existing_features if isinstance(item, dict)} last_feature_id = str(existing_features[-1].get("id") or "") if existing_features and isinstance(existing_features[-1], dict) else "" materialized_sketches: list[dict[str, Any]] = [] materialized_features: list[dict[str, Any]] = [] sketch_index = 0 for source_feature in features: # Materialization injects server-owned ids, host faces and pattern # sources. Work on a private copy so the original tool-call fragment # remains intact in audit records and diagnostic replacement cards. source_feature = deepcopy(source_feature) forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature) if forbidden: raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden))) atomic_id = str(source_feature.get("atomic_id") or "") if not atomic_id: raise AutonomousFragmentError("Each fragment feature must declare a runtime atomic_id") contract = feature_atomic_contract(engine, atomic_id) params = source_feature.get("params") if not isinstance(params, dict): raise AutonomousFragmentError("Each fragment feature must contain a params object") if atomic_id.startswith("revolve_") and params.get("angle_deg") is None: # A shared batch axis is deliberately limited to the axis. A # default revolution angle would silently turn valid partial # revolves into a different solid, so it remains author-owned. raise AutonomousFragmentError( f"{atomic_id} requires params.angle_deg; revolve_axis (including shared_revolve_axis) " "supplies only params.axis. Declare an explicit angle in degrees." ) token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard" tokens = source_feature.pop("selector_tokens", []) token_list_is_valid = ( isinstance(tokens, list) and all(isinstance(token, str) for token in tokens) and len(set(tokens)) == len(tokens) ) if not token_list_is_valid and not token_backed_param: raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens") if not token_list_is_valid: tokens = [] selected: list[dict[str, Any]] = [] invalid_tokens: list[str] = [] for token in tokens: candidate = selector_tokens.get(token) if candidate is None: if token_backed_param: invalid_tokens.append(token) continue raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot") selected.append(deepcopy(candidate["selector"])) slot = contract.get("selector_slot") if token_backed_param: # Report all author-correctable hole errors at once. A hole is # topology-sensitive, so its host face remains server-owned and # must be injected from one current face token. issues: list[str] = [] author_params = (set(contract["required_params"]) | set(contract["optional_params"])) - {"host_face"} if "host_face" in params: issues.append("params.host_face is server-owned; use selector_tokens") missing = [name for name in contract["required_params"] if name != "host_face" and name not in params] if missing: issues.append("missing params: " + ", ".join(missing)) unexpected = sorted(name for name in params if name not in author_params and name != "host_face") if unexpected: issues.append("unsupported params: " + ", ".join(unexpected)) if not token_list_is_valid: issues.append("selector_tokens must be a unique array of opaque tokens") if invalid_tokens: issues.append("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot") if len(tokens) != 1 or len(selected) != 1 or str((selected[0] if selected else {}).get("kind") or "") != "face": issues.append(f"{atomic_id} requires exactly one face selector token for its host face") if issues: raise AutonomousFragmentError("HOLE_FRAGMENT_INVALID: " + "; ".join(issues)) if not slot and tokens and not token_backed_param: raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens") if isinstance(slot, dict): minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0) if not minimum <= len(selected) <= maximum: raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)") elif token_backed_param: # Hole token validation above deliberately aggregates every # actionable error before this materialization boundary. pass elif atomic_id.startswith("revolve_"): axis = params.get("axis") if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis: raise AutonomousFragmentError( f"{atomic_id} requires params.axis with explicit origin_mm and direction; " "the revolve axis is author-defined geometry, not a topology selector token" ) feature_id = _autonomous_id("feature", used_feature_ids) output = {key: deepcopy(value) for key, value in source_feature.items() if key != "selector_tokens"} output["id"] = feature_id output["depends_on"] = [last_feature_id] if last_feature_id else [] if contract["requires_sketch"]: if sketch_index >= len(sketches): raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment") sketch = sketches[sketch_index] sketch_index += 1 if "id" in sketch or "attachment" in sketch or "profile_from" in sketch: raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned") sketch_id = _autonomous_id("sketch", used_sketch_ids) sketch["id"] = sketch_id if atomic_id.startswith("revolve_"): _validate_revolve_axis_in_sketch_plane(atomic_id, params, sketch) materialized_sketches.append(sketch) output["sketch_id"] = sketch_id if atomic_id.startswith("pattern_") and "source_feature_ids" not in params: if not last_feature_id: raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature") params["source_feature_ids"] = [last_feature_id] if isinstance(slot, dict) and slot.get("path") == "feature.selectors": output["selectors"] = selected elif isinstance(slot, dict) and slot.get("path") == "params.mirror_plane": output["params"]["mirror_plane"] = selected[0] output["selectors"] = [] elif isinstance(slot, dict) and slot.get("path") == "params.host_face": output["params"]["host_face"] = selected[0] output["selectors"] = [] elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard": output["params"]["host_face"] = selected[0] output["selectors"] = [] else: output["selectors"] = [] materialized_features.append(output) last_feature_id = feature_id if sketch_index != len(sketches): raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch") document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features}) return document, { "schema_version": "cad.autonomous-fragment.v1", "source_fragment": deepcopy(fragment), "normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None, "compatibility_fixes": compatibility_fixes, "compatibility_fix_count": len(compatibility_fixes), "legacy_input": source != "tool_call" or bool(compatibility_fixes), "assigned_sketch_ids": [item["id"] for item in materialized_sketches], "assigned_feature_ids": [item["id"] for item in materialized_features], "selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])], }