feat(selector): 增加离线候选遍历与严格回放验证 Demo

- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
This commit is contained in:
2026-09-10 15:12:57 +08:00
parent d8fc2a9207
commit 994d06aaea
36 changed files with 9268 additions and 753 deletions
+61 -18
View File
@@ -17,12 +17,28 @@ _Ctx = dict[str, Any]
_TOLERANCE_MM = 1e-5
def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx:
return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
def _circle(
center: list[float],
radius_mm: float,
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx:
return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
def _line(
start: list[float],
end: list[float],
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _point(point: list[float]) -> list[float]:
@@ -118,7 +134,8 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
cx, cy = float(center[0]), float(center[1])
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
return [_circle([cx, cy], radius)], []
source_entity_id = profile.get("source_entity_id")
return [_circle([cx, cy], radius, source_entity_id=source_entity_id if isinstance(source_entity_id, str) else None)], []
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
@@ -215,7 +232,16 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]:
clockwise = bool(segment.get("clockwise", False))
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
edges = [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
source_entity_id = segment.get("source_entity_id")
if isinstance(source_entity_id, str) and source_entity_id:
# Region construction needs four arc segments, but this marker records
# that all four came from exactly one logical source circle. It is not
# an edge anchor: callers must rebuild one native circle wire and pass
# final-face identity checks before using it for lineage.
for edge in edges:
edge["logical_circle_source_entity_id"] = source_entity_id
return edges
def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
@@ -242,14 +268,14 @@ def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
kind = segment.get("type")
if kind == "line":
return [_contour_line(segment["start"], segment["end"])]
if kind == "arc":
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
if kind == "circle":
return _circle_edges(segment)
if kind == "ellipse":
return _ellipse_edges(segment)
if kind == "bspline":
edges = [_contour_line(segment["start"], segment["end"])]
elif kind == "arc":
edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
elif kind == "circle":
edges = _circle_edges(segment)
elif kind == "ellipse":
edges = _ellipse_edges(segment)
elif kind == "bspline":
points = segment.get("points") or []
if len(points) < 2:
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
@@ -304,8 +330,18 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
output["start_tangent_mm"] = _point(start_tangent)
output["end_tangent_mm"] = _point(end_tangent)
return [output]
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
edges = [output]
else:
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
source_entity_id = segment.get("source_entity_id")
# Only a one-edge construction has the direct, one-to-one source identity
# required by profile-to-prism lineage. Circles expanded to arcs carry a
# separate logical-circle marker, not a source edge identity; the adapter
# may use it only to reconstruct one native circle wire with exact final
# membership proof. Other multi-edge approximations remain unanchored.
if isinstance(source_entity_id, str) and len(edges) == 1:
edges[0]["source_entity_id"] = source_entity_id
return edges
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
@@ -466,11 +502,18 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
raw_edges: list[_Ctx] = []
for segment in contour.get("segments") or []:
if segment.get("type") == "line":
entities.append(_line(segment["start"], segment["end"]))
entities.append(_line(
segment["start"], segment["end"],
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
elif segment.get("type") == "circle":
if contour_open:
raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment")
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
entities.append(_circle(
segment.get("center") or [0.0, 0.0],
float(segment.get("radius_mm") or 0.0),
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
raw_edges.extend(_segment_edges(segment))
if raw_edges:
edges = _join(raw_edges, allow_open=contour_open)