Files
cdsl-cad/backend/app/cad_agent/adapters/runtime.py
T

308 lines
17 KiB
Python

"""CDSL runtime adapter driven exclusively by profile operation contracts."""
from __future__ import annotations
from copy import deepcopy
import json
import math
from pathlib import Path
from typing import Any
from app.cad_agent.domain.operation_contract import (
OperationContractError,
canonical_hash,
is_authoring_schema_closed,
validate_operation_contract,
)
from app.cad_agent.ports import AdapterUnavailable
from app.services.derived_metrics import derive_metrics, make_prefix_runner
from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl, validate_cdsl_shape
from app.services.render_bundle import RenderBundleError, render_checkpoint
from app.settings import Settings
from vendor.cdsl_preview_runtime import step_to_glb
class RuntimeAdapterError(RuntimeError):
pass
class RuntimeServiceUnavailable(AdapterUnavailable):
"""A renderer or artifact service outage that must not spend a repair."""
class ProfileCadRuntime:
"""Adapter that owns engine imports; application code sees only its port."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.engine = load_engine(settings)
schema_path = Path(str(self.engine.__file__)).with_name("profile_schema.json")
try:
self._profile = json.loads(schema_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile schema is unavailable") from error
profile_contracts = self._profile.get("operation_contracts")
if not isinstance(profile_contracts, dict):
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no operation contracts")
declared = {str(item) for item in profile_contracts}
registered = {str(item) for item in getattr(self.engine, "SUPPORTED_ATOMIC_IDS", ())}
if declared != registered:
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime and profile operation registries disagree")
self._contracts: dict[str, dict[str, Any]] = {}
for atomic_id, contract in profile_contracts.items():
if not isinstance(contract, dict):
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not an object")
# A legacy opaque operation schema is still executable internally,
# but cannot enter the sole LLM-facing Authoring protocol.
if not is_authoring_schema_closed(contract.get("author_params_schema")):
continue
try:
validate_operation_contract(contract)
except OperationContractError as error:
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error
self._contracts[str(atomic_id)] = contract
if not self._contracts:
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no strict authoring operations")
def supported_atomic_ids(self) -> tuple[str, ...]:
return tuple(sorted(self._contracts))
def operation_contract(self, atomic_id: str) -> dict[str, Any]:
contract = self._contracts.get(atomic_id)
if not isinstance(contract, dict):
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: unknown operation {atomic_id}")
result = deepcopy(contract)
result["contract_hash"] = canonical_hash(contract)
result["registry_revision"] = str(self._profile.get("schema_version") or "")
return result
def compile_authoring(self, document: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
"""Compile model-facing Authoring CDSL using server-owned contracts."""
from app.cad_agent.application.authoring_compiler import AuthoringCompiler
from app.cad_agent.application.authoring_compiler import AuthoringCompileError
runtime, audit = AuthoringCompiler(self.operation_contract).compile(document)
try:
self._validate_finite_tree(runtime)
validate_cdsl_shape(runtime, self.engine)
except Exception as error:
raise AuthoringCompileError(
"RUNTIME_CONTRACT_INVALID",
f"compiled Runtime CDSL is not executable: {error}",
) from error
return runtime, audit
def build_checkpoint(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
"""Build the exact geometry checkpoint required by every DAG node.
Rendering is deliberately absent here: a renderer outage must never
make a geometrically valid atomic feature fail its local acceptance.
"""
root = Path(output_dir)
root.mkdir(parents=True, exist_ok=True)
cdsl_copy = deepcopy(cdsl)
cdsl_copy["part_id"] = task_id
step_path = root / "model.step"
try:
self._validate_finite_tree(cdsl_copy)
validate_cdsl(cdsl_copy, self.engine)
engine_result = self.engine.run_cdsl_only(cdsl_copy, step_path)
if str(engine_result.get("engine") or "") != "cdsl_only":
raise RuntimeAdapterError("engine did not execute CDSL-only rebuild")
health = self._health(engine_result, step_path)
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id)
self._write_json(root / "model.topology.json", topology)
# Derived metrics are backfilled after validation, so they can
# never influence acceptance; the persisted document carries them
# for training while rebuild behavior stays untouched.
derived = derive_metrics(
cdsl_copy, engine_result, topology,
prefix_runner=make_prefix_runner(self.engine.run_cdsl_only),
)
cdsl_copy.setdefault("meta", {})["derived_metrics"] = derived
self._write_json(root / "model.cdsl.json", cdsl_copy)
report = {"engine_result": engine_result, "preview": {}, "health": health, "render_manifest": {}}
self._write_json(root / "rebuild-report.json", report)
return {"health": health, "topology": topology, "report": report, "render_manifest": {}, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "topology": "model.topology.json", "report": "rebuild-report.json"}}
except OSError:
raise
except RuntimeAdapterError:
raise
except Exception as error:
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
def create_preview(self, output_dir: str) -> dict[str, Any]:
"""Create an optional GLB preview for a completed checkpoint."""
root = Path(output_dir)
step_path = root / "model.step"
glb_path = root / "model.glb"
preview = step_to_glb(step_path, glb_path)
report_path = root / "rebuild-report.json"
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
report["preview"] = preview
self._write_json(report_path, report)
return {"preview": preview, "path": "model.glb"}
def render_bundle(self, output_dir: str) -> dict[str, Any]:
root = Path(output_dir)
manifest = render_checkpoint(self.settings, step_path=root / "model.step", output_dir=root / "renders")
report_path = root / "rebuild-report.json"
report = json.loads(report_path.read_text(encoding="utf-8")) if report_path.is_file() else {}
report["render_manifest"] = manifest
self._write_json(report_path, report)
return manifest
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
"""Build one complete checkpoint and derive all publishable artifacts."""
built = self.build_checkpoint(cdsl, output_dir, task_id, revision_id)
root = Path(output_dir)
try:
preview = self.create_preview(output_dir)
manifest = self.render_bundle(output_dir)
report = json.loads((root / "rebuild-report.json").read_text(encoding="utf-8"))
return {**built, "report": report, "preview": preview["preview"], "render_manifest": manifest, "paths": {**built["paths"], "glb": "model.glb", "render_manifest": "renders/render-manifest.json"}}
except OSError:
# Artifact writes are a recoverable infrastructure outage. Let the
# application handler park the same candidate stage for replay.
raise
except RenderBundleError as error:
raise RuntimeServiceUnavailable(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error
except Exception as error:
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
def rebuild_best_effort(
self,
cdsl: dict[str, Any],
output_dir: str,
task_id: str,
revision_id: str,
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Build independent feature prefixes without discarding prior geometry.
A full CDSL document can contain several features even though the
action protocol normally appends one at a time. Engine validation is
all-or-nothing, so replay features in source order and retain each
executable feature. A failed feature is reported as a diagnostic;
it does not erase already executable geometry or prevent later,
independent features from being tried.
"""
features = cdsl.get("features") if isinstance(cdsl.get("features"), list) else []
accepted: list[dict[str, Any]] = []
accepted_ids: set[str] = set()
failures: list[dict[str, Any]] = []
latest: dict[str, Any] | None = None
for index, feature in enumerate(features):
if not isinstance(feature, dict):
failures.append({"feature_index": index, "feature_id": "", "message": "Feature must be an object."})
continue
feature_id = str(feature.get("id") or f"feature_{index + 1:03d}")
dependencies = [str(value) for value in feature.get("depends_on") or () if isinstance(value, str)]
unavailable = [value for value in dependencies if value not in accepted_ids]
if unavailable:
failures.append({
"code": "SELECTOR_DEPENDENCY_UNAVAILABLE" if feature.get("selectors") else "DEPENDENCY_UNAVAILABLE",
"feature_index": index,
"feature_id": feature_id,
"message": "Feature was skipped because an earlier dependency did not execute.",
"dependencies": unavailable,
})
continue
candidate = self._feature_subset(cdsl, [*accepted, feature])
try:
latest = self.rebuild(candidate, output_dir, task_id, revision_id)
except (OSError, AdapterUnavailable):
# Preview/render/storage failures are service faults. Preserve
# the staging input and let the workflow replay this exact
# document without spending an Authoring repair.
raise
except Exception as error:
failures.append(self._build_failure(index, feature_id, feature, error))
continue
accepted.append(deepcopy(feature))
accepted_ids.add(feature_id)
if not accepted:
if failures:
return {}, failures
raise RuntimeAdapterError("RUNTIME_EXECUTION_FAILURE: CDSL document has no executable features")
# A failed later attempt may have left partial files in the stage.
# Rebuild the retained feature set once so all published artifacts are
# guaranteed to describe the same successful checkpoint.
latest = self.rebuild(self._feature_subset(cdsl, accepted), output_dir, task_id, revision_id)
return {**latest, "executed_feature_ids": sorted(accepted_ids)}, failures
@staticmethod
def _build_failure(index: int, feature_id: str, feature: dict[str, Any], error: Exception) -> dict[str, Any]:
diagnostic = getattr(error, "diagnostic", None)
message = str(getattr(diagnostic, "message", "") or error)
diagnostic_code = str(getattr(diagnostic, "code", "") or "")
if not diagnostic_code:
diagnostic_code = next((
code for code in (
"selector_output_role_not_found", "selector_geometry_mismatch",
"selector_output_role_ambiguous", "selector_relation_non_unique",
"selector_output_role_owner_required", "selector_output_role_active_body_required",
"selector_owner_required", "selector_output_role_mixed_evidence",
"unsupported_output_role_selector", "invalid_output_role_selector",
)
if code in message
), "")
selector_code = {
"selector_output_role_not_found": "SELECTOR_NOT_FOUND",
"selector_geometry_mismatch": "SELECTOR_NOT_FOUND",
"selector_output_role_ambiguous": "SELECTOR_AMBIGUOUS",
"selector_relation_non_unique": "SELECTOR_AMBIGUOUS",
"selector_output_role_owner_required": "SELECTOR_DEPENDENCY_UNAVAILABLE",
"selector_output_role_active_body_required": "SELECTOR_DEPENDENCY_UNAVAILABLE",
"selector_owner_required": "SELECTOR_DEPENDENCY_UNAVAILABLE",
"selector_output_role_mixed_evidence": "SELECTOR_KIND_MISMATCH",
"unsupported_output_role_selector": "SELECTOR_KIND_MISMATCH",
"invalid_output_role_selector": "SELECTOR_KIND_MISMATCH",
}.get(diagnostic_code, "ENGINE_EXECUTION_FAILED")
provenance = getattr(error, "selector_resolutions", None)
return {
"code": selector_code,
"runtime_code": diagnostic_code or "execution_failed",
"feature_index": index,
"feature_id": feature_id,
"atomic_id": str(feature.get("atomic_id") or ""),
"input_summary": {"params": sorted((feature.get("params") or {}).keys()), "selector_count": len(feature.get("selectors") or [])},
"message": message[:1000],
"selector_provenance": provenance if isinstance(provenance, list) else [],
}
@staticmethod
def _feature_subset(cdsl: dict[str, Any], features: list[dict[str, Any]]) -> dict[str, Any]:
document = deepcopy(cdsl)
document["features"] = deepcopy(features)
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else []
sketch_ids = {str(feature.get("sketch_id") or "") for feature in features}
document["geometry"] = {
**geometry,
"sketches": [deepcopy(sketch) for sketch in sketches if isinstance(sketch, dict) and str(sketch.get("id") or "") in sketch_ids],
}
return document
@staticmethod
def _validate_finite_tree(value: Any, path: str = "$") -> None:
if isinstance(value, float) and not math.isfinite(value):
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: non-finite number at {path}")
if isinstance(value, dict):
for key, child in value.items():
ProfileCadRuntime._validate_finite_tree(child, f"{path}.{key}")
elif isinstance(value, list):
for index, child in enumerate(value):
ProfileCadRuntime._validate_finite_tree(child, f"{path}[{index}]")
@staticmethod
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
@staticmethod
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path | None = None) -> dict[str, Any]:
bbox = engine_result.get("bbox_mm") if isinstance(engine_result.get("bbox_mm"), dict) else {}
minimum, maximum = bbox.get("min"), bbox.get("max")
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or (glb_path is not None and not glb_path.is_file()):
raise RuntimeAdapterError("rebuild did not produce complete deterministic artifacts")
return {"bbox_mm": {"min": [float(item) for item in minimum], "max": [float(item) for item in maximum], "dimensions": [float(maximum[index]) - float(minimum[index]) for index in range(3)]}, "volume_mm3": float(engine_result["volume_mm3"]), "solid_count": int(engine_result["solid_count"]), "feature_count": len(engine_result.get("feature_results") or [])}