Files
cdsl-cad/backend/engine/cdsl_engine/topology.py
T
likang 994d06aaea feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
2026-09-10 15:12:57 +08:00

2483 lines
121 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Runtime-neutral CDSL diagnostics, planning, and topology contracts.
This module deliberately has no build123d dependency. The planner and
selector resolver can therefore be used by validation, batch reporting, and
any geometry adapter without importing OCC objects.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from math import sqrt
from typing import Any, Iterable
from .selector_capabilities import (
known_selector_query_standard_library_versions,
known_selector_query_versions,
selector_query_capability,
)
from .specs import AxisSpec, PlaneSpec, Vector3, _length, _vector3, normalize_selector_geometry
@dataclass(frozen=True)
class RuntimeDiagnostic:
code: str
message: str
feature_id: str | None = None
detail: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
output: dict[str, Any] = {"code": self.code, "message": self.message}
if self.feature_id is not None:
output["feature_id"] = self.feature_id
if self.detail:
output["detail"] = self.detail
return output
_SELECTOR_PROVENANCE_DERIVATIONS = frozenset({
"continuation", "fragment", "merge", "intersection", "boundary", "replacement",
})
def selector_has_provenance_intent(selector: dict[str, Any]) -> bool:
"""Whether ``selector`` must use the FeatureScript provenance contract."""
intent = selector.get("selector_intent")
return isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC"
def selector_intent_policy(intent: dict[str, Any]) -> tuple[set[str], str]:
"""Return the supported derivations and requested cardinality policy."""
policy = intent.get("derivation_policy")
if not isinstance(policy, dict):
policy = {}
return (
{
value for value in policy.get("allowed") or ()
if value in _SELECTOR_PROVENANCE_DERIVATIONS
},
str(policy.get("multiplicity") or "one"),
)
def validate_selector_provenance_intent(selector: dict[str, Any]) -> RuntimeDiagnostic | None:
"""Return the common provenance gate failure before resolving topology."""
if not selector_has_provenance_intent(selector):
return None
intent = selector["selector_intent"]
legacy_version = selector.get("selector_intent_version")
if legacy_version is not None and legacy_version != intent.get("version"):
return RuntimeDiagnostic(
"selector_query_unsupported",
"Selector intent has conflicting nested and legacy version fields",
detail={"nested_version": intent.get("version"), "legacy_version": legacy_version},
)
source_query = intent.get("source_query")
source_version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None
if not isinstance(source_version, str) or not source_version or source_version == "0":
return RuntimeDiagnostic(
"selector_query_version_unknown",
"FeatureScript query source version is unavailable",
detail={"query_family": intent.get("query_family")},
)
standard_library = source_query.get("standard_library") if isinstance(source_query, dict) else None
standard_library_version = source_query.get("standard_library_version") if isinstance(source_query, dict) else None
if not isinstance(standard_library, str) or not standard_library or not isinstance(standard_library_version, str) or not standard_library_version:
return RuntimeDiagnostic(
"selector_query_version_unknown",
"FeatureScript query standard-library import or version is unavailable",
detail={"query_family": intent.get("query_family")},
)
capability = selector_query_capability(intent)
if capability is None:
family = str(intent.get("query_family") or "")
return RuntimeDiagnostic(
"selector_query_unsupported",
"FeatureScript query semantics are not registered for this source version",
detail={
"query_family": family,
"featurescript_version": source_version,
"verified_versions": list(known_selector_query_versions(family)),
"standard_library": standard_library,
"standard_library_version": standard_library_version,
"verified_standard_libraries": [
{"path": path, "version": version}
for path, version in known_selector_query_standard_library_versions(family)
],
},
)
allowed, multiplicity = selector_intent_policy(intent)
if multiplicity == "none" or not allowed:
return RuntimeDiagnostic(
"selector_query_unsupported",
"Selector derivation policy does not permit execution",
detail={
"query_family": intent.get("query_family"),
"multiplicity": multiplicity,
"allowed": sorted(allowed),
},
)
return None
@dataclass(frozen=True)
class CapabilityResult:
feature_id: str
atomic_id: str
resolved_status: str
required_capabilities: tuple[str, ...] = ()
blockers: tuple[RuntimeDiagnostic, ...] = ()
@property
def executable(self) -> bool:
return self.resolved_status == "executable"
def as_dict(self) -> dict[str, Any]:
return {
"feature_id": self.feature_id,
"atomic_id": self.atomic_id,
"resolved_status": self.resolved_status,
"required_capabilities": list(self.required_capabilities),
"blockers": [blocker.as_dict() for blocker in self.blockers],
}
@dataclass(frozen=True)
class FeaturePlanNode:
feature_id: str
atomic_id: str
name: str | None
depends_on: tuple[str, ...]
params: dict[str, Any]
selectors: tuple[dict[str, Any], ...]
sketch_id: str | None
declared_status: str | None
source_feature: dict[str, Any]
@dataclass
class FeatureResult:
feature_id: str
atomic_id: str
status: str
body_id: str | None = None
surface_id: str | None = None
context: PlaneSpec | AxisSpec | None = None
replay_definition: dict[str, Any] | None = None
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
output: dict[str, Any] = {
"feature_id": self.feature_id,
"atomic_id": self.atomic_id,
"status": self.status,
"diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics],
}
if self.body_id is not None:
output["body_id"] = self.body_id
if self.surface_id is not None:
output["surface_id"] = self.surface_id
if self.context is not None:
output["context"] = self.context.as_dict()
if self.replay_definition is not None:
output["replay_definition"] = self.replay_definition
return output
@dataclass(frozen=True)
class TopologyRecord:
"""Runtime-side signature of a topology item or context object.
``feature_id`` identifies the feature that produced this *snapshot*.
``owner_feature_ids`` is durable semantic provenance for an unchanged
current B-rep item. Boolean and dress-up operations replace OCC objects,
so keeping these concepts separate prevents a later mutation from making
every surviving face appear to be owned by that mutation.
"""
record_id: str
kind: str
feature_id: str
body_id: str | None = None
geometry: dict[str, Any] = field(default_factory=dict)
value: Any = None
owner_feature_ids: tuple[str, ...] = ()
# Builder-produced roles describe a particular result subshape. They are
# deliberately separate from the geometric signature: equal geometry does
# not prove that two faces have the same feature-output meaning.
output_roles: tuple[str, ...] = ()
# Generated roles may carry the direct feature-output role that the kernel
# operation transformed. This is semantic provenance, not a stable-id
# shortcut: the resolver still requires the exact active result snapshot.
output_role_sources: tuple[tuple[str, str, str], ...] = ()
# A source profile anchor is a transient, exact B-rep subshape built from
# one declared FeatureScript sketch entity. It is not active topology and
# never participates in geometry fallback selection.
source_entity: tuple[str, str] | None = None
# A direct prism can generate an edge from a profile vertex jointly named
# by its incident source entities. The tuple is canonicalized by the
# adapter before registration; a partial set is intentionally not equal.
source_entities: tuple[tuple[str, str], ...] = ()
# A transient record is retained solely as a historical boolean input.
# It can supply exact source handles to a later builder relation, but can
# never be returned as current/selectable model topology.
transient: bool = False
@property
def owners(self) -> tuple[str, ...]:
"""Return durable provenance, retaining compatibility for contexts."""
return self.owner_feature_ids or (self.feature_id,)
def public_dict(self) -> dict[str, Any]:
output: dict[str, Any] = {
"record_id": self.record_id,
"kind": self.kind,
"feature_id": self.feature_id,
"geometry": self.geometry,
}
if self.body_id is not None:
output["body_id"] = self.body_id
if self.owner_feature_ids:
output["owner_feature_ids"] = list(self.owner_feature_ids)
if self.output_roles:
output["output_roles"] = list(self.output_roles)
if self.output_role_sources:
output["output_role_sources"] = [
{"output_role": role, "owner_feature_id": owner, "source_output_role": source_role}
for role, owner, source_role in self.output_role_sources
]
if self.source_entity is not None:
output["source_entity"] = {
"sketch_id": self.source_entity[0],
"entity_id": self.source_entity[1],
}
if self.source_entities:
output["source_entities"] = [
{"sketch_id": sketch_id, "entity_id": entity_id}
for sketch_id, entity_id in self.source_entities
]
if self.transient:
output["transient"] = True
return output
@dataclass(frozen=True)
class TopologyDeltaRelation:
"""One opaque kernel-history relationship for a topology subshape.
Geometry adapters retain ownership of the values in this structure. They
are intentionally opaque to the runtime: a build123d/OCC adapter may use
``TopoDS_Shape`` values while another adapter can use its native handles.
The registry only asks whether a handle is exactly the same topology item;
it never uses this contract to score nearby geometry.
"""
event: str
kind: str
source_value: Any
result_values: tuple[Any, ...] = ()
output_role: str | None = None
derivation: str | None = None
source_role: str | None = None
result_role: str | None = None
source_slot: str | None = None
result_slot: str | None = None
coverage: str = "complete"
status: str = "proven"
# ``kind`` remains the compatibility shorthand for same-type history.
# Builders may also generate edge -> face or vertex -> edge relations.
source_kind: str | None = None
result_kind: str | None = None
def __post_init__(self) -> None:
if self.event not in {"preserved", "modified", "generated", "deleted"}:
raise ValueError(f"unsupported topology delta event {self.event!r}")
if self.kind not in {"face", "edge", "vertex"}:
raise ValueError(f"unsupported topology delta kind {self.kind!r}")
for name, value in (("source_kind", self.source_kind), ("result_kind", self.result_kind)):
if value is not None and value not in {"face", "edge", "vertex"}:
raise ValueError(f"unsupported topology delta {name} {value!r}")
if self.event == "deleted" and (self.result_values or self.output_role is not None):
raise ValueError("deleted topology delta relations cannot have result values or an output role")
if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role):
raise ValueError("topology delta output_role must be a non-empty string when provided")
derivation = self.derivation
if derivation is None:
derivation = {
"preserved": "continuation",
"modified": "fragment" if len(self.result_values) > 1 else "continuation",
"generated": "boundary",
"deleted": "replacement",
}[self.event]
object.__setattr__(self, "derivation", derivation)
if derivation not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"}:
raise ValueError(f"unsupported topology derivation {derivation!r}")
if self.coverage not in {"complete", "partial", "none"}:
raise ValueError(f"unsupported topology coverage {self.coverage!r}")
if self.status not in {"proven", "unknown", "rejected"}:
raise ValueError(f"unsupported topology relation status {self.status!r}")
@dataclass(frozen=True)
class TopologySectionRelation:
"""One source-qualified boolean section edge.
``SectionEdges()`` alone proves that a boolean produced an edge, but not
which input faces produced it. OCC can provide that additional fact when
the exact section edge is returned by ``Generated(face)`` for each of two
input faces. Keep this as a separate relation because the ordinary delta
relation contract is intentionally one-source-to-many-results.
"""
source_values: tuple[Any, Any]
result_value: Any
coverage: str = "complete"
status: str = "proven"
def __post_init__(self) -> None:
if self.source_values[0] is self.source_values[1]:
raise ValueError("section relation requires two distinct source faces")
if self.coverage not in {"complete", "partial", "none"}:
raise ValueError(f"unsupported section relation coverage {self.coverage!r}")
if self.status not in {"proven", "unknown", "rejected"}:
raise ValueError(f"unsupported section relation status {self.status!r}")
@dataclass(frozen=True)
class TopologyDelta:
"""Kernel-backed topology history for one adapter operation.
``operation`` is evidence only. The runtime transfers durable provenance
solely from a unique exact relationship, never from an operation name or a
geometric resemblance.
"""
operation: str
relations: tuple[TopologyDeltaRelation, ...] = ()
# Boolean section edges are distinct from source continuations. INTERSECT
# selectors may only rely on this exact builder evidence.
section_values: tuple[Any, ...] = ()
# A section value is executable provenance only when this relation proves
# both source faces and the exact final edge. Unqualified values remain
# diagnostic facts in ``section_values``.
section_relations: tuple[TopologySectionRelation, ...] = ()
history_status: str = "proven"
history_reason: str | None = None
def __post_init__(self) -> None:
if self.history_status not in {"proven", "partial", "unknown", "rejected"}:
raise ValueError(f"unsupported topology delta history status {self.history_status!r}")
@dataclass(frozen=True)
class TopologyLineage:
"""An N:M semantic edge backed by an adapter history relation."""
source_record_ids: tuple[str, ...]
result_record_ids: tuple[str, ...]
derivation: str
evidence: str
coverage: str
status: str
operation: str
output_role: str | None = None
feature_id: str | None = None
source_kind: str | None = None
result_kind: str | None = None
def as_dict(self) -> dict[str, Any]:
result = {
"source_record_ids": list(self.source_record_ids),
"result_record_ids": list(self.result_record_ids),
"derivation": self.derivation,
"evidence": self.evidence,
"coverage": self.coverage,
"status": self.status,
"operation": self.operation,
}
if self.output_role is not None:
result["output_role"] = self.output_role
if self.feature_id is not None:
result["feature_id"] = self.feature_id
if self.source_kind is not None:
result["source_kind"] = self.source_kind
if self.result_kind is not None:
result["result_kind"] = self.result_kind
return result
@dataclass(frozen=True)
class SelectorResolution:
selector: dict[str, Any]
status: str
record: TopologyRecord | None = None
# ``records`` carries policy-authorized 1:N selector results. ``record``
# remains the compatibility field for a unique selection and context use.
records: tuple[TopologyRecord, ...] = ()
candidates: tuple[dict[str, Any], ...] = ()
diagnostic: RuntimeDiagnostic | None = None
# This is assigned by the resolution branch, never reconstructed from
# serialized selector fields. That keeps audit evidence honest when a
# legacy selector and a provenance selector share the same shape.
resolution_mode: str = "unresolved"
evidence: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
output = {
"selector": self.selector,
"status": self.status,
"resolution_mode": self.resolution_mode,
"candidates": list(self.candidates),
}
if self.record is not None:
output["record"] = self.record.public_dict()
output["selected"] = self.record.public_dict()
score = next(
(candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id),
None,
)
if score is not None:
output["score"] = score
if self.records:
output["records"] = [record.public_dict() for record in self.records]
if self.diagnostic is not None:
output["diagnostic"] = self.diagnostic.as_dict()
if self.evidence:
output["evidence"] = self.evidence
return output
class TopologyRegistry:
"""Feature-scoped context/topology registry with explainable matching."""
def __init__(self) -> None:
self._records: list[TopologyRecord] = []
self._by_feature: dict[str, list[TopologyRecord]] = {}
self._active_body_id: str | None = None
self._topology_deltas: list[dict[str, Any]] = []
self._lineage: list[TopologyLineage] = []
# #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer
# 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何
# 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的
# stable_id 引用可以解析到 active body 内的新形态。
self._successors: dict[str, list[str]] = {}
def register(self, record: TopologyRecord) -> None:
self._records.append(record)
self._by_feature.setdefault(record.feature_id, []).append(record)
def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]:
return tuple(self._by_feature.get(feature_id, ()))
def records(self) -> tuple[TopologyRecord, ...]:
return tuple(self._records)
def topology_deltas(self) -> tuple[dict[str, Any], ...]:
"""Return serializable evidence derived from exact adapter history."""
return tuple(self._topology_deltas)
def lineage(self) -> tuple[TopologyLineage, ...]:
"""Return kernel-backed N:M lineage without heuristic successors."""
return tuple(self._lineage)
@classmethod
def from_public_snapshot(
cls,
records: Iterable[dict[str, Any]],
topology_deltas: Iterable[dict[str, Any]] = (),
) -> "TopologyRegistry":
"""Rehydrate resolver facts from a prior session report.
Prefix binding uses this only as a diagnostic compatibility adapter.
It receives exported record IDs and already-recorded kernel relations,
never geometry guesses promoted to lineage. The live execution path
still owns the opaque OCC values and records the original facts.
"""
registry = cls()
for item in records:
if not isinstance(item, dict):
continue
role_sources = []
for source in item.get("output_role_sources") or ():
if not isinstance(source, dict):
continue
role, owner, source_role = (
source.get("output_role"), source.get("owner_feature_id"), source.get("source_output_role"),
)
if all(isinstance(value, str) for value in (role, owner, source_role)):
role_sources.append((role, owner, source_role))
record_id = str(item.get("record_id") or "")
kind = str(item.get("kind") or "")
# Old prefix reports did not export context feature IDs. Retain
# a synthetic report-local producer for legacy geometric binding;
# provenance selectors still require their own source relation.
feature_id = str(item.get("feature_id") or "report_snapshot")
if not record_id or not kind:
continue
source_entity = item.get("source_entity")
source_pair = (
(str(source_entity["sketch_id"]), str(source_entity["entity_id"]))
if isinstance(source_entity, dict)
and isinstance(source_entity.get("sketch_id"), str)
and isinstance(source_entity.get("entity_id"), str)
else None
)
source_pairs = tuple(
(str(source["sketch_id"]), str(source["entity_id"]))
for source in item.get("source_entities") or ()
if isinstance(source, dict)
and isinstance(source.get("sketch_id"), str)
and isinstance(source.get("entity_id"), str)
)
registry.register(TopologyRecord(
record_id=record_id,
kind=kind,
feature_id=feature_id,
body_id=str(item["body_id"]) if item.get("body_id") is not None else None,
geometry=dict(item.get("geometry") or {}),
# Record IDs are opaque-but-exact within one exported report.
value=record_id,
owner_feature_ids=tuple(str(value) for value in item.get("owner_feature_ids") or ()),
output_roles=tuple(str(value) for value in item.get("output_roles") or ()),
output_role_sources=tuple(role_sources),
source_entity=source_pair,
source_entities=source_pairs,
transient=bool(item.get("transient")),
))
for delta in topology_deltas:
if not isinstance(delta, dict):
continue
feature_id = str(delta.get("feature_id") or "")
operation = str(delta.get("operation") or "")
for item in delta.get("lineage") or ():
if not isinstance(item, dict):
continue
registry._lineage.append(TopologyLineage(
source_record_ids=tuple(str(value) for value in item.get("source_record_ids") or ()),
result_record_ids=tuple(str(value) for value in item.get("result_record_ids") or ()),
derivation=str(item.get("derivation") or ""),
evidence=str(item.get("evidence") or "kernel_history"),
coverage=str(item.get("coverage") or "partial"),
status=str(item.get("status") or "unknown"),
operation=str(item.get("operation") or operation),
output_role=str(item["output_role"]) if item.get("output_role") is not None else None,
feature_id=str(item.get("feature_id") or feature_id) or None,
source_kind=str(item["source_kind"]) if item.get("source_kind") is not None else None,
result_kind=str(item["result_kind"]) if item.get("result_kind") is not None else None,
))
return registry
def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord:
kind = "plane" if isinstance(context, PlaneSpec) else "axis"
record = TopologyRecord(
record_id=f"{feature_id}:{kind}",
kind=kind,
feature_id=feature_id,
geometry=context.as_dict(),
value=context,
)
self.register(record)
return record
def register_transient_snapshot(
self,
feature_id: str,
snapshot_id: str,
records: Iterable[TopologyRecord],
*,
topology_delta: TopologyDelta,
anchors: Iterable[TopologyRecord],
) -> tuple[TopologyRecord, ...]:
"""Register a non-selectable direct-prism input for one following boolean.
A primary ``REMOVE`` builds its tool only long enough to feed a cut;
unlike a ``new_body`` tool, that B-rep never becomes active model
topology. Its exact faces and direct profile anchors are nevertheless
needed to prove a source-qualified section edge. This records that
limited historical snapshot without advancing ``_active_body_id`` or
enabling geometry/stable-ID selection against the tool.
"""
if (
topology_delta.operation != "extrude"
or topology_delta.history_status != "proven"
or not topology_delta.relations
):
raise ValueError("transient snapshot requires complete direct-prism history")
raw_anchors = list(anchors)
raw_records = list(records)
if not snapshot_id or not raw_anchors or not raw_records:
raise ValueError("transient snapshot requires source anchors and result topology")
transient_anchors = [
TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=feature_id,
body_id=None,
geometry=dict(record.geometry),
value=record.value,
owner_feature_ids=(feature_id,),
output_roles=record.output_roles,
output_role_sources=record.output_role_sources,
source_entity=record.source_entity,
source_entities=record.source_entities,
transient=True,
)
for record in raw_anchors
]
for anchor in transient_anchors:
self.register(anchor)
current = [
TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=feature_id,
body_id=snapshot_id,
geometry=dict(record.geometry),
value=record.value,
owner_feature_ids=(feature_id,),
output_roles=record.output_roles,
output_role_sources=record.output_role_sources,
source_entity=record.source_entity,
source_entities=record.source_entities,
transient=True,
)
for record in raw_records
]
(
_exact_predecessors,
_exact_successors,
_kernel_covered_predecessors,
exact_output_roles,
exact_output_role_sources,
delta_evidence,
) = self._exact_delta_links(topology_delta, transient_anchors, current)
registered = [
TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=feature_id,
body_id=snapshot_id,
geometry=dict(record.geometry),
value=record.value,
owner_feature_ids=(feature_id,),
output_roles=tuple(sorted({*record.output_roles, *exact_output_roles.get(record.record_id, ())})),
output_role_sources=tuple(sorted({*record.output_role_sources, *exact_output_role_sources.get(record.record_id, ())})),
source_entity=record.source_entity,
source_entities=record.source_entities,
transient=True,
)
for record in current
]
for record in registered:
self.register(record)
self._append_delta_evidence(
feature_id, topology_delta, transient_anchors, current, delta_evidence,
)
return tuple(registered)
def _append_delta_evidence(
self,
feature_id: str,
topology_delta: TopologyDelta,
previous: Iterable[TopologyRecord],
current: Iterable[TopologyRecord],
delta_evidence: list[dict[str, Any]] | None,
) -> None:
"""Persist exact adapter evidence after any registered snapshot."""
if delta_evidence is None:
return
previous_records = list(previous)
current_records = list(current)
operation_lineage = [
TopologyLineage(
source_record_ids=tuple(item["source_record_ids"]),
result_record_ids=tuple(item["result_record_ids"]),
derivation=str(item["derivation"]),
evidence="kernel_history",
coverage=str(item["coverage"]),
status=str(item["lineage_status"]),
operation=topology_delta.operation,
output_role=item.get("output_role"),
feature_id=feature_id,
source_kind=item.get("source_kind"),
result_kind=item.get("result_kind"),
)
for item in delta_evidence
]
self._lineage.extend(operation_lineage)
self._topology_deltas.append({
"feature_id": feature_id,
"operation": topology_delta.operation,
"input_snapshot_ids": sorted({str(record.body_id) for record in previous_records if record.body_id}),
"output_snapshot_ids": sorted({str(record.body_id) for record in current_records if record.body_id}),
"input_body_members": sorted({str(record.body_id) for record in previous_records if record.body_id}),
"output_body_members": sorted({str(record.body_id) for record in current_records if record.body_id}),
"history_status": topology_delta.history_status,
**({"history_reason": topology_delta.history_reason} if topology_delta.history_reason else {}),
"relations": delta_evidence,
"lineage": [lineage.as_dict() for lineage in operation_lineage],
})
def replace_body_topology(
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
*, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None,
additional_predecessors: Iterable[TopologyRecord] = (),
) -> None:
self.replace_body_topologies(
feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta,
additional_predecessors=additional_predecessors,
)
def replace_body_topologies(
self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]],
*, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None,
additional_predecessors: Iterable[TopologyRecord] = (),
) -> None:
"""Record a fresh B-rep snapshot after a feature mutates the body.
OCC topology object identity is invalidated by most body mutations.
We therefore keep old objects out of active selector resolution but
carry their semantic owners forward when, and only when, one current
object has one geometrically equivalent predecessor. A changed or
split object intentionally becomes owned by this feature instead of
being guessed as belonging to an older one.
``active_body_id`` names the whole-body group when ``body_id`` is a
member of a multi-solid body (issue #7): the group id keeps the next
mutation's predecessor lookup scoped to every solid of the previous
body, while each member keeps its own ``body:{feature}:{index}`` id.
"""
active_previous = [
record for record in self._records
if self._active_body_id is not None and record.body_id is not None
and (
record.body_id == self._active_body_id
or record.body_id.startswith(f"{self._active_body_id}:")
)
]
# A pattern COPY can carry a chain of exact transform/boolean builder
# histories before its final aggregate snapshot is registered. These
# temporary records are valid predecessors only for that documented
# kernel-history bridge. They are deliberately excluded from geometric
# fallback matching: an equal-looking final face never proves that it
# belongs to one particular copy instance.
transient_previous = list(additional_predecessors)
previous = [*active_previous, *transient_previous]
# 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们
# 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。
# pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index
# 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。
current = [(body_id, list(records)) for body_id, records in bodies]
current_records = [record for _body_id, records in current for record in records]
(
exact_predecessors,
exact_successors,
kernel_covered_predecessors,
exact_output_roles,
exact_output_role_sources,
delta_evidence,
) = self._exact_delta_links(
topology_delta, previous, current_records,
)
previous_member_ids = {
suffix for record in active_previous
for suffix in [str(record.body_id).rsplit(":", 1)[-1]]
if suffix.isdigit()
}
use_member_indexes = len(current) > 1 and previous_member_ids
consumed_predecessors: set[str] = set()
registered: list[TopologyRecord] = []
for body_id, records in current:
member_id = str(body_id).rsplit(":", 1)[-1]
local_predecessors = [
record for record in active_previous
if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id
]
for record in records:
exact_predecessor_id = exact_predecessors.get(record.record_id)
# Member order is a useful isolation boundary for geometric
# fallback matching, especially for coincident pattern
# copies. It is not a provenance boundary when a kernel
# builder explicitly relates one source subshape to one
# result subshape: boolean/delete lifecycle can remove an
# earlier member and shift a surviving source to another
# member index. A unique OCC continuation remains exact
# evidence across that index change.
predecessor = next(
(prior for prior in previous if prior.record_id == exact_predecessor_id),
None,
)
if predecessor is None and record.record_id not in exact_predecessors:
predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors)
owners = predecessor.owners if predecessor is not None else (feature_id,)
output_roles = set(record.output_roles)
output_roles.update(exact_output_roles.get(record.record_id, ()))
output_role_sources = set(record.output_role_sources)
output_role_sources.update(exact_output_role_sources.get(record.record_id, ()))
# A feature-output role can survive a later operation only
# through the same unique kernel continuation used for owner
# provenance. Geometry equivalence alone never carries it.
if predecessor is not None and record.record_id in exact_predecessors:
output_roles.update(predecessor.output_roles)
if predecessor is not None and record.record_id not in exact_predecessors:
consumed_predecessors.add(predecessor.record_id)
registered.append(TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=feature_id,
body_id=body_id,
geometry=dict(record.geometry),
value=record.value,
owner_feature_ids=owners,
output_roles=tuple(sorted(output_roles)),
output_role_sources=tuple(sorted(output_role_sources)),
source_entity=record.source_entity,
source_entities=record.source_entities,
))
for record in registered:
self.register(record)
for predecessor_id, successor_ids in exact_successors.items():
known = self._successors.setdefault(predecessor_id, [])
for successor_id in successor_ids:
if successor_id not in known:
known.append(successor_id)
if topology_delta is not None:
self._append_delta_evidence(
feature_id, topology_delta, previous, current_records, delta_evidence,
)
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
# 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列
# (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。
for prior in previous:
if (
prior.record_id in consumed_predecessors
or prior.record_id in exact_successors
or prior.record_id in kernel_covered_predecessors
):
continue
candidates = sorted(
(
(self._evolved_drift(prior, record), record.record_id)
for record in registered if self._evolved_equivalent(prior, record)
),
key=lambda item: item[0],
)
if not candidates:
continue
best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None)
if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]):
self._successors[prior.record_id] = [best[1]]
self._active_body_id = active_body_id or body_id
@staticmethod
def _same_topology_value(left: Any, right: Any) -> bool:
"""Compare adapter handles only through their exact topology identity."""
left_value = getattr(left, "wrapped", left)
right_value = getattr(right, "wrapped", right)
if left_value is right_value:
return True
for candidate, other in ((left_value, right_value), (right_value, left_value)):
for method_name in ("IsSame", "is_same"):
method = getattr(candidate, method_name, None)
if callable(method):
try:
return bool(method(other))
except (AttributeError, TypeError, ValueError):
continue
return False
@classmethod
def _exact_delta_links(
cls,
topology_delta: TopologyDelta | None,
previous: list[TopologyRecord],
current: list[TopologyRecord],
) -> tuple[
dict[str, str],
dict[str, list[str]],
set[str],
dict[str, tuple[str, ...]],
dict[str, tuple[tuple[str, str, str], ...]],
list[dict[str, Any]] | None,
]:
"""Bind opaque kernel history to snapshots without geometric guessing.
Ownership transfer is intentionally limited to a single source item and
a single output item. Split/merge history remains useful evidence, but
has no unique owner continuation until a later operation-specific
contract can express it.
"""
if topology_delta is None:
return {}, {}, set(), {}, {}, None
candidate_sources: dict[str, set[str]] = {}
kernel_covered_predecessors: set[str] = set()
output_roles: dict[str, set[str]] = {}
output_role_sources: dict[str, set[tuple[str, str, str]]] = {}
relation_links: list[tuple[str, str] | None] = []
evidence: list[dict[str, Any]] = []
for relation in topology_delta.relations:
source_kind = relation.source_kind or relation.kind
result_kind = relation.result_kind or relation.kind
sources = [
record for record in previous
if record.kind == source_kind and cls._same_topology_value(record.value, relation.source_value)
]
outputs = [
record for record in current
if record.kind == result_kind
and any(cls._same_topology_value(record.value, value) for value in relation.result_values)
]
item = {
"event": relation.event,
# Keep the legacy shorthand for same-kind consumers while
# exposing both sides of a cross-topology builder relation.
"kind": relation.kind,
"source_kind": source_kind,
"result_kind": result_kind,
"source_record_ids": [record.record_id for record in sources],
"result_record_ids": [record.record_id for record in outputs],
"proof": "kernel_history",
"derivation": relation.derivation,
"coverage": relation.coverage if len(outputs) == len(relation.result_values) else "partial",
"lineage_status": (
relation.status if topology_delta.history_status == "proven" else "unknown"
),
}
for field_name in ("source_role", "result_role", "source_slot", "result_slot"):
value = getattr(relation, field_name)
if value is not None:
item[field_name] = value
if relation.output_role is not None:
item["output_role"] = relation.output_role
role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1
item["output_role_status"] = (
"unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot"
)
if role_is_unique:
output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role)
for source in sources:
for source_role in source.output_roles:
for source_owner in source.owners:
output_role_sources.setdefault(outputs[0].record_id, set()).add(
(relation.output_role, source_owner, source_role)
)
if len(sources) == 1:
kernel_covered_predecessors.add(sources[0].record_id)
can_transfer = (
relation.event in {"preserved", "modified"}
and len(relation.result_values) == 1
and len(sources) == 1
and len(outputs) == 1
)
if can_transfer:
source_id, result_id = sources[0].record_id, outputs[0].record_id
candidate_sources.setdefault(result_id, set()).add(source_id)
relation_links.append((source_id, result_id))
else:
relation_links.append(None)
evidence.append(item)
for relation in topology_delta.section_relations:
sources = [
[
record for record in previous
if record.kind == "face" and cls._same_topology_value(record.value, source_value)
]
for source_value in relation.source_values
]
outputs = [
record for record in current
if record.kind == "edge" and cls._same_topology_value(record.value, relation.result_value)
]
source_records = [records[0] for records in sources if len(records) == 1]
complete = (
len(source_records) == 2
and len({record.record_id for record in source_records}) == 2
and not cls._same_topology_value(source_records[0].value, source_records[1].value)
and len(outputs) == 1
and topology_delta.history_status == "proven"
and relation.coverage == "complete"
and relation.status == "proven"
)
evidence.append({
"event": "generated",
"kind": "edge",
"source_kind": "face",
"result_kind": "edge",
"source_record_ids": [record.record_id for record in source_records],
"result_record_ids": [record.record_id for record in outputs],
"proof": "kernel_history",
"derivation": "intersection",
"coverage": "complete" if complete else "partial",
"lineage_status": "proven" if complete else "unknown",
"section_edge": True,
"source_qualified": True,
"status": "source_qualified_section_edge" if complete else "incomplete_source_qualified_section_edge",
})
if topology_delta.section_values:
section_outputs = [
record for record in current
if record.kind == "edge"
and any(cls._same_topology_value(record.value, value) for value in topology_delta.section_values)
]
evidence.append({
"event": "generated",
"kind": "edge",
"source_kind": "face",
"result_kind": "edge",
"source_record_ids": [],
"result_record_ids": [record.record_id for record in section_outputs],
"proof": "kernel_history",
"derivation": "intersection",
"coverage": "complete" if len(section_outputs) == len(topology_delta.section_values) else "partial",
"lineage_status": (
"proven"
if topology_delta.history_status == "proven" and len(section_outputs) == len(topology_delta.section_values)
else "unknown"
),
"section_edge": True,
"status": "recorded_section_edge",
})
predecessors = {
result_id: next(iter(source_ids))
for result_id, source_ids in candidate_sources.items()
if len(source_ids) == 1
}
successors: dict[str, list[str]] = {}
for result_id, source_id in predecessors.items():
successors.setdefault(source_id, []).append(result_id)
for item, relation, link in zip(evidence, topology_delta.relations, relation_links):
if link is not None:
_source_id, result_id = link
item["status"] = (
"unique_exact_continuation"
if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation"
)
elif relation.event in {"preserved", "modified"}:
item["status"] = "non_unique_or_incomplete"
else:
item["status"] = "recorded_without_owner_transfer"
if item["coverage"] != "complete" or relation.status != "proven":
item["lineage_status"] = "unknown"
return (
predecessors,
successors,
kernel_covered_predecessors,
{record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()},
{record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()},
evidence,
)
@staticmethod
def _record_is_active(record: TopologyRecord, active_body_id: str | None) -> bool:
return not record.transient and (active_body_id is None or (
record.body_id == active_body_id
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
))
def _operation_component(
self,
seed: TopologyLineage,
*,
output_role: str | None = None,
unqualified_output_role: bool = False,
result_connected_only: bool = False,
) -> tuple[TopologyLineage, ...]:
"""Return the connected N:M relation component for one operation.
A builder can report A -> R and B -> R as two API callbacks. Treating
either callback in isolation would mislabel the operation as 1:1 and
let a selector bypass merge policy. Components are connected through
exact final-snapshot records, never through geometric similarity.
A single builder may also expose unrelated cross-topology facts, or
intermediate handles that do not survive in the registered output.
Those remain diagnostics in ``TopologyDelta`` but cannot turn one
face -> face continuation into an apparent face fragment. Cardinality
is therefore measured per complete/proven source/result topology pair.
"""
same_operation = [
edge for edge in self._lineage
if edge.feature_id == seed.feature_id and edge.operation == seed.operation
and edge.evidence == "kernel_history"
and edge.coverage == "complete" and edge.status == "proven"
and edge.source_kind == seed.source_kind and edge.result_kind == seed.result_kind
and (output_role is None or edge.output_role == output_role)
and (not unqualified_output_role or edge.output_role is None)
]
component: list[TopologyLineage] = []
record_ids = set(seed.result_record_ids) if result_connected_only else (
set(seed.source_record_ids) | set(seed.result_record_ids)
)
pending = True
while pending:
pending = False
for edge in same_operation:
edge_ids = set(edge.result_record_ids) if result_connected_only else (
set(edge.source_record_ids) | set(edge.result_record_ids)
)
if edge not in component and record_ids.intersection(edge_ids):
component.append(edge)
before = len(record_ids)
record_ids.update(edge_ids)
pending = pending or len(record_ids) != before
return tuple(component)
def _operation_cardinality_error(
self,
edge: TopologyLineage,
*,
allowed: set[str],
source_record_id: str,
output_role: str | None = None,
unqualified_output_role: bool = False,
result_connected_only: bool = False,
) -> RuntimeDiagnostic | None:
"""Reject relation components whose N:M semantics the selector disallows.
Builder APIs commonly surface a merge as separate callbacks. This
applies the same component-level policy whether a selector starts at a
stable source or directly names an output role.
"""
component = self._operation_component(
edge,
output_role=output_role,
unqualified_output_role=unqualified_output_role,
result_connected_only=result_connected_only,
)
component_sources = set().union(*(set(item.source_record_ids) for item in component))
component_results = set().union(*(set(item.result_record_ids) for item in component))
required = set()
if len(component_sources) > 1:
required.add("merge")
if len(component_results) > 1:
required.add("fragment")
if required.issubset(allowed):
return None
return RuntimeDiagnostic(
"selector_relation_non_unique",
"The topology operation's complete cardinality is not allowed by this selector policy",
detail={
"source_record_id": source_record_id,
"operation": edge.operation,
"derivation": edge.derivation,
"required": sorted(required),
"allowed": sorted(allowed),
"source_count": len(component_sources),
"result_count": len(component_results),
},
)
def _intent_lineage_successors(
self,
source_record_id: str,
*,
allowed: set[str],
active_body_id: str | None,
initial_output_role: str | None = None,
target_kind: str | None = None,
) -> tuple[list[TopologyRecord], list[TopologyLineage], RuntimeDiagnostic | None]:
"""Traverse complete kernel lineage and enforce operation cardinality.
``all_fragments`` is deliberately stricter than collecting whatever
active descendants happen to be registered: every branch reached from
the source must have complete, proven evidence.
"""
pending = [source_record_id]
visited = {source_record_id}
result_ids: set[str] = set()
used: list[TopologyLineage] = []
while pending:
current = pending.pop(0)
outgoing = [
edge for edge in self._lineage
if current in edge.source_record_ids
and (target_kind is None or edge.result_kind == target_kind)
]
role_scope = initial_output_role if current == source_record_id else None
unqualified_role_scope = current == source_record_id and role_scope is None
if role_scope is not None:
outgoing = [edge for edge in outgoing if edge.output_role == role_scope]
elif unqualified_role_scope:
# A direct prism exposes distinct cap-edge mappings through
# explicit output roles. They are not fragments of the
# unqualified edge -> side-face SWEPT relation.
outgoing = [edge for edge in outgoing if edge.output_role is None]
# An adapter can report intermediate builder handles which are not
# members of the final snapshot. They carry ``partial`` evidence
# with no record id and are retained in the delta for diagnosis,
# but cannot be a reachable lineage branch. In contrast, a
# partial relation that does bind a final record, or a complete
# deletion, remains visible below and blocks resolution.
outgoing = [
edge for edge in outgoing
if edge.result_record_ids or edge.coverage == "complete"
]
if not outgoing:
if role_scope is not None:
return [], used, RuntimeDiagnostic(
"selector_kernel_history_missing",
"No complete builder relation proves the requested source-profile output role",
detail={
"source_record_id": current,
"output_role": role_scope,
"allowed": sorted(allowed),
},
)
continue
for edge in outgoing:
if edge.evidence != "kernel_history" or edge.coverage != "complete" or edge.status != "proven":
return [], used, RuntimeDiagnostic(
"selector_kernel_history_missing",
"Topology lineage is incomplete or unavailable for a reachable source branch",
detail={"source_record_id": current, "operation": edge.operation, "coverage": edge.coverage, "status": edge.status},
)
cardinality_error = self._operation_cardinality_error(
edge,
allowed=allowed,
source_record_id=current,
output_role=role_scope,
unqualified_output_role=unqualified_role_scope,
)
if edge.derivation not in allowed or cardinality_error is not None:
detail = cardinality_error.detail if cardinality_error is not None else {
"source_record_id": current,
"operation": edge.operation,
"derivation": edge.derivation,
"required": [],
"allowed": sorted(allowed),
}
return [], used, RuntimeDiagnostic(
"selector_relation_non_unique",
"The topology operation's complete cardinality is not allowed by this selector policy",
detail=detail,
)
if edge not in used:
used.append(edge)
for record_id in edge.result_record_ids:
result_ids.add(record_id)
if record_id not in visited:
visited.add(record_id)
pending.append(record_id)
if not used:
return [], used, RuntimeDiagnostic(
"selector_kernel_history_missing",
"No complete proven lineage starts at the selector source",
detail={"source_record_id": source_record_id, "allowed": sorted(allowed)},
)
active = [
record for record in self._records
if record.record_id in result_ids and self._record_is_active(record, active_body_id)
]
if not active:
return [], used, RuntimeDiagnostic(
"selector_body_member_inactive",
"The proven selector lineage does not reach an active body member",
detail={"source_record_id": source_record_id, "active_body_id": active_body_id},
)
return active, used, None
@staticmethod
def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
try:
return abs(float(left) - float(right)) <= tolerance
except (TypeError, ValueError):
return False
@classmethod
def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
try:
first = _vector3(left, field_name="prior topology geometry")
second = _vector3(right, field_name="current topology geometry")
except ValueError:
return False
return all(abs(a - b) <= tolerance for a, b in zip(first, second))
@classmethod
def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool:
"""Check a complete, orientation-aware snapshot signature.
This is intentionally much stricter than selector scoring. Selector
scoring may compare partial source evidence; provenance transfer must
never manufacture ownership from a merely similar candidate.
"""
if prior.kind != current.kind:
return False
left, right = prior.geometry, current.geometry
for key in ("surface_type", "curve_type"):
if left.get(key) != right.get(key):
return False
for key in ("bbox_mm", "center_mm", "normal", "plane_normal"):
if key in left or key in right:
if key not in left or key not in right:
return False
left_value, right_value = left[key], right[key]
if key == "bbox_mm":
if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)):
return False
if len(left_value) != 6 or len(right_value) != 6:
return False
if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)):
return False
elif not cls._vectors_equal(left_value, right_value):
return False
for key in ("area_mm2", "length_mm", "plane_offset_mm"):
if key in left or key in right:
if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]):
return False
for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"):
if key in left or key in right:
if key not in left or key not in right or left[key] != right[key]:
return False
left_start, left_end = left.get("start_mm"), left.get("end_mm")
right_start, right_end = right.get("start_mm"), right.get("end_mm")
if any(value is not None for value in (left_start, left_end, right_start, right_end)):
if None in (left_start, left_end, right_start, right_end):
return False
same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end)
reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start)
if not same_direction and not reverse_direction:
return False
return True
@classmethod
def _unique_equivalent_predecessor(
cls,
current: TopologyRecord,
predecessors: Iterable[TopologyRecord],
consumed_predecessors: set[str],
) -> TopologyRecord | None:
matches = [
record for record in predecessors
if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current)
]
return matches[0] if len(matches) == 1 else None
@staticmethod
def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None:
"""Endpoint drift between direction-aligned straight edges.
Returns the minimum total endpoint drift (mm) when the two edges are
collinear straight lines (either orientation), otherwise ``None``.
"""
if prior.kind != current.kind:
return None
left, right = prior.geometry, current.geometry
if left.get("curve_type") != "line" or right.get("curve_type") != "line":
return None
if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")):
return None
def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (b[0] - a[0], b[1] - a[1], b[2] - a[2])
def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
left_dir = _delta(left["start_mm"], left["end_mm"])
right_dir = _delta(right["start_mm"], right["end_mm"])
if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9:
return None
cross = (
left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1],
left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2],
left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0],
)
if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3:
return None
same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"])
reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"])
return min(same_order, reversed_order)
@classmethod
def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool:
"""Loose "position trajectory" equivalence used for evolved successors.
Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance),
this deliberately tolerates small endpoint drift: fillet/chamfer split a
straight edge into segments (a middle straight run plus end arcs). The
straight run keeps the same direction and stays within ``drift_mm`` of the
original edge, so it can serve as the edge's evolved successor. Uniqueness
is enforced by the caller (only a single best candidate is recorded).
"""
drift = cls._evolved_drift(prior, current)
return drift is not None and drift <= drift_mm
@staticmethod
def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None:
try:
left = _vector3(expected, field_name="selector geometry")
right = _vector3(actual, field_name="record geometry")
except ValueError:
return None
error = _length(tuple(a - b for a, b in zip(left, right)))
return max(0.0, 1.0 - error / tolerance)
@classmethod
def _undirected_vector_score(cls, expected: Any, actual: Any, tolerance: float = 1e-4) -> tuple[float | None, bool]:
"""Score a geometric axis/plane normal independent of orientation."""
try:
left = _vector3(expected, field_name="selector geometry")
right = _vector3(actual, field_name="record geometry")
except ValueError:
return None, False
direct = _length(tuple(a - b for a, b in zip(left, right)))
reversed_error = _length(tuple(a + b for a, b in zip(left, right)))
reversed_direction = reversed_error < direct
return max(0.0, 1.0 - min(direct, reversed_error) / tolerance), reversed_direction
@classmethod
def _geometry_score(cls, selector_geometry: dict[str, Any], record_geometry: dict[str, Any]) -> float | None:
if not selector_geometry:
return 0.0
scores: list[float] = []
reversed_plane_normal = False
for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm", "axis_origin_mm", "axis_direction"):
if key in selector_geometry:
# A cap source identifies a geometric plane, not OCC's face
# orientation. When plane offset is present, prefer the
# adapter's canonical plane normal for a consistent equation.
actual = record_geometry.get("plane_normal") if key == "normal" and "plane_offset_mm" in selector_geometry else record_geometry.get(key)
if key in {"normal", "plane_normal", "axis_direction", "direction"}:
score, reversed_direction = cls._undirected_vector_score(selector_geometry[key], actual)
if key == "normal" and "plane_offset_mm" in selector_geometry:
reversed_plane_normal = reversed_direction
else:
score = cls._vector_score(selector_geometry[key], actual)
if score is None:
return None
scores.append(score)
for key in ("surface_type", "curve_type"):
if key in selector_geometry:
if record_geometry.get(key) != selector_geometry[key]:
# #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、
# 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点)
# 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全
# 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的
# 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。
scores.append(0.5)
else:
scores.append(1.0)
if "bbox_mm" in selector_geometry:
expected = selector_geometry["bbox_mm"]
actual = record_geometry.get("bbox_mm")
if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual):
return None
delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual))
scores.append(max(0.0, 1.0 - delta / 1e-4))
if "plane_offset_mm" in selector_geometry:
try:
actual_offset = float(record_geometry.get("plane_offset_mm"))
if reversed_plane_normal:
actual_offset = -actual_offset
delta = abs(float(selector_geometry["plane_offset_mm"]) - actual_offset)
except (TypeError, ValueError):
return None
scores.append(max(0.0, 1.0 - delta / 1e-4))
if "radius_mm" in selector_geometry:
try:
delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm")))
except (TypeError, ValueError):
return None
scores.append(max(0.0, 1.0 - delta / 1e-4))
if "area_mm2" in selector_geometry:
try:
expected_area = float(selector_geometry["area_mm2"])
actual_area = float(record_geometry.get("area_mm2"))
except (TypeError, ValueError):
return None
relative_delta = abs(expected_area - actual_area) / max(abs(expected_area), 1e-9)
scores.append(max(0.0, 1.0 - relative_delta / 1e-4))
if "minimum_area_mm2" in selector_geometry:
try:
if float(record_geometry.get("area_mm2")) + 1e-6 < float(selector_geometry["minimum_area_mm2"]):
return None
except (TypeError, ValueError):
return None
return sum(scores) / len(scores) if scores else 0.0
@staticmethod
def _dot(left: list[float], right: list[float]) -> float:
return sum(float(a) * float(b) for a, b in zip(left, right))
@classmethod
def _source_circle_candidates(
cls,
geometry: dict[str, Any],
candidates: Iterable[TopologyRecord],
) -> list[TopologyRecord]:
"""Resolve CADFS' explicit source-circle geometry without a heuristic.
This is a declared geometric selector for ordinary CDSL, not a
FeatureScript provenance fallback. The same implementation is used
by live runtime and prefix-binding report rehydration.
"""
center = geometry.get("source_circle_center_mm")
normal = geometry.get("source_plane_normal")
try:
radius = float(geometry.get("source_circle_radius_mm") or 0)
except (TypeError, ValueError):
return []
if not isinstance(center, list) or not isinstance(normal, list) or radius <= 0:
return []
plane_offset = cls._dot(center, normal)
matches = []
for record in candidates:
record_geometry = record.geometry
if record.kind != "edge" or record_geometry.get("curve_type") != "circle":
continue
points = [record_geometry.get("start_mm"), record_geometry.get("end_mm")]
if not all(isinstance(point, list) and len(point) == 3 for point in points):
continue
if any(abs(cls._dot(point, normal) - plane_offset) > 0.05 for point in points):
continue
radial = []
for point in points:
delta = [float(point[index]) - float(center[index]) for index in range(3)]
axial = cls._dot(delta, normal)
radial.append(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 _intersection_source_face(
self,
source: dict[str, Any],
) -> tuple[TopologyRecord | None, list[TopologyLineage], RuntimeDiagnostic | None]:
"""Resolve one historical face anchor for a qualified section query.
This deliberately resolves the *input snapshot* of a boolean, rather
than whichever descendant happens to be active now. CAP faces are
identified by their exact builder role; direct-prism side faces start
at one source-profile edge anchor. Both paths require the runtime
facts registered when the input body was built.
"""
family = source.get("query_family")
owner = source.get("owner_feature_id")
if not isinstance(owner, str) or not owner:
return None, [], RuntimeDiagnostic(
"selector_source_unavailable",
"An INTERSECT source face has no owning feature",
)
if family == "CAP_FACE":
role = source.get("output_role")
if role not in {"extrude.start", "extrude.end"}:
return None, [], RuntimeDiagnostic(
"selector_source_unavailable",
"A CAP_FACE INTERSECT source requires one direct prism cap role",
detail={"owner_feature_id": owner, "output_role": role},
)
candidates = [
record for record in self._records
if record.kind == "face"
and (owner in record.owners or record.feature_id == owner)
and role in record.output_roles
]
if len(candidates) != 1:
return None, [], RuntimeDiagnostic(
"selector_source_unavailable" if not candidates else "selector_ambiguous",
"The CAP_FACE input snapshot is unavailable or non-unique",
detail={"owner_feature_id": owner, "output_role": role, "candidate_count": len(candidates)},
)
# Output roles are cached on records only after an exact builder
# relation binds to one final snapshot. Confirm that evidence is
# still present instead of treating the cache as source proof.
role_facts = [
relation
for delta in self._topology_deltas
if delta.get("feature_id") == owner
for relation in delta.get("relations") or ()
if relation.get("output_role") == role
and candidates[0].record_id in relation.get("result_record_ids", ())
and relation.get("coverage") == "complete"
and relation.get("lineage_status") == "proven"
]
if len(role_facts) != 1:
return None, [], RuntimeDiagnostic(
"selector_kernel_history_missing",
"The CAP_FACE input has no unique complete builder-role fact",
detail={"owner_feature_id": owner, "output_role": role, "fact_count": len(role_facts)},
)
return candidates[0], [], None
if family == "SWEPT_FACE":
source_entity = source.get("source_entity")
if not isinstance(source_entity, dict):
return None, [], RuntimeDiagnostic(
"selector_source_unavailable",
"A SWEPT_FACE INTERSECT source requires one source-profile edge",
detail={"owner_feature_id": owner},
)
sketch_id, entity_id = source_entity.get("sketch_id"), source_entity.get("entity_id")
if not isinstance(sketch_id, str) or not sketch_id or not isinstance(entity_id, str) or not entity_id:
return None, [], RuntimeDiagnostic(
"selector_source_unavailable",
"The SWEPT_FACE source-profile edge is incomplete",
detail={"owner_feature_id": owner},
)
anchors = [
record for record in self._records
if record.kind == "edge" and record.source_entity == (sketch_id, entity_id)
]
if len(anchors) != 1:
return None, [], RuntimeDiagnostic(
"selector_source_unavailable" if not anchors else "selector_ambiguous",
"The SWEPT_FACE source-profile edge is unavailable or non-unique",
detail={"owner_feature_id": owner, "candidate_count": len(anchors)},
)
relations = [
edge for edge in self._lineage
if edge.feature_id == owner
and edge.operation == "extrude"
and edge.derivation == "boundary"
and edge.coverage == "complete"
and edge.status == "proven"
and edge.source_kind == "edge"
and edge.result_kind == "face"
and edge.source_record_ids == (anchors[0].record_id,)
and len(edge.result_record_ids) == 1
]
result_ids = {record_id for edge in relations for record_id in edge.result_record_ids}
candidates = [
record for record in self._records
if record.record_id in result_ids
and record.kind == "face"
and (owner in record.owners or record.feature_id == owner)
]
if len(relations) != 1 or len(candidates) != 1:
return None, relations, RuntimeDiagnostic(
"selector_kernel_history_missing" if not candidates else "selector_relation_non_unique",
"The SWEPT_FACE input has no unique complete direct-prism relation",
detail={"owner_feature_id": owner, "relation_count": len(relations), "candidate_count": len(candidates)},
)
return candidates[0], relations, None
return None, [], RuntimeDiagnostic(
"selector_query_unsupported",
"INTERSECT currently supports only CAP_FACE and direct-prism SWEPT_FACE inputs",
detail={"query_family": family},
)
def _resolve_intersection_intent(
self,
selector: dict[str, Any],
intent: dict[str, Any],
*,
allowed: set[str],
multiplicity: str,
active_body_id: str | None,
) -> SelectorResolution:
"""Resolve one two-face source-qualified boolean section edge."""
if selector.get("kind") != "edge" or allowed != {"intersection"} or multiplicity != "one":
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(
"selector_query_unsupported",
"INTERSECT requires exactly the intersection/one derivation policy",
detail={"kind": selector.get("kind"), "allowed": sorted(allowed), "multiplicity": multiplicity},
),
)
sources = intent.get("intersection_sources")
if not isinstance(sources, list) or len(sources) != 2 or not all(isinstance(source, dict) for source in sources):
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(
"selector_source_unavailable",
"INTERSECT requires exactly two explicit source face anchors",
),
)
source_records: list[TopologyRecord] = []
source_relations: list[TopologyLineage] = []
for source in sources:
record, relations, error = self._intersection_source_face(source)
if error is not None or record is None:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=error or RuntimeDiagnostic("selector_source_unavailable", "INTERSECT source face is unavailable"),
)
source_records.append(record)
source_relations.extend(relations)
if self._same_topology_value(source_records[0].value, source_records[1].value):
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(
"selector_source_unavailable",
"INTERSECT source anchors must resolve to two distinct faces",
detail={"source_record_ids": [record.record_id for record in source_records]},
),
)
relations = []
for edge in self._lineage:
if (
edge.derivation != "intersection"
or edge.coverage != "complete"
or edge.status != "proven"
or edge.source_kind != "face"
or edge.result_kind != "edge"
or len(edge.source_record_ids) != 2
or len(edge.result_record_ids) != 1
):
continue
relation_sources = [
record for record in self._records if record.record_id in edge.source_record_ids and record.kind == "face"
]
if len(relation_sources) != 2:
continue
# Boolean aggregation may give an unchanged input member a new
# snapshot record ID. Exact IsSame, not role copying or geometry,
# connects the historical source anchor to that input record.
matches = [
[
relation_source for relation_source in relation_sources
if self._same_topology_value(anchor.value, relation_source.value)
]
for anchor in source_records
]
if (
len(matches[0]) == 1
and len(matches[1]) == 1
and matches[0][0].record_id != matches[1][0].record_id
):
relations.append(edge)
if len(relations) != 1:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(
"selector_kernel_history_missing" if not relations else "selector_relation_non_unique",
"No unique complete source-qualified section edge matches the two source faces",
detail={"source_record_ids": [record.record_id for record in source_records], "relation_count": len(relations)},
),
)
result = next((
record for record in self._records
if record.record_id == relations[0].result_record_ids[0] and record.kind == "edge"
), None)
if result is None:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("selector_kernel_history_missing", "The section edge is absent from the result snapshot"),
)
if not self._record_is_active(result, active_body_id):
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(
"selector_body_member_inactive",
"The source-qualified section edge is not in the active body member",
detail={"record_id": result.record_id, "active_body_id": active_body_id},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=result,
candidates=({"score": 1.0, **result.public_dict()},),
resolution_mode="kernel_intersection",
evidence={
"source_query": intent.get("source_query"),
"semantic_anchor": {"type": "source_qualified_section", "sources": sources},
"source_records": [record.record_id for record in source_records],
"result_records": [result.record_id],
"relations": [edge.as_dict() for edge in [*source_relations, relations[0]]],
"body_member": result.body_id,
"policy": intent.get("derivation_policy"),
},
)
def resolve(
self,
selector: dict[str, Any],
*,
minimum_score: float = 0.8,
active_body_id: str | None = None,
) -> SelectorResolution:
kind = selector.get("kind")
owner = selector.get("owner_feature_id")
intent = selector.get("selector_intent")
provenance_intent = selector_has_provenance_intent(selector)
def unresolved(code: str, message: str, *, detail: dict[str, Any] | None = None, candidates: tuple[dict[str, Any], ...] = ()) -> SelectorResolution:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=candidates,
diagnostic=RuntimeDiagnostic(code, message, detail=detail or {}),
)
if provenance_intent:
validation_error = validate_selector_provenance_intent(selector)
if validation_error is not None:
return unresolved(
validation_error.code,
validation_error.message,
detail=validation_error.detail,
)
allowed, multiplicity = selector_intent_policy(intent)
else:
allowed, multiplicity = set(), "one"
if provenance_intent and intent.get("query_family") == "INTERSECT":
return self._resolve_intersection_intent(
selector, intent, allowed=allowed, multiplicity=multiplicity,
active_body_id=active_body_id,
)
if provenance_intent and intent.get("query_family") == "SWEPT_BODY":
mixed_evidence = any(
selector.get(key) is not None
for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role")
)
if (
kind != "body"
or not owner
or selector.get("source") != "runtime_snapshot"
or intent.get("evidence") != "active_body_member"
or intent.get("body_member_contract") != "direct_new_body"
or allowed != {"boundary"}
or multiplicity != "one"
or mixed_evidence
):
return unresolved(
"selector_source_unavailable",
"The SWEPT_BODY selector does not satisfy the direct active-member contract",
detail={"query_family": "SWEPT_BODY", "owner_feature_id": owner},
)
owner_records = [
record for record in self._records
if record.kind == "body"
and not record.transient
and record.feature_id == owner
and owner in record.owners
]
active_records = [
record for record in owner_records
if active_body_id is not None and record.body_id == active_body_id
]
public_candidates = tuple(
{"score": 1.0, **record.public_dict()} for record in active_records
)
if len(active_records) == 1:
record = active_records[0]
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=public_candidates,
resolution_mode="body_member",
evidence={
"source_query": intent.get("source_query"),
"semantic_anchor": {"type": "direct_body_member", "owner_feature_id": owner},
"source_records": [record.record_id],
"result_records": [record.record_id],
"relations": [],
"body_member": record.body_id,
"policy": intent.get("derivation_policy"),
},
)
if len(active_records) > 1:
return unresolved(
"selector_ambiguous",
"More than one active body member satisfies the SWEPT_BODY contract",
detail={"owner_feature_id": owner, "candidate_count": len(active_records)},
candidates=public_candidates,
)
return unresolved(
"selector_body_member_inactive" if owner_records else "selector_source_unavailable",
"The direct SWEPT_BODY member is not the active body" if owner_records else "The SWEPT_BODY producer has no body member record",
detail={"owner_feature_id": owner, "active_body_id": active_body_id},
)
candidates = [record for record in self._records if record.kind == kind and not record.transient]
if active_body_id and kind in {"face", "edge", "vertex", "body"}:
# #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体
# 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的
# 记录(不同 feature 前缀)不会泄漏进来。
candidates = [
record for record in candidates
if record.body_id == active_body_id
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
]
output_role = str(selector.get("output_role") or "").strip()
if owner and not output_role:
candidates = [record for record in candidates if owner in record.owners]
if kind == "plane" and selector.get("frame") is not None:
# #6 pattern 引用重解析:pattern 重放 sourcepattern_mirror)时,
# mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame
# _translated_node / _mirrored_node),这里直接构造 PlaneSpec
# 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。
try:
plane = PlaneSpec.from_mapping(selector.get("frame") or {})
except (TypeError, ValueError):
plane = None
if plane is not None:
record = TopologyRecord(
record_id=f"inline:{id(plane)}",
kind="plane",
feature_id=str(owner or "inline"),
geometry=plane.as_dict(),
value=plane,
)
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=({"score": 1.0, **record.public_dict()},),
resolution_mode="explicit_datum",
evidence={"source_records": [], "result_records": [record.record_id], "facts": ["inline_plane_frame"]},
)
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_frame_incomplete",
message="An inline plane frame requires origin_mm, x_dir and normal",
detail={"frame": selector.get("frame")},
),
)
geometry = normalize_selector_geometry(selector.get("geometry"))
source_circle_candidates = self._source_circle_candidates(geometry, candidates)
if source_circle_candidates:
candidates = source_circle_candidates
# The source-circle fields themselves were just checked exactly
# enough for this explicit geometric contract. They are not part
# of the generic score schema.
geometry = {}
if selector.get("snapshot_id") and not owner:
return unresolved(
"selector_owner_required",
"A snapshot selector requires owner_feature_id",
detail={"minimum_score": minimum_score},
)
if output_role:
if not owner:
return unresolved(
"selector_output_role_owner_required",
"A feature output role selector requires owner_feature_id",
detail={"output_role": output_role},
)
if active_body_id is None:
return unresolved(
"selector_output_role_active_body_required",
"A feature output role selector requires an active body snapshot",
detail={"output_role": output_role},
)
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
return unresolved(
"selector_output_role_mixed_evidence",
"A feature output role selector cannot mix stable or geometry evidence",
detail={"output_role": output_role},
)
# Direct output roles are written to the relation evidence before
# the result snapshot is registered. The record's retained role
# is a convenient cache, not the sole proof source.
role_record_ids = {
record_id
for edge in self._lineage
if edge.output_role == output_role
for record_id in edge.result_record_ids
}
role_candidates = [
record for record in candidates
if output_role in record.output_roles or record.record_id in role_record_ids
]
role_source = selector.get("output_role_source")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
if not isinstance(source_owner, str) or not isinstance(source_role, str):
return unresolved(
"selector_output_role_source_invalid",
"An output role selector source requires owner_feature_id and output_role",
)
if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}:
return unresolved(
"selector_output_role_source_unsupported",
"Output role sources are currently supported only for shell.offset_face from an extrusion cap",
)
role_candidates = [
record for record in role_candidates
if (output_role, source_owner, source_role) in record.output_role_sources
]
public_candidates = tuple(
{"score": 1.0, **record.public_dict()} for record in role_candidates
)
if provenance_intent:
if multiplicity == "source_qualified" and role_source is None and not intent.get("disambiguation"):
return unresolved(
"selector_source_unavailable",
"A source-qualified output role selector has no source qualification",
detail={"output_role": output_role},
candidates=public_candidates,
)
role_edges = [edge for edge in self._lineage if edge.output_role == output_role]
proven_candidates: list[tuple[TopologyRecord, list[TopologyLineage]]] = []
cardinality_errors: list[RuntimeDiagnostic] = []
for candidate in role_candidates:
for edge in role_edges:
if edge.coverage != "complete" or edge.status != "proven":
continue
if edge.derivation not in allowed:
continue
cardinality_error = self._operation_cardinality_error(
edge,
allowed=allowed,
source_record_id=candidate.record_id,
result_connected_only=True,
)
if cardinality_error is not None:
cardinality_errors.append(cardinality_error)
continue
if candidate.record_id in edge.result_record_ids:
proven_candidates.append((candidate, [edge]))
break
for source_record_id in edge.result_record_ids:
descendants, relations, error = self._intent_lineage_successors(
source_record_id, allowed=allowed, active_body_id=active_body_id,
target_kind=candidate.kind,
)
if error is None and any(item.record_id == candidate.record_id for item in descendants):
proven_candidates.append((candidate, [edge, *relations]))
break
else:
continue
break
if not proven_candidates:
if cardinality_errors:
error = cardinality_errors[0]
return unresolved(
error.code,
error.message,
detail=error.detail,
candidates=public_candidates,
)
return unresolved(
"selector_kernel_history_missing",
"No complete builder relation proves the requested active output role",
detail={"output_role": output_role, "allowed": sorted(allowed)},
candidates=public_candidates,
)
role_candidates = [candidate for candidate, _relations in proven_candidates]
public_candidates = tuple({"score": 1.0, **record.public_dict()} for record in role_candidates)
if len(role_candidates) == 1:
relations = proven_candidates[0][1] if provenance_intent else []
return SelectorResolution(
selector=selector,
status="resolved",
record=role_candidates[0],
candidates=public_candidates,
resolution_mode="operation_role",
evidence={
"source_query": intent.get("source_query") if provenance_intent else None,
"semantic_anchor": {"type": "output_role", "owner_feature_id": owner, "output_role": output_role},
"source_records": [],
"result_records": [role_candidates[0].record_id],
"relations": [edge.as_dict() for edge in relations],
"body_member": role_candidates[0].body_id,
"policy": intent.get("derivation_policy") if provenance_intent else None,
},
)
if len(role_candidates) > 1:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=public_candidates,
diagnostic=RuntimeDiagnostic(
code="selector_output_role_ambiguous",
message="More than one active topology record has the requested output role",
detail={"output_role": output_role, "candidate_count": len(role_candidates)},
),
)
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_not_found",
message="No active topology record has the requested output role",
detail={"output_role": output_role, "candidate_count": 0},
),
)
if provenance_intent and intent.get("query_family") in {"CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE"}:
source_entity = intent.get("source_entity")
source_entities = intent.get("source_entities")
source_kind: str | None = None
semantic_anchor: dict[str, Any] | None = None
anchor_records: list[TopologyRecord] = []
if isinstance(source_entity, dict):
sketch_id, entity_id = source_entity.get("sketch_id"), source_entity.get("entity_id")
if isinstance(sketch_id, str) and sketch_id and isinstance(entity_id, str) and entity_id:
source_kind = "edge"
semantic_anchor = {"type": "source_entity", "sketch_id": sketch_id, "entity_id": entity_id}
anchor_records = [
record for record in self._records
if record.kind == source_kind and record.source_entity == (sketch_id, entity_id)
]
elif isinstance(source_entities, list):
pairs = tuple(sorted(
(str(source.get("sketch_id")), str(source.get("entity_id")))
for source in source_entities
if isinstance(source, dict)
and isinstance(source.get("sketch_id"), str)
and isinstance(source.get("entity_id"), str)
))
if len(pairs) == len(source_entities) and len(pairs) >= 2 and len(set(pairs)) == len(pairs):
source_kind = "vertex"
semantic_anchor = {
"type": "source_vertex",
"source_entities": [
{"sketch_id": sketch_id, "entity_id": entity_id}
for sketch_id, entity_id in pairs
],
}
anchor_records = [
record for record in self._records
if record.kind == source_kind and record.source_entities == pairs
]
if source_kind is None:
return unresolved(
"selector_source_unavailable",
"The provenance selector has no valid source-profile semantic anchor",
detail={"query_family": intent.get("query_family")},
)
lineage_role = intent.get("lineage_role")
if intent.get("query_family") == "CAP_EDGE":
if kind != "edge" or lineage_role not in {"extrude.start", "extrude.end"}:
return unresolved(
"selector_source_unavailable",
"The CAP_EDGE provenance selector lacks a valid direct-prism cap role",
detail={"query_family": "CAP_EDGE", "lineage_role": lineage_role},
)
else:
lineage_role = None
public_anchors = tuple({"score": 1.0, **record.public_dict()} for record in anchor_records)
if len(anchor_records) != 1:
return unresolved(
"selector_source_unavailable" if not anchor_records else "selector_ambiguous",
"The source-profile semantic anchor is unavailable or non-unique in this replay",
detail={
"query_family": intent.get("query_family"),
"semantic_anchor": semantic_anchor,
"candidate_count": len(anchor_records),
},
candidates=public_anchors,
)
successors, relations, error = self._intent_lineage_successors(
anchor_records[0].record_id,
allowed=allowed,
active_body_id=active_body_id,
initial_output_role=lineage_role,
target_kind=kind,
)
successors = [record for record in successors if record.kind == kind]
public_successors = tuple({"score": 1.0, **record.public_dict()} for record in successors)
if error is not None:
return unresolved(
error.code,
error.message,
detail={**error.detail, "semantic_anchor": semantic_anchor},
candidates=public_successors,
)
if multiplicity == "all_fragments":
return SelectorResolution(
selector=selector,
status="resolved",
records=tuple(successors),
candidates=public_successors,
resolution_mode="kernel_lineage",
evidence={
"source_query": intent.get("source_query"),
"semantic_anchor": semantic_anchor,
"source_records": [anchor_records[0].record_id],
"result_records": [record.record_id for record in successors],
"relations": [edge.as_dict() for edge in relations],
"body_members": [record.body_id for record in successors],
"policy": intent.get("derivation_policy"),
},
)
if len(successors) != 1:
return SelectorResolution(
selector=selector,
status="ambiguous" if successors else "not_found",
candidates=public_successors,
diagnostic=RuntimeDiagnostic(
"selector_relation_non_unique" if successors else "selector_body_member_inactive",
"The complete proven source-profile lineage does not yield one active result",
detail={
"semantic_anchor": semantic_anchor,
"candidate_count": len(successors),
"multiplicity": multiplicity,
},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=successors[0],
candidates=public_successors,
resolution_mode="kernel_lineage",
evidence={
"source_query": intent.get("source_query"),
"semantic_anchor": semantic_anchor,
"source_records": [anchor_records[0].record_id],
"result_records": [successors[0].record_id],
"relations": [edge.as_dict() for edge in relations],
"body_member": successors[0].body_id,
"policy": intent.get("derivation_policy"),
},
)
stable_id = str(selector.get("stable_id") or "").strip()
if stable_id:
# #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确
# 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。
# 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body
# 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到
# 几何打分流程。
stable_records = [
record for record in self._records
if record.kind == kind and not record.transient and (not owner or owner in record.owners)
]
exact = [record for record in stable_records if record.record_id == stable_id]
if len(exact) == 1:
record = exact[0]
if provenance_intent:
successors, relations, error = self._intent_lineage_successors(
record.record_id, allowed=allowed, active_body_id=active_body_id,
target_kind=kind,
)
public_successors = tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors)
if error is not None:
return unresolved(
error.code,
error.message,
detail={**error.detail, "stable_id": stable_id},
candidates=public_successors,
)
if multiplicity == "all_fragments":
return SelectorResolution(
selector=selector,
status="resolved",
records=tuple(successors),
candidates=public_successors,
resolution_mode="kernel_lineage",
evidence={
"source_query": intent.get("source_query"),
"source_records": [record.record_id],
"result_records": [candidate.record_id for candidate in successors],
"relations": [edge.as_dict() for edge in relations],
"body_members": [candidate.body_id for candidate in successors],
"policy": intent.get("derivation_policy"),
},
)
if len(successors) != 1:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=public_successors,
diagnostic=RuntimeDiagnostic(
code="selector_relation_non_unique",
message="The complete proven topology lineage does not yield one active result",
detail={"stable_id": stable_id, "candidate_count": len(successors), "multiplicity": multiplicity},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=successors[0],
candidates=public_successors,
resolution_mode="kernel_lineage",
evidence={
"source_query": intent.get("source_query"),
"source_records": [record.record_id],
"result_records": [successors[0].record_id],
"relations": [edge.as_dict() for edge in relations],
"body_member": successors[0].body_id,
"policy": intent.get("derivation_policy"),
},
)
is_active = active_body_id is None or (
record.body_id == active_body_id
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
)
if not is_active and provenance_intent:
policy = intent.get("derivation_policy") or {}
allowed = {
value for value in policy.get("allowed") or ()
if value in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"}
}
successors = self._intent_lineage_successors(
record.record_id,
allowed=allowed,
active_body_id=active_body_id,
target_kind=kind,
)
if len(successors) == 1:
record = successors[0]
is_active = True
elif len(successors) > 1:
if policy.get("multiplicity") == "all_fragments" and "fragment" in allowed:
return SelectorResolution(
selector=selector,
status="resolved",
records=tuple(successors),
candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors),
)
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors),
diagnostic=RuntimeDiagnostic(
code="selector_relation_non_unique",
message="The proven topology lineage has more than one active result",
detail={"stable_id": stable_id, "candidate_count": len(successors), "multiplicity": policy.get("multiplicity")},
),
)
else:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_kernel_history_missing",
message="No complete proven lineage reaches an active topology record",
detail={"stable_id": stable_id, "allowed": sorted(allowed)},
),
)
if not is_active:
successors = [
candidate for candidate in stable_records
if candidate.record_id in self._successors.get(record.record_id, ())
and (
candidate.body_id == active_body_id
or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:"))
)
]
if len(successors) == 1:
record = successors[0]
is_active = True
elif len(successors) > 1:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors),
diagnostic=RuntimeDiagnostic(
code="selector_ambiguous",
message="More than one evolved successor record satisfies the stable_id",
detail={"stable_id": stable_id, "candidate_count": len(successors)},
),
)
if is_active:
# A stable ID is only a lookup accelerator for snapshot-aware
# selectors. It cannot revive a B-rep entity whose geometric
# signature changed after an upstream rebuild.
if selector.get("snapshot_id"):
score = self._geometry_score(geometry, record.geometry) if geometry else None
if score is None or score < minimum_score:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch",
message="The stable selector record no longer matches its geometry signature",
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
resolution_mode="runtime_snapshot",
evidence={"source_records": [], "result_records": [record.record_id], "facts": ["runtime_stable_id"]},
)
if not geometry:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_stable_id_inactive",
message="The stable selector record is not active and has no geometry signature for rebinding",
detail={"stable_id": stable_id},
),
)
if len(exact) > 1:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact),
diagnostic=RuntimeDiagnostic(
code="selector_ambiguous",
message="More than one runtime topology record has the requested stable_id",
detail={"stable_id": stable_id, "candidate_count": len(exact)},
),
)
if selector.get("snapshot_id") and not geometry:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch",
message="A snapshot selector requires a geometry signature",
detail={"minimum_score": minimum_score},
),
)
if provenance_intent:
return unresolved(
"selector_source_unavailable",
"The provenance selector has no runtime-resolvable semantic source anchor",
detail={"query_family": intent.get("query_family"), "anchors": [key for key in ("output_role", "source_entity") if intent.get(key)]},
)
scored: list[tuple[float, TopologyRecord]] = []
for candidate in candidates:
# An owner-qualified context selector is deterministic when it has
# a single runtime candidate even if its source stable_id cannot
# survive the SolidWorks -> OCC boundary.
score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry)
if score is not None:
scored.append((score, candidate))
scored.sort(key=lambda item: (-item[0], item[1].record_id))
public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored)
if not scored or scored[0][0] < minimum_score:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=public_candidates,
diagnostic=RuntimeDiagnostic(
code="selector_not_found",
message="No runtime topology record satisfies the selector",
detail={"candidate_count": len(scored), "minimum_score": minimum_score},
),
)
if selector.get("match_mode") == "all":
matches = tuple(record for score, record in scored if score >= minimum_score)
return SelectorResolution(
selector=selector,
status="resolved",
records=matches,
candidates=public_candidates,
resolution_mode="geometry",
evidence={"source_records": [], "result_records": [record.record_id for record in matches], "facts": ["explicit_geometry"]},
)
best_score, best_record = scored[0]
if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=public_candidates,
diagnostic=RuntimeDiagnostic(
code="selector_ambiguous",
message="More than one runtime topology record has the best selector score",
detail={"best_score": best_score, "candidate_count": len(scored)},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=best_record,
candidates=public_candidates,
resolution_mode="geometry",
evidence={"source_records": [], "result_records": [best_record.record_id], "facts": ["explicit_geometry"]},
)