feat(cadfs): expand selector provenance coverage
This commit is contained in:
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import math
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -29,10 +30,19 @@ def strip_provenance_intents(value: Any) -> int:
|
||||
"""Remove provenance-only selector fields from one copied CDSL value."""
|
||||
removed = 0
|
||||
if isinstance(value, dict):
|
||||
removed_intent = False
|
||||
for key in ("selector_intent", "selector_intent_version"):
|
||||
if key in value:
|
||||
value.pop(key)
|
||||
removed += 1
|
||||
removed_intent = True
|
||||
# Feature output roles are semantic builder evidence, just like a
|
||||
# selector intent. The geometry-only probe must not leave an output
|
||||
# role in place after discarding the evidence that validates it.
|
||||
if removed_intent:
|
||||
for key in ("output_role", "output_role_source"):
|
||||
if key in value:
|
||||
value.pop(key)
|
||||
for child in value.values():
|
||||
removed += strip_provenance_intents(child)
|
||||
elif isinstance(value, list):
|
||||
@@ -63,7 +73,7 @@ def _compare_worker(gold: str, rebuilt: str, result: str) -> None:
|
||||
write_json(Path(result), compare_steps(Path(gold), Path(rebuilt)))
|
||||
|
||||
|
||||
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> str:
|
||||
def _isolated(target: Any, args: tuple[Any, ...], result_path: Path, timeout_seconds: float) -> str:
|
||||
"""Bound an OCC experiment so one candidate cannot stall the demo."""
|
||||
result_path.unlink(missing_ok=True)
|
||||
process = multiprocessing.get_context("spawn").Process(target=target, args=args)
|
||||
@@ -134,6 +144,41 @@ def _strict_passed(comparison: dict[str, Any] | None) -> bool:
|
||||
return bool(isinstance(comparison, dict) and (comparison.get("strict") or {}).get("passed"))
|
||||
|
||||
|
||||
_SEARCH_TOKEN = "_selector_search_token"
|
||||
|
||||
|
||||
def _selector_search_token(feature_id: str, location: str) -> str:
|
||||
"""Use a structural token while a group expands a public selector list."""
|
||||
return f"{feature_id}:{location}"
|
||||
|
||||
|
||||
def _annotate_selector_search_tokens(cdsl: dict[str, Any]) -> int:
|
||||
"""Mark copied diagnostic selectors without creating a durable CDSL field."""
|
||||
count = 0
|
||||
for feature in cdsl.get("features") or []:
|
||||
if not isinstance(feature, dict) or not isinstance(feature.get("id"), str):
|
||||
continue
|
||||
for location, selector in _selector_sites(feature):
|
||||
selector[_SEARCH_TOKEN] = _selector_search_token(feature["id"], location)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _strip_selector_search_tokens(value: Any) -> int:
|
||||
"""Remove experiment-only branch tokens before a CDSL is persisted."""
|
||||
removed = 0
|
||||
if isinstance(value, dict):
|
||||
if _SEARCH_TOKEN in value:
|
||||
value.pop(_SEARCH_TOKEN)
|
||||
removed += 1
|
||||
for child in value.values():
|
||||
removed += _strip_selector_search_tokens(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
removed += _strip_selector_search_tokens(child)
|
||||
return removed
|
||||
|
||||
|
||||
def _selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""Return the public selector positions of one CDSL feature.
|
||||
|
||||
@@ -270,6 +315,8 @@ def _emit_strict_selector_record(
|
||||
*,
|
||||
rebuild_timeout_seconds: float,
|
||||
comparison_timeout_seconds: float,
|
||||
query_groups: list[dict[str, Any]] | None = None,
|
||||
candidate_replay_kind: str = "geometry_probe",
|
||||
) -> dict[str, Any]:
|
||||
"""Create and independently verify a compiled CDSL only after strict proof."""
|
||||
if not _strict_passed(geometry_comparison):
|
||||
@@ -286,6 +333,7 @@ def _emit_strict_selector_record(
|
||||
}
|
||||
|
||||
compiled_candidate = deepcopy(bound_cdsl)
|
||||
_strip_selector_search_tokens(compiled_candidate)
|
||||
compiled_path = directory / "selector_record.compiled.cdsl.json"
|
||||
write_json(compiled_path, compiled_candidate)
|
||||
compiled = _run_rebuild(
|
||||
@@ -322,20 +370,29 @@ def _emit_strict_selector_record(
|
||||
result["reason"] = "no_featurescript_selector_binding"
|
||||
return result
|
||||
record_path = directory / "selector-record.json"
|
||||
write_json(record_path, {
|
||||
verification = {
|
||||
"compiled_cdsl_strict": True,
|
||||
"compiled_cdsl": str(compiled_path),
|
||||
}
|
||||
if candidate_replay_kind == "geometry_probe":
|
||||
verification["geometry_probe_strict"] = True
|
||||
else:
|
||||
verification["selector_search_branch_strict"] = True
|
||||
record_payload: dict[str, Any] = {
|
||||
"schema": "cadfs_to_cdsl.selector_record_set.v1",
|
||||
"sample_id": sample.sample_id,
|
||||
"source": {
|
||||
"featurescript": sample.files.get("featurescript"),
|
||||
"step": sample.files.get("step"),
|
||||
},
|
||||
"verification": {
|
||||
"geometry_probe_strict": True,
|
||||
"compiled_cdsl_strict": True,
|
||||
"compiled_cdsl": str(compiled_path),
|
||||
},
|
||||
"verification": verification,
|
||||
"records": records,
|
||||
})
|
||||
}
|
||||
if query_groups:
|
||||
# These are the query-to-record *sets* selected by the offline search.
|
||||
# A FeatureScript query is not necessarily a single OCC record.
|
||||
record_payload["query_groups"] = deepcopy(query_groups)
|
||||
write_json(record_path, record_payload)
|
||||
result.update({
|
||||
"status": "strict_replayed",
|
||||
"record_count": len(records),
|
||||
@@ -359,41 +416,58 @@ def _searchable_selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[
|
||||
]
|
||||
|
||||
|
||||
def _replace_selector_site(cdsl: dict[str, Any], feature_id: str, location: str, selector: dict[str, Any]) -> None:
|
||||
"""Replace one public direct selector site in a copied candidate CDSL."""
|
||||
def _replace_selector_group(
|
||||
cdsl: dict[str, Any],
|
||||
feature_id: str,
|
||||
token: str,
|
||||
selectors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Expand one selector root to an explicit branch-local record set."""
|
||||
feature = next(
|
||||
(item for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id") == feature_id),
|
||||
None,
|
||||
)
|
||||
if feature is None:
|
||||
raise ValueError(f"search branch cannot find feature {feature_id}")
|
||||
if location.startswith("selectors[") and location.endswith("]"):
|
||||
index = int(location[len("selectors["):-1])
|
||||
selectors = feature.get("selectors") or []
|
||||
if index < 0 or index >= len(selectors) or not isinstance(selectors[index], dict):
|
||||
raise ValueError(f"search branch selector site is unavailable: {feature_id}:{location}")
|
||||
selectors[index] = deepcopy(selector)
|
||||
roots = feature.get("selectors") or []
|
||||
index = next(
|
||||
(
|
||||
item_index
|
||||
for item_index, selector in enumerate(roots)
|
||||
if isinstance(selector, dict) and selector.get(_SEARCH_TOKEN) == token
|
||||
),
|
||||
None,
|
||||
)
|
||||
if index is not None:
|
||||
roots[index:index + 1] = [deepcopy(selector) for selector in selectors]
|
||||
feature["selectors"] = roots
|
||||
return
|
||||
prefix = "params."
|
||||
suffix = ".reference"
|
||||
if location.startswith(prefix) and location.endswith(suffix):
|
||||
name = location[len(prefix):-len(suffix)]
|
||||
for name in ("end_condition", "reverse_end_condition"):
|
||||
condition = (feature.get("params") or {}).get(name)
|
||||
if not isinstance(condition, dict) or not isinstance(condition.get("reference"), dict):
|
||||
raise ValueError(f"search branch selector site is unavailable: {feature_id}:{location}")
|
||||
condition["reference"] = deepcopy(selector)
|
||||
return
|
||||
raise ValueError(f"search branch does not support nested selector site: {feature_id}:{location}")
|
||||
reference = condition.get("reference") if isinstance(condition, dict) else None
|
||||
if isinstance(reference, dict) and reference.get(_SEARCH_TOKEN) == token:
|
||||
if len(selectors) != 1:
|
||||
raise ValueError("search group cannot expand an extent reference")
|
||||
condition["reference"] = deepcopy(selectors[0])
|
||||
return
|
||||
raise ValueError(f"search branch selector token is unavailable: {feature_id}:{token}")
|
||||
|
||||
|
||||
def _forced_candidate_selector(placeholder: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Turn one public runtime record into a branch-local explicit selector."""
|
||||
def _forced_candidate_selectors(placeholder: dict[str, Any], candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Turn a query's runtime record set into explicit branch-local roots."""
|
||||
from .selector_binding import _bound_selector
|
||||
|
||||
selector, _bound = _bound_selector(placeholder, [candidate])
|
||||
if not isinstance(selector, dict):
|
||||
raise ValueError("search branch produced an invalid explicit selector")
|
||||
return selector
|
||||
selectors: list[dict[str, Any]] = []
|
||||
for candidate in candidates:
|
||||
selector, _bound = _bound_selector(placeholder, [candidate])
|
||||
if not isinstance(selector, dict):
|
||||
raise ValueError("search branch produced an invalid explicit selector")
|
||||
if placeholder.get(_SEARCH_TOKEN) is not None:
|
||||
selector[_SEARCH_TOKEN] = placeholder[_SEARCH_TOKEN]
|
||||
selectors.append(selector)
|
||||
if not selectors:
|
||||
raise ValueError("search group has no runtime records")
|
||||
return selectors
|
||||
|
||||
|
||||
def _resolve_search_selector(placeholder: dict[str, Any], *, registry: Any, active_body_id: str | None) -> Any:
|
||||
@@ -432,12 +506,552 @@ def _search_candidate_records(
|
||||
return [deepcopy(item) for item in candidates[:maximum]], len(candidates)
|
||||
|
||||
|
||||
def _search_replay_worker(candidate: str, step: str, result: str, maximum_candidates: int) -> None:
|
||||
def _vector(value: Any) -> list[float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||
return None
|
||||
try:
|
||||
result = [float(component) for component in value]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result if all(math.isfinite(component) for component in result) else None
|
||||
|
||||
|
||||
def _sub(left: list[float], right: list[float]) -> list[float]:
|
||||
return [left[index] - right[index] for index in range(3)]
|
||||
|
||||
|
||||
def _dot(left: list[float], right: list[float]) -> float:
|
||||
return sum(left[index] * right[index] for index in range(3))
|
||||
|
||||
|
||||
def _cross(left: list[float], right: list[float]) -> list[float]:
|
||||
return [
|
||||
left[1] * right[2] - left[2] * right[1],
|
||||
left[2] * right[0] - left[0] * right[2],
|
||||
left[0] * right[1] - left[1] * right[0],
|
||||
]
|
||||
|
||||
|
||||
def _norm(value: list[float]) -> float:
|
||||
return math.sqrt(_dot(value, value))
|
||||
|
||||
|
||||
def _unit(value: list[float] | None) -> list[float] | None:
|
||||
if value is None or len(value) != 3:
|
||||
return None
|
||||
length = _norm(value)
|
||||
if length <= 1e-9:
|
||||
return None
|
||||
return [component / length for component in value]
|
||||
|
||||
|
||||
def _distance(left: list[float], right: list[float]) -> float:
|
||||
return _norm(_sub(left, right))
|
||||
|
||||
|
||||
def _score_distance(distance_mm: float, *, tolerance_mm: float = 0.05) -> float:
|
||||
return max(0.0, 1.0 - distance_mm / tolerance_mm)
|
||||
|
||||
|
||||
def _source_selector_map(provenance_candidate: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Index source selectors by an experiment-only structural token."""
|
||||
selectors: dict[str, dict[str, Any]] = {}
|
||||
for feature in provenance_candidate.get("features") or []:
|
||||
if not isinstance(feature, dict) or not isinstance(feature.get("id"), str):
|
||||
continue
|
||||
for location, selector in _selector_sites(feature):
|
||||
if isinstance(selector.get("selector_intent"), dict):
|
||||
selectors[_selector_search_token(feature["id"], location)] = deepcopy(selector)
|
||||
return selectors
|
||||
|
||||
|
||||
def _source_feature(provenance_candidate: dict[str, Any], feature_id: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(feature_id, str):
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
feature
|
||||
for feature in provenance_candidate.get("features") or []
|
||||
if isinstance(feature, dict) and feature.get("id") == feature_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _source_sketch(provenance_candidate: dict[str, Any], producer: dict[str, Any], source_sketch_id: str | None) -> dict[str, Any] | None:
|
||||
sketches = (provenance_candidate.get("geometry") or {}).get("sketches") or []
|
||||
sketch_id = producer.get("sketch_id")
|
||||
if isinstance(sketch_id, str):
|
||||
sketch = next((item for item in sketches if isinstance(item, dict) and item.get("id") == sketch_id), None)
|
||||
if isinstance(sketch, dict):
|
||||
return sketch
|
||||
if isinstance(source_sketch_id, str):
|
||||
sketch = next(
|
||||
(
|
||||
item
|
||||
for item in sketches
|
||||
if isinstance(item, dict) and item.get("source_sketch_id") == source_sketch_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if isinstance(sketch, dict):
|
||||
return sketch
|
||||
return None
|
||||
|
||||
|
||||
def _source_curve(sketch: dict[str, Any], entity_id: str) -> dict[str, Any] | None:
|
||||
profile = sketch.get("profile") or {}
|
||||
for entry in profile.get("source_entities") or []:
|
||||
if isinstance(entry, dict) and entry.get("id") == entity_id and isinstance(entry.get("curve"), dict):
|
||||
return entry["curve"]
|
||||
for contour in profile.get("contours") or []:
|
||||
if not isinstance(contour, dict):
|
||||
continue
|
||||
for segment in contour.get("segments") or []:
|
||||
if isinstance(segment, dict) and segment.get("source_entity_id") == entity_id:
|
||||
return segment
|
||||
for segment in profile.get("construction") or []:
|
||||
if isinstance(segment, dict) and segment.get("source_entity_id") == entity_id:
|
||||
return segment
|
||||
return None
|
||||
|
||||
|
||||
def _producer_frame(
|
||||
provenance_candidate: dict[str, Any],
|
||||
source_selector: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any], list[float], float] | None:
|
||||
"""Recover only the direct sketch/extrude frame needed for candidate rank."""
|
||||
intent = source_selector.get("selector_intent") or {}
|
||||
source_entity = intent.get("source_entity") if isinstance(intent.get("source_entity"), dict) else None
|
||||
source_entities = intent.get("source_entities") or []
|
||||
source_sketch_id = (
|
||||
source_entity.get("sketch_id")
|
||||
if isinstance(source_entity, dict)
|
||||
else next(
|
||||
(
|
||||
item.get("sketch_id")
|
||||
for item in source_entities
|
||||
if isinstance(item, dict) and isinstance(item.get("sketch_id"), str)
|
||||
),
|
||||
None,
|
||||
)
|
||||
)
|
||||
producer = _source_feature(provenance_candidate, source_selector.get("owner_feature_id"))
|
||||
if producer is None or not str(producer.get("atomic_id") or "").startswith("extrude_"):
|
||||
return None
|
||||
sketch = _source_sketch(provenance_candidate, producer, source_sketch_id)
|
||||
if sketch is None:
|
||||
return None
|
||||
plane = sketch.get("workplane") or {}
|
||||
origin = _vector(plane.get("origin_mm"))
|
||||
normal = _unit(_vector(plane.get("normal")) or [])
|
||||
if origin is None or normal is None:
|
||||
return None
|
||||
params = producer.get("params") or {}
|
||||
try:
|
||||
span = abs(float(params["distance_mm"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if span <= 1e-9:
|
||||
return None
|
||||
if bool(params.get("reverse") or params.get("opposite_direction")):
|
||||
normal = [-component for component in normal]
|
||||
return producer, sketch, normal, span
|
||||
|
||||
|
||||
def _world_point(sketch: dict[str, Any], point: Any) -> list[float] | None:
|
||||
local = point if isinstance(point, (list, tuple)) and len(point) in {2, 3} else None
|
||||
if local is None:
|
||||
return None
|
||||
try:
|
||||
coordinates = [float(value) for value in local]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
plane = sketch.get("workplane") or {}
|
||||
origin = _vector(plane.get("origin_mm"))
|
||||
x_axis = _unit(_vector(plane.get("x_dir")) or [])
|
||||
normal = _unit(_vector(plane.get("normal")) or [])
|
||||
if origin is None or x_axis is None or normal is None:
|
||||
return None
|
||||
y_axis = _unit(_cross(normal, x_axis))
|
||||
if y_axis is None:
|
||||
return None
|
||||
z = coordinates[2] if len(coordinates) == 3 else 0.0
|
||||
return [
|
||||
origin[index] + x_axis[index] * coordinates[0] + y_axis[index] * coordinates[1] + normal[index] * z
|
||||
for index in range(3)
|
||||
]
|
||||
|
||||
|
||||
def _source_entity_ids(source_selector: dict[str, Any]) -> list[str]:
|
||||
intent = source_selector.get("selector_intent") or {}
|
||||
entity = intent.get("source_entity")
|
||||
if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str):
|
||||
return [entity["entity_id"]]
|
||||
return sorted({
|
||||
item["entity_id"]
|
||||
for item in intent.get("source_entities") or []
|
||||
if isinstance(item, dict) and isinstance(item.get("entity_id"), str)
|
||||
})
|
||||
|
||||
|
||||
def _cap_role_hint(source_selector: dict[str, Any]) -> str | None:
|
||||
intent = source_selector.get("selector_intent") or {}
|
||||
role = source_selector.get("output_role") or intent.get("output_role")
|
||||
if isinstance(role, str) and role in {"extrude.start", "extrude.end"}:
|
||||
return role.rsplit(".", 1)[1]
|
||||
ast = ((intent.get("source_query") or {}).get("ast") or {})
|
||||
args = ast.get("args") if isinstance(ast, dict) else None
|
||||
options = args[3] if isinstance(args, list) and len(args) >= 4 and isinstance(args[3], dict) else {}
|
||||
is_start = options.get("isStart")
|
||||
if is_start in {True, "true"}:
|
||||
return "start"
|
||||
if is_start in {False, "false"}:
|
||||
return "end"
|
||||
return None
|
||||
|
||||
|
||||
def _query_group_descriptor(token: str, source_selector: dict[str, Any]) -> dict[str, Any]:
|
||||
intent = source_selector.get("selector_intent") or {}
|
||||
policy = intent.get("derivation_policy") or {}
|
||||
return {
|
||||
"token": token,
|
||||
"feature_id": token.split(":", 1)[0],
|
||||
"source_location": token.split(":", 1)[1] if ":" in token else token,
|
||||
"kind": source_selector.get("kind"),
|
||||
"owner_feature_id": source_selector.get("owner_feature_id"),
|
||||
"query_family": intent.get("query_family"),
|
||||
"multiplicity": policy.get("multiplicity", "one"),
|
||||
"source_entity_ids": _source_entity_ids(source_selector),
|
||||
"source_make_query": deepcopy((intent.get("source_query") or {}).get("ast")),
|
||||
}
|
||||
|
||||
|
||||
def _rank_cap_face(
|
||||
provenance_candidate: dict[str, Any],
|
||||
source_selector: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
) -> tuple[float, dict[str, Any]] | None:
|
||||
frame = _producer_frame(provenance_candidate, source_selector)
|
||||
role = _cap_role_hint(source_selector)
|
||||
if frame is None or role is None:
|
||||
return None
|
||||
_producer, sketch, direction, span = frame
|
||||
origin = _vector((sketch.get("workplane") or {}).get("origin_mm"))
|
||||
geometry = candidate.get("geometry") or {}
|
||||
actual_normal = _unit(_vector(geometry.get("plane_normal") or geometry.get("normal")) or [])
|
||||
if origin is None or actual_normal is None or geometry.get("surface_type") != "plane":
|
||||
return None
|
||||
point = origin if role == "start" else [origin[index] + direction[index] * span for index in range(3)]
|
||||
expected_offset = _dot(direction, point)
|
||||
actual_offset = geometry.get("plane_offset_mm")
|
||||
try:
|
||||
actual_offset = float(actual_offset)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
alignment = abs(_dot(direction, actual_normal))
|
||||
signed_offset = actual_offset if _dot(direction, actual_normal) >= 0 else -actual_offset
|
||||
offset_error = abs(expected_offset - signed_offset)
|
||||
return (alignment + _score_distance(offset_error)) / 2.0, {
|
||||
"method": "extrude_cap_plane",
|
||||
"cap_role": role,
|
||||
"normal_alignment": round(alignment, 6),
|
||||
"plane_offset_error_mm": round(offset_error, 9),
|
||||
}
|
||||
|
||||
|
||||
def _rank_swept_face(
|
||||
provenance_candidate: dict[str, Any],
|
||||
source_selector: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
) -> tuple[float, dict[str, Any]] | None:
|
||||
frame = _producer_frame(provenance_candidate, source_selector)
|
||||
entity_ids = _source_entity_ids(source_selector)
|
||||
if frame is None or len(entity_ids) != 1:
|
||||
return None
|
||||
_producer, sketch, direction, _span = frame
|
||||
curve = _source_curve(sketch, entity_ids[0])
|
||||
geometry = candidate.get("geometry") or {}
|
||||
if not isinstance(curve, dict):
|
||||
return None
|
||||
if curve.get("type") == "line":
|
||||
start, end = _world_point(sketch, curve.get("start")), _world_point(sketch, curve.get("end"))
|
||||
actual_normal = _unit(_vector(geometry.get("plane_normal") or geometry.get("normal")) or [])
|
||||
if start is None or end is None or actual_normal is None or geometry.get("surface_type") != "plane":
|
||||
return None
|
||||
expected_normal = _unit(_cross(_sub(end, start), direction))
|
||||
if expected_normal is None:
|
||||
return None
|
||||
try:
|
||||
actual_offset = float(geometry["plane_offset_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
alignment = abs(_dot(expected_normal, actual_normal))
|
||||
expected_offset = _dot(expected_normal, start)
|
||||
signed_offset = actual_offset if _dot(expected_normal, actual_normal) >= 0 else -actual_offset
|
||||
offset_error = abs(expected_offset - signed_offset)
|
||||
return (alignment + _score_distance(offset_error)) / 2.0, {
|
||||
"method": "source_line_supporting_plane",
|
||||
"normal_alignment": round(alignment, 6),
|
||||
"plane_offset_error_mm": round(offset_error, 9),
|
||||
}
|
||||
if curve.get("type") not in {"arc", "circle"}:
|
||||
return None
|
||||
center = _world_point(sketch, curve.get("center"))
|
||||
actual_origin = _vector(geometry.get("axis_origin_mm"))
|
||||
actual_direction = _unit(_vector(geometry.get("axis_direction")) or [])
|
||||
try:
|
||||
radius_error = abs(float(curve["radius_mm"]) - float(geometry["radius_mm"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if center is None or actual_origin is None or actual_direction is None or geometry.get("surface_type") != "cylinder":
|
||||
return None
|
||||
alignment = abs(_dot(direction, actual_direction))
|
||||
radial_offset = _norm(_cross(_sub(center, actual_origin), actual_direction))
|
||||
return (
|
||||
alignment + _score_distance(radial_offset) + _score_distance(radius_error)
|
||||
) / 3.0, {
|
||||
"method": "source_arc_cylinder",
|
||||
"axis_alignment": round(alignment, 6),
|
||||
"axis_offset_mm": round(radial_offset, 9),
|
||||
"radius_error_mm": round(radius_error, 9),
|
||||
}
|
||||
|
||||
|
||||
def _point2(value: Any) -> tuple[float, float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||
return None
|
||||
try:
|
||||
point = float(value[0]), float(value[1])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return point if all(math.isfinite(component) for component in point) else None
|
||||
|
||||
|
||||
def _curve_contains_point(curve: dict[str, Any], point: tuple[float, float], *, tolerance_mm: float = 0.05) -> bool:
|
||||
curve_type = curve.get("type")
|
||||
if curve_type == "line":
|
||||
start, end = _point2(curve.get("start")), _point2(curve.get("end"))
|
||||
if start is None or end is None:
|
||||
return False
|
||||
direction = end[0] - start[0], end[1] - start[1]
|
||||
length = math.hypot(*direction)
|
||||
if length <= 1e-9:
|
||||
return False
|
||||
offset = point[0] - start[0], point[1] - start[1]
|
||||
distance = abs(direction[0] * offset[1] - direction[1] * offset[0]) / length
|
||||
parameter = (direction[0] * offset[0] + direction[1] * offset[1]) / (length * length)
|
||||
return distance <= tolerance_mm and -tolerance_mm / length <= parameter <= 1.0 + tolerance_mm / length
|
||||
center = _point2(curve.get("center"))
|
||||
try:
|
||||
radius = float(curve["radius_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if center is None or radius <= 1e-9 or abs(math.dist(center, point) - radius) > tolerance_mm:
|
||||
return False
|
||||
if curve_type == "circle":
|
||||
return True
|
||||
if curve_type != "arc":
|
||||
return False
|
||||
start, end = _point2(curve.get("start")), _point2(curve.get("end"))
|
||||
if start is None or end is None:
|
||||
return False
|
||||
angle = math.atan2(point[1] - center[1], point[0] - center[0])
|
||||
start_angle = math.atan2(start[1] - center[1], start[0] - center[0])
|
||||
end_angle = math.atan2(end[1] - center[1], end[0] - center[0])
|
||||
full_turn = 2.0 * math.pi
|
||||
if bool(curve.get("clockwise")):
|
||||
travelled = (start_angle - angle) % full_turn
|
||||
total = (start_angle - end_angle) % full_turn
|
||||
else:
|
||||
travelled = (angle - start_angle) % full_turn
|
||||
total = (end_angle - start_angle) % full_turn
|
||||
angular_tolerance = tolerance_mm / radius
|
||||
return travelled <= total + angular_tolerance
|
||||
|
||||
|
||||
def _curve_intersections_2d(left: dict[str, Any], right: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
"""Intersect line/arc/circle supports, retaining only source curve spans."""
|
||||
left_type, right_type = left.get("type"), right.get("type")
|
||||
points: list[tuple[float, float]] = []
|
||||
if left_type == "line" and right_type == "line":
|
||||
left_start, left_end = _point2(left.get("start")), _point2(left.get("end"))
|
||||
right_start, right_end = _point2(right.get("start")), _point2(right.get("end"))
|
||||
if None not in (left_start, left_end, right_start, right_end):
|
||||
left_dx, left_dy = left_end[0] - left_start[0], left_end[1] - left_start[1]
|
||||
right_dx, right_dy = right_end[0] - right_start[0], right_end[1] - right_start[1]
|
||||
determinant = left_dx * right_dy - left_dy * right_dx
|
||||
if abs(determinant) > 1e-9:
|
||||
offset_x, offset_y = right_start[0] - left_start[0], right_start[1] - left_start[1]
|
||||
parameter = (offset_x * right_dy - offset_y * right_dx) / determinant
|
||||
points.append((left_start[0] + parameter * left_dx, left_start[1] + parameter * left_dy))
|
||||
else:
|
||||
line, circular = (left, right) if left_type == "line" else (right, left)
|
||||
if line.get("type") == "line" and circular.get("type") in {"arc", "circle"}:
|
||||
start, end, center = _point2(line.get("start")), _point2(line.get("end")), _point2(circular.get("center"))
|
||||
try:
|
||||
radius = float(circular["radius_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
radius = 0.0
|
||||
if start is not None and end is not None and center is not None and radius > 1e-9:
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
ox, oy = start[0] - center[0], start[1] - center[1]
|
||||
quadratic_a = dx * dx + dy * dy
|
||||
quadratic_b = 2.0 * (dx * ox + dy * oy)
|
||||
quadratic_c = ox * ox + oy * oy - radius * radius
|
||||
discriminant = quadratic_b * quadratic_b - 4.0 * quadratic_a * quadratic_c
|
||||
if quadratic_a > 1e-12 and discriminant >= -1e-9:
|
||||
root = math.sqrt(max(0.0, discriminant))
|
||||
for parameter in ((-quadratic_b - root) / (2.0 * quadratic_a), (-quadratic_b + root) / (2.0 * quadratic_a)):
|
||||
points.append((start[0] + parameter * dx, start[1] + parameter * dy))
|
||||
elif left_type in {"arc", "circle"} and right_type in {"arc", "circle"}:
|
||||
left_center, right_center = _point2(left.get("center")), _point2(right.get("center"))
|
||||
try:
|
||||
left_radius, right_radius = float(left["radius_mm"]), float(right["radius_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
left_radius = right_radius = 0.0
|
||||
if left_center is not None and right_center is not None and left_radius > 1e-9 and right_radius > 1e-9:
|
||||
dx, dy = right_center[0] - left_center[0], right_center[1] - left_center[1]
|
||||
center_distance = math.hypot(dx, dy)
|
||||
if 1e-9 < center_distance <= left_radius + right_radius + 0.05:
|
||||
along = (left_radius * left_radius - right_radius * right_radius + center_distance * center_distance) / (2.0 * center_distance)
|
||||
height_squared = left_radius * left_radius - along * along
|
||||
if height_squared >= -1e-9:
|
||||
height = math.sqrt(max(0.0, height_squared))
|
||||
base_x, base_y = left_center[0] + along * dx / center_distance, left_center[1] + along * dy / center_distance
|
||||
offset_x, offset_y = -dy * height / center_distance, dx * height / center_distance
|
||||
points.extend(((base_x + offset_x, base_y + offset_y), (base_x - offset_x, base_y - offset_y)))
|
||||
valid: list[tuple[float, float]] = []
|
||||
for point in points:
|
||||
if not _curve_contains_point(left, point) or not _curve_contains_point(right, point):
|
||||
continue
|
||||
if not any(math.dist(point, existing) <= 1e-6 for existing in valid):
|
||||
valid.append(point)
|
||||
return valid
|
||||
|
||||
|
||||
def _source_curve_intersection(sketch: dict[str, Any], entity_ids: list[str]) -> list[float] | None:
|
||||
if len(entity_ids) != 2:
|
||||
return None
|
||||
curves = [_source_curve(sketch, entity_id) for entity_id in entity_ids]
|
||||
if any(not isinstance(curve, dict) for curve in curves):
|
||||
return None
|
||||
intersections = _curve_intersections_2d(curves[0], curves[1])
|
||||
if len(intersections) != 1:
|
||||
return None
|
||||
return _world_point(sketch, intersections[0])
|
||||
|
||||
|
||||
def _rank_swept_edge(
|
||||
provenance_candidate: dict[str, Any],
|
||||
source_selector: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
) -> tuple[float, dict[str, Any]] | None:
|
||||
frame = _producer_frame(provenance_candidate, source_selector)
|
||||
entity_ids = _source_entity_ids(source_selector)
|
||||
if frame is None:
|
||||
return None
|
||||
_producer, sketch, direction, _span = frame
|
||||
source_point = _source_curve_intersection(sketch, entity_ids)
|
||||
geometry = candidate.get("geometry") or {}
|
||||
start, end = _vector(geometry.get("start_mm")), _vector(geometry.get("end_mm"))
|
||||
if source_point is None or start is None or end is None or geometry.get("curve_type") != "line":
|
||||
return None
|
||||
edge_direction = _unit(_sub(end, start))
|
||||
if edge_direction is None:
|
||||
return None
|
||||
alignment = abs(_dot(direction, edge_direction))
|
||||
supporting_line_error = _norm(_cross(_sub(source_point, start), direction))
|
||||
return (alignment + _score_distance(supporting_line_error)) / 2.0, {
|
||||
"method": "source_pair_extrusion_vertex",
|
||||
"axis_alignment": round(alignment, 6),
|
||||
"source_vertex_offset_mm": round(supporting_line_error, 9),
|
||||
}
|
||||
|
||||
|
||||
def _query_candidate_groups(
|
||||
provenance_candidate: dict[str, Any],
|
||||
token: str,
|
||||
source_selector: dict[str, Any] | None,
|
||||
resolution: Any,
|
||||
*,
|
||||
maximum: int,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]], int]:
|
||||
"""Rank branch alternatives as FeatureScript query result sets.
|
||||
|
||||
This is deliberately an offline geometric heuristic. It gets one chance
|
||||
to reduce a query to record *sets*, then final STEP strict replay decides
|
||||
whether that set is useful. It never adds a geometry relation to the
|
||||
production selector graph.
|
||||
"""
|
||||
normal_candidates, normal_count = _search_candidate_records(resolution, maximum=maximum)
|
||||
if not isinstance(source_selector, dict):
|
||||
descriptor = {"token": token, "ranking_status": "source_query_missing"}
|
||||
return descriptor, [
|
||||
{"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}}
|
||||
for candidate in normal_candidates
|
||||
], normal_count
|
||||
descriptor = _query_group_descriptor(token, source_selector)
|
||||
family = descriptor.get("query_family")
|
||||
ranker = {
|
||||
"CAP_FACE": _rank_cap_face,
|
||||
"SWEPT_FACE": _rank_swept_face,
|
||||
"SWEPT_EDGE": _rank_swept_edge,
|
||||
}.get(family)
|
||||
if ranker is None:
|
||||
descriptor["ranking_status"] = "query_family_not_ranked"
|
||||
return descriptor, [
|
||||
{"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}}
|
||||
for candidate in normal_candidates
|
||||
], normal_count
|
||||
ranked: list[tuple[float, dict[str, Any], dict[str, Any]]] = []
|
||||
for candidate in resolution.candidates:
|
||||
if not isinstance(candidate, dict) or not candidate.get("record_id") or float(candidate.get("score") or 0.0) < 0.8:
|
||||
continue
|
||||
result = ranker(provenance_candidate, source_selector, candidate)
|
||||
if result is None:
|
||||
continue
|
||||
score, reason = result
|
||||
if score >= 0.98:
|
||||
ranked.append((score, deepcopy(candidate), reason))
|
||||
ranked.sort(key=lambda item: (-item[0], str(item[1].get("record_id"))))
|
||||
if not ranked:
|
||||
descriptor["ranking_status"] = "source_geometry_unavailable"
|
||||
return descriptor, [
|
||||
{"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}}
|
||||
for candidate in normal_candidates
|
||||
], normal_count
|
||||
descriptor["ranking_status"] = "source_geometry_ranked"
|
||||
descriptor["ranked_record_count"] = len(ranked)
|
||||
if descriptor.get("multiplicity") == "all_fragments":
|
||||
records = [candidate for _score, candidate, _reason in ranked]
|
||||
descriptor["selected_set_cardinality"] = len(records)
|
||||
return descriptor, [{
|
||||
"records": records,
|
||||
"score": round(sum(score for score, _candidate, _reason in ranked) / len(ranked), 6),
|
||||
"ranking": {"method": "all_fragments_source_geometry", "records": [reason for _score, _candidate, reason in ranked]},
|
||||
}], 1
|
||||
groups = [
|
||||
{"records": [candidate], "score": round(score, 6), "ranking": reason}
|
||||
for score, candidate, reason in ranked[:maximum]
|
||||
]
|
||||
return descriptor, groups, len(ranked)
|
||||
|
||||
|
||||
def _search_replay_worker(
|
||||
candidate: str,
|
||||
provenance: str,
|
||||
step: str,
|
||||
result: str,
|
||||
maximum_candidates: int,
|
||||
) -> None:
|
||||
"""Replay one branch until it builds or exposes its next selector decision."""
|
||||
from engine.cdsl_engine.runtime import finalize_cdsl_execution, prepare_cdsl_execution
|
||||
from .selector_binding import _bound_selector, _selector_key
|
||||
|
||||
bound = read_json(Path(candidate))
|
||||
provenance_candidate = read_json(Path(provenance))
|
||||
source_selectors = _source_selector_map(provenance_candidate)
|
||||
execution = prepare_cdsl_execution(bound)
|
||||
if not execution.analysis.runtime_eligible:
|
||||
first = next((item for item in execution.analysis.feature_results if not item.executable), None)
|
||||
@@ -485,19 +1099,25 @@ def _search_replay_worker(candidate: str, step: str, result: str, maximum_candid
|
||||
active_body_id=active_body_id,
|
||||
)
|
||||
if resolution.status != "resolved":
|
||||
candidates, eligible_count = _search_candidate_records(
|
||||
token = placeholder.get(_SEARCH_TOKEN)
|
||||
descriptor, candidate_groups, group_count = _query_candidate_groups(
|
||||
provenance_candidate,
|
||||
str(token or f"{node.feature_id}:{location}"),
|
||||
source_selectors.get(str(token)),
|
||||
resolution,
|
||||
maximum=maximum_candidates,
|
||||
)
|
||||
if candidates:
|
||||
if candidate_groups:
|
||||
write_json(Path(result), {
|
||||
"status": "branchable_selector",
|
||||
"status": "branchable_query_group",
|
||||
"feature_id": node.feature_id,
|
||||
"source_location": location,
|
||||
"selector_token": token,
|
||||
"resolution_status": resolution.status,
|
||||
"diagnostic": resolution.diagnostic.as_dict() if resolution.diagnostic is not None else None,
|
||||
"candidates": candidates,
|
||||
"candidate_count": eligible_count,
|
||||
"query_group": descriptor,
|
||||
"candidate_groups": candidate_groups,
|
||||
"candidate_group_count": group_count,
|
||||
"bound_cdsl": bound,
|
||||
"selector_binding": evidence,
|
||||
})
|
||||
@@ -572,11 +1192,18 @@ def _search_replay_worker(candidate: str, step: str, result: str, maximum_candid
|
||||
})
|
||||
|
||||
|
||||
def _run_search_replay(candidate_path: Path, step: Path, *, timeout_seconds: float, maximum_candidates: int) -> dict[str, Any]:
|
||||
def _run_search_replay(
|
||||
candidate_path: Path,
|
||||
provenance_path: Path,
|
||||
step: Path,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
maximum_candidates: int,
|
||||
) -> dict[str, Any]:
|
||||
worker = step.with_suffix(".worker.json")
|
||||
outcome = _isolated(
|
||||
_search_replay_worker,
|
||||
(str(candidate_path), str(step), str(worker), maximum_candidates),
|
||||
(str(candidate_path), str(provenance_path), str(step), str(worker), maximum_candidates),
|
||||
worker,
|
||||
timeout_seconds,
|
||||
)
|
||||
@@ -596,6 +1223,8 @@ def _public_branch_summary(result: dict[str, Any], choices: list[dict[str, Any]]
|
||||
"status": result.get("status"),
|
||||
"feature_id": result.get("feature_id"),
|
||||
"source_location": result.get("source_location"),
|
||||
"query_group": result.get("query_group"),
|
||||
"candidate_group_count": result.get("candidate_group_count"),
|
||||
"reason": result.get("reason"),
|
||||
"resolution_status": result.get("resolution_status"),
|
||||
"choice_path": choices,
|
||||
@@ -630,7 +1259,10 @@ def _run_selector_search(
|
||||
return {"status": "disabled"}, None
|
||||
search_dir = directory / "selector-search"
|
||||
search_dir.mkdir(parents=True, exist_ok=True)
|
||||
pending: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [(deepcopy(geometry_candidate), [])]
|
||||
search_candidate = deepcopy(geometry_candidate)
|
||||
_annotate_selector_search_tokens(search_candidate)
|
||||
provenance_path = directory / "provenance.candidate.cdsl.json"
|
||||
pending: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [(search_candidate, [])]
|
||||
seen: set[str] = set()
|
||||
terminals: list[tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]], dict[str, Any] | None]] = []
|
||||
branch_summaries: list[dict[str, Any]] = []
|
||||
@@ -653,32 +1285,43 @@ def _run_selector_search(
|
||||
write_json(candidate_path, candidate)
|
||||
outcome = _run_search_replay(
|
||||
candidate_path,
|
||||
provenance_path,
|
||||
step_path,
|
||||
timeout_seconds=rebuild_timeout_seconds,
|
||||
maximum_candidates=maximum_candidates,
|
||||
)
|
||||
_write_rebuild_artifacts(search_dir, branch_name, outcome)
|
||||
if outcome.get("status") == "branchable_selector":
|
||||
candidates = list(outcome.get("candidates") or [])
|
||||
total = int(outcome.get("candidate_count") or len(candidates))
|
||||
if total > len(candidates):
|
||||
if outcome.get("status") == "branchable_query_group":
|
||||
groups = [item for item in outcome.get("candidate_groups") or [] if isinstance(item, dict)]
|
||||
total = int(outcome.get("candidate_group_count") or len(groups))
|
||||
if total > len(groups):
|
||||
candidates_truncated = True
|
||||
branch_summaries.append(_public_branch_summary(outcome, choices))
|
||||
for candidate_record in candidates:
|
||||
for group in groups:
|
||||
child = deepcopy(outcome["bound_cdsl"])
|
||||
feature_id = str(outcome["feature_id"])
|
||||
location = str(outcome["source_location"])
|
||||
source_feature = next(item for item in child["features"] if item.get("id") == feature_id)
|
||||
source_selector = dict(_searchable_selector_sites(source_feature)[
|
||||
next(index for index, (name, _selector) in enumerate(_searchable_selector_sites(source_feature)) if name == location)
|
||||
][1])
|
||||
forced = _forced_candidate_selector(source_selector, candidate_record)
|
||||
_replace_selector_site(child, feature_id, location, forced)
|
||||
token = outcome.get("selector_token")
|
||||
source_selector = next(
|
||||
(
|
||||
selector
|
||||
for _location, selector in _searchable_selector_sites(source_feature)
|
||||
if selector.get(_SEARCH_TOKEN) == token
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not isinstance(source_selector, dict):
|
||||
raise ValueError(f"search branch selector token is unavailable: {feature_id}:{token}")
|
||||
records = [item for item in group.get("records") or [] if isinstance(item, dict)]
|
||||
forced = _forced_candidate_selectors(source_selector, records)
|
||||
_replace_selector_group(child, feature_id, str(token), forced)
|
||||
pending.append((child, [*choices, {
|
||||
"feature_id": feature_id,
|
||||
"source_location": location,
|
||||
"record_id": candidate_record.get("record_id"),
|
||||
"score": candidate_record.get("score"),
|
||||
"source_location": outcome.get("source_location"),
|
||||
"query_group": deepcopy(outcome.get("query_group") or {}),
|
||||
"resolved_runtime_records": records,
|
||||
"score": group.get("score"),
|
||||
"ranking": deepcopy(group.get("ranking") or {}),
|
||||
}]))
|
||||
continue
|
||||
comparison = None
|
||||
@@ -731,6 +1374,8 @@ def _run_selector_search(
|
||||
gold_step,
|
||||
rebuild_timeout_seconds=rebuild_timeout_seconds,
|
||||
comparison_timeout_seconds=comparison_timeout_seconds,
|
||||
query_groups=choices,
|
||||
candidate_replay_kind="selector_search_branch",
|
||||
)
|
||||
report["selector_record"] = selector_record
|
||||
return report, selector_record if selector_record.get("status") == "strict_replayed" else None
|
||||
|
||||
Reference in New Issue
Block a user