994d06aaea
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector - 对候选分支执行有界重建与严格 STEP 比较 - 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录 - 增加 selector 候选搜索、预算限制和记录生成的测试 - 保持生产 selector resolver 不受 Demo 逻辑影响 - 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
359 lines
19 KiB
Python
359 lines
19 KiB
Python
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
|