wip: preserve local engine and agent workflow changes before ganjihong refactor integration

This commit is contained in:
2026-09-09 17:08:24 +08:00
parent a5c3ec1f36
commit 387b99e255
112 changed files with 3543 additions and 12177 deletions
@@ -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(
+19 -4
View File
@@ -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",
+57 -2
View File
@@ -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"],
+72 -12
View File
@@ -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}
+226
View File
@@ -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: