Files
cdsl-cad/backend/app/services/feature_plan.py
T
2026-08-26 14:13:11 +08:00

223 lines
9.1 KiB
Python

"""Deterministic feature-plan validation and readiness calculations."""
from __future__ import annotations
from collections import defaultdict, deque
from copy import deepcopy
from typing import Any, Iterable
PLAN_SCHEMA_VERSION = "cad.feature-plan.v1"
NODE_STATUSES = {
"planned",
"ready",
"waiting_for_topology",
"waiting_for_selection",
"blocked",
"executing",
"executed",
"failed",
"completed",
}
_TOPOLOGY_REQUIRED_ATOMICS = {
"fillet",
"chamfer",
"hole_blind",
"hole_countersink",
"hole_counterbore",
"pattern_mirror",
}
class FeaturePlanError(ValueError):
"""A feature plan is not a valid acyclic executable plan."""
def _text(value: Any, field: str, *, required: bool = True) -> str:
result = str(value or "").strip()
if required and not result:
raise FeaturePlanError(f"{field} is required")
return result
def _bool(value: Any) -> bool:
return value is True
def _normalise_node(raw: Any, index: int) -> dict[str, Any]:
if not isinstance(raw, dict):
raise FeaturePlanError(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")
depends_on = raw.get("depends_on") or []
if not isinstance(depends_on, list) or not all(isinstance(item, str) and item.strip() for item in depends_on):
raise FeaturePlanError(f"nodes[{index}].depends_on must be an array of non-empty strings")
feature_ids = raw.get("cdsl_feature_ids")
if feature_ids is None:
feature_ids = [node_id]
if not isinstance(feature_ids, list) or not feature_ids or not all(isinstance(item, str) and item.strip() for item in feature_ids):
raise FeaturePlanError(f"nodes[{index}].cdsl_feature_ids must be a non-empty string array")
query = raw.get("topology_query")
if query is not None and not isinstance(query, dict):
raise FeaturePlanError(f"nodes[{index}].topology_query must be an object")
requires_topology = _bool(raw.get("requires_topology")) or atomic_id in _TOPOLOGY_REQUIRED_ATOMICS
status = str(raw.get("status") or "planned")
if status not in NODE_STATUSES:
raise FeaturePlanError(f"nodes[{index}].status is unsupported: {status}")
node = {
"id": node_id,
"intent": _text(raw.get("intent"), f"nodes[{index}].intent", required=False),
"atomic_id": atomic_id,
"depends_on": list(dict.fromkeys(item.strip() for item in depends_on)),
"requires_topology": requires_topology,
"topology_query": deepcopy(query) if query is not None else None,
"status": status,
"cdsl_feature_ids": list(dict.fromkeys(item.strip() for item in feature_ids)),
}
if raw.get("selector_required") is not None:
node["selector_required"] = _bool(raw.get("selector_required"))
if isinstance(raw.get("failure"), dict):
node["failure"] = deepcopy(raw["failure"])
return node
def validate_feature_plan(plan: dict[str, Any], *, supported_atomic_ids: Iterable[str] = ()) -> dict[str, Any]:
if not isinstance(plan, dict):
raise FeaturePlanError("Feature plan must be an object")
version = str(plan.get("schema_version") or PLAN_SCHEMA_VERSION)
if version != PLAN_SCHEMA_VERSION:
raise FeaturePlanError(f"Unsupported feature plan schema: {version}")
plan_id = _text(plan.get("plan_id"), "plan_id")
task_id = _text(plan.get("task_id"), "task_id", required=False)
topology_snapshot_id = _text(plan.get("topology_snapshot_id"), "topology_snapshot_id", required=False)
raw_nodes = plan.get("nodes")
if not isinstance(raw_nodes, list) or not raw_nodes:
raise FeaturePlanError("Feature plan requires a non-empty nodes array")
nodes = [_normalise_node(item, index) for index, item in enumerate(raw_nodes)]
by_id: dict[str, dict[str, Any]] = {}
node_index: dict[str, int] = {}
feature_owner: dict[str, str] = {}
supported = {str(item) for item in supported_atomic_ids if str(item)}
for index, node in enumerate(nodes):
if node["id"] in by_id:
raise FeaturePlanError(f"Duplicate feature plan node: {node['id']}")
if supported and node["atomic_id"] not in supported:
raise FeaturePlanError(f"Unsupported feature plan atomic_id: {node['atomic_id']}")
by_id[node["id"]] = node
node_index[node["id"]] = index
for feature_id in node["cdsl_feature_ids"]:
if feature_id in feature_owner:
raise FeaturePlanError(f"CDSL feature belongs to multiple plan nodes: {feature_id}")
feature_owner[feature_id] = node["id"]
indegree = {node_id: 0 for node_id in by_id}
children: dict[str, list[str]] = defaultdict(list)
for node in nodes:
for dependency in node["depends_on"]:
if dependency not in by_id:
raise FeaturePlanError(f"Node {node['id']} has missing dependency: {dependency}")
if node_index[dependency] >= node_index[node["id"]]:
raise FeaturePlanError(f"Node {node['id']} must appear after dependency: {dependency}")
indegree[node["id"]] += 1
children[dependency].append(node["id"])
queue = deque(node_id for node_id, degree in indegree.items() if degree == 0)
visited: list[str] = []
while queue:
node_id = queue.popleft()
visited.append(node_id)
for child in children[node_id]:
indegree[child] -= 1
if indegree[child] == 0:
queue.append(child)
if len(visited) != len(nodes):
raise FeaturePlanError("Feature plan contains a dependency cycle")
return {
"schema_version": PLAN_SCHEMA_VERSION,
"plan_id": plan_id,
"task_id": task_id,
"topology_snapshot_id": topology_snapshot_id,
"nodes": nodes,
"feature_owner": feature_owner,
}
def _topology_available(topology: dict[str, Any] | None) -> bool:
if not isinstance(topology, dict):
return False
return any(
isinstance(record, dict)
and record.get("executable", True) is not False
and record.get("kind") in {"face", "edge", "vertex", "body", "plane", "axis"}
for record in topology.get("records") or ()
)
def compute_node_statuses(
plan: dict[str, Any],
*,
cdsl: dict[str, Any] | None = None,
topology: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return a copy with deterministic status and readiness information."""
checked = validate_feature_plan(plan)
present_features = {
str(feature.get("id"))
for feature in (cdsl or {}).get("features") or ()
if isinstance(feature, dict) and feature.get("id")
}
has_topology = _topology_available(topology)
topology_snapshot_id = str((topology or {}).get("snapshot_id") or "")
selection_ready = bool(topology_snapshot_id and checked.get("topology_snapshot_id") == topology_snapshot_id)
by_id = {node["id"]: node for node in checked["nodes"]}
result_nodes: list[dict[str, Any]] = []
for original in checked["nodes"]:
node = deepcopy(original)
if node["status"] in {"failed", "blocked"}:
result_nodes.append(node)
continue
if all(feature_id in present_features for feature_id in node["cdsl_feature_ids"]):
node["status"] = "completed"
result_nodes.append(node)
continue
dependencies_done = all(by_id[item]["status"] in {"executed", "completed"} or all(
feature_id in present_features for feature_id in by_id[item]["cdsl_feature_ids"]
) for item in node["depends_on"])
if not dependencies_done:
node["status"] = "planned"
elif node["requires_topology"] and not has_topology:
node["status"] = "waiting_for_topology"
elif node["requires_topology"] and not selection_ready:
node["status"] = "waiting_for_selection"
else:
node["status"] = "ready"
result_nodes.append(node)
ready = [node["id"] for node in result_nodes if node["status"] == "ready"]
waiting = [node["id"] for node in result_nodes if node["status"] in {"waiting_for_topology", "waiting_for_selection"}]
blocked = [node["id"] for node in result_nodes if node["status"] == "blocked"]
completed = [node["id"] for node in result_nodes if node["status"] == "completed"]
return {
**checked,
"nodes": result_nodes,
"ready_nodes": ready,
"waiting_nodes": waiting,
"blocked_nodes": blocked,
"completed_nodes": completed,
"complete": len(completed) == len(result_nodes) and not blocked,
}
def plan_feature_ids(plan: dict[str, Any], node_ids: Iterable[str]) -> set[str]:
checked = validate_feature_plan(plan)
wanted = set(node_ids)
return {
feature_id
for node in checked["nodes"]
if node["id"] in wanted
for feature_id in node["cdsl_feature_ids"]
}
def node_for_feature(plan: dict[str, Any], feature_id: str) -> dict[str, Any] | None:
checked = validate_feature_plan(plan)
return next((node for node in checked["nodes"] if feature_id in node["cdsl_feature_ids"]), None)