wip: preserve local engine and agent workflow changes before ganjihong refactor integration
This commit is contained in:
@@ -14,7 +14,7 @@ from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFille
|
||||
from OCP.BRepOffset import BRepOffset_Skin
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
|
||||
from OCP.Geom import Geom_SurfaceOfRevolution
|
||||
from OCP.GeomAbs import GeomAbs_Arc
|
||||
from OCP.LocOpe import LocOpe_DPrism
|
||||
@@ -23,7 +23,7 @@ from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
from OCP.TopoDS import TopoDS
|
||||
from OCP.gp import gp_Ax1, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
from OCP.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
|
||||
from .parametric_bend import build_bend_solid
|
||||
from .parametric_gears import build_gear_solid, build_rack_solid
|
||||
@@ -1131,6 +1131,37 @@ class Build123dGeometryAdapter:
|
||||
)
|
||||
return Solid.make_cylinder(radius_mm, height_mm, build_plane)
|
||||
|
||||
@staticmethod
|
||||
def cylinder_with_topology_delta(
|
||||
radius_mm: float,
|
||||
height_mm: float,
|
||||
axis: AxisSpec | None = None,
|
||||
) -> tuple[Solid, TopologyDelta]:
|
||||
"""Build a cylinder with exact OCC witnesses for its two cap faces."""
|
||||
origin = axis.origin_mm if axis is not None else (0.0, 0.0, 0.0)
|
||||
direction = axis.direction if axis is not None else (0.0, 0.0, 1.0)
|
||||
placement = gp_Ax2(
|
||||
gp_Pnt(float(origin[0]), float(origin[1]), float(origin[2])),
|
||||
gp_Dir(float(direction[0]), float(direction[1]), float(direction[2])),
|
||||
)
|
||||
builder = BRepPrimAPI_MakeCylinder(placement, float(radius_mm), float(height_mm))
|
||||
builder.Build()
|
||||
if not builder.IsDone():
|
||||
raise ValueError("OCC cylinder operation did not complete")
|
||||
result = Solid(builder.Solid())
|
||||
if not result.is_valid or not Build123dGeometryAdapter.body_solids(result) or result.volume <= 1e-9:
|
||||
raise ValueError("OCC cylinder operation did not produce a valid solid")
|
||||
primitive = builder.Cylinder()
|
||||
relations = tuple(
|
||||
TopologyDeltaRelation("generated", "face", result.wrapped, (face,), output_role=role)
|
||||
for face, role in (
|
||||
(primitive.BottomFace(), "cylinder.start"),
|
||||
(primitive.TopFace(), "cylinder.end"),
|
||||
)
|
||||
if not face.IsNull()
|
||||
)
|
||||
return result, TopologyDelta(operation="cylinder", relations=relations)
|
||||
|
||||
@staticmethod
|
||||
def intersect(left: Any, right: Any) -> Any:
|
||||
# 布尔交:取两实体公共部分。结果可能为空(不相交或仅边界接触),
|
||||
@@ -1237,7 +1268,21 @@ class Build123dGeometryAdapter:
|
||||
relations.append(TopologyDeltaRelation("preserved", kind, source_value, (source_value,)))
|
||||
if generated:
|
||||
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
|
||||
return TopologyDelta(operation=operation_name, relations=tuple(relations))
|
||||
section_values: tuple[Any, ...] = ()
|
||||
section_edges = getattr(operation, "SectionEdges", None)
|
||||
if callable(section_edges):
|
||||
try:
|
||||
# BRepAlgoAPI boolean builders expose the exact intersection
|
||||
# edges. Builders without that API simply carry no section
|
||||
# evidence; callers must not infer it from result geometry.
|
||||
section_values = tuple(section_edges())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
section_values = ()
|
||||
return TopologyDelta(
|
||||
operation=operation_name,
|
||||
relations=tuple(relations),
|
||||
section_values=section_values,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _shell_topology_delta(
|
||||
|
||||
@@ -82,6 +82,19 @@ def _mappings(value: Any):
|
||||
yield from _mappings(child)
|
||||
|
||||
|
||||
def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Read only the selector slot declared by the operation contract."""
|
||||
slot = str((contract or {}).get("selector_slot") or "")
|
||||
if slot == "feature.selectors":
|
||||
values = node.selectors
|
||||
elif slot.startswith("params.") and slot.count(".") == 1:
|
||||
value = node.params.get(slot.removeprefix("params."))
|
||||
values = value if isinstance(value, list) else [value]
|
||||
else:
|
||||
values = []
|
||||
return [value for value in values if isinstance(value, dict)]
|
||||
|
||||
|
||||
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
|
||||
"""Return internal body-member keys named by a transform contract."""
|
||||
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
|
||||
@@ -543,18 +556,20 @@ class CapabilityAnalyzer:
|
||||
"Loft currently requires exactly one outer profile without holes",
|
||||
sketch_id=sketch_id,
|
||||
))
|
||||
contract_selectors = _contract_selectors(node, contract)
|
||||
contract_selector_ids = {id(selector) for selector in contract_selectors}
|
||||
for selector in _mappings(params):
|
||||
if selector.get("output_role") is not None:
|
||||
if selector.get("output_role") is not None and id(selector) not in contract_selector_ids:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector_context",
|
||||
"Feature output role selectors are only supported in feature.selectors",
|
||||
"Feature output role selector is outside the operation contract slot",
|
||||
))
|
||||
for selector_index, selector in enumerate(node.selectors):
|
||||
for selector_index, selector in enumerate(contract_selectors):
|
||||
if selector.get("output_role") is None:
|
||||
continue
|
||||
required.append("selector:feature_output_role")
|
||||
if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
|
||||
if contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector",
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
"description": "Complete self-contained CDSL. Runtime-supported operations can be rebuilt by the local CDSL-only engine; deferred operations are retained for future engine implementations.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schema": {"const": "cad.cdsl.llm.v1"},
|
||||
"schema": {"enum": ["cad.cdsl.llm.v1", "cad.runtime.v1"]},
|
||||
"schema_version": {"type": "string"},
|
||||
"kind": {"type": "string", "minLength": 1},
|
||||
"part_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{3,80}$"},
|
||||
"meta": {"type": "object"},
|
||||
"bodies": {"type": "array", "items": {"$ref": "#/$defs/runtimeBody"}},
|
||||
"geometry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector_intent_version": {"const": "1.0"},
|
||||
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
|
||||
},
|
||||
"required": ["sketches"],
|
||||
@@ -26,6 +28,15 @@
|
||||
"number": {"type": "number"},
|
||||
"positive": {"type": "number", "exclusiveMinimum": 0},
|
||||
"positiveInteger": {"type": "integer", "minimum": 1},
|
||||
"runtimeBody": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^body_[0-9]{3}$"},
|
||||
"name": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}
|
||||
},
|
||||
"required": ["id", "name"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"point2": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 2, "maxItems": 2},
|
||||
"point3": {"type": "array", "items": {"$ref": "#/$defs/number"}, "minItems": 3, "maxItems": 3},
|
||||
"circleItem": {
|
||||
@@ -541,7 +552,7 @@
|
||||
},
|
||||
"featureOutputRole": {
|
||||
"enum": [
|
||||
"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end",
|
||||
"extrude.start", "extrude.end", "sweep.start", "sweep.end", "loft.start", "loft.end", "cylinder.start", "cylinder.end",
|
||||
"shell.offset_face", "shell.closing_descendant", "shell.body_face"
|
||||
]
|
||||
},
|
||||
@@ -554,6 +565,48 @@
|
||||
"required": ["owner_feature_id", "output_role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntentSourceQuery": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ast": {},
|
||||
"featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"},
|
||||
"standard_library": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["ast", "featurescript_version"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {"const": "1.0"},
|
||||
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
|
||||
"query_family": {"enum": ["CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY", "GEOMETRIC"]},
|
||||
"source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_role": {"$ref": "#/$defs/featureOutputRole"},
|
||||
"derivation_policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"allowed": {"type": "array", "minItems": 1, "items": {"enum": ["continuation", "fragment", "merge", "intersection", "boundary", "replacement"]}},
|
||||
"multiplicity": {"enum": ["one", "all_fragments", "source_qualified", "none"]}
|
||||
},
|
||||
"required": ["allowed", "multiplicity"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {"enum": ["kernel_history", "operation_role", "feature_script_query", "explicit_datum", "geometry_hint"]},
|
||||
"disambiguation": {"type": "object"}
|
||||
},
|
||||
"required": ["version", "query_family", "source_query", "derivation_policy", "evidence"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -570,6 +623,8 @@
|
||||
"match_mode": {"enum": ["unique", "all"]},
|
||||
"matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"selector_intent_version": {"const": "1.0"},
|
||||
"selector_intent": {"$ref": "#/$defs/selectorIntent"},
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
||||
},
|
||||
"required": ["kind", "source", "confidence"],
|
||||
|
||||
@@ -114,6 +114,7 @@ class GeometryAdapter(Protocol):
|
||||
def cut(self, body: Any, tool: Any) -> Any: ...
|
||||
def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ...
|
||||
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
|
||||
def cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ...
|
||||
def thread_solid(self, spec: ThreadSpec) -> Any: ...
|
||||
def bend_solid(self, spec: BendSpec) -> Any: ...
|
||||
def gear_solid(self, spec: GearSpec) -> Any: ...
|
||||
@@ -232,11 +233,15 @@ class ExecutionSession:
|
||||
binding_feature_id = selector.get("binding_feature_id")
|
||||
active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id
|
||||
resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))]
|
||||
failures = [item for item in resolved if item.status != "resolved" or item.record is None]
|
||||
failures = [item for item in resolved if item.status != "resolved" or (item.record is None and not item.records)]
|
||||
if failures:
|
||||
detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved"
|
||||
raise FeatureExecutionError("intersection_selector_component_unresolved", detail)
|
||||
return [item.record for item in resolved if item.record is not None]
|
||||
return [
|
||||
record
|
||||
for item in resolved
|
||||
for record in (item.records or ((item.record,) if item.record is not None else ()))
|
||||
]
|
||||
|
||||
def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution:
|
||||
components = selector.get("intersection_of")
|
||||
@@ -1108,6 +1113,8 @@ def _register_added_solid(
|
||||
session: ExecutionSession,
|
||||
node: FeaturePlanNode,
|
||||
solid: Any,
|
||||
*,
|
||||
topology_delta: TopologyDelta | None = None,
|
||||
) -> None:
|
||||
"""Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend).
|
||||
|
||||
@@ -1121,10 +1128,57 @@ def _register_added_solid(
|
||||
if node.params.get("result_mode") == "new_body":
|
||||
combined = session.adapter.combine(session.body, solid)
|
||||
members = {**session.body_members, node.feature_id: solid}
|
||||
session.register_body(node.feature_id, combined, replay_node=node, body_members=members)
|
||||
session.register_body(
|
||||
node.feature_id, combined, replay_node=node, body_members=members,
|
||||
topology_delta=topology_delta,
|
||||
)
|
||||
return
|
||||
fused = session.adapter.fuse(session.body, solid)
|
||||
session.register_body(node.feature_id, fused, replay_node=node)
|
||||
if session.body is None:
|
||||
session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta)
|
||||
return
|
||||
if topology_delta is None:
|
||||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||||
return
|
||||
# Preserve primitive output roles only through the exact OCC fuse history.
|
||||
# The temporary records are not selectable snapshots; they provide the
|
||||
# source handles that let TopologyRegistry carry a role to the final body.
|
||||
role_records = _direct_output_role_records(session, node, solid, topology_delta)
|
||||
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid)
|
||||
session.register_body(
|
||||
node.feature_id, fused, replay_node=node, topology_delta=fuse_delta,
|
||||
topology_predecessors=role_records,
|
||||
)
|
||||
|
||||
|
||||
def _direct_output_role_records(
|
||||
session: ExecutionSession,
|
||||
node: FeaturePlanNode,
|
||||
solid: Any,
|
||||
topology_delta: TopologyDelta,
|
||||
) -> list[TopologyRecord]:
|
||||
"""Attach only builder-proven output roles to a transient primitive snapshot."""
|
||||
records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}")
|
||||
result: list[TopologyRecord] = []
|
||||
for record in records:
|
||||
roles = {
|
||||
relation.output_role
|
||||
for relation in topology_delta.relations
|
||||
if relation.output_role is not None
|
||||
and relation.kind == record.kind
|
||||
and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values)
|
||||
}
|
||||
if roles:
|
||||
result.append(TopologyRecord(
|
||||
record_id=record.record_id,
|
||||
kind=record.kind,
|
||||
feature_id=record.feature_id,
|
||||
body_id=record.body_id,
|
||||
geometry=record.geometry,
|
||||
value=record.value,
|
||||
owner_feature_ids=(node.feature_id,),
|
||||
output_roles=tuple(sorted(roles)),
|
||||
))
|
||||
return result
|
||||
|
||||
|
||||
def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||||
@@ -1187,9 +1241,9 @@ def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> Featu
|
||||
raise ValueError("cylinder_add axis must define origin_mm and direction")
|
||||
axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None
|
||||
# 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。
|
||||
solid = session.adapter.cylinder(radius, height, axis)
|
||||
solid, topology_delta = session.adapter.cylinder_with_topology_delta(radius, height, axis)
|
||||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||
_register_added_solid(session, node, solid)
|
||||
_register_added_solid(session, node, solid, topology_delta=topology_delta)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@@ -1395,10 +1449,12 @@ def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent
|
||||
|
||||
edges: list[Any] = []
|
||||
for item in resolved:
|
||||
if item.record.kind == "edge":
|
||||
edges.append(item.record.value)
|
||||
elif item.record.kind == "face":
|
||||
edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge))
|
||||
records = item.records or ((item.record,) if item.record is not None else ())
|
||||
for record in records:
|
||||
if record.kind == "edge":
|
||||
edges.append(record.value)
|
||||
elif record.kind == "face":
|
||||
edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge))
|
||||
if not edges:
|
||||
raise ValueError("selectors did not resolve any edges")
|
||||
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
|
||||
@@ -1412,7 +1468,11 @@ def _shell_target(node: FeaturePlanNode, session: ExecutionSession) -> tuple[Any
|
||||
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||
if failed:
|
||||
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
|
||||
records = [item.record for item in resolved if item.record is not None]
|
||||
records = [
|
||||
record
|
||||
for item in resolved
|
||||
for record in (item.records or ((item.record,) if item.record is not None else ()))
|
||||
]
|
||||
if not records or any(record.kind != "face" for record in records):
|
||||
raise ValueError("shell selectors must resolve to faces")
|
||||
target_ids = {record.body_id for record in records}
|
||||
|
||||
@@ -768,6 +768,13 @@ class TopologyDeltaRelation:
|
||||
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"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.event not in {"preserved", "modified", "generated", "deleted"}:
|
||||
@@ -778,6 +785,21 @@ class TopologyDeltaRelation:
|
||||
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)
|
||||
@@ -791,6 +813,38 @@ class TopologyDelta:
|
||||
|
||||
operation: str
|
||||
relations: tuple[TopologyDeltaRelation, ...] = ()
|
||||
# Boolean section edges are not ordinary source continuations. They are
|
||||
# retained independently so INTERSECT selectors can require this kernel
|
||||
# evidence instead of matching a nearby edge geometrically.
|
||||
section_values: tuple[Any, ...] = ()
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -798,13 +852,29 @@ 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
|
||||
|
||||
@property
|
||||
def resolution_mode(self) -> str:
|
||||
if self.status == "resolved":
|
||||
if self.records:
|
||||
return "kernel_lineage"
|
||||
if self.selector.get("output_role"):
|
||||
return "operation_role"
|
||||
if self.selector.get("selector_intent"):
|
||||
return "kernel_lineage"
|
||||
return "geometry"
|
||||
return "unresolved"
|
||||
|
||||
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:
|
||||
@@ -816,8 +886,15 @@ class SelectorResolution:
|
||||
)
|
||||
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.status == "resolved":
|
||||
output["evidence"] = {
|
||||
"source_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))],
|
||||
"result_records": [record.record_id for record in (self.records or ((self.record,) if self.record else ()))],
|
||||
}
|
||||
return output
|
||||
|
||||
|
||||
@@ -829,6 +906,7 @@ class TopologyRegistry:
|
||||
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 的
|
||||
@@ -849,6 +927,10 @@ class TopologyRegistry:
|
||||
"""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)
|
||||
|
||||
def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord:
|
||||
kind = "plane" if isinstance(context, PlaneSpec) else "axis"
|
||||
record = TopologyRecord(
|
||||
@@ -983,10 +1065,25 @@ class TopologyRegistry:
|
||||
if successor_id not in known:
|
||||
known.append(successor_id)
|
||||
if delta_evidence is not None:
|
||||
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"),
|
||||
)
|
||||
for item in delta_evidence
|
||||
]
|
||||
self._lineage.extend(operation_lineage)
|
||||
self._topology_deltas.append({
|
||||
"feature_id": feature_id,
|
||||
"operation": topology_delta.operation,
|
||||
"relations": delta_evidence,
|
||||
"lineage": [lineage.as_dict() for lineage in operation_lineage],
|
||||
})
|
||||
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
|
||||
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
|
||||
@@ -1076,7 +1173,14 @@ class TopologyRegistry:
|
||||
"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,
|
||||
}
|
||||
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
|
||||
@@ -1106,6 +1210,24 @@ class TopologyRegistry:
|
||||
else:
|
||||
relation_links.append(None)
|
||||
evidence.append(item)
|
||||
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_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 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()
|
||||
@@ -1125,6 +1247,8 @@ class TopologyRegistry:
|
||||
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,
|
||||
@@ -1134,6 +1258,45 @@ class TopologyRegistry:
|
||||
evidence,
|
||||
)
|
||||
|
||||
def _intent_lineage_successors(
|
||||
self,
|
||||
source_record_id: str,
|
||||
*,
|
||||
allowed: set[str],
|
||||
active_body_id: str | None,
|
||||
) -> list[TopologyRecord]:
|
||||
"""Follow only complete, proven CDSL lineage edges to active records."""
|
||||
pending = [source_record_id]
|
||||
visited = {source_record_id}
|
||||
result_ids: set[str] = set()
|
||||
while pending:
|
||||
current = pending.pop()
|
||||
for edge in self._lineage:
|
||||
if (
|
||||
current not in edge.source_record_ids
|
||||
or edge.derivation not in allowed
|
||||
or edge.evidence != "kernel_history"
|
||||
or edge.coverage != "complete"
|
||||
or edge.status != "proven"
|
||||
):
|
||||
continue
|
||||
for record_id in edge.result_record_ids:
|
||||
if record_id not in visited:
|
||||
visited.add(record_id)
|
||||
pending.append(record_id)
|
||||
result_ids.add(record_id)
|
||||
active: list[TopologyRecord] = []
|
||||
for record in self._records:
|
||||
if record.record_id not in result_ids:
|
||||
continue
|
||||
if active_body_id is not None and not (
|
||||
record.body_id == active_body_id
|
||||
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
|
||||
):
|
||||
continue
|
||||
active.append(record)
|
||||
return active
|
||||
|
||||
@staticmethod
|
||||
def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
|
||||
try:
|
||||
@@ -1330,6 +1493,8 @@ class TopologyRegistry:
|
||||
) -> SelectorResolution:
|
||||
kind = selector.get("kind")
|
||||
owner = selector.get("owner_feature_id")
|
||||
intent = selector.get("selector_intent")
|
||||
provenance_intent = isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC"
|
||||
candidates = [record for record in self._records if record.kind == kind]
|
||||
if active_body_id and kind in {"face", "edge", "vertex", "body"}:
|
||||
# #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体
|
||||
@@ -1500,6 +1665,49 @@ class TopologyRegistry:
|
||||
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,
|
||||
)
|
||||
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
|
||||
@@ -1579,6 +1787,24 @@ class TopologyRegistry:
|
||||
detail={"minimum_score": minimum_score},
|
||||
),
|
||||
)
|
||||
if provenance_intent:
|
||||
evidence = intent.get("evidence")
|
||||
code = "selector_geometry_only" if evidence == "geometry_hint" else "selector_kernel_history_missing"
|
||||
message = (
|
||||
"A FeatureScript provenance selector cannot resolve from geometry alone"
|
||||
if code == "selector_geometry_only"
|
||||
else "The FeatureScript selector has no stable active record or complete kernel history"
|
||||
)
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=RuntimeDiagnostic(
|
||||
code=code,
|
||||
message=message,
|
||||
detail={"query_family": intent.get("query_family")},
|
||||
),
|
||||
)
|
||||
scored: list[tuple[float, TopologyRecord]] = []
|
||||
for candidate in candidates:
|
||||
# An owner-qualified context selector is deterministic when it has
|
||||
|
||||
@@ -32,6 +32,66 @@ def _mappings(value: Any):
|
||||
yield from _mappings(child)
|
||||
|
||||
|
||||
def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the selectors at the operation contract's declared slot."""
|
||||
slot = str(contract.get("selector_slot") or "")
|
||||
if slot == "feature.selectors":
|
||||
values = feature.get("selectors") or []
|
||||
elif slot.startswith("params.") and slot.count(".") == 1:
|
||||
value = (feature.get("params") or {}).get(slot.removeprefix("params."))
|
||||
values = value if isinstance(value, list) else [value]
|
||||
else:
|
||||
values = []
|
||||
return [value for value in values if isinstance(value, dict)]
|
||||
|
||||
|
||||
def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: int) -> None:
|
||||
"""Enforce the provenance boundary before the runtime can bind a selector."""
|
||||
intent = selector.get("selector_intent")
|
||||
if intent is None:
|
||||
if selector.get("selector_intent_version") is not None:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent")
|
||||
return
|
||||
if selector.get("selector_intent_version") not in {None, "1.0"}:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent version")
|
||||
if not isinstance(intent, dict) or intent.get("version") != "1.0":
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent")
|
||||
if intent.get("kind") not in {None, selector.get("kind")}:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind")
|
||||
family = intent.get("query_family")
|
||||
derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY"}
|
||||
if family in derived and not selector.get("owner_feature_id"):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id")
|
||||
policy = intent.get("derivation_policy") or {}
|
||||
allowed = policy.get("allowed") or []
|
||||
if not allowed or any(value not in {"continuation", "fragment", "merge", "intersection", "boundary", "replacement"} for value in allowed):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an invalid lineage derivation policy")
|
||||
if policy.get("multiplicity") not in {"one", "all_fragments", "source_qualified", "none"}:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an invalid lineage multiplicity")
|
||||
if policy.get("multiplicity") == "all_fragments" and "fragment" not in allowed:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} all_fragments policy requires fragment lineage")
|
||||
if family in derived and intent.get("evidence") == "geometry_hint":
|
||||
raise ValueError(f"Feature {feature_id} selector {index} cannot use geometry_hint for FeatureScript provenance")
|
||||
if selector.get("owner_match_required") and intent.get("evidence") == "geometry_hint":
|
||||
raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback")
|
||||
source_query = intent.get("source_query") or {}
|
||||
version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None
|
||||
if not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version")
|
||||
if intent.get("output_role") is not None and intent.get("output_role") != selector.get("output_role"):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} intent output role differs from selector output role")
|
||||
forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"}
|
||||
stack = [intent]
|
||||
while stack:
|
||||
value = stack.pop()
|
||||
if isinstance(value, dict):
|
||||
if forbidden.intersection(value):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} intent contains a runtime identifier")
|
||||
stack.extend(value.values())
|
||||
elif isinstance(value, list):
|
||||
stack.extend(value)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _schema() -> dict[str, Any]:
|
||||
path = Path(__file__).with_name("cdsl_schema.json")
|
||||
@@ -69,7 +129,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
if not isinstance(cdsl, dict):
|
||||
raise ValueError("CDSL must be a JSON object")
|
||||
if cdsl.get("schema") != "cad.cdsl.llm.v1":
|
||||
if cdsl.get("schema") not in {"cad.cdsl.llm.v1", "cad.runtime.v1"}:
|
||||
raise ValueError("Unsupported CDSL schema")
|
||||
schema_error = _schema_error(cdsl)
|
||||
if schema_error:
|
||||
@@ -124,15 +184,21 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
if feature.get("execution_status") == "deferred":
|
||||
deferred.append(fid)
|
||||
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
|
||||
feature_selectors = feature.get("selectors") or []
|
||||
feature_selectors = _contract_selectors(feature, contract)
|
||||
output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)}
|
||||
selector_intent_ids = {
|
||||
id(selector.get("selector_intent"))
|
||||
for selector in feature_selectors
|
||||
if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict)
|
||||
}
|
||||
for index, selector in enumerate(feature_selectors):
|
||||
_validate_selector_intent(selector, fid, index)
|
||||
owner = selector.get("owner_feature_id")
|
||||
binding_owner = selector.get("binding_feature_id")
|
||||
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
|
||||
if selector.get("output_role") is not None:
|
||||
if contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
|
||||
if not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
|
||||
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
|
||||
if selector.get("kind") != "face" or not owner or owner not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id")
|
||||
@@ -174,8 +240,9 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
selector.get("output_role") is not None
|
||||
and selector.get("kind") is not None
|
||||
and id(selector) not in output_role_selector_ids
|
||||
and id(selector) not in selector_intent_ids
|
||||
):
|
||||
raise ValueError(f"Feature {fid} output role selectors are only supported in feature.selectors")
|
||||
raise ValueError(f"Feature {fid} output role selector is outside its operation contract slot")
|
||||
if feature.get("atomic_id") == "shell":
|
||||
target_feature_id = (feature.get("params") or {}).get("target_feature_id")
|
||||
if target_feature_id is not None and target_feature_id not in feature_ids:
|
||||
|
||||
Reference in New Issue
Block a user