332 lines
15 KiB
Python
332 lines
15 KiB
Python
"""Validation for the semantic planning artifact between a request and CDSL."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from copy import deepcopy
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from jsonschema import Draft202012Validator
|
|
from jsonschema.exceptions import SchemaError
|
|
|
|
|
|
class DesignIntentError(ValueError):
|
|
"""A plan-contract violation with a stable error code for the Agent."""
|
|
|
|
def __init__(self, code: str, message: str) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
|
|
|
|
def _schema_path(engine: Any) -> Path:
|
|
return Path(str(engine.__file__)).with_name("design_intent_schema.json")
|
|
|
|
|
|
def load_design_intent_schema(engine: Any) -> dict[str, Any]:
|
|
path = _schema_path(engine)
|
|
try:
|
|
schema = json.loads(path.read_text(encoding="utf-8"))
|
|
Draft202012Validator.check_schema(schema)
|
|
except (OSError, json.JSONDecodeError, SchemaError) as error:
|
|
raise RuntimeError("The local DesignIntent JSON Schema is unavailable or invalid") from error
|
|
return schema
|
|
|
|
|
|
def _location(error: Any) -> str:
|
|
return "$" + "".join(
|
|
f"[{item}]" if isinstance(item, int) else f".{item}"
|
|
for item in error.absolute_path
|
|
)
|
|
|
|
|
|
def _schema_validate(intent: dict[str, Any], engine: Any) -> None:
|
|
errors = sorted(
|
|
Draft202012Validator(load_design_intent_schema(engine)).iter_errors(intent),
|
|
key=lambda error: (list(error.absolute_path), error.message),
|
|
)
|
|
if errors:
|
|
error = errors[0]
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
f"DesignIntent schema violation at {_location(error)}: {error.message}",
|
|
)
|
|
|
|
|
|
def _runtime_capabilities(engine: Any) -> tuple[set[str], set[str], dict[str, dict[str, Any]]]:
|
|
try:
|
|
profile_schema = json.loads(Path(str(engine.__file__)).with_name("profile_schema.json").read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as error:
|
|
raise RuntimeError("The local CDSL profile schema is unavailable or invalid") from error
|
|
atomic_ids = set(profile_schema.get("runtime_supported_atomic_ids") or getattr(engine, "SUPPORTED_ATOMIC_IDS", ()))
|
|
profiles = {
|
|
name
|
|
for name, contract in (profile_schema.get("profiles") or {}).items()
|
|
if isinstance(contract, dict) and contract.get("agent_allowed") is True
|
|
}
|
|
atomic_contracts = {
|
|
str(atomic_id): contract
|
|
for atomic_id, contract in (profile_schema.get("feature_atomic_ids") or {}).items()
|
|
if isinstance(contract, dict)
|
|
}
|
|
return {str(item) for item in atomic_ids}, {str(item) for item in profiles}, atomic_contracts
|
|
|
|
|
|
def _blocking(intent: dict[str, Any]) -> bool:
|
|
questions = intent.get("open_questions") or []
|
|
gaps = intent.get("capability_gaps") or []
|
|
return any(isinstance(item, dict) and item.get("blocking") is True for item in [*questions, *gaps])
|
|
|
|
|
|
def validate_design_intent(
|
|
intent: dict[str, Any],
|
|
engine: Any,
|
|
*,
|
|
current_revision_id: str = "",
|
|
) -> dict[str, Any]:
|
|
"""Validate DesignIntent syntax and semantic ordering against runtime capability."""
|
|
if not isinstance(intent, dict):
|
|
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent must be a JSON object")
|
|
_schema_validate(intent, engine)
|
|
|
|
normalized = deepcopy(intent)
|
|
mode = str(normalized["mode"])
|
|
base_revision_id = str(normalized["base_revision_id"] or "")
|
|
if mode == "create" and base_revision_id:
|
|
raise DesignIntentError("INVALID_DESIGN_INTENT", "create DesignIntent must not set base_revision_id")
|
|
if mode == "revise":
|
|
if not base_revision_id:
|
|
raise DesignIntentError("INVALID_DESIGN_INTENT", "revise DesignIntent requires base_revision_id")
|
|
if current_revision_id and base_revision_id != current_revision_id:
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
"revise DesignIntent base_revision_id must be the current successful revision",
|
|
)
|
|
|
|
structures = normalized["structures"]
|
|
ids = [str(item["id"]) for item in structures]
|
|
feature_ids = [str(item["cdsl_feature_id"]) for item in structures]
|
|
if len(set(ids)) != len(ids):
|
|
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent structure ids must be unique")
|
|
if len(set(feature_ids)) != len(feature_ids):
|
|
raise DesignIntentError("INVALID_DESIGN_INTENT", "DesignIntent cdsl_feature_id values must be unique")
|
|
|
|
feature_order = [str(item) for item in normalized["feature_order"]]
|
|
if len(set(feature_order)) != len(feature_order) or set(feature_order) != set(ids):
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
"feature_order must list every DesignIntent structure exactly once",
|
|
)
|
|
order = {structure_id: index for index, structure_id in enumerate(feature_order)}
|
|
atomic_ids, profile_types, atomic_contracts = _runtime_capabilities(engine)
|
|
for structure in structures:
|
|
structure_id = str(structure["id"])
|
|
for dependency in structure["depends_on"]:
|
|
dependency_id = str(dependency)
|
|
if dependency_id not in order:
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
f"Structure {structure_id} depends on an unknown structure {dependency_id}",
|
|
)
|
|
if order[dependency_id] >= order[structure_id]:
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
f"Structure {structure_id} must depend only on earlier feature_order entries",
|
|
)
|
|
strategy = structure["cdsl_strategy"]
|
|
atomic_id = str(strategy["atomic_id"])
|
|
if atomic_id not in atomic_ids:
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
f"Structure {structure_id} uses unsupported atomic_id {atomic_id}",
|
|
)
|
|
profile_type = str(strategy.get("profile_type") or "")
|
|
if atomic_contracts.get(atomic_id, {}).get("requires_sketch") and not profile_type:
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
f"Structure {structure_id} uses {atomic_id} and requires a profile_type",
|
|
)
|
|
if profile_type and profile_type not in profile_types:
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
f"Structure {structure_id} uses unsupported or agent-disallowed profile_type {profile_type}",
|
|
)
|
|
|
|
if normalized["status"] == "ready" and _blocking(normalized):
|
|
raise DesignIntentError(
|
|
"DESIGN_INTENT_BLOCKED",
|
|
"A DesignIntent with blocking questions or capability gaps must need clarification",
|
|
)
|
|
if normalized["status"] == "needs_clarification" and not _blocking(normalized):
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
"needs_clarification requires a blocking question or capability gap",
|
|
)
|
|
if normalized["status"] == "ready" and not structures:
|
|
raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready DesignIntent needs at least one structure")
|
|
if normalized["status"] == "ready" and mode == "create" and not any(item["role"] == "base" for item in structures):
|
|
raise DesignIntentError("INVALID_DESIGN_INTENT", "A ready create DesignIntent needs a base structure")
|
|
nonblocking_gaps = [item for item in normalized["capability_gaps"] if not item["blocking"]]
|
|
if nonblocking_gaps and not normalized["assumptions"]:
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
"Non-blocking capability gaps require an explicit approximation assumption",
|
|
)
|
|
for gap in normalized["capability_gaps"]:
|
|
if str(gap["structure_id"]) not in set(ids):
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
f"Capability gap references an unknown structure {gap['structure_id']}",
|
|
)
|
|
valid_expectation_ids = set(ids) | set(feature_ids)
|
|
for expectation in normalized["verification_expectations"]:
|
|
if expectation["type"] == "feature_count" and str(expectation["feature_id"]) not in valid_expectation_ids:
|
|
raise DesignIntentError(
|
|
"INVALID_DESIGN_INTENT",
|
|
f"feature_count expectation references an unknown structure or CDSL feature {expectation['feature_id']}",
|
|
)
|
|
return normalized
|
|
|
|
|
|
def _has_selector_evidence(value: Any, role: str) -> bool:
|
|
if isinstance(value, dict):
|
|
for key, child in value.items():
|
|
if role in {"selector", "selectors"} and key == "selectors" and isinstance(child, list) and child:
|
|
return True
|
|
if key == role and child not in (None, "", [], {}):
|
|
if not (isinstance(child, dict) and set(child) == {"unresolved"}):
|
|
return True
|
|
if _has_selector_evidence(child, role):
|
|
return True
|
|
elif isinstance(value, list):
|
|
return any(_has_selector_evidence(item, role) for item in value)
|
|
return False
|
|
|
|
|
|
def validate_intent_cdsl(
|
|
intent: dict[str, Any],
|
|
cdsl: dict[str, Any],
|
|
engine: Any,
|
|
*,
|
|
current_cdsl: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Ensure an executable CDSL document faithfully realizes one accepted plan."""
|
|
normalized = validate_design_intent(intent, engine)
|
|
if normalized["status"] != "ready":
|
|
raise DesignIntentError("DESIGN_INTENT_BLOCKED", "CDSL generation is blocked until the DesignIntent is ready")
|
|
if not isinstance(cdsl, dict):
|
|
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must be a JSON object")
|
|
features = cdsl.get("features")
|
|
sketches = (cdsl.get("geometry") or {}).get("sketches")
|
|
if not isinstance(features, list) or not isinstance(sketches, list):
|
|
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL must contain features and sketches")
|
|
feature_by_id = {str(feature.get("id") or ""): feature for feature in features if isinstance(feature, dict)}
|
|
if len(feature_by_id) != len(features):
|
|
raise DesignIntentError("INTENT_CDSL_MISMATCH", "CDSL features must have unique ids")
|
|
structure_by_id = {str(item["id"]): item for item in normalized["structures"]}
|
|
expected_feature_ids = [str(structure_by_id[item]["cdsl_feature_id"]) for item in normalized["feature_order"]]
|
|
if normalized["mode"] == "revise" and isinstance(current_cdsl, dict):
|
|
base_feature_ids = {
|
|
str(feature.get("id") or "")
|
|
for feature in current_cdsl.get("features") or []
|
|
if isinstance(feature, dict) and feature.get("id")
|
|
}
|
|
if not base_feature_ids.issubset(set(expected_feature_ids)):
|
|
missing = ", ".join(sorted(base_feature_ids - set(expected_feature_ids)))
|
|
raise DesignIntentError(
|
|
"INTENT_CDSL_MISMATCH",
|
|
f"revise DesignIntent must explicitly preserve current features: {missing}",
|
|
)
|
|
actual_feature_ids = [str(feature.get("id") or "") for feature in features]
|
|
if actual_feature_ids != expected_feature_ids:
|
|
raise DesignIntentError(
|
|
"INTENT_CDSL_MISMATCH",
|
|
"CDSL feature ids and order must exactly match DesignIntent feature_order",
|
|
)
|
|
sketch_by_id = {str(sketch.get("id") or ""): sketch for sketch in sketches if isinstance(sketch, dict)}
|
|
feature_for_structure = {
|
|
str(structure["id"]): feature_by_id[str(structure["cdsl_feature_id"])]
|
|
for structure in normalized["structures"]
|
|
}
|
|
for structure in normalized["structures"]:
|
|
structure_id = str(structure["id"])
|
|
feature = feature_for_structure[structure_id]
|
|
strategy = structure["cdsl_strategy"]
|
|
if feature.get("atomic_id") != strategy["atomic_id"]:
|
|
raise DesignIntentError(
|
|
"INTENT_CDSL_MISMATCH",
|
|
f"CDSL feature {feature.get('id')} atomic_id does not match structure {structure_id}",
|
|
)
|
|
expected_dependencies = {
|
|
str(structure_by_id[dependency]["cdsl_feature_id"])
|
|
for dependency in structure["depends_on"]
|
|
}
|
|
actual_dependencies = {str(item) for item in feature.get("depends_on") or []}
|
|
if not expected_dependencies.issubset(actual_dependencies):
|
|
raise DesignIntentError(
|
|
"INTENT_CDSL_MISMATCH",
|
|
f"CDSL feature {feature.get('id')} is missing planned dependencies",
|
|
)
|
|
profile_type = str(strategy.get("profile_type") or "")
|
|
if profile_type:
|
|
sketch_id = str(feature.get("sketch_id") or "")
|
|
sketch = sketch_by_id.get(sketch_id)
|
|
actual_profile_type = str(((sketch or {}).get("profile") or {}).get("type") or "")
|
|
if actual_profile_type != profile_type:
|
|
raise DesignIntentError(
|
|
"INTENT_CDSL_MISMATCH",
|
|
f"CDSL feature {feature.get('id')} must use profile_type {profile_type}",
|
|
)
|
|
for selector_role in strategy["selector_roles"]:
|
|
if not _has_selector_evidence(feature, str(selector_role)):
|
|
raise DesignIntentError(
|
|
"INTENT_CDSL_MISMATCH",
|
|
f"CDSL feature {feature.get('id')} is missing selector evidence for {selector_role}",
|
|
)
|
|
|
|
|
|
def design_intent_from_cdsl(cdsl: dict[str, Any], *, request: str, mode: str = "create", base_revision_id: str = "") -> dict[str, Any]:
|
|
"""Create a conservative audit-only plan for legacy revisions when needed."""
|
|
sketches = (cdsl.get("geometry") or {}).get("sketches") or []
|
|
profile_by_sketch_id = {
|
|
str(sketch.get("id") or ""): str((sketch.get("profile") or {}).get("type") or "")
|
|
for sketch in sketches
|
|
if isinstance(sketch, dict)
|
|
}
|
|
structures: list[dict[str, Any]] = []
|
|
for index, feature in enumerate(cdsl.get("features") or []):
|
|
if not isinstance(feature, dict):
|
|
continue
|
|
feature_id = str(feature.get("id") or f"feature_{index + 1}")
|
|
atomic_id = str(feature.get("atomic_id") or "")
|
|
profile_type = profile_by_sketch_id.get(str(feature.get("sketch_id") or ""), "")
|
|
strategy: dict[str, Any] = {
|
|
"atomic_id": atomic_id,
|
|
"parameter_roles": sorted(str(key) for key in (feature.get("params") or {}).keys()),
|
|
"selector_roles": [],
|
|
}
|
|
if profile_type:
|
|
strategy["profile_type"] = profile_type
|
|
structures.append({
|
|
"id": feature_id,
|
|
"cdsl_feature_id": feature_id,
|
|
"role": "base" if index == 0 else "subtractive" if "cut" in atomic_id or atomic_id.startswith("hole") else "additive",
|
|
"purpose": str(feature.get("name") or feature_id),
|
|
"depends_on": [str(item) for item in feature.get("depends_on") or []],
|
|
"cdsl_strategy": strategy,
|
|
})
|
|
return {
|
|
"schema": "cad.cdsl.design-intent.v1",
|
|
"schema_version": "1.0",
|
|
"mode": mode,
|
|
"request": request,
|
|
"base_revision_id": base_revision_id,
|
|
"structures": structures,
|
|
"feature_order": [item["id"] for item in structures],
|
|
"assumptions": ["Synthesized from a legacy CDSL revision."],
|
|
"open_questions": [],
|
|
"capability_gaps": [],
|
|
"verification_expectations": [],
|
|
"status": "ready",
|
|
}
|