"""Strict contracts for persistent, node-by-node CAD generation.""" from __future__ import annotations from collections import defaultdict, deque from copy import deepcopy from hashlib import sha256 import re from typing import Any, Iterable from app.services.feature_plan import FeaturePlanError, validate_feature_plan GENERATION_PLAN_SCHEMA_VERSION = "cad.generation-plan.v2" BACKEND_ID_STRATEGY = "backend-derived-v1" REQUIREMENT_SOURCES = {"explicit", "assumption"} REQUIREMENT_PRIORITIES = {"hard", "soft"} # Keep this in sync with the engine's profile_schema.json. The plan is # deliberately atomic: an operation that consumes a profile owns one new # sketch, while all other operations own none. SKETCH_REQUIRED_ATOMICS = { "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", } class GenerationPlanError(ValueError): """The authoring plan cannot safely drive an incremental build.""" def _text(value: Any, field: str, *, required: bool = True) -> str: result = str(value or "").strip() if required and not result: raise GenerationPlanError(f"{field} is required") return result def _string_list(value: Any, field: str, *, required: bool = False) -> list[str]: if value is None: value = [] if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value): raise GenerationPlanError(f"{field} must be an array of non-empty strings") result = list(dict.fromkeys(item.strip() for item in value)) if required and not result: raise GenerationPlanError(f"{field} must not be empty") return result def _normalise_requirement(raw: Any, index: int) -> dict[str, Any]: if not isinstance(raw, dict): raise GenerationPlanError(f"requirements[{index}] must be an object") source = _text(raw.get("source") or "assumption", f"requirements[{index}].source") priority = _text(raw.get("priority") or "hard", f"requirements[{index}].priority") if source not in REQUIREMENT_SOURCES: raise GenerationPlanError(f"requirements[{index}].source is unsupported: {source}") if priority not in REQUIREMENT_PRIORITIES: raise GenerationPlanError(f"requirements[{index}].priority is unsupported: {priority}") return { "id": _text(raw.get("id"), f"requirements[{index}].id"), "source": source, "priority": priority, "description": _text(raw.get("description"), f"requirements[{index}].description"), "value": deepcopy(raw.get("value")), "unit": _text(raw.get("unit"), f"requirements[{index}].unit", required=False), "tolerance": deepcopy(raw.get("tolerance")), } def _backend_cdsl_id(kind: str, node_id: str) -> str: """Create a valid, stable CDSL identifier without trusting model naming.""" slug = re.sub(r"[^A-Za-z0-9_-]+", "_", node_id).strip("_-").lower() or "node" digest = sha256(node_id.encode("utf-8")).hexdigest()[:8] return f"{kind}_{slug[:60]}_{digest}" def _backend_node_outputs(node_id: str, atomic_id: str) -> tuple[list[str], list[str]]: feature_ids = [_backend_cdsl_id("feature", node_id)] sketch_ids = [_backend_cdsl_id("sketch", node_id)] if atomic_id in SKETCH_REQUIRED_ATOMICS else [] return feature_ids, sketch_ids def _stored_plan_ids(raw: dict[str, Any]) -> bool: """Retain IDs of plans already materialised by an earlier backend version.""" return str(raw.get("id_strategy") or "") == BACKEND_ID_STRATEGY or isinstance(raw.get("feature_owner"), dict) def validate_generation_plan( document: dict[str, Any], *, supported_atomic_ids: Iterable[str] = (), task_id: str = "", ) -> dict[str, Any]: """Normalise a planner response and prove every hard requirement is owned.""" if not isinstance(document, dict): raise GenerationPlanError("Generation plan must be an object") version = str(document.get("schema_version") or GENERATION_PLAN_SCHEMA_VERSION) if version != GENERATION_PLAN_SCHEMA_VERSION: raise GenerationPlanError(f"Unsupported generation plan schema: {version}") requirements_raw = document.get("requirements") if not isinstance(requirements_raw, list) or not requirements_raw: raise GenerationPlanError("Generation plan requires a non-empty requirements array") requirements = [_normalise_requirement(item, index) for index, item in enumerate(requirements_raw)] requirement_ids = [item["id"] for item in requirements] if len(requirement_ids) != len(set(requirement_ids)): raise GenerationPlanError("Generation plan has duplicate requirement ids") raw_nodes = document.get("nodes") if not isinstance(raw_nodes, list) or not raw_nodes: raise GenerationPlanError("Generation plan requires a non-empty nodes array") preserve_stored_ids = _stored_plan_ids(document) feature_nodes: list[dict[str, Any]] = [] node_metadata: dict[str, dict[str, Any]] = {} sketch_owner: dict[str, str] = {} for index, raw in enumerate(raw_nodes): if not isinstance(raw, dict): raise GenerationPlanError(f"nodes[{index}] must be an object") node_id = _text(raw.get("id"), f"nodes[{index}].id") atomic_id = _text(raw.get("atomic_id"), f"nodes[{index}].atomic_id") if preserve_stored_ids: feature_ids = _string_list(raw.get("cdsl_feature_ids"), f"nodes[{index}].cdsl_feature_ids", required=True) sketch_ids = _string_list(raw.get("cdsl_sketch_ids"), f"nodes[{index}].cdsl_sketch_ids") else: # New plans own semantic node IDs only. CDSL object IDs are a # deterministic backend implementation detail, not model output. feature_ids, sketch_ids = _backend_node_outputs(node_id, atomic_id) for sketch_id in sketch_ids: previous = sketch_owner.get(sketch_id) if previous: raise GenerationPlanError(f"CDSL sketch belongs to multiple plan nodes: {sketch_id} ({previous}, {node_id})") sketch_owner[sketch_id] = node_id coverage = _string_list(raw.get("requirement_ids"), f"nodes[{index}].requirement_ids") unknown = sorted(set(coverage) - set(requirement_ids)) if unknown: raise GenerationPlanError(f"Node {node_id} references unknown requirements: {', '.join(unknown)}") rules = raw.get("verification_rules") or [] if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules): raise GenerationPlanError(f"nodes[{index}].verification_rules must be an array of objects") targets = raw.get("review_targets") or [] if not isinstance(targets, list) or not all(isinstance(item, dict) for item in targets): raise GenerationPlanError(f"nodes[{index}].review_targets must be an array of objects") feature_nodes.append({ "id": node_id, "intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False), "atomic_id": atomic_id, "depends_on": _string_list(raw.get("depends_on"), f"nodes[{index}].depends_on"), "requires_topology": raw.get("requires_topology") is True, "topology_query": deepcopy(raw.get("topology_query")) if raw.get("topology_query") is not None else None, "cdsl_feature_ids": feature_ids, }) node_metadata[node_id] = { "cdsl_sketch_ids": sketch_ids, "requires_sketch": bool(sketch_ids), "requirement_ids": coverage, "verification_rules": deepcopy(rules), "review_targets": deepcopy(targets), "attempts": {"authoring": 0, "repair": 0, "replan": 0}, } try: feature_plan = validate_feature_plan({ "schema_version": "cad.feature-plan.v1", "plan_id": document.get("plan_id"), "task_id": task_id or document.get("task_id"), "nodes": feature_nodes, }, supported_atomic_ids=supported_atomic_ids) except FeaturePlanError as error: raise GenerationPlanError(str(error)) from error covered = { requirement_id for metadata in node_metadata.values() for requirement_id in metadata["requirement_ids"] } uncovered = [item["id"] for item in requirements if item["priority"] == "hard" and item["id"] not in covered] if uncovered: raise GenerationPlanError("Hard requirements are not covered: " + ", ".join(uncovered)) nodes = [] for node in feature_plan["nodes"]: nodes.append({**node, **node_metadata[node["id"]]}) return { "schema_version": GENERATION_PLAN_SCHEMA_VERSION, "id_strategy": BACKEND_ID_STRATEGY, "plan_id": feature_plan["plan_id"], "task_id": task_id or feature_plan["task_id"], "requirements": requirements, "assumptions": _string_list(document.get("assumptions"), "assumptions"), "nodes": nodes, "feature_owner": feature_plan["feature_owner"], "sketch_owner": sketch_owner, } def descendant_closure(plan: dict[str, Any], root_node_id: str) -> set[str]: """Return one node and every node whose model depends on it.""" nodes = plan.get("nodes") if isinstance(plan, dict) else None if not isinstance(nodes, list): raise GenerationPlanError("Generation plan has no nodes") children: dict[str, set[str]] = defaultdict(set) known = {str(node.get("id")) for node in nodes if isinstance(node, dict)} if root_node_id not in known: raise GenerationPlanError(f"Unknown generation-plan node: {root_node_id}") for node in nodes: if not isinstance(node, dict): continue for dependency in node.get("depends_on") or (): children[str(dependency)].add(str(node.get("id"))) result: set[str] = set() queue: deque[str] = deque([root_node_id]) while queue: node_id = queue.popleft() if node_id in result: continue result.add(node_id) queue.extend(sorted(children[node_id] - result)) return result def mark_nodes_stale(plan: dict[str, Any], root_node_id: str, *, reason: str) -> dict[str, Any]: """Invalidate a node/subtree after an upstream geometry change or rollback.""" updated = deepcopy(plan) stale = descendant_closure(updated, root_node_id) for node in updated.get("nodes") or (): if isinstance(node, dict) and str(node.get("id")) in stale: node["status"] = "planned" node["stale"] = True node["stale_reason"] = reason node.pop("topology_snapshot_id", None) return updated