From 273b4b5c7b30ef0f940a2c022cc751c879721661 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 16 Sep 2026 14:06:44 +0800 Subject: [PATCH] =?UTF-8?q?cdsl=E6=96=B0=E5=A2=9E=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E7=AC=BC=E7=BB=9F=E7=9A=84=E6=8C=87=E6=A0=87=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/agent/skills/cad-authoring/SKILL.md | 4 + backend/app/cad_agent/adapters/runtime.py | 11 +- .../cad_agent/domain/operation_contract.py | 3 +- backend/app/services/derived_metrics.py | 154 ++++++++++++++++++ backend/engine/cdsl_engine/cdsl_schema.json | 63 ++++++- 5 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 backend/app/services/derived_metrics.py diff --git a/backend/agent/skills/cad-authoring/SKILL.md b/backend/agent/skills/cad-authoring/SKILL.md index 27d2a480..9cab443f 100644 --- a/backend/agent/skills/cad-authoring/SKILL.md +++ b/backend/agent/skills/cad-authoring/SKILL.md @@ -182,6 +182,10 @@ Feature level — attach `intent` to every feature: - Do not invent fields inside `intent`, and never treat a mismatch between an annotation and geometry as acceptable — the label must match the feature actually constructed. +- Geometry metrics (`meta.derived_metrics`: volumes, hole counts, per-feature + deltas) are computed and backfilled by the server after rebuild. Never + author or estimate them; if a document already carries `derived_metrics`, + leave it untouched. ## Sketches And Coordinates diff --git a/backend/app/cad_agent/adapters/runtime.py b/backend/app/cad_agent/adapters/runtime.py index 4d6de80d..27c1ff8e 100644 --- a/backend/app/cad_agent/adapters/runtime.py +++ b/backend/app/cad_agent/adapters/runtime.py @@ -15,6 +15,7 @@ from app.cad_agent.domain.operation_contract import ( 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 @@ -102,7 +103,6 @@ class ProfileCadRuntime: cdsl_copy = deepcopy(cdsl) cdsl_copy["part_id"] = task_id step_path = root / "model.step" - self._write_json(root / "model.cdsl.json", cdsl_copy) try: self._validate_finite_tree(cdsl_copy) validate_cdsl(cdsl_copy, self.engine) @@ -112,6 +112,15 @@ class ProfileCadRuntime: 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"}} diff --git a/backend/app/cad_agent/domain/operation_contract.py b/backend/app/cad_agent/domain/operation_contract.py index 36c41f33..40828dea 100644 --- a/backend/app/cad_agent/domain/operation_contract.py +++ b/backend/app/cad_agent/domain/operation_contract.py @@ -49,7 +49,8 @@ def validate_operation_contract(contract: dict[str, Any]) -> None: "server_injected_paths", "reference_policy", "semantic_preflight", "candidate_verifiers", "runtime_capability", } - if not required.issubset(contract) or set(contract) - required - {"contract_hash", "registry_revision"}: + optional = {"contract_hash", "registry_revision", "nested_selector_policies"} + if not required.issubset(contract) or set(contract) - required - optional: raise OperationContractError("Operation contract has unknown or missing fields") if not isinstance(contract["atomic_id"], str) or not contract["atomic_id"]: raise OperationContractError("Operation contract atomic_id is invalid") diff --git a/backend/app/services/derived_metrics.py b/backend/app/services/derived_metrics.py new file mode 100644 index 00000000..223f01b7 --- /dev/null +++ b/backend/app/services/derived_metrics.py @@ -0,0 +1,154 @@ +"""Derived-metrics backfill for CDSL documents (training-grade, server-owned). + +The metrics describe the *reconstructed geometry* of a document and are +written exclusively by the server after a successful rebuild. Models never +author them; the fields are excluded from validation ordering (backfilled +after validation) so they can never influence acceptance. + +Counting conventions: +- ``hole_count`` : inner cylindrical walls on the final body (cylinder faces + whose ``cylinder_role`` is ``inner``). Countersink/counterbore bores add + their own walls; no coaxial clustering is applied in v1. +- ``fillet_count``: torus faces (rolling-ball blends) on the final body. +- ``face_type_distribution``: final-body faces by ``surface_type``. +- ``per_feature`` : prefix re-execution sampling. Prefix ``i`` re-runs the + first ``i`` features; sampling stops at the first non-executable prefix. +""" + +from __future__ import annotations + +from collections import Counter +from copy import deepcopy +from pathlib import Path +import tempfile +from typing import Any, Callable + +from app.services.engine_service import topology_snapshot + +_METRIC_SNAPSHOT_KEYS = ( + "volume_mm3", "surface_area_mm2", "bbox_mm", "solid_count", + "face_count", "edge_count", "vertex_count", "hole_count", + "fillet_count", "face_type_distribution", +) +_DELTA_KEYS = ("volume_mm3", "surface_area_mm2", "face_count", "hole_count") + +#: Prefix sampling above this feature count degrades to final-only metrics, +#: keeping the O(n^2) re-execution cost bounded on very large documents. +MAX_PER_FEATURE_SAMPLES = 64 + + +def _face_stats(topology: dict[str, Any]) -> dict[str, Any]: + faces = [ + record for record in (topology.get("records") or []) + if isinstance(record, dict) and record.get("kind") == "face" + ] + distribution: Counter[str] = Counter() + hole_walls = 0 + torus = 0 + for face in faces: + geometry = face.get("geometry") or {} + surface_type = str(geometry.get("surface_type") or "unknown") + distribution[surface_type] += 1 + if surface_type == "cylinder" and geometry.get("cylinder_role") == "inner": + hole_walls += 1 + if surface_type == "torus": + torus += 1 + return { + "face_type_distribution": dict(sorted(distribution.items())), + "hole_count": hole_walls, + "fillet_count": torus, + } + + +def _snapshot_from_engine_result(engine_result: dict[str, Any], topology: dict[str, Any]) -> dict[str, Any]: + records = [r for r in (topology.get("records") or []) if isinstance(r, dict)] + faces = [r for r in records if r.get("kind") == "face"] + area = sum(float((f.get("geometry") or {}).get("area_mm2") or 0.0) for f in faces) + snapshot = { + "volume_mm3": float(engine_result.get("volume_mm3") or 0.0), + "surface_area_mm2": area or float(engine_result.get("surface_area_mm2") or 0.0), + "bbox_mm": [float(value) for value in (engine_result.get("bbox_mm") or {}).get("min", [])] + + [float(value) for value in (engine_result.get("bbox_mm") or {}).get("max", [])], + "solid_count": int(engine_result.get("solid_count") or 0), + "face_count": len(faces), + "edge_count": sum(1 for r in records if r.get("kind") == "edge"), + "vertex_count": sum(1 for r in records if r.get("kind") == "vertex"), + } + snapshot.update(_face_stats(topology)) + return {key: snapshot[key] for key in _METRIC_SNAPSHOT_KEYS} + + +def _delta(after: dict[str, Any], before: dict[str, Any]) -> dict[str, Any]: + return { + "volume_mm3": float(after["volume_mm3"]) - float(before["volume_mm3"]), + "surface_area_mm2": float(after["surface_area_mm2"]) - float(before["surface_area_mm2"]), + "face_count": int(after["face_count"]) - int(before["face_count"]), + "hole_count": int(after["hole_count"]) - int(before["hole_count"]), + } + + +def derive_metrics( + cdsl: dict[str, Any], + engine_result: dict[str, Any], + topology: dict[str, Any], + *, + engine_build: str = "", + prefix_runner: Callable[[dict[str, Any]], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build the ``meta.derived_metrics`` payload for a successfully rebuilt document. + + ``prefix_runner`` re-executes a prefix of the document's features and + returns the same shape as ``engine_result``; when omitted, ``per_feature`` + degrades to status entries without geometric snapshots. + """ + features = cdsl.get("features") or [] + final = _snapshot_from_engine_result(engine_result, topology) + zero = { + "volume_mm3": 0.0, "surface_area_mm2": 0.0, + "face_count": 0, "hole_count": 0, + } + + per_feature: list[dict[str, Any]] = [] + if prefix_runner is not None and 1 <= len(features) <= MAX_PER_FEATURE_SAMPLES: + previous = zero + for index in range(1, len(features) + 1): + prefix_doc = deepcopy(cdsl) + prefix_doc["features"] = deepcopy(features[:index]) + try: + prefix_result = prefix_runner(prefix_doc) + topology_prefix = topology_snapshot(prefix_result) + after = _snapshot_from_engine_result(prefix_result, topology_prefix) + status = str(next( + (fr.get("status") for fr in prefix_result.get("feature_results") or [] + if fr.get("feature_id") == features[index - 1].get("id")), + "executed", + )) + except Exception: + break # prefix chain broken; keep samples collected so far + per_feature.append({ + "feature_id": str(features[index - 1].get("id") or ""), + "atomic_id": str(features[index - 1].get("atomic_id") or ""), + "status": status, + "after": after, + "delta": _delta(after, previous), + }) + previous = after + + histogram = Counter(str(feature.get("atomic_id") or "") for feature in features) + metrics: dict[str, Any] = { + "engine": str(engine_result.get("engine") or ""), + "final": final, + "per_feature": per_feature, + "atomic_histogram": dict(sorted(histogram.items())), + } + if engine_build: + metrics["engine_build"] = engine_build + return metrics + + +def make_prefix_runner(run_cdsl_only: Callable[[dict[str, Any], Path], dict[str, Any]]): + """Wrap an engine ``run_cdsl_only`` entry point for prefix sampling.""" + def runner(prefix_doc: dict[str, Any]) -> dict[str, Any]: + with tempfile.TemporaryDirectory() as tmp: + return run_cdsl_only(prefix_doc, Path(tmp) / "prefix.step") + return runner diff --git a/backend/engine/cdsl_engine/cdsl_schema.json b/backend/engine/cdsl_engine/cdsl_schema.json index 6f2cb533..cd7585ba 100644 --- a/backend/engine/cdsl_engine/cdsl_schema.json +++ b/backend/engine/cdsl_engine/cdsl_schema.json @@ -9,7 +9,13 @@ "schema_version": {"type": "string"}, "kind": {"type": "string", "minLength": 1}, "part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"}, - "meta": {"type": "object"}, + "meta": { + "type": "object", + "properties": { + "derived_metrics": {"$ref": "#/$defs/derivedMetrics"} + }, + "additionalProperties": true + }, "bodies": {"type": "array", "items": {"$ref": "#/$defs/runtimeBody"}}, "geometry": { "type": "object", @@ -24,6 +30,61 @@ "required": ["schema", "geometry", "features"], "additionalProperties": false, "$defs": { + "metricSnapshot": { + "type": "object", + "properties": { + "volume_mm3": {"type": "number"}, + "surface_area_mm2": {"type": "number"}, + "bbox_mm": {"type": "array", "items": {"type": "number"}, "minItems": 6, "maxItems": 6}, + "solid_count": {"type": "integer", "minimum": 0}, + "face_count": {"type": "integer", "minimum": 0}, + "edge_count": {"type": "integer", "minimum": 0}, + "vertex_count": {"type": "integer", "minimum": 0}, + "hole_count": {"type": "integer", "minimum": 0}, + "fillet_count": {"type": "integer", "minimum": 0}, + "face_type_distribution": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}} + }, + "required": ["volume_mm3", "surface_area_mm2", "bbox_mm", "solid_count", "face_count", "edge_count", "vertex_count", "hole_count", "fillet_count", "face_type_distribution"], + "additionalProperties": false + }, + "metricDelta": { + "type": "object", + "properties": { + "volume_mm3": {"type": "number"}, + "surface_area_mm2": {"type": "number"}, + "face_count": {"type": "integer"}, + "hole_count": {"type": "integer"} + }, + "required": ["volume_mm3", "surface_area_mm2", "face_count", "hole_count"], + "additionalProperties": false + }, + "derivedMetrics": { + "type": "object", + "properties": { + "engine": {"type": "string", "minLength": 1}, + "engine_build": {"type": "string", "minLength": 1}, + "generated_at": {"type": "string", "minLength": 1}, + "final": {"$ref": "#/$defs/metricSnapshot"}, + "per_feature": { + "type": "array", + "items": { + "type": "object", + "properties": { + "feature_id": {"type": "string", "minLength": 1}, + "atomic_id": {"$ref": "#/$defs/feature_atomic_ids"}, + "status": {"type": "string", "minLength": 1}, + "after": {"$ref": "#/$defs/metricSnapshot"}, + "delta": {"$ref": "#/$defs/metricDelta"} + }, + "required": ["feature_id", "atomic_id", "status", "after", "delta"], + "additionalProperties": false + } + }, + "atomic_histogram": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}} + }, + "required": ["engine", "final", "per_feature", "atomic_histogram"], + "additionalProperties": false + }, "featureIntent": { "type": "object", "properties": {