266 lines
13 KiB
Python
266 lines
13 KiB
Python
"""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
|
|
)
|