from __future__ import annotations import math from copy import deepcopy from dataclasses import dataclass from pathlib import Path import tempfile from typing import Any def _score(expected: dict[str, Any], actual: dict[str, Any]) -> float | None: scores: list[float] = [] reversed_plane_normal = False for key in ("center_mm", "circle_center_mm", "start_mm", "end_mm", "normal", "axis_origin_mm", "axis_direction"): if key in expected: # plane_offset_mm 与平面方程绑定,必须使用记录平面方程时采用的 # plane_normal。face 的局部采样 normal 在 OCC 中可能与其相反。 use_plane_normal = key == "normal" and "plane_offset_mm" in expected and actual.get("plane_normal") is not None right = actual.get("plane_normal") if use_plane_normal else actual.get(key) left = expected[key] if not isinstance(right, (list, tuple)) or len(left) != len(right): return None delta = math.sqrt(sum((float(a) - float(b)) ** 2 for a, b in zip(left, right))) if key in {"normal", "axis_direction"}: # A source CAP_FACE identifies a geometric plane, not the OCC # orientation of the resulting face. The two kernels may # report the same cap with inverse normals, especially for a # start cap. A rotational-face axis is likewise a geometric # line, whose direction may be reversed by OCC. reversed_delta = math.sqrt(sum((float(a) + float(b)) ** 2 for a, b in zip(left, right))) if key == "normal" and use_plane_normal and reversed_delta < delta: reversed_plane_normal = True delta = min(delta, reversed_delta) scores.append(max(0.0, 1.0 - delta / 0.05)) for key in ("radius_mm", "plane_offset_mm"): if key in expected: try: value = float(actual[key]) # 平面偏移是 normal · point。面法向反向时,同一几何平面的 # 有符号偏移也必须同步反号,不能只放宽 normal 的比较。 if key == "plane_offset_mm" and reversed_plane_normal: value = -value delta = abs(float(expected[key]) - value) except Exception: return None scores.append(max(0.0, 1.0 - delta / 0.05)) if "minimum_area_mm2" in expected: try: if float(actual["area_mm2"]) + 1e-6 < float(expected["minimum_area_mm2"]): return None except Exception: return None if "bbox_mm" in expected: actual_box = actual.get("bbox_mm") if not isinstance(actual_box, (list, tuple)) or len(actual_box) != 6: return None delta = max(abs(float(a) - float(b)) for a, b in zip(expected["bbox_mm"], actual_box)) scores.append(max(0.0, 1.0 - delta / 0.05)) return sum(scores) / len(scores) if scores else 0.0 def bind_selector(kind: str, owner_feature_id: str, geometry: dict[str, Any], records: list[dict[str, Any]], *, minimum_score: float = 0.8) -> dict[str, Any]: candidates = [] for record in records: owners = record.get("owner_feature_ids") or [record.get("feature_id")] if record.get("kind") != kind or owner_feature_id not in owners: continue score = _score(geometry, record.get("geometry") or {}) if score is not None and score >= minimum_score: candidates.append((score, record)) candidates.sort(key=lambda item: (-item[0], str(item[1].get("record_id")))) if not candidates: raise ValueError("selector_not_found") if len(candidates) > 1 and abs(candidates[0][0] - candidates[1][0]) <= 1e-9: raise ValueError("selector_ambiguous") score, record = candidates[0] return {"kind": kind, "owner_feature_id": owner_feature_id, "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": round(score, 6), "geometry": record.get("geometry") or geometry} def _dot(left: list[float], right: list[float]) -> float: return sum(float(a)*float(b) for a, b in zip(left, right)) def _circle_records(expected: dict[str, Any], records: list[dict[str, Any]]) -> list[dict[str, Any]]: center = expected.get("source_circle_center_mm"); normal = expected.get("source_plane_normal"); radius = float(expected.get("source_circle_radius_mm") or 0) if not isinstance(center, list) or not isinstance(normal, list) or radius <= 0: return [] plane_offset = _dot(center, normal); matches = [] for record in records: geometry = record.get("geometry") or {} if record.get("kind") != "edge" or geometry.get("curve_type") != "circle": continue points = [geometry.get("start_mm"), geometry.get("end_mm")] if not all(isinstance(point, list) and len(point) == 3 for point in points): continue if any(abs(_dot(point, normal) - plane_offset) > 0.05 for point in points): continue radial = [] for point in points: delta = [float(point[i])-float(center[i]) for i in range(3)]; axial = _dot(delta, normal) radial.append(math.sqrt(max(0.0, sum(value*value for value in delta)-axial*axial))) if all(abs(value-radius) <= max(0.05, radius*1e-4) for value in radial): matches.append(record) return matches def _binding_targets(selector: dict[str, Any]): components = selector.get("intersection_of") if isinstance(components, list): for component in components: if isinstance(component, dict): yield from _binding_targets(component) return yield selector def _runtime_selector(placeholder: dict[str, Any]) -> dict[str, Any]: """Convert a legacy prefix placeholder to the runtime selector contract.""" selector = dict(placeholder) selector.setdefault("source", "runtime_snapshot") selector.setdefault("confidence", 1.0) return selector def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) -> tuple[dict[str, Any], list[dict[str, Any]]]: bound = [] for record in records: owners = record.get("owner_feature_ids") or [record.get("feature_id")] selector = {"kind": placeholder["kind"], "owner_feature_id": str(owners[0]), "stable_id": record["record_id"], "snapshot_id": record["record_id"], "source": "runtime_snapshot", "confidence": 1.0, "geometry": record.get("geometry") or {}} if placeholder.get("binding_feature_id") is not None: selector["binding_feature_id"] = placeholder["binding_feature_id"] if placeholder.get("owner_match_required"): selector["owner_match_required"] = True bound.append(selector) if placeholder.get("match_mode") == "all": selector = dict(placeholder); selector["matched_selectors"] = bound return selector, bound return bound[0], bound def _selector_roots(feature: dict[str, Any]) -> list[dict[str, Any]]: roots = list(feature.get("selectors") or []) for name in ("end_condition", "reverse_end_condition"): condition = (feature.get("params") or {}).get(name) reference = condition.get("reference") if isinstance(condition, dict) else None if isinstance(reference, dict): roots.append(reference) return roots def _selector_key(selector: dict[str, Any]) -> tuple[Any, ...]: stable_id = selector.get("stable_id") if stable_id is not None: return ("stable_id", str(stable_id)) source = selector.get("output_role_source") return ( "output_role", selector.get("owner_feature_id"), selector.get("kind"), selector.get("output_role"), source.get("owner_feature_id") if isinstance(source, dict) else None, source.get("output_role") if isinstance(source, dict) else None, ) def _bind_feature_selectors( feature: dict[str, Any], *, registry: Any, body_id_for_feature: dict[str, str | None], ) -> list[dict[str, Any]]: """Bind one feature immediately before it runs in the shared session.""" targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)] resolved: list[dict[str, Any]] = [] for placeholder in targets: binding_feature_id = placeholder.get("binding_feature_id") if binding_feature_id is None: active_body_id = body_id_for_feature.get("__current__") else: if not isinstance(binding_feature_id, str) or binding_feature_id not in body_id_for_feature: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward") active_body_id = body_id_for_feature[binding_feature_id] intent = placeholder.get("selector_intent") runtime_selector = _runtime_selector(placeholder) resolution = registry.resolve(runtime_selector, active_body_id=active_body_id) # Legacy owner-qualified, geometry-free context selectors predate the # runtime's canonical evidence fields. Their compatibility contract # permits a unique active context object, but never relaxes a # provenance, output-role, or instance-locked selector. if ( resolution.status != "resolved" and not isinstance(intent, dict) and not runtime_selector.get("owner_match_required") and not runtime_selector.get("output_role") ): fallback = dict(runtime_selector) fallback.pop("owner_feature_id", None) resolution = registry.resolve(fallback, active_body_id=active_body_id) if resolution.status != "resolved": code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}" raise ValueError(f"{feature['id']}: {code} during incremental replay") selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ())) if not selected: raise ValueError(f"{feature['id']}: selector_not_found during incremental replay") public_records = [record.public_dict() for record in selected] # Operation-role and provenance selectors stay declarative in bound # CDSL. A runtime record ID is execution evidence, never their durable # semantic replacement. if (isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC") or placeholder.get("output_role"): resolved.extend(public_records) continue selector, bound_selectors = _bound_selector(placeholder, public_records) placeholder.clear() placeholder.update(selector) resolved.extend(bound_selectors) selectors = feature.get("selectors") or [] feature["selectors"] = list({_selector_key(selector): selector for selector in selectors}.values()) if feature.get("atomic_id") == "pattern_mirror": planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"] if len(planes) != 1: raise ValueError(f"{feature['id']}: mirror plane binding is not unique") feature.setdefault("params", {})["mirror_plane"] = planes[0] return resolved @dataclass class IncrementalBindingReplay: """Bound candidate plus the one session that produced its evidence.""" bound_cdsl: dict[str, Any] evidence: list[dict[str, Any]] execution: Any def bind_and_execute_candidate_selectors(cdsl: dict[str, Any]) -> IncrementalBindingReplay: """Bind and execute a CADFS candidate in one ordered kernel replay. A feature is bound only against topology facts registered by earlier features in this session. Historical ``binding_feature_id`` values select a retained snapshot ID, so the binder never needs to re-run a prefix or recreate an OCC body. Resolver failures retain the live session for the caller to export its last executable checkpoint. """ from engine.cdsl_engine.runtime import prepare_cdsl_execution bound = deepcopy(cdsl) execution = prepare_cdsl_execution(bound) if not execution.analysis.runtime_eligible: first = next((result for result in execution.analysis.feature_results if not result.executable), None) code = first.blockers[0].code if first and first.blockers else "runtime_ineligible" raise ValueError(code) bound_features = {str(feature.get("id") or ""): feature for feature in bound.get("features") or []} body_id_for_feature: dict[str, str | None] = {"__current__": None} evidence: list[dict[str, Any]] = [] for index, node in enumerate(execution.analysis.plan): feature = node.source_feature try: resolved = _bind_feature_selectors( feature, registry=execution.session.topology, body_id_for_feature=body_id_for_feature, ) public_feature = bound_features[node.feature_id] public_feature["selectors"] = deepcopy(feature.get("selectors") or []) public_feature["params"] = deepcopy(feature.get("params") or {}) evidence.append({ "feature_id": node.feature_id, "prefix_feature_count": index, "selectors": public_feature["selectors"], "resolved": resolved, }) execution.execute_next(strict=True) body_id_for_feature[node.feature_id] = execution.session.body_id body_id_for_feature["__current__"] = execution.session.body_id except Exception as error: setattr(error, "bound_cdsl", bound) setattr(error, "selector_binding", evidence) setattr(error, "incremental_execution", execution) setattr(error, "failed_feature_id", node.feature_id) raise return IncrementalBindingReplay(bound, evidence, execution) def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]: """Legacy diagnostic binder for incomplete or externally supplied CDSL. The production CADFS rebuild path uses ``bind_and_execute_candidate_selectors``. This compatibility entry point deliberately keeps prefix replay explicit for callers that need to inspect a partial document before it is runtime eligible. It rehydrates exact exported topology facts and calls the same resolver; provenance selectors never become geometry guesses here. """ from engine.cdsl_engine.runtime import rebuild_cdsl from engine.cdsl_engine.topology import TopologyRegistry bound = deepcopy(cdsl) evidence: list[dict[str, Any]] = [] with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary: for index, feature in enumerate(bound.get("features") or []): targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)] if not targets: continue prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]] = {} def prefix_snapshot(binding_feature_id: str | None) -> tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]: prefix_count = index if binding_feature_id is not None: binding_index = next( (item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), None, ) if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward") prefix_count = binding_index + 1 if prefix_count not in prefix_cache: prefix = deepcopy(bound) prefix["features"] = bound["features"][:prefix_count] if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix") report = rebuild_cdsl( prefix, Path(temporary) / f"prefix-{index}-{prefix_count}.step", strict=True, ) body_id = next( (item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None, ) prefix_cache[prefix_count] = ( list(report.get("topology_records") or []), body_id, list(report.get("topology_deltas") or []), ) return prefix_cache[prefix_count] resolved: list[dict[str, Any]] = [] for placeholder in targets: records, body_id, topology_deltas = prefix_snapshot(placeholder.get("binding_feature_id")) registry = TopologyRegistry.from_public_snapshot(records, topology_deltas) intent = placeholder.get("selector_intent") runtime_selector = _runtime_selector(placeholder) resolution = registry.resolve(runtime_selector, active_body_id=body_id) if ( resolution.status != "resolved" and not isinstance(intent, dict) and not runtime_selector.get("owner_match_required") and not runtime_selector.get("output_role") ): fallback = dict(runtime_selector) fallback.pop("owner_feature_id", None) resolution = registry.resolve(fallback, active_body_id=body_id) if resolution.status != "resolved": code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}" raise ValueError(f"{feature['id']}: {code} after prefix rebuild") selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ())) if not selected: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild") public_records = [record.public_dict() for record in selected] if (isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC") or placeholder.get("output_role"): resolved.extend(public_records) continue selector, bound_selectors = _bound_selector(placeholder, public_records) placeholder.clear() placeholder.update(selector) resolved.extend(bound_selectors) feature["selectors"] = list({_selector_key(selector): selector for selector in feature.get("selectors") or []}.values()) if feature.get("atomic_id") == "pattern_mirror": planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"] if len(planes) != 1: raise ValueError(f"{feature['id']}: mirror plane binding is not unique") feature.setdefault("params", {})["mirror_plane"] = planes[0] evidence.append({ "feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"], "resolved": resolved, }) return bound, evidence