Files
cdsl-cad/backend/app/services/cdsl_fragment.py
T

482 lines
24 KiB
Python

"""Append-only CDSL materialisation for the autonomous authoring loop."""
from __future__ import annotations
from copy import deepcopy
from hashlib import sha256
import json
from typing import Any
class CdslFragmentError(ValueError):
"""A fragment cannot safely be applied to the current CDSL state."""
class AutonomousFragmentError(CdslFragmentError):
"""A free-form candidate violates the autonomous append-only boundary."""
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 materialize_fragment(base_cdsl: dict[str, Any] | None, fragment: dict[str, Any]) -> dict[str, Any]:
"""Create the complete CDSL document that will be rebuilt from scratch."""
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", {})
sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None
features = document.setdefault("features", [])
if not isinstance(sketches, list) or not isinstance(features, list):
raise CdslFragmentError("Base CDSL has invalid geometry 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 runtime selector choices as build 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 {}
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(resolution.get("candidates") or [])),
"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}
def _autonomous_id(prefix: str, used: set[str]) -> str:
index = 1
while True:
candidate = f"{prefix}_{index:03d}"
if candidate not in used:
used.add(candidate)
return candidate
index += 1
def autonomous_selector_tokens(snapshot: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
"""Make opaque, revision-scoped selector tokens from executable topology."""
if not isinstance(snapshot, dict):
return {}
snapshot_id = str(snapshot.get("snapshot_id") or "")
if not snapshot_id:
return {}
values: dict[str, dict[str, Any]] = {}
for record in snapshot.get("records") or ():
if not isinstance(record, dict) or not record.get("executable"):
continue
record_id = str(record.get("record_id") or "")
kind = str(record.get("kind") or "")
if not record_id or kind not in {"face", "edge", "vertex", "plane", "axis", "body"}:
continue
token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16]
geometry = deepcopy(record.get("geometry") or {})
owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""]
values[token] = {
"token": token,
"kind": kind,
"geometry": geometry,
"selector": {
"kind": kind,
"stable_id": record_id,
"owner_feature_id": str(owners[0] or ""),
"geometry": geometry,
"source": "runtime_snapshot",
"snapshot_id": snapshot_id,
"confidence": 1.0,
},
}
return values
def _compact_prompt_geometry(geometry: dict[str, Any]) -> dict[str, Any]:
"""Keep only selector-choice facts useful to an author model.
Runtime snapshots also carry adjacency signatures, curve endpoints and
other diagnostic detail. Those fields are required for deterministic
engine work, but repeatedly placing them in an LLM prompt is expensive
and does not help choose an opaque token. Full records remain available
on disk and through targeted measurement tools.
"""
useful = (
"bbox_mm", "center_mm", "normal", "plane_normal", "plane_offset_mm",
"surface_type", "curve_type", "radius_mm", "length_mm", "area_mm2",
"volume_mm3", "solid_count",
)
return {key: deepcopy(geometry[key]) for key in useful if key in geometry}
def autonomous_candidate_prompt_tokens(tokens: dict[str, dict[str, Any]], *, kind: str = "") -> list[dict[str, Any]]:
"""Return compact safe token fields, never persistent selector IDs."""
return [
{"token": token, "kind": value["kind"], "geometry": _compact_prompt_geometry(value["geometry"])}
for token, value in sorted(tokens.items())
if not kind or value["kind"] == kind
]
def _fragment_lists(fragment: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
if not isinstance(fragment, dict):
raise AutonomousFragmentError("fragment_json must decode to a JSON object")
# Ordinary tool-call providers occasionally lift the unambiguous selector
# array one level out of a single-feature payload. Accept that shorthand
# only when it can be moved to exactly one feature without choosing or
# changing any selector ourselves.
allowed = {"sketch", "feature", "sketches", "features", "add_sketches", "add_features", "selector_tokens", "revolve_axis"}
unknown = sorted(set(fragment) - allowed)
if unknown:
raise AutonomousFragmentError("fragment_json may contain only sketch(es) and feature(s); unexpected: " + ", ".join(unknown))
sketches_raw = fragment.get("sketches", fragment.get("add_sketches", []))
features_raw = fragment.get("features", fragment.get("add_features", []))
if "sketch" in fragment:
if sketches_raw:
raise AutonomousFragmentError("Use either sketch or sketches, not both")
sketches_raw = [fragment["sketch"]]
if "feature" in fragment:
if features_raw:
raise AutonomousFragmentError("Use either feature or features, not both")
features_raw = [fragment["feature"]]
if not isinstance(sketches_raw, list) or not all(isinstance(item, dict) for item in sketches_raw):
raise AutonomousFragmentError("fragment sketches must be an array of objects")
if not isinstance(features_raw, list) or not features_raw or not all(isinstance(item, dict) for item in features_raw):
raise AutonomousFragmentError("fragment features must be a non-empty array of objects")
sketches = deepcopy(sketches_raw)
features = deepcopy(features_raw)
top_level_tokens = fragment.get("selector_tokens")
if top_level_tokens is not None:
if len(features) != 1:
raise AutonomousFragmentError("top-level selector_tokens are allowed only with exactly one feature")
if "selector_tokens" in features[0]:
raise AutonomousFragmentError("selector_tokens must appear either at fragment top level or feature level, not both")
features[0]["selector_tokens"] = deepcopy(top_level_tokens)
return sketches, features
def _move_equivalent_field(
value: dict[str, Any],
*,
source: str,
target: str,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Move a lossless spelling alias without choosing any CAD geometry."""
if source not in value:
return
if target in value:
if value[source] != value[target]:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and {target} were supplied with different values"
)
value.pop(source)
fixes.append({"path": location, "from": source, "to": target, "action": "deduplicated_equivalent"})
return
value[target] = value.pop(source)
fixes.append({"path": location, "from": source, "to": target, "action": "renamed_equivalent"})
def _move_axis_component(
params: dict[str, Any],
axis: dict[str, Any],
*,
source: str,
target: str,
location: str,
fixes: list[dict[str, str]],
) -> None:
"""Move an explicit top-level axis alias into the canonical axis object."""
if source not in params:
return
value = params.pop(source)
if target in axis and axis[target] != value:
raise AutonomousFragmentError(
f"CONFLICTING_PARAMETER_ALIASES at {location}: both {source} and axis.{target} were supplied with different values"
)
if target in axis:
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "deduplicated_equivalent"})
return
axis[target] = value
fixes.append({"path": location, "from": source, "to": f"axis.{target}", "action": "renamed_equivalent"})
def _normalize_axis_mapping(axis: dict[str, Any], *, location: str, fixes: list[dict[str, str]]) -> None:
"""Normalize only lossless aliases used inside an already explicit axis."""
for source in ("origin", "point_mm", "axis_origin_mm", "axis_point_mm"):
_move_equivalent_field(axis, source=source, target="origin_mm", location=location, fixes=fixes)
for source in ("axis_dir", "axis_direction", "dir"):
_move_equivalent_field(axis, source=source, target="direction", location=location, fixes=fixes)
def _lift_feature_local_sketches(fragment: dict[str, Any], *, fixes: list[dict[str, str]]) -> None:
"""Accept the common one-feature/one-sketch nesting without choosing geometry.
The public fragment grammar owns one ordered sketch list and one ordered
feature list. Some tool-call models naturally nest each sketch below its
feature. That representation is losslessly transformable only when every
feature supplies exactly one sketch and no root sketch collection exists.
"""
if any(key in fragment for key in ("sketch", "sketches", "add_sketches")):
return
features = fragment.get("features", fragment.get("add_features"))
if not isinstance(features, list) or not features or not all(isinstance(item, dict) for item in features):
return
lifted: list[dict[str, Any]] = []
for index, feature in enumerate(features):
nested = feature.get("sketches", feature.get("sketch"))
if isinstance(nested, dict):
sketches = [nested]
elif isinstance(nested, list):
sketches = nested
else:
return
if len(sketches) != 1 or not isinstance(sketches[0], dict):
return
feature.pop("sketch", None)
feature.pop("sketches", None)
lifted.append(sketches[0])
fixes.append({"path": f"features[{index}]", "from": "feature-local sketch", "to": "sketches[]", "action": "lifted_equivalent"})
fragment["sketches"] = lifted
def normalize_autonomous_fragment(fragment: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, str]]]:
"""Normalize only explicitly equivalent author spellings.
Normalization is deliberately narrow. It accepts common CAD vocabulary
where the target runtime field has identical units and meaning, while
refusing inputs that would need a guessed profile, direction, selector,
coordinate system, or topology decision. The original fragment and every
applied fix are retained in the candidate audit record.
"""
if not isinstance(fragment, dict):
return fragment, []
normalized = deepcopy(fragment)
fixes: list[dict[str, str]] = []
_lift_feature_local_sketches(normalized, fixes=fixes)
feature_values: list[tuple[dict[str, Any], str]] = []
feature = normalized.get("feature")
if isinstance(feature, dict):
feature_values.append((feature, "feature"))
features = normalized.get("features", normalized.get("add_features"))
if isinstance(features, list):
feature_values.extend(
(item, f"features[{index}]")
for index, item in enumerate(features)
if isinstance(item, dict)
)
for current_feature, location in feature_values:
atomic_id = str(current_feature.get("atomic_id") or "")
params = current_feature.get("params")
if not isinstance(params, dict):
continue
if atomic_id in {"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"}:
# CAD systems commonly call a blind extrusion's travel "depth".
# CDSL calls the exact same signed scalar ``distance_mm``.
_move_equivalent_field(
params,
source="depth_mm",
target="distance_mm",
location=f"{location}.params",
fixes=fixes,
)
if atomic_id in {"revolve_add", "revolve_cut"}:
_move_equivalent_field(
params,
source="angle_degrees",
target="angle_deg",
location=f"{location}.params",
fixes=fixes,
)
axis = params.get("axis")
if axis is None:
axis = {}
params["axis"] = axis
if isinstance(axis, dict):
_move_axis_component(params, axis, source="axis_origin_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_point_mm", target="origin_mm", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_dir", target="direction", location=f"{location}.params", fixes=fixes)
_move_axis_component(params, axis, source="axis_direction", target="direction", location=f"{location}.params", fixes=fixes)
axis = params.get("axis")
if isinstance(axis, dict):
_normalize_axis_mapping(axis, location=f"{location}.params.axis", fixes=fixes)
shared_axis = normalized.get("revolve_axis")
if shared_axis is not None:
if not isinstance(shared_axis, dict):
raise AutonomousFragmentError("revolve_axis must be an explicit {origin_mm, direction} object")
_normalize_axis_mapping(shared_axis, location="revolve_axis", fixes=fixes)
revolved = [feature for feature, _ in feature_values if str(feature.get("atomic_id") or "").startswith("revolve_")]
if not revolved:
raise AutonomousFragmentError("revolve_axis is valid only in a fragment containing revolve_add or revolve_cut")
for feature, location in feature_values:
if not str(feature.get("atomic_id") or "").startswith("revolve_"):
continue
params = feature.get("params")
if not isinstance(params, dict):
continue
axis = params.get("axis")
if not isinstance(axis, dict) or not axis:
params["axis"] = deepcopy(shared_axis)
fixes.append({"path": f"{location}.params", "from": "revolve_axis", "to": "axis", "action": "copied_explicit_batch_axis"})
sketch_values: list[tuple[dict[str, Any], str]] = []
sketch = normalized.get("sketch")
if isinstance(sketch, dict):
sketch_values.append((sketch, "sketch"))
sketches = normalized.get("sketches", normalized.get("add_sketches"))
if isinstance(sketches, list):
sketch_values.extend((item, f"sketches[{index}]") for index, item in enumerate(sketches) if isinstance(item, dict))
for current_sketch, location in sketch_values:
profile = current_sketch.get("profile")
if isinstance(profile, dict) and profile.get("type") == "polygon":
_move_equivalent_field(profile, source="points", target="vertices", location=f"{location}.profile", fixes=fixes)
return normalized, fixes
def materialize_autonomous_fragment(
base_cdsl: dict[str, Any] | None,
fragment: dict[str, Any],
*,
engine: Any,
selector_tokens: dict[str, dict[str, Any]],
max_features: int,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Append authored geometry while assigning only server-owned metadata.
Workplanes, profiles, feature parameters, directions and boolean meaning
pass through exactly as the author supplied them. Local CDSL validation and
full engine rebuild happen after this function, before a checkpoint exists.
"""
# Delayed import avoids engine_service -> selector_bindings -> this module
# becoming an import cycle.
from app.services.engine_service import feature_atomic_contract
normalized_fragment, compatibility_fixes = normalize_autonomous_fragment(fragment)
sketches, features = _fragment_lists(normalized_fragment)
if len(features) > max_features:
raise AutonomousFragmentError(f"A fragment may add at most {max_features} feature(s)")
document = materialize_fragment(base_cdsl, {"add_sketches": [], "add_features": []})
geometry = document.get("geometry") if isinstance(document.get("geometry"), dict) else {}
existing_sketches = geometry.get("sketches") if isinstance(geometry.get("sketches"), list) else []
existing_features = document.get("features") if isinstance(document.get("features"), list) else []
used_sketch_ids = {str(item.get("id") or "") for item in existing_sketches if isinstance(item, dict)}
used_feature_ids = {str(item.get("id") or "") for item in existing_features if isinstance(item, dict)}
last_feature_id = str(existing_features[-1].get("id") or "") if existing_features and isinstance(existing_features[-1], dict) else ""
materialized_sketches: list[dict[str, Any]] = []
materialized_features: list[dict[str, Any]] = []
for index, source_feature in enumerate(features):
forbidden = {"id", "depends_on", "sketch_id", "selectors"} & set(source_feature)
if forbidden:
raise AutonomousFragmentError("Feature identity, dependencies, sketch_id and raw selectors are server-owned: " + ", ".join(sorted(forbidden)))
atomic_id = str(source_feature.get("atomic_id") or "")
if not atomic_id:
raise AutonomousFragmentError("Each fragment feature must declare a runtime atomic_id")
contract = feature_atomic_contract(engine, atomic_id)
params = source_feature.get("params")
if not isinstance(params, dict):
raise AutonomousFragmentError("Each fragment feature must contain a params object")
if (atomic_id.startswith("hole_") or atomic_id == "hole_wizard") and "host_face" in params:
raise AutonomousFragmentError(
f"{atomic_id} host_face is server-owned: put exactly one face token in selector_tokens and use "
"positions as [{\"mm\":[x_mm,y_mm,z_mm]}], not raw host_face or bare coordinate arrays"
)
tokens = source_feature.pop("selector_tokens", [])
if not isinstance(tokens, list) or not all(isinstance(token, str) for token in tokens) or len(set(tokens)) != len(tokens):
raise AutonomousFragmentError("selector_tokens must be a unique array of opaque tokens")
selected: list[dict[str, Any]] = []
for token in tokens:
candidate = selector_tokens.get(token)
if candidate is None:
raise AutonomousFragmentError("TOPOLOGY_TOKEN_INVALID: selector token is not from the active snapshot")
selected.append(deepcopy(candidate["selector"]))
slot = contract.get("selector_slot")
token_backed_param = atomic_id.startswith("hole_") or atomic_id == "hole_wizard"
if not slot and tokens and not token_backed_param:
raise AutonomousFragmentError(f"{atomic_id} does not accept selector tokens")
if isinstance(slot, dict):
minimum, maximum = int(slot.get("min_items") or 0), int(slot.get("max_items") or 0)
if not minimum <= len(selected) <= maximum:
raise AutonomousFragmentError(f"{atomic_id} requires {minimum}..{maximum} selector token(s)")
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
if len(selected) != 1 or str(selected[0].get("kind") or "") != "face":
raise AutonomousFragmentError(f"{atomic_id} requires exactly one face selector token for its host face")
elif atomic_id.startswith("revolve_"):
axis = params.get("axis")
if not isinstance(axis, dict) or "origin_mm" not in axis or "direction" not in axis:
raise AutonomousFragmentError(
f"{atomic_id} requires params.axis with explicit origin_mm and direction; "
"the revolve axis is author-defined geometry, not a topology selector token"
)
feature_id = _autonomous_id("feature", used_feature_ids)
output = {key: deepcopy(value) for key, value in source_feature.items() if key != "selector_tokens"}
output["id"] = feature_id
output["depends_on"] = [last_feature_id] if last_feature_id else []
if contract["requires_sketch"]:
if index >= len(sketches):
raise AutonomousFragmentError(f"{atomic_id} requires one new sketch in the same fragment")
sketch = sketches[index]
if "id" in sketch or "attachment" in sketch or "profile_from" in sketch:
raise AutonomousFragmentError("Sketch identity and topology attachment are server-owned")
sketch_id = _autonomous_id("sketch", used_sketch_ids)
sketch["id"] = sketch_id
materialized_sketches.append(sketch)
output["sketch_id"] = sketch_id
elif index < len(sketches):
raise AutonomousFragmentError(f"{atomic_id} does not accept a sketch")
if atomic_id.startswith("pattern_") and "source_feature_ids" not in params:
if not last_feature_id:
raise AutonomousFragmentError(f"{atomic_id} needs a committed source feature")
params["source_feature_ids"] = [last_feature_id]
if isinstance(slot, dict) and slot.get("path") == "feature.selectors":
output["selectors"] = selected
elif isinstance(slot, dict) and slot.get("path") == "params.mirror_plane":
output["params"]["mirror_plane"] = selected[0]
output["selectors"] = []
elif isinstance(slot, dict) and slot.get("path") == "params.host_face":
output["params"]["host_face"] = selected[0]
output["selectors"] = []
elif atomic_id.startswith("hole_") or atomic_id == "hole_wizard":
output["params"]["host_face"] = selected[0]
output["selectors"] = []
else:
output["selectors"] = []
materialized_features.append(output)
last_feature_id = feature_id
if len(sketches) != len(materialized_sketches):
raise AutonomousFragmentError("Each sketch must be consumed by a feature that requires a sketch")
document = materialize_fragment(document, {"add_sketches": materialized_sketches, "add_features": materialized_features})
return document, {
"schema_version": "cad.autonomous-fragment.v1",
"source_fragment": deepcopy(fragment),
"normalized_fragment": deepcopy(normalized_fragment) if compatibility_fixes else None,
"compatibility_fixes": compatibility_fixes,
"assigned_sketch_ids": [item["id"] for item in materialized_sketches],
"assigned_feature_ids": [item["id"] for item in materialized_features],
"selector_candidate_ids": [token for feature in features for token in feature.get("selector_tokens", [])],
}