229 lines
10 KiB
Python
229 lines
10 KiB
Python
"""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
|