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