integrate ganjihong engine refactor

This commit is contained in:
2026-09-09 18:41:56 +08:00
parent cf9cd0366a
commit 21a5185926
116 changed files with 3596 additions and 12259 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
"""Pure domain objects and policies for protocol v3."""
"""Pure domain objects and policies for the Authoring CDSL protocol."""
from .errors import ErrorCode, WorkflowError
from .state import TaskPhase, TaskState, transition
@@ -1,20 +0,0 @@
"""Canonical partial matching for acceptance-claim expectations."""
from __future__ import annotations
from typing import Any
def contains_expected(actual: Any, required: Any) -> bool:
"""Allow an actual claim to add provider-chosen optional fields only."""
if isinstance(required, dict):
return isinstance(actual, dict) and all(
key in actual and contains_expected(actual[key], value)
for key, value in required.items()
)
if isinstance(required, list):
return isinstance(actual, list) and len(actual) == len(required) and all(
contains_expected(actual_item, required_item)
for actual_item, required_item in zip(actual, required, strict=True)
)
return actual == required
+11 -15
View File
@@ -1,4 +1,4 @@
"""Typed, serializable failures used across the v3 workflow."""
"""Typed, serializable failures used across the single-stage workflow."""
from __future__ import annotations
@@ -9,29 +9,25 @@ from typing import Any
class ErrorCode(StrEnum):
CANCELLED = "CANCELLED"
AUTHOR_FORMAT_INVALID = "AUTHOR_FORMAT_INVALID"
AUTHOR_DECISION_REJECTED = "AUTHOR_DECISION_REJECTED"
STALE_WORKING_HEAD = "STALE_WORKING_HEAD"
FAILED_AUTHOR_FORMAT = "FAILED_AUTHOR_FORMAT"
RUNTIME_PRECONDITION_FAILED = "RUNTIME_PRECONDITION_FAILED"
RUNTIME_CONTRACT_INVALID = "RUNTIME_CONTRACT_INVALID"
CANDIDATE_BUILD_FAILED = "CANDIDATE_BUILD_FAILED"
CANDIDATE_REVIEW_REJECTED = "CANDIDATE_REVIEW_REJECTED"
VERIFIER_UNAVAILABLE = "VERIFIER_UNAVAILABLE"
CLAIM_VERIFICATION_FAILED = "CLAIM_VERIFICATION_FAILED"
MODEL_STRUCTURED_OUTPUT_UNSUPPORTED = "MODEL_STRUCTURED_OUTPUT_UNSUPPORTED"
MODEL_PROTOCOL_CHECK_PENDING = "MODEL_PROTOCOL_CHECK_PENDING"
AUTHOR_TRANSPORT_UNAVAILABLE = "AUTHOR_TRANSPORT_UNAVAILABLE"
REVIEW_SERVICE_UNAVAILABLE = "REVIEW_SERVICE_UNAVAILABLE"
RENDER_SERVICE_UNAVAILABLE = "RENDER_SERVICE_UNAVAILABLE"
STORAGE_FAILURE = "STORAGE_FAILURE"
CALL_BUDGET_EXHAUSTED = "CALL_BUDGET_EXHAUSTED"
REQUIREMENTS_SPEC_INVALID = "REQUIREMENTS_SPEC_INVALID"
NO_PROGRESS_LIMIT = "NO_PROGRESS_LIMIT"
RUNTIME_EXECUTION_FAILURE = "RUNTIME_EXECUTION_FAILURE"
BEST_EFFORT_COMPLETED = "BEST_EFFORT_COMPLETED"
FAILED_INTERNAL = "FAILED_INTERNAL"
WAITING_FOR_USER = "WAITING_FOR_USER"
AUTHOR_FORBIDDEN_FIELD = "AUTHOR_FORBIDDEN_FIELD"
AUTHOR_SCHEMA_INVALID = "AUTHOR_SCHEMA_INVALID"
AUTHOR_REFERENCE_INVALID = "AUTHOR_REFERENCE_INVALID"
AUTHOR_CYCLE = "AUTHOR_CYCLE"
OPERATION_UNSUPPORTED = "OPERATION_UNSUPPORTED"
SELECTOR_NOT_FOUND = "SELECTOR_NOT_FOUND"
SELECTOR_AMBIGUOUS = "SELECTOR_AMBIGUOUS"
SELECTOR_DEPENDENCY_UNAVAILABLE = "SELECTOR_DEPENDENCY_UNAVAILABLE"
SELECTOR_KIND_MISMATCH = "SELECTOR_KIND_MISMATCH"
ENGINE_EXECUTION_FAILED = "ENGINE_EXECUTION_FAILED"
@dataclass(frozen=True, slots=True)
@@ -1,265 +0,0 @@
"""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
)
@@ -1,8 +1,7 @@
"""Versioned runtime operation contracts and dynamic fragment schemas."""
"""Versioned runtime operation contracts for the Authoring compiler."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any
@@ -15,25 +14,6 @@ class OperationContractError(ValueError):
pass
SEMANTIC_PREFLIGHT_NAMES = frozenset({
"sketch_workplane",
"profile_non_self_intersecting",
"host_face_exists",
"hole_positions_on_host_plane",
"cut_exit_distance",
"requires_active_solid",
"revolve_axis_on_sketch",
"reference_plane_nonzero_normal",
"reference_axis_nonzero_direction",
"selected_edges_exist",
"source_features_exist",
"mirror_plane_exists",
"loft_profiles_exist",
"loft_profiles_closed",
"loft_profiles_single_region",
})
def canonical_hash(value: dict[str, Any]) -> str:
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
@@ -58,10 +38,16 @@ def _is_closed(schema: Any) -> bool:
return True
def is_authoring_schema_closed(schema: Any) -> bool:
"""Whether an operation can safely be exposed in the Authoring whitelist."""
return _is_closed(schema)
def validate_operation_contract(contract: dict[str, Any]) -> None:
required = {
"atomic_id", "contract_version", "fragment_shape", "author_params_schema", "selector_policy",
"server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers",
"runtime_capability",
}
if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}:
raise OperationContractError("Operation contract has unknown or missing fields")
@@ -111,113 +97,43 @@ def validate_operation_contract(contract: dict[str, Any]) -> None:
if reference["mode"] == "snapshot_bound":
if set(reference) != {"mode", "slot", "token_kind", "min_items", "max_items", "snapshot_bound"}:
raise OperationContractError("Snapshot-bound reference policy is invalid")
if not isinstance(reference["slot"], str) or not reference["slot"].startswith("params.") or reference["token_kind"] != "feature" or not isinstance(reference["min_items"], int) or not isinstance(reference["max_items"], int) or not 1 <= reference["min_items"] <= reference["max_items"] <= 64 or reference["snapshot_bound"] is not True:
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
reference_name = reference["slot"].removeprefix("params.")
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
reference_schema = properties.get(reference_name)
required_params = params.get("required") if isinstance(params.get("required"), list) else []
slot = reference["slot"]
if (
not isinstance(reference_schema, dict)
or reference_schema.get("type") != "array"
or not isinstance(reference_schema.get("items"), dict)
or reference_name not in required_params
not isinstance(slot, str)
or (slot != "feature.selectors" and not slot.startswith("params."))
or reference["token_kind"] not in {"face", "edge", "plane", "axis", "body", "feature"}
or not isinstance(reference["min_items"], int)
or not isinstance(reference["max_items"], int)
or not 0 <= reference["min_items"] <= reference["max_items"] <= 64
or reference["snapshot_bound"] is not True
):
raise OperationContractError("Snapshot-bound reference slot must be a required author array")
raise OperationContractError("Snapshot-bound reference policy has invalid bounds")
if slot == "feature.selectors":
if shape["selector_tokens"] != "required" or selector["token_kind"] != reference["token_kind"]:
raise OperationContractError("Feature selector reference policy disagrees with selector policy")
else:
reference_name = slot.removeprefix("params.")
properties = params.get("properties") if isinstance(params.get("properties"), dict) else {}
reference_schema = properties.get(reference_name)
if not isinstance(reference_schema, dict) or reference_schema.get("type") not in {"array", "string"}:
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
if reference_schema.get("type") == "string" and reference["max_items"] > 1:
raise OperationContractError("Scalar snapshot-bound reference must allow at most one item")
if not isinstance(contract["semantic_preflight"], list) or not all(isinstance(item, str) and item for item in contract["semantic_preflight"]):
raise OperationContractError("Operation semantic preflight is invalid")
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]) or set(contract["semantic_preflight"]) - SEMANTIC_PREFLIGHT_NAMES:
raise OperationContractError("Operation semantic preflight names are unknown or duplicated")
if len(set(contract["semantic_preflight"])) != len(contract["semantic_preflight"]):
raise OperationContractError("Operation semantic preflight names are duplicated")
if not isinstance(contract["candidate_verifiers"], list) or not all(isinstance(item, str) and item for item in contract["candidate_verifiers"]):
raise OperationContractError("Operation candidate_verifiers are invalid")
if len(set(contract["candidate_verifiers"])) != len(contract["candidate_verifiers"]):
raise OperationContractError("Operation candidate verifiers are duplicated")
def fragment_schema(contract: dict[str, Any], *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> dict[str, Any]:
"""Build the one-operation schema exposed for one pending action."""
validate_operation_contract(contract)
shape = contract["fragment_shape"]
params = deepcopy(contract["author_params_schema"])
reference = contract["reference_policy"]
if reference["mode"] == "snapshot_bound":
slot = str(reference["slot"]).removeprefix("params.")
items = params.get("properties", {}).get(slot, {}).get("items") if isinstance(params.get("properties"), dict) else None
if not isinstance(items, dict):
raise OperationContractError("Snapshot-bound reference slot is absent from author params schema")
items.clear()
items.update({"enum": reference_tokens or []})
feature_properties: dict[str, Any] = {
"atomic_id": {"const": contract["atomic_id"]},
"params": params,
runtime_capability = contract["runtime_capability"]
required_capability_flags = {
"body_mutating", "requires_active_body", "replayable", "requires_selector", "open_profile_ok",
}
feature_required = ["atomic_id", "params"]
if shape["selector_tokens"] == "required":
policy = contract["selector_policy"]
feature_properties["selector_tokens"] = {
"type": "array", "items": {"enum": selector_tokens}, "minItems": policy["min_items"],
"maxItems": policy["max_items"], "uniqueItems": True,
"description": (
"Required author input. Copy the opaque selector token returned by the current topology "
"snapshot here; do not omit it and do not put a host face in params. The server resolves this "
"token into the host selector after schema validation."
),
}
feature_required.append("selector_tokens")
feature = {
"type": "object",
"description": (
"One atomic feature. selector_tokens, when present, is an author-supplied topology token array "
"rather than a server-filled params field."
),
"properties": feature_properties,
"required": feature_required,
"additionalProperties": False,
}
properties: dict[str, Any] = {"feature": feature}
required = ["feature"]
if shape["sketch"] == "required":
properties["sketch"] = _sketch_schema(root_xy_datum=root_xy_datum and contract["atomic_id"] in {"extrude_add_blind", "extrude_add_two_sided"})
required.insert(0, "sketch")
schema = {"$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": properties, "required": required, "additionalProperties": False}
Draft202012Validator.check_schema(schema)
return schema
def validate_fragment(contract: dict[str, Any], fragment: Any, *, selector_tokens: list[str], reference_tokens: list[str] | None = None, root_xy_datum: bool = False) -> list[dict[str, str]]:
schema = fragment_schema(contract, selector_tokens=selector_tokens, reference_tokens=reference_tokens, root_xy_datum=root_xy_datum)
return [
{"path": "/" + "/".join(str(part) for part in error.absolute_path), "message": error.message}
for error in sorted(Draft202012Validator(schema).iter_errors(fragment), key=lambda item: (list(item.absolute_path), item.message))
]
def _point(size: int) -> dict[str, Any]:
return {"type": "array", "items": {"type": "number"}, "minItems": size, "maxItems": size}
def _sketch_schema(*, root_xy_datum: bool = False) -> dict[str, Any]:
point2 = _point(2)
point3 = _point(3)
workplane = {
"type": "object",
"description": "origin_mm is the world position of sketch local (0,0); profile coordinates are local to this plane. normal is positive extrusion direction and x_dir is local +X in world coordinates.",
"properties": {"origin_mm": point3, "x_dir": deepcopy(point3), "normal": deepcopy(point3)},
"required": ["origin_mm", "x_dir", "normal"],
"additionalProperties": False,
}
if root_xy_datum:
workplane["description"] += " Root extrusion uses fixed world XY datum: origin X/Y are 0, normal is +Z, x_dir is +X. Only origin Z is task-defined."
workplane["properties"] = {
"origin_mm": {"type": "array", "prefixItems": [{"const": 0}, {"const": 0}, {"type": "number"}], "items": False, "minItems": 3, "maxItems": 3},
"x_dir": {"const": [1, 0, 0]},
"normal": {"const": [0, 0, 1]},
}
profile = {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "radius_mm"], "additionalProperties": False},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": deepcopy(point2)}}, "required": ["type", "vertices"], "additionalProperties": False},
{"type": "object", "properties": {"type": {"const": "analytic_contours"}, "contours": {"type": "array", "minItems": 1, "maxItems": 8, "items": {"type": "object", "properties": {"role": {"enum": ["outer", "inner"]}, "closed": {"const": True}, "segments": {"type": "array", "minItems": 1, "items": {"oneOf": [{"type": "object", "properties": {"type": {"const": "line"}, "start": deepcopy(point2), "end": deepcopy(point2)}, "required": ["type", "start", "end"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "circle"}, "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}}, "required": ["type", "center", "radius_mm"], "additionalProperties": False}, {"type": "object", "properties": {"type": {"const": "arc"}, "start": deepcopy(point2), "end": deepcopy(point2), "center": deepcopy(point2), "radius_mm": {"type": "number", "exclusiveMinimum": 0}, "clockwise": {"type": "boolean"}}, "required": ["type", "start", "end", "center", "radius_mm"], "additionalProperties": False}]}}}, "required": ["role", "closed", "segments"], "additionalProperties": False}}}, "required": ["type", "contours"], "additionalProperties": False},
]
}
return {"type": "object", "properties": {"workplane": workplane, "profile": profile}, "required": ["workplane", "profile"], "additionalProperties": False}
if (
not isinstance(runtime_capability, dict)
or set(runtime_capability) != required_capability_flags
or not all(isinstance(value, bool) for value in runtime_capability.values())
):
raise OperationContractError("Operation runtime capability is invalid")
+70 -185
View File
@@ -1,56 +1,24 @@
"""Finite workflow state machine. This module has no persistence imports."""
"""Finite state for the single-stage Authoring CDSL protocol."""
from __future__ import annotations
from dataclasses import dataclass, replace
from enum import StrEnum
from .errors import ErrorCode, WorkflowError
from .errors import ErrorCode
class TaskPhase(StrEnum):
DRAFTING_REQUIREMENTS_DOCUMENT = "DRAFTING_REQUIREMENTS_DOCUMENT"
DRAFTING_COMPLETION_TARGET = "DRAFTING_COMPLETION_TARGET"
COMPILING_REQUIREMENTS = "COMPILING_REQUIREMENTS"
COMPILING_FEATURE_PLAN = "COMPILING_FEATURE_PLAN"
SCHEDULING_FEATURE = "SCHEDULING_FEATURE"
FEATURE_PENDING = "FEATURE_PENDING"
FEATURE_BUILDING = "FEATURE_BUILDING"
REPLANNING_FEATURE_SUBGRAPH = "REPLANNING_FEATURE_SUBGRAPH"
# Legacy v3.1 phases remain readable only so an interrupted process can
# fail cleanly during the protocol reset. New v3.2 tasks never enter them.
DRAFTING_MODELING_PLAN = "DRAFTING_MODELING_PLAN"
AWAITING_ACTION = "AWAITING_ACTION"
ACTION_PENDING = "ACTION_PENDING"
CANDIDATE_BUILDING = "CANDIDATE_BUILDING"
CANDIDATE_REVIEW = "CANDIDATE_REVIEW"
FINAL_VALIDATION = "FINAL_VALIDATION"
WAITING_RETRY = "WAITING_RETRY"
ANALYZING_REQUEST = "ANALYZING_REQUEST"
AUTHORING_CDSL = "AUTHORING_CDSL"
COMPILING_CDSL = "COMPILING_CDSL"
BUILDING = "BUILDING"
REPAIRING = "REPAIRING"
PUBLISHING_BEST_EFFORT = "PUBLISHING_BEST_EFFORT"
WAITING_FOR_USER = "WAITING_FOR_USER"
CANCELLED = "CANCELLED"
COMPLETED = "COMPLETED"
FAILED = "FAILED"
@dataclass(frozen=True, slots=True)
class PendingAction:
action_id: str
working_head: str
intent: str
requirement_ids: tuple[str, ...]
atomic_id: str
expected_change: str
contract_hash: str
idempotency_key: str
node_id: str = ""
plan_hash: str = ""
claim_ids: tuple[str, ...] = ()
depends_on_node_ids: tuple[str, ...] = ()
# The stored JSON key remains ``pending_action_json`` only in old artifacts.
# v3.2 code uses this alias to make the ownership boundary explicit.
PendingFeature = PendingAction
CANCELLED = "CANCELLED"
@dataclass(frozen=True, slots=True)
@@ -59,167 +27,84 @@ class TaskState:
phase: TaskPhase
version: int
active_revision: str = ""
pending_action: PendingAction | None = None
candidate_id: str = ""
candidate_stage_id: str = ""
repair_required: bool = False
repair_count: int = 0
last_error: ErrorCode | None = None
retry_from_phase: TaskPhase | None = None
requirements_spec_path: str = ""
requirements_document_path: str = ""
completion_target_path: str = ""
modeling_plan_path: str = ""
feature_plan_path: str = ""
feature_plan_hash: str = ""
feature_stage_id: str = ""
requirements_path: str = ""
authoring_path: str = ""
runtime_cdsl_path: str = ""
compile_audit_path: str = ""
diagnostics_path: str = ""
completion_path: str = ""
clarification_path: str = ""
requirements_contract_path: str = ""
@property
def pending_feature(self) -> PendingFeature | None:
return self.pending_action
@property
def working_head(self) -> str:
return f"{self.task_id}:{self.active_revision or 'root'}:v{self.version}"
# Legal state transitions. Events are intentionally terse persistence-neutral
# names used by command handlers and architecture tests.
_TRANSITIONS: dict[tuple[TaskPhase, str], TaskPhase] = {
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "image_observed"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "requirements_document_written"): TaskPhase.DRAFTING_COMPLETION_TARGET,
(TaskPhase.DRAFTING_COMPLETION_TARGET, "completion_target_written"): TaskPhase.COMPILING_REQUIREMENTS,
(TaskPhase.COMPILING_REQUIREMENTS, "requirements_compiled"): TaskPhase.COMPILING_FEATURE_PLAN,
(TaskPhase.COMPILING_FEATURE_PLAN, "feature_plan_written"): TaskPhase.SCHEDULING_FEATURE,
# Retained for direct v3.1 handler callers only. The v3.2 workflow never
# exposes this event or accepts a Markdown plan from a model.
(TaskPhase.COMPILING_FEATURE_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
(TaskPhase.REPLANNING_FEATURE_SUBGRAPH, "feature_plan_revised"): TaskPhase.SCHEDULING_FEATURE,
(TaskPhase.SCHEDULING_FEATURE, "feature_scheduled"): TaskPhase.FEATURE_PENDING,
(TaskPhase.SCHEDULING_FEATURE, "final_requested"): TaskPhase.FINAL_VALIDATION,
(TaskPhase.SCHEDULING_FEATURE, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FEATURE_PENDING, "feature_started"): TaskPhase.FEATURE_BUILDING,
(TaskPhase.FEATURE_PENDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
(TaskPhase.FEATURE_BUILDING, "feature_verified"): TaskPhase.SCHEDULING_FEATURE,
(TaskPhase.FEATURE_BUILDING, "feature_retry"): TaskPhase.FEATURE_PENDING,
(TaskPhase.FEATURE_PENDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FEATURE_BUILDING, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.DRAFTING_MODELING_PLAN, "modeling_plan_written"): TaskPhase.AWAITING_ACTION,
(TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
# User clarifications are durable task evidence. Resume on the same task
# so its frozen request remains authoritative
# instead of turning a clarification into a new CAD request.
(TaskPhase.WAITING_FOR_USER, "requirements_clarified"): TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
(TaskPhase.AWAITING_ACTION, "action_proposed"): TaskPhase.ACTION_PENDING,
(TaskPhase.AWAITING_ACTION, "diagnosis_recorded"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "rollback"): TaskPhase.AWAITING_ACTION,
(TaskPhase.ACTION_PENDING, "candidate_started"): TaskPhase.CANDIDATE_BUILDING,
# Runtime semantic preflight happens before candidate staging. It must
# leave the checkpoint untouched and return control to the author for a
# fresh observation/action decision, never masquerade as a format error.
(TaskPhase.ACTION_PENDING, "runtime_precondition_rejected"): TaskPhase.AWAITING_ACTION,
(TaskPhase.ACTION_PENDING, "diagnosis_recorded"): TaskPhase.ACTION_PENDING,
(TaskPhase.ACTION_PENDING, "diagnosis_return_to_action_selection"): TaskPhase.AWAITING_ACTION,
(TaskPhase.CANDIDATE_BUILDING, "candidate_built"): TaskPhase.CANDIDATE_REVIEW,
(TaskPhase.CANDIDATE_BUILDING, "candidate_rejected"): TaskPhase.ACTION_PENDING,
(TaskPhase.CANDIDATE_REVIEW, "candidate_accepted"): TaskPhase.AWAITING_ACTION,
(TaskPhase.CANDIDATE_REVIEW, "candidate_rejected"): TaskPhase.AWAITING_ACTION,
(TaskPhase.AWAITING_ACTION, "final_requested"): TaskPhase.FINAL_VALIDATION,
(TaskPhase.FINAL_VALIDATION, "feature_replan"): TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
(TaskPhase.FINAL_VALIDATION, "final_accepted"): TaskPhase.COMPLETED,
(TaskPhase.FINAL_VALIDATION, "final_repair"): TaskPhase.AWAITING_ACTION,
(TaskPhase.ANALYZING_REQUEST, "analysis_written"): TaskPhase.AUTHORING_CDSL,
(TaskPhase.ANALYZING_REQUEST, "waiting_for_user"): TaskPhase.WAITING_FOR_USER,
(TaskPhase.WAITING_FOR_USER, "clarification_received"): TaskPhase.ANALYZING_REQUEST,
(TaskPhase.AUTHORING_CDSL, "authoring_written"): TaskPhase.COMPILING_CDSL,
(TaskPhase.AUTHORING_CDSL, "repair_required"): TaskPhase.REPAIRING,
(TaskPhase.COMPILING_CDSL, "compiled"): TaskPhase.BUILDING,
(TaskPhase.COMPILING_CDSL, "repair_required"): TaskPhase.REPAIRING,
(TaskPhase.BUILDING, "build_completed"): TaskPhase.PUBLISHING_BEST_EFFORT,
(TaskPhase.BUILDING, "repair_required"): TaskPhase.REPAIRING,
(TaskPhase.REPAIRING, "repair_started"): TaskPhase.AUTHORING_CDSL,
(TaskPhase.PUBLISHING_BEST_EFFORT, "published"): TaskPhase.COMPLETED,
(TaskPhase.PUBLISHING_BEST_EFFORT, "failed"): TaskPhase.FAILED,
}
_TRANSITIONS.update({
(phase, "best_effort_completed"): TaskPhase.COMPLETED
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_TRANSITIONS.update({
(phase, "failed"): TaskPhase.FAILED
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_TRANSITIONS.update({
(phase, "cancelled"): TaskPhase.CANCELLED
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}
})
_RETRY_RESUMABLE_PHASES = frozenset({
TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT,
TaskPhase.DRAFTING_COMPLETION_TARGET,
TaskPhase.COMPILING_REQUIREMENTS,
TaskPhase.COMPILING_FEATURE_PLAN,
TaskPhase.SCHEDULING_FEATURE,
TaskPhase.FEATURE_PENDING,
TaskPhase.FEATURE_BUILDING,
TaskPhase.REPLANNING_FEATURE_SUBGRAPH,
TaskPhase.DRAFTING_MODELING_PLAN,
TaskPhase.AWAITING_ACTION,
TaskPhase.ACTION_PENDING,
TaskPhase.CANDIDATE_BUILDING,
TaskPhase.CANDIDATE_REVIEW,
TaskPhase.FINAL_VALIDATION,
})
_TRANSITIONS.update({
(TaskPhase.WAITING_RETRY, f"resume_{phase.value.lower()}"): phase
for phase in _RETRY_RESUMABLE_PHASES
})
_TRANSITIONS.update({
(phase, "waiting_retry"): TaskPhase.WAITING_RETRY
for phase in TaskPhase
if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_RETRY}
})
for _phase in TaskPhase:
if _phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
_TRANSITIONS[(_phase, "failed")] = TaskPhase.FAILED
_TRANSITIONS[(_phase, "cancelled")] = TaskPhase.CANCELLED
if _phase in {
TaskPhase.AUTHORING_CDSL, TaskPhase.COMPILING_CDSL,
TaskPhase.BUILDING, TaskPhase.REPAIRING,
}:
_TRANSITIONS[(_phase, "publish_best_effort")] = TaskPhase.PUBLISHING_BEST_EFFORT
def legal_transitions() -> dict[tuple[TaskPhase, str], TaskPhase]:
"""Return a copy of the protocol transition table for architecture tests."""
return dict(_TRANSITIONS)
def retry_resume_event(state: TaskState) -> str | None:
"""Return the only lossless resume event for a parked service failure."""
if state.phase != TaskPhase.WAITING_RETRY or state.retry_from_phase not in _RETRY_RESUMABLE_PHASES:
return None
return f"resume_{state.retry_from_phase.value.lower()}"
def transition(state: TaskState, event: str, *, pending_action: PendingAction | None | object = ..., active_revision: str | None = None, candidate_id: str | None = None, candidate_stage_id: str | None = None, feature_stage_id: str | None = None, repair_required: bool | None = None, error: ErrorCode | None = None, requirements_spec_path: str | None = None, requirements_document_path: str | None = None, completion_target_path: str | None = None, modeling_plan_path: str | None = None, feature_plan_path: str | None = None, feature_plan_hash: str | None = None, clarification_path: str | None = None, requirements_contract_path: str | None = None) -> TaskState:
"""Apply one legal transition and advance optimistic-concurrency version."""
target = _TRANSITIONS.get((state.phase, event))
def transition(
state: TaskState,
event: str,
*,
active_revision: str | None = None,
repair_count: int | None = None,
error: ErrorCode | None = None,
requirements_path: str | None = None,
authoring_path: str | None = None,
runtime_cdsl_path: str | None = None,
compile_audit_path: str | None = None,
diagnostics_path: str | None = None,
completion_path: str | None = None,
clarification_path: str | None = None,
) -> TaskState:
target = state.retry_from_phase if state.phase == TaskPhase.FAILED and event == "resume" else _TRANSITIONS.get((state.phase, event))
if target is None:
raise ValueError(f"Illegal v3 transition: {state.phase.value} --{event}--> ?")
if state.phase == TaskPhase.WAITING_RETRY and event.startswith("resume_") and state.retry_from_phase != target:
raise ValueError("WAITING_RETRY resume event does not match its persisted source phase")
next_pending = state.pending_action if pending_action is ... else pending_action
if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED}:
next_pending = None
raise ValueError(f"Illegal single-stage transition: {state.phase.value} --{event}--> ?")
next_repairs = state.repair_count if repair_count is None else repair_count
if not 0 <= next_repairs <= 2:
raise ValueError("Single-stage repair count must be within [0, 2]")
return replace(
state,
phase=target,
version=state.version + 1,
active_revision=state.active_revision if active_revision is None else active_revision,
pending_action=next_pending,
candidate_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_id if candidate_id is None else candidate_id,
candidate_stage_id="" if target in {TaskPhase.AWAITING_ACTION, TaskPhase.SCHEDULING_FEATURE, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.candidate_stage_id if candidate_stage_id is None else candidate_stage_id,
repair_required=state.repair_required if repair_required is None else repair_required,
repair_count=next_repairs,
last_error=error,
retry_from_phase=state.phase if target == TaskPhase.WAITING_RETRY else None,
requirements_spec_path=state.requirements_spec_path if requirements_spec_path is None else requirements_spec_path,
requirements_document_path=state.requirements_document_path if requirements_document_path is None else requirements_document_path,
completion_target_path=state.completion_target_path if completion_target_path is None else completion_target_path,
modeling_plan_path=state.modeling_plan_path if modeling_plan_path is None else modeling_plan_path,
feature_plan_path=state.feature_plan_path if feature_plan_path is None else feature_plan_path,
feature_plan_hash=state.feature_plan_hash if feature_plan_hash is None else feature_plan_hash,
feature_stage_id="" if target in {TaskPhase.SCHEDULING_FEATURE, TaskPhase.FEATURE_PENDING, TaskPhase.REPLANNING_FEATURE_SUBGRAPH, TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED} else state.feature_stage_id if feature_stage_id is None else feature_stage_id,
retry_from_phase=state.phase if target == TaskPhase.FAILED and error in {
ErrorCode.AUTHOR_TRANSPORT_UNAVAILABLE, ErrorCode.STORAGE_FAILURE,
ErrorCode.RUNTIME_EXECUTION_FAILURE,
} else None,
requirements_path=state.requirements_path if requirements_path is None else requirements_path,
authoring_path=state.authoring_path if authoring_path is None else authoring_path,
runtime_cdsl_path=state.runtime_cdsl_path if runtime_cdsl_path is None else runtime_cdsl_path,
compile_audit_path=state.compile_audit_path if compile_audit_path is None else compile_audit_path,
diagnostics_path=state.diagnostics_path if diagnostics_path is None else diagnostics_path,
completion_path=state.completion_path if completion_path is None else completion_path,
clarification_path=state.clarification_path if clarification_path is None else clarification_path,
requirements_contract_path=state.requirements_contract_path if requirements_contract_path is None else requirements_contract_path,
)
def reject_stale_head(state: TaskState, supplied_head: str) -> WorkflowError | None:
if supplied_head != state.working_head:
return WorkflowError(
ErrorCode.STALE_WORKING_HEAD,
"The command was bound to an obsolete working head.",
details={"expected_working_head": state.working_head, "supplied_working_head": supplied_head},
)
return None