"""Immutable feature-DAG planning contracts and deterministic scheduling.""" from __future__ import annotations from hashlib import sha256 import json from typing import Any from pydantic import BaseModel, ConfigDict, Field, model_validator class _StrictModel(BaseModel): model_config = ConfigDict(extra="forbid", strict=True, str_strip_whitespace=True) class FeatureNode(_StrictModel): node_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$") priority: int = Field(ge=0, le=100_000) intent: str = Field(min_length=1, max_length=360) atomic_id: str = Field(pattern=r"^[a-z][a-z0-9_:-]{0,95}$") depends_on: list[str] = Field(default_factory=list, max_length=64) claim_ids: list[str] = Field(default_factory=list, max_length=128) expected_change: str = Field(min_length=1, max_length=360) @model_validator(mode="after") def _unique_references(self) -> "FeatureNode": if len(self.depends_on) != len(set(self.depends_on)): raise ValueError("depends_on must not contain duplicates") if len(self.claim_ids) != len(set(self.claim_ids)): raise ValueError("claim_ids must not contain duplicates") if self.node_id in self.depends_on: raise ValueError("a feature node cannot depend on itself") return self class FeaturePlan(_StrictModel): schema_version: str = Field(pattern=r"^cad\.v3\.2\.feature-plan\.v1$") parent_plan_hash: str = Field(default="", pattern=r"^(|[a-f0-9]{64})$") replaces_node_ids: list[str] = Field(default_factory=list, max_length=128) nodes: list[FeatureNode] = Field(min_length=1, max_length=256) final_claim_ids: list[str] = Field(default_factory=list, max_length=128) @model_validator(mode="after") def _unique_plan_fields(self) -> "FeaturePlan": node_ids = [node.node_id for node in self.nodes] priorities = [node.priority for node in self.nodes] if len(node_ids) != len(set(node_ids)): raise ValueError("node_id values must be unique") if len(priorities) != len(set(priorities)): raise ValueError("priority values must be unique") if len(self.replaces_node_ids) != len(set(self.replaces_node_ids)): raise ValueError("replaces_node_ids must not contain duplicates") if len(self.final_claim_ids) != len(set(self.final_claim_ids)): raise ValueError("final_claim_ids must not contain duplicates") return self def plan_hash(plan: FeaturePlan | dict[str, Any]) -> str: payload = plan.model_dump(mode="json") if isinstance(plan, FeaturePlan) else plan return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() def node_hash(node: FeatureNode | dict[str, Any]) -> str: payload = node.model_dump(mode="json") if isinstance(node, FeatureNode) else node return sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest() def contract_claims(contract: dict[str, Any]) -> dict[str, dict[str, Any]]: result: dict[str, dict[str, Any]] = {} for requirement in contract.get("requirements") or (): if not isinstance(requirement, dict): continue for claim in requirement.get("acceptance_claims") or (): if isinstance(claim, dict) and isinstance(claim.get("claim_id"), str): result[claim["claim_id"]] = claim return result def validate_feature_plan( plan: FeaturePlan, contract: dict[str, Any], atomic_ids: set[str] | frozenset[str] | tuple[str, ...], *, previous_plan: FeaturePlan | None = None, completed_node_hashes: dict[str, str] | None = None, required_replacements: set[str] | None = None, ) -> list[dict[str, str]]: """Return stable, tool-facing validation errors for a frozen plan.""" errors: list[dict[str, str]] = [] nodes = {node.node_id: node for node in plan.nodes} known_atoms = set(atomic_ids) claims = contract_claims(contract) assigned: dict[str, str] = {} for index, node in enumerate(plan.nodes): prefix = f"/nodes/{index}" if node.atomic_id not in known_atoms: errors.append({"path": f"{prefix}/atomic_id", "message": "atomic_id is not supported by the runtime"}) for dependency in node.depends_on: if dependency not in nodes: errors.append({"path": f"{prefix}/depends_on", "message": f"unknown dependency '{dependency}'"}) for claim_id in node.claim_ids: claim = claims.get(claim_id) if claim is None: errors.append({"path": f"{prefix}/claim_ids", "message": f"unknown frozen claim '{claim_id}'"}) continue if claim.get("verification_mode") != "deterministic": errors.append({"path": f"{prefix}/claim_ids", "message": f"visual claim '{claim_id}' belongs in final_claim_ids"}) owner = assigned.setdefault(claim_id, node.node_id) if owner != node.node_id: errors.append({"path": f"{prefix}/claim_ids", "message": f"claim '{claim_id}' is already owned by '{owner}'"}) final = set(plan.final_claim_ids) for claim_id in plan.final_claim_ids: claim = claims.get(claim_id) if claim is None: errors.append({"path": "/final_claim_ids", "message": f"unknown frozen claim '{claim_id}'"}) elif claim.get("verification_mode") == "deterministic": errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' must belong to one node"}) for claim_id, claim in claims.items(): deterministic = claim.get("verification_mode") == "deterministic" if deterministic and claim_id not in assigned: errors.append({"path": "/nodes", "message": f"deterministic claim '{claim_id}' has no owner"}) if not deterministic and claim_id not in final: errors.append({"path": "/final_claim_ids", "message": f"visual claim '{claim_id}' has no final-review owner"}) if deterministic and claim_id in final: errors.append({"path": "/final_claim_ids", "message": f"deterministic claim '{claim_id}' has two owners"}) errors.extend(_cycle_errors(nodes)) if previous_plan is None: if plan.parent_plan_hash: errors.append({"path": "/parent_plan_hash", "message": "initial plan cannot have a parent_plan_hash"}) if plan.replaces_node_ids: errors.append({"path": "/replaces_node_ids", "message": "initial plan cannot replace nodes"}) else: errors.extend(_revision_errors(plan, previous_plan, completed_node_hashes or {}, required_replacements or set())) return errors def _cycle_errors(nodes: dict[str, FeatureNode]) -> list[dict[str, str]]: visiting: set[str] = set() visited: set[str] = set() errors: list[dict[str, str]] = [] def walk(current: str) -> None: if current in visiting: errors.append({"path": "/nodes", "message": "feature dependencies contain a cycle"}) return if current in visited: return visiting.add(current) for dependency in nodes[current].depends_on: if dependency in nodes: walk(dependency) visiting.remove(current) visited.add(current) for node_id in nodes: walk(node_id) return errors[:1] def _revision_errors(plan: FeaturePlan, previous: FeaturePlan, completed: dict[str, str], required_replacements: set[str]) -> list[dict[str, str]]: errors: list[dict[str, str]] = [] old_nodes = {node.node_id: node for node in previous.nodes} next_nodes = {node.node_id: node for node in plan.nodes} if plan.parent_plan_hash != plan_hash(previous): errors.append({"path": "/parent_plan_hash", "message": "parent_plan_hash does not match the active plan"}) replaced = set(plan.replaces_node_ids) if required_replacements and replaced != required_replacements: errors.append({"path": "/replaces_node_ids", "message": "failed node and its unresolved downstream subgraph must be replaced together"}) for node_id, frozen_hash in completed.items(): node = next_nodes.get(node_id) if node is None: errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was removed"}) elif node_hash(node) != frozen_hash: errors.append({"path": "/nodes", "message": f"completed node '{node_id}' was modified"}) if node_id in replaced: errors.append({"path": "/replaces_node_ids", "message": f"completed node '{node_id}' cannot be replaced"}) for node_id, old_node in old_nodes.items(): if node_id in replaced: continue current = next_nodes.get(node_id) if current is None: errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was removed outside the replacement subgraph"}) elif node_hash(current) != node_hash(old_node): errors.append({"path": "/nodes", "message": f"unrelated node '{node_id}' was modified outside the replacement subgraph"}) for node_id in replaced: if node_id not in old_nodes: errors.append({"path": "/replaces_node_ids", "message": f"unknown replaced node '{node_id}'"}) if node_id in next_nodes: errors.append({"path": "/nodes", "message": f"replacement must use a new node_id, found '{node_id}'"}) return errors class FeatureScheduler: """Derive a plan's runnable node from immutable ledger evidence.""" def __init__(self, plan: FeaturePlan, events: list[dict[str, Any]]) -> None: self.plan = plan self.events = events self._nodes = {node.node_id: node for node in plan.nodes} def statuses(self) -> dict[str, str]: states: dict[str, str] = {node_id: "pending" for node_id in self._nodes} expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()} for event in self.events: node_id = str(event.get("node_id") or "") if node_id not in states or event.get("node_hash") != expected_hashes[node_id]: continue if event.get("event") == "feature_node_verified": states[node_id] = "done" elif event.get("event") == "feature_node_invalidated" and states[node_id] != "done": states[node_id] = "invalidated" elif event.get("event") == "feature_node_failed" and states[node_id] != "done": states[node_id] = "failed" if bool(event.get("terminal")) else "pending" elif event.get("event") == "feature_node_scheduled" and states[node_id] == "pending": states[node_id] = "running" for node in self.plan.nodes: if states[node.node_id] in {"done", "failed", "invalidated"}: continue dependency_states = [states.get(dependency, "blocked") for dependency in node.depends_on] if any(value in {"failed", "invalidated", "blocked"} for value in dependency_states): states[node.node_id] = "blocked" elif all(value == "done" for value in dependency_states): states[node.node_id] = "ready" if states[node.node_id] != "running" else "running" return states def next_ready(self) -> FeatureNode | None: statuses = self.statuses() ready = [node for node in self.plan.nodes if statuses[node.node_id] == "ready"] return min(ready, key=lambda node: node.priority) if ready else None def all_done(self) -> bool: return all(value == "done" for value in self.statuses().values()) def feature_ids(self) -> dict[str, str]: expected_hashes = {node_id: node_hash(node) for node_id, node in self._nodes.items()} result: dict[str, str] = {} for event in self.events: node_id = str(event.get("node_id") or "") feature_id = str(event.get("feature_id") or "") if event.get("event") == "feature_node_verified" and node_id in expected_hashes and event.get("node_hash") == expected_hashes[node_id] and feature_id: result[node_id] = feature_id return result def completed_node_hashes(self) -> dict[str, str]: statuses = self.statuses() return { node_id: node_hash(self._nodes[node_id]) for node_id, status in statuses.items() if status == "done" } def failure_count(self, node_id: str, failure_class: str) -> int: expected = node_hash(self._nodes[node_id]) return sum( 1 for event in self.events if event.get("event") == "feature_node_failed" and event.get("node_id") == node_id and event.get("node_hash") == expected and event.get("failure_class") == failure_class )