273 lines
13 KiB
Python
273 lines
13 KiB
Python
"""Controlled CDSL fragments; the backend, never string concatenation, materialises a model."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from copy import deepcopy
|
|
from hashlib import sha256
|
|
import json
|
|
from typing import Any
|
|
|
|
from app.services.generation_plan import GenerationPlanError
|
|
|
|
|
|
class CdslFragmentError(ValueError):
|
|
"""A node fragment cannot be applied safely to its declared base revision."""
|
|
|
|
|
|
def cdsl_sha256(cdsl: dict[str, Any] | None) -> str:
|
|
value = cdsl or {"geometry": {"sketches": []}, "features": []}
|
|
return sha256(json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _items(value: Any, field: str) -> list[dict[str, Any]]:
|
|
if value is None:
|
|
return []
|
|
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
|
|
raise CdslFragmentError(f"{field} must be an array of objects")
|
|
return deepcopy(value)
|
|
|
|
|
|
def _ids(items: list[dict[str, Any]], field: str) -> list[str]:
|
|
values = [str(item.get("id") or "").strip() for item in items]
|
|
if not all(values) or len(values) != len(set(values)):
|
|
raise CdslFragmentError(f"{field} must have unique non-empty ids")
|
|
return values
|
|
|
|
|
|
def _node(plan: dict[str, Any], node_id: str) -> dict[str, Any]:
|
|
node = next((item for item in plan.get("nodes") or () if isinstance(item, dict) and item.get("id") == node_id), None)
|
|
if node is None:
|
|
raise CdslFragmentError(f"Fragment references an unknown node: {node_id}")
|
|
return node
|
|
|
|
|
|
def _single_output_id(node: dict[str, Any], field: str) -> str:
|
|
values = [str(item) for item in node.get(field) or () if str(item)]
|
|
if len(values) != 1:
|
|
raise CdslFragmentError(f"Node {node.get('id')} must declare exactly one {field} output")
|
|
return values[0]
|
|
|
|
|
|
def _node_feature_id(plan: dict[str, Any], node_id: str) -> str:
|
|
return _single_output_id(_node(plan, node_id), "cdsl_feature_ids")
|
|
|
|
|
|
def _materialize_node_references(value: Any, plan: dict[str, Any]) -> Any:
|
|
"""Translate fragment-only node references into CDSL feature references."""
|
|
if isinstance(value, list):
|
|
return [_materialize_node_references(item, plan) for item in value]
|
|
if not isinstance(value, dict):
|
|
return value
|
|
materialized = {key: _materialize_node_references(item, plan) for key, item in value.items()}
|
|
owner_node_id = materialized.pop("owner_node_id", None)
|
|
if owner_node_id is not None:
|
|
if not isinstance(owner_node_id, str) or not owner_node_id.strip():
|
|
raise CdslFragmentError("owner_node_id must be a non-empty plan node id")
|
|
materialized["owner_feature_id"] = _node_feature_id(plan, owner_node_id.strip())
|
|
source_node_ids = materialized.pop("source_node_ids", None)
|
|
if source_node_ids is not None:
|
|
if not isinstance(source_node_ids, list) or not source_node_ids or not all(isinstance(item, str) and item.strip() for item in source_node_ids):
|
|
raise CdslFragmentError("source_node_ids must be a non-empty array of plan node ids")
|
|
materialized["source_feature_ids"] = [_node_feature_id(plan, item.strip()) for item in source_node_ids]
|
|
return materialized
|
|
|
|
|
|
def _selector_references(value: Any) -> list[dict[str, Any]]:
|
|
"""Collect selector-shaped objects from feature selectors and params."""
|
|
found: list[dict[str, Any]] = []
|
|
if isinstance(value, dict):
|
|
if "kind" in value and ("stable_id" in value or "snapshot_id" in value or "owner_feature_id" in value or "owner_node_id" in value):
|
|
found.append(value)
|
|
for child in value.values():
|
|
found.extend(_selector_references(child))
|
|
elif isinstance(value, list):
|
|
for child in value:
|
|
found.extend(_selector_references(child))
|
|
return found
|
|
|
|
|
|
def validate_fragment(
|
|
fragment: dict[str, Any],
|
|
*,
|
|
plan: dict[str, Any],
|
|
node_id: str,
|
|
base_revision_id: str,
|
|
base_cdsl: dict[str, Any] | None,
|
|
required_snapshot_id: str = "",
|
|
) -> dict[str, Any]:
|
|
if not isinstance(fragment, dict):
|
|
raise CdslFragmentError("CDSL fragment must be an object")
|
|
version = str(fragment.get("schema_version") or "cad.cdsl-fragment.v1")
|
|
if version != "cad.cdsl-fragment.v1":
|
|
raise CdslFragmentError(f"Unsupported CDSL fragment schema: {version}")
|
|
declared_node_id = str(fragment.get("node_id") or "")
|
|
if declared_node_id != node_id:
|
|
raise CdslFragmentError("Fragment node_id does not match the active node")
|
|
if str(fragment.get("base_revision_id") or "") != base_revision_id:
|
|
raise CdslFragmentError("Fragment base_revision_id does not match the active revision")
|
|
if str(fragment.get("base_cdsl_sha256") or "") != cdsl_sha256(base_cdsl):
|
|
raise CdslFragmentError("Fragment base_cdsl_sha256 does not match the active CDSL")
|
|
snapshot_id = str(fragment.get("required_snapshot_id") or "")
|
|
if required_snapshot_id and snapshot_id != required_snapshot_id:
|
|
raise CdslFragmentError("Fragment required_snapshot_id does not match the active topology snapshot")
|
|
if not required_snapshot_id and snapshot_id:
|
|
raise CdslFragmentError("Fragment cannot use a topology snapshot before one exists")
|
|
sketches = _items(fragment.get("add_sketches"), "add_sketches")
|
|
features = _items(fragment.get("add_features"), "add_features")
|
|
node = _node(plan, node_id)
|
|
expected = {str(item) for item in node.get("cdsl_feature_ids") or ()}
|
|
expected_sketches = {str(item) for item in node.get("cdsl_sketch_ids") or ()}
|
|
if len(expected) != 1:
|
|
# Compatibility path for pre-incremental plans which could own more
|
|
# than one CDSL feature in a single node.
|
|
feature_ids = _ids(features, "add_features")
|
|
if set(feature_ids) != expected:
|
|
raise CdslFragmentError(
|
|
f"Fragment features must exactly match node outputs: expected {sorted(expected)}, got {sorted(feature_ids)}"
|
|
)
|
|
elif len(features) != 1:
|
|
raise CdslFragmentError("An atomic plan node must generate exactly one feature")
|
|
if len(expected_sketches) > 1:
|
|
sketch_ids = _ids(sketches, "add_sketches")
|
|
if set(sketch_ids) != expected_sketches:
|
|
raise CdslFragmentError(
|
|
f"Fragment sketches must exactly match node outputs: expected {sorted(expected_sketches)}, got {sorted(sketch_ids)}"
|
|
)
|
|
elif len(sketches) != len(expected_sketches):
|
|
expected_description = "one" if expected_sketches else "no"
|
|
raise CdslFragmentError(f"This node requires {expected_description} new sketch")
|
|
|
|
# The planner and fragment author never own CDSL object identities. The
|
|
# merger writes node-derived IDs and dependencies after it has validated
|
|
# the current immutable plan.
|
|
if len(expected) == 1:
|
|
features[0]["id"] = next(iter(expected))
|
|
if len(expected_sketches) == 1:
|
|
sketch_id = next(iter(expected_sketches))
|
|
sketches[0]["id"] = sketch_id
|
|
features[0]["sketch_id"] = sketch_id
|
|
elif not expected_sketches and len(features) == 1:
|
|
features[0].pop("sketch_id", None)
|
|
features = _materialize_node_references(features, plan)
|
|
sketches = _materialize_node_references(sketches, plan)
|
|
sketch_ids = _ids(sketches, "add_sketches")
|
|
feature_ids = _ids(features, "add_features")
|
|
atomic_id = str(node.get("atomic_id") or "")
|
|
if len(expected) == 1:
|
|
features[0]["atomic_id"] = atomic_id
|
|
elif any(str(feature.get("atomic_id") or "") != atomic_id for feature in features):
|
|
raise CdslFragmentError(f"Fragment features must use plan atomic_id {atomic_id}")
|
|
base_sketch_ids = {
|
|
str(item.get("id")) for item in ((base_cdsl or {}).get("geometry") or {}).get("sketches") or ()
|
|
if isinstance(item, dict)
|
|
}
|
|
base_feature_ids = {
|
|
str(item.get("id")) for item in (base_cdsl or {}).get("features") or () if isinstance(item, dict)
|
|
}
|
|
if base_sketch_ids & set(sketch_ids):
|
|
raise CdslFragmentError("Fragment attempts to overwrite an existing sketch")
|
|
if base_feature_ids & set(feature_ids):
|
|
raise CdslFragmentError("Fragment attempts to overwrite a frozen feature")
|
|
predecessor_features = {
|
|
feature_id
|
|
for dependency in node.get("depends_on") or ()
|
|
for feature_id in (_node(plan, str(dependency)).get("cdsl_feature_ids") or ())
|
|
}
|
|
if len(expected) == 1:
|
|
features[0]["depends_on"] = sorted(predecessor_features)
|
|
available_features = base_feature_ids | set(feature_ids)
|
|
available_sketches = base_sketch_ids | set(sketch_ids)
|
|
for feature in features:
|
|
feature_id = str(feature["id"])
|
|
dependencies = feature.get("depends_on") or []
|
|
if not isinstance(dependencies, list) or any(str(item) not in available_features for item in dependencies):
|
|
raise CdslFragmentError(f"Feature {feature_id} has a missing dependency")
|
|
sketch_id = str(feature.get("sketch_id") or "")
|
|
if sketch_id and sketch_id not in available_sketches:
|
|
raise CdslFragmentError(f"Feature {feature_id} references an unknown sketch")
|
|
if required_snapshot_id:
|
|
selectors = [selector for feature in features for selector in _selector_references(feature)]
|
|
if not selectors:
|
|
raise CdslFragmentError("Topology-dependent fragment must provide runtime snapshot selectors")
|
|
for selector in selectors:
|
|
if str(selector.get("snapshot_id") or "") != required_snapshot_id:
|
|
raise CdslFragmentError("Topology selector must reference the active snapshot")
|
|
if not str(selector.get("owner_feature_id") or ""):
|
|
raise CdslFragmentError("Topology selector must declare owner_feature_id")
|
|
if not isinstance(selector.get("geometry"), dict) or not selector["geometry"]:
|
|
raise CdslFragmentError("Topology selector must declare a non-empty geometry signature")
|
|
declared_dependencies = {
|
|
str(dependency)
|
|
for feature in features
|
|
for dependency in feature.get("depends_on") or ()
|
|
}
|
|
if not predecessor_features.issubset(declared_dependencies):
|
|
raise CdslFragmentError("Fragment does not preserve all plan-node dependencies")
|
|
rules = fragment.get("verification_rules") or []
|
|
if not isinstance(rules, list) or not all(isinstance(item, dict) for item in rules):
|
|
raise CdslFragmentError("verification_rules must be an array of objects")
|
|
assumptions = fragment.get("assumptions") or []
|
|
if not isinstance(assumptions, list) or not all(isinstance(item, str) for item in assumptions):
|
|
raise CdslFragmentError("assumptions must be an array of strings")
|
|
return {
|
|
"schema_version": "cad.cdsl-fragment.v1",
|
|
"node_id": node_id,
|
|
"base_revision_id": base_revision_id,
|
|
"base_cdsl_sha256": cdsl_sha256(base_cdsl),
|
|
"required_snapshot_id": snapshot_id,
|
|
"add_sketches": sketches,
|
|
"add_features": features,
|
|
"expected_feature_ids": sorted(expected),
|
|
"verification_rules": deepcopy(rules),
|
|
"assumptions": [item.strip() for item in assumptions if item.strip()],
|
|
}
|
|
|
|
|
|
def materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return the complete, append-only document that the runtime must rebuild."""
|
|
if base_cdsl is None:
|
|
document: dict[str, Any] = {
|
|
"schema": "cad.cdsl.llm.v1",
|
|
"schema_version": "1.1.0",
|
|
"kind": "part",
|
|
"part_id": "agent_preflight",
|
|
"geometry": {"sketches": []},
|
|
"features": [],
|
|
}
|
|
else:
|
|
document = deepcopy(base_cdsl)
|
|
geometry = document.setdefault("geometry", {})
|
|
if not isinstance(geometry, dict):
|
|
raise CdslFragmentError("Base CDSL geometry must be an object")
|
|
sketches = geometry.setdefault("sketches", [])
|
|
features = document.setdefault("features", [])
|
|
if not isinstance(sketches, list) or not isinstance(features, list):
|
|
raise CdslFragmentError("Base CDSL has invalid collections")
|
|
sketches.extend(deepcopy(fragment["add_sketches"]))
|
|
features.extend(deepcopy(fragment["add_features"]))
|
|
return document
|
|
|
|
|
|
def selector_bindings(engine_result: dict[str, Any], *, node_id: str, snapshot_id: str) -> dict[str, Any]:
|
|
"""Persist the runtime's actual selector choices as auditable node evidence."""
|
|
values = []
|
|
for resolution in engine_result.get("selector_resolution") or ():
|
|
if not isinstance(resolution, dict):
|
|
continue
|
|
selector = resolution.get("selector") if isinstance(resolution.get("selector"), dict) else {}
|
|
candidates = resolution.get("candidates") if isinstance(resolution.get("candidates"), (list, tuple)) else []
|
|
values.append({
|
|
"consumer_node_id": node_id,
|
|
"consumer_feature_id": str(resolution.get("feature_id") or ""),
|
|
"source_snapshot_id": str(selector.get("snapshot_id") or snapshot_id),
|
|
"kind": str(selector.get("kind") or ""),
|
|
"owner_feature_id": str(selector.get("owner_feature_id") or ""),
|
|
"stable_id": str(selector.get("stable_id") or ""),
|
|
"geometry": deepcopy(selector.get("geometry") or {}),
|
|
"status": str(resolution.get("status") or ""),
|
|
"candidates": deepcopy(list(candidates)),
|
|
"score": resolution.get("score"),
|
|
"selected": deepcopy(resolution.get("selected") or resolution.get("record") or {}),
|
|
})
|
|
return {"schema_version": "cad.selector-bindings.v1", "node_id": node_id, "bindings": values}
|