223 lines
9.1 KiB
Python
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)
|