generated from coulomb/repo-seed
feat: add zone visualization resolver
This commit is contained in:
661
railiance_fabric/zone_view.py
Normal file
661
railiance_fabric/zone_view.py
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneAttractionRule:
|
||||||
|
edge_type: str | None = None
|
||||||
|
direction: str = "both"
|
||||||
|
depth: int = 1
|
||||||
|
node_filter: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
edge_filter: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.direction not in {"out", "in", "both"}:
|
||||||
|
raise ValueError(f"Unsupported attraction direction: {self.direction}")
|
||||||
|
if self.depth < 0:
|
||||||
|
raise ValueError("Attraction depth must be zero or greater")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Mapping[str, Any]) -> ZoneAttractionRule:
|
||||||
|
return cls(
|
||||||
|
edge_type=_optional_string(data.get("edge_type", data.get("edgeType"))),
|
||||||
|
direction=str(data.get("direction") or "both"),
|
||||||
|
depth=int(data.get("depth", 1)),
|
||||||
|
node_filter=_mapping_or_empty(data.get("node_filter", data.get("nodeFilter"))),
|
||||||
|
edge_filter=_mapping_or_empty(data.get("edge_filter", data.get("edgeFilter"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"direction": self.direction,
|
||||||
|
"depth": self.depth,
|
||||||
|
}
|
||||||
|
if self.edge_type:
|
||||||
|
result["edge_type"] = self.edge_type
|
||||||
|
if self.node_filter:
|
||||||
|
result["node_filter"] = dict(self.node_filter)
|
||||||
|
if self.edge_filter:
|
||||||
|
result["edge_filter"] = dict(self.edge_filter)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneLayout:
|
||||||
|
algorithm: str = "preset"
|
||||||
|
options: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Mapping[str, Any] | None) -> ZoneLayout:
|
||||||
|
if not data:
|
||||||
|
return cls()
|
||||||
|
return cls(
|
||||||
|
algorithm=str(data.get("algorithm") or "preset"),
|
||||||
|
options=_mapping_or_empty(data.get("options")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {"algorithm": self.algorithm, "options": dict(self.options)}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZonePresentation:
|
||||||
|
height: int = 0
|
||||||
|
color: str = ""
|
||||||
|
opacity: float = 0.16
|
||||||
|
blur_below: bool = False
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Mapping[str, Any] | None) -> ZonePresentation:
|
||||||
|
if not data:
|
||||||
|
return cls()
|
||||||
|
return cls(
|
||||||
|
height=int(data.get("height", 0)),
|
||||||
|
color=str(data.get("color") or ""),
|
||||||
|
opacity=float(data.get("opacity", 0.16)),
|
||||||
|
blur_below=bool(data.get("blur_below", data.get("blurBelow", False))),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"height": self.height,
|
||||||
|
"color": self.color,
|
||||||
|
"opacity": self.opacity,
|
||||||
|
"blur_below": self.blur_below,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneCollapse:
|
||||||
|
enabled: bool = False
|
||||||
|
label: str = ""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Mapping[str, Any] | None) -> ZoneCollapse:
|
||||||
|
if not data:
|
||||||
|
return cls()
|
||||||
|
return cls(
|
||||||
|
enabled=bool(data.get("enabled", False)),
|
||||||
|
label=str(data.get("label") or ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {"enabled": self.enabled, "label": self.label}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneDefinition:
|
||||||
|
id: str
|
||||||
|
label: str = ""
|
||||||
|
enabled: bool = True
|
||||||
|
membership: Mapping[str, Any] = field(default_factory=dict)
|
||||||
|
attraction_rules: tuple[ZoneAttractionRule, ...] = ()
|
||||||
|
layout: ZoneLayout = field(default_factory=ZoneLayout)
|
||||||
|
presentation: ZonePresentation = field(default_factory=ZonePresentation)
|
||||||
|
collapse: ZoneCollapse = field(default_factory=ZoneCollapse)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: Mapping[str, Any]) -> ZoneDefinition:
|
||||||
|
attraction = data.get("attraction", ())
|
||||||
|
if isinstance(attraction, Mapping):
|
||||||
|
raw_attraction_rules = attraction.get("rules", ())
|
||||||
|
else:
|
||||||
|
raw_attraction_rules = attraction
|
||||||
|
rules = tuple(
|
||||||
|
rule if isinstance(rule, ZoneAttractionRule) else ZoneAttractionRule.from_dict(rule)
|
||||||
|
for rule in raw_attraction_rules
|
||||||
|
if isinstance(rule, Mapping) or isinstance(rule, ZoneAttractionRule)
|
||||||
|
)
|
||||||
|
zone_id = str(data["id"])
|
||||||
|
return cls(
|
||||||
|
id=zone_id,
|
||||||
|
label=str(data.get("label") or zone_id),
|
||||||
|
enabled=bool(data.get("enabled", True)),
|
||||||
|
membership=_mapping_or_empty(data.get("membership")),
|
||||||
|
attraction_rules=rules,
|
||||||
|
layout=ZoneLayout.from_dict(_mapping_or_none(data.get("layout"))),
|
||||||
|
presentation=ZonePresentation.from_dict(_mapping_or_none(data.get("presentation"))),
|
||||||
|
collapse=ZoneCollapse.from_dict(_mapping_or_none(data.get("collapse"))),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label or self.id,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"membership": dict(self.membership),
|
||||||
|
"attraction": {"rules": [rule.to_dict() for rule in self.attraction_rules]},
|
||||||
|
"layout": self.layout.to_dict(),
|
||||||
|
"presentation": self.presentation.to_dict(),
|
||||||
|
"collapse": self.collapse.to_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneNodeAssignment:
|
||||||
|
node_id: str
|
||||||
|
zone_id: str
|
||||||
|
reason: str
|
||||||
|
depth: int = 0
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"node_id": self.node_id,
|
||||||
|
"zone_id": self.zone_id,
|
||||||
|
"reason": self.reason,
|
||||||
|
"depth": self.depth,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneBoundaryEdge:
|
||||||
|
edge_id: str
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
edge_type: str
|
||||||
|
source_zone_id: str | None
|
||||||
|
target_zone_id: str | None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"edge_id": self.edge_id,
|
||||||
|
"source": self.source,
|
||||||
|
"target": self.target,
|
||||||
|
"edge_type": self.edge_type,
|
||||||
|
"source_zone_id": self.source_zone_id,
|
||||||
|
"target_zone_id": self.target_zone_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneDiagnostic:
|
||||||
|
severity: str
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
node_id: str | None = None
|
||||||
|
edge_id: str | None = None
|
||||||
|
zone_ids: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
result: dict[str, Any] = {
|
||||||
|
"severity": self.severity,
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
"zone_ids": list(self.zone_ids),
|
||||||
|
}
|
||||||
|
if self.node_id:
|
||||||
|
result["node_id"] = self.node_id
|
||||||
|
if self.edge_id:
|
||||||
|
result["edge_id"] = self.edge_id
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneInstance:
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
definition: ZoneDefinition
|
||||||
|
node_ids: tuple[str, ...]
|
||||||
|
seed_node_ids: tuple[str, ...]
|
||||||
|
attracted_node_ids: tuple[str, ...]
|
||||||
|
internal_edge_ids: tuple[str, ...]
|
||||||
|
boundary_edge_ids: tuple[str, ...]
|
||||||
|
diagnostics: tuple[ZoneDiagnostic, ...]
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"definition": self.definition.to_dict(),
|
||||||
|
"node_ids": list(self.node_ids),
|
||||||
|
"seed_node_ids": list(self.seed_node_ids),
|
||||||
|
"attracted_node_ids": list(self.attracted_node_ids),
|
||||||
|
"internal_edge_ids": list(self.internal_edge_ids),
|
||||||
|
"boundary_edge_ids": list(self.boundary_edge_ids),
|
||||||
|
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneResolution:
|
||||||
|
zones: tuple[ZoneInstance, ...]
|
||||||
|
node_assignments: Mapping[str, ZoneNodeAssignment]
|
||||||
|
boundary_edges: tuple[ZoneBoundaryEdge, ...]
|
||||||
|
diagnostics: tuple[ZoneDiagnostic, ...]
|
||||||
|
|
||||||
|
def zone_by_id(self, zone_id: str) -> ZoneInstance | None:
|
||||||
|
return next((zone for zone in self.zones if zone.id == zone_id), None)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"zones": [zone.to_dict() for zone in self.zones],
|
||||||
|
"node_assignments": {
|
||||||
|
node_id: assignment.to_dict()
|
||||||
|
for node_id, assignment in self.node_assignments.items()
|
||||||
|
},
|
||||||
|
"boundary_edges": [edge.to_dict() for edge in self.boundary_edges],
|
||||||
|
"diagnostics": [diagnostic.to_dict() for diagnostic in self.diagnostics],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _NodeRecord:
|
||||||
|
id: str
|
||||||
|
data: Mapping[str, Any]
|
||||||
|
order: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _EdgeRecord:
|
||||||
|
id: str
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
edge_type: str
|
||||||
|
data: Mapping[str, Any]
|
||||||
|
order: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _Candidate:
|
||||||
|
node_id: str
|
||||||
|
zone_id: str
|
||||||
|
reason: str
|
||||||
|
depth: int
|
||||||
|
definition_order: int
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_zones(
|
||||||
|
nodes: Iterable[Mapping[str, Any]],
|
||||||
|
edges: Iterable[Mapping[str, Any]],
|
||||||
|
zone_definitions: Iterable[ZoneDefinition | Mapping[str, Any]],
|
||||||
|
) -> ZoneResolution:
|
||||||
|
"""Resolve zone definitions against graph data without mutating the graph."""
|
||||||
|
|
||||||
|
node_records = _node_records(nodes)
|
||||||
|
edge_records = _edge_records(edges)
|
||||||
|
definitions = tuple(
|
||||||
|
definition if isinstance(definition, ZoneDefinition) else ZoneDefinition.from_dict(definition)
|
||||||
|
for definition in zone_definitions
|
||||||
|
)
|
||||||
|
enabled_definitions = tuple(
|
||||||
|
(index, definition)
|
||||||
|
for index, definition in enumerate(definitions)
|
||||||
|
if definition.enabled
|
||||||
|
)
|
||||||
|
diagnostics: list[ZoneDiagnostic] = []
|
||||||
|
candidates_by_node_id: dict[str, list[_Candidate]] = defaultdict(list)
|
||||||
|
seed_node_ids_by_zone_id: dict[str, set[str]] = defaultdict(set)
|
||||||
|
|
||||||
|
for definition_order, definition in enabled_definitions:
|
||||||
|
for node in node_records:
|
||||||
|
if _rule_matches(node.data, definition.membership, empty_matches=False):
|
||||||
|
seed_node_ids_by_zone_id[definition.id].add(node.id)
|
||||||
|
candidates_by_node_id[node.id].append(
|
||||||
|
_Candidate(node.id, definition.id, "seed", 0, definition_order)
|
||||||
|
)
|
||||||
|
if not seed_node_ids_by_zone_id[definition.id]:
|
||||||
|
diagnostics.append(
|
||||||
|
ZoneDiagnostic(
|
||||||
|
severity="WARN",
|
||||||
|
code="ZONE_EMPTY_SEED_SET",
|
||||||
|
message=f"Zone {definition.id} did not match any seed nodes.",
|
||||||
|
zone_ids=(definition.id,),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
attraction_candidates = _attraction_candidates(
|
||||||
|
node_records,
|
||||||
|
edge_records,
|
||||||
|
enabled_definitions,
|
||||||
|
seed_node_ids_by_zone_id,
|
||||||
|
)
|
||||||
|
for candidate in attraction_candidates:
|
||||||
|
candidates_by_node_id[candidate.node_id].append(candidate)
|
||||||
|
|
||||||
|
height_by_zone_id = {
|
||||||
|
definition.id: definition.presentation.height
|
||||||
|
for _, definition in enabled_definitions
|
||||||
|
}
|
||||||
|
assignments: dict[str, ZoneNodeAssignment] = {}
|
||||||
|
for node in node_records:
|
||||||
|
candidates = candidates_by_node_id.get(node.id, [])
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
seed_candidates = _unique_zone_candidates(
|
||||||
|
candidate for candidate in candidates if candidate.reason == "seed"
|
||||||
|
)
|
||||||
|
attraction_candidates_for_node = _unique_zone_candidates(
|
||||||
|
candidate for candidate in candidates if candidate.reason == "attraction"
|
||||||
|
)
|
||||||
|
if len(seed_candidates) > 1:
|
||||||
|
diagnostics.append(
|
||||||
|
ZoneDiagnostic(
|
||||||
|
severity="WARN",
|
||||||
|
code="ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES",
|
||||||
|
message=f"Node {node.id} matched multiple zone membership rules.",
|
||||||
|
node_id=node.id,
|
||||||
|
zone_ids=tuple(candidate.zone_id for candidate in seed_candidates),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(attraction_candidates_for_node) > 1:
|
||||||
|
diagnostics.append(
|
||||||
|
ZoneDiagnostic(
|
||||||
|
severity="WARN",
|
||||||
|
code="ZONE_NODE_ATTRACTED_BY_MULTIPLE_ZONES",
|
||||||
|
message=f"Node {node.id} was attracted by multiple zones.",
|
||||||
|
node_id=node.id,
|
||||||
|
zone_ids=tuple(candidate.zone_id for candidate in attraction_candidates_for_node),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if seed_candidates and any(
|
||||||
|
candidate.zone_id not in {seed.zone_id for seed in seed_candidates}
|
||||||
|
for candidate in attraction_candidates_for_node
|
||||||
|
):
|
||||||
|
diagnostics.append(
|
||||||
|
ZoneDiagnostic(
|
||||||
|
severity="INFO",
|
||||||
|
code="ZONE_SEED_OVERRIDES_ATTRACTION",
|
||||||
|
message=f"Node {node.id} kept seed membership over attraction.",
|
||||||
|
node_id=node.id,
|
||||||
|
zone_ids=tuple(
|
||||||
|
candidate.zone_id
|
||||||
|
for candidate in [*seed_candidates, *attraction_candidates_for_node]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pool = seed_candidates or attraction_candidates_for_node
|
||||||
|
chosen = _choose_candidate(pool, height_by_zone_id)
|
||||||
|
assignments[node.id] = ZoneNodeAssignment(
|
||||||
|
node_id=node.id,
|
||||||
|
zone_id=chosen.zone_id,
|
||||||
|
reason=chosen.reason,
|
||||||
|
depth=chosen.depth,
|
||||||
|
)
|
||||||
|
|
||||||
|
boundary_edges: list[ZoneBoundaryEdge] = []
|
||||||
|
internal_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
|
||||||
|
boundary_edge_ids_by_zone_id: dict[str, list[str]] = defaultdict(list)
|
||||||
|
for edge in edge_records:
|
||||||
|
source_zone_id = assignments.get(edge.source).zone_id if edge.source in assignments else None
|
||||||
|
target_zone_id = assignments.get(edge.target).zone_id if edge.target in assignments else None
|
||||||
|
if source_zone_id and source_zone_id == target_zone_id:
|
||||||
|
internal_edge_ids_by_zone_id[source_zone_id].append(edge.id)
|
||||||
|
continue
|
||||||
|
if source_zone_id or target_zone_id:
|
||||||
|
boundary_edges.append(
|
||||||
|
ZoneBoundaryEdge(
|
||||||
|
edge_id=edge.id,
|
||||||
|
source=edge.source,
|
||||||
|
target=edge.target,
|
||||||
|
edge_type=edge.edge_type,
|
||||||
|
source_zone_id=source_zone_id,
|
||||||
|
target_zone_id=target_zone_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for zone_id in {source_zone_id, target_zone_id} - {None}:
|
||||||
|
boundary_edge_ids_by_zone_id[str(zone_id)].append(edge.id)
|
||||||
|
|
||||||
|
zones: list[ZoneInstance] = []
|
||||||
|
for _, definition in enabled_definitions:
|
||||||
|
node_ids = tuple(
|
||||||
|
node.id
|
||||||
|
for node in node_records
|
||||||
|
if node.id in assignments and assignments[node.id].zone_id == definition.id
|
||||||
|
)
|
||||||
|
seed_node_ids = tuple(
|
||||||
|
node_id
|
||||||
|
for node_id in node_ids
|
||||||
|
if assignments[node_id].reason == "seed"
|
||||||
|
)
|
||||||
|
attracted_node_ids = tuple(
|
||||||
|
node_id
|
||||||
|
for node_id in node_ids
|
||||||
|
if assignments[node_id].reason == "attraction"
|
||||||
|
)
|
||||||
|
zone_diagnostics = tuple(
|
||||||
|
diagnostic
|
||||||
|
for diagnostic in diagnostics
|
||||||
|
if definition.id in diagnostic.zone_ids
|
||||||
|
)
|
||||||
|
zones.append(
|
||||||
|
ZoneInstance(
|
||||||
|
id=definition.id,
|
||||||
|
label=definition.label or definition.id,
|
||||||
|
definition=definition,
|
||||||
|
node_ids=node_ids,
|
||||||
|
seed_node_ids=seed_node_ids,
|
||||||
|
attracted_node_ids=attracted_node_ids,
|
||||||
|
internal_edge_ids=tuple(internal_edge_ids_by_zone_id[definition.id]),
|
||||||
|
boundary_edge_ids=tuple(boundary_edge_ids_by_zone_id[definition.id]),
|
||||||
|
diagnostics=zone_diagnostics,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ZoneResolution(
|
||||||
|
zones=tuple(zones),
|
||||||
|
node_assignments=assignments,
|
||||||
|
boundary_edges=tuple(boundary_edges),
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _attraction_candidates(
|
||||||
|
node_records: tuple[_NodeRecord, ...],
|
||||||
|
edge_records: tuple[_EdgeRecord, ...],
|
||||||
|
enabled_definitions: tuple[tuple[int, ZoneDefinition], ...],
|
||||||
|
seed_node_ids_by_zone_id: Mapping[str, set[str]],
|
||||||
|
) -> list[_Candidate]:
|
||||||
|
nodes_by_id = {node.id: node for node in node_records}
|
||||||
|
adjacency: dict[str, list[_EdgeRecord]] = defaultdict(list)
|
||||||
|
for edge in edge_records:
|
||||||
|
adjacency[edge.source].append(edge)
|
||||||
|
adjacency[edge.target].append(edge)
|
||||||
|
|
||||||
|
candidates: dict[tuple[str, str], _Candidate] = {}
|
||||||
|
for definition_order, definition in enabled_definitions:
|
||||||
|
seed_node_ids = seed_node_ids_by_zone_id.get(definition.id, set())
|
||||||
|
if not seed_node_ids:
|
||||||
|
continue
|
||||||
|
for rule in definition.attraction_rules:
|
||||||
|
queue: deque[tuple[str, int]] = deque((node_id, 0) for node_id in seed_node_ids)
|
||||||
|
seen_depth_by_node_id = {node_id: 0 for node_id in seed_node_ids}
|
||||||
|
while queue:
|
||||||
|
node_id, depth = queue.popleft()
|
||||||
|
if depth >= rule.depth:
|
||||||
|
continue
|
||||||
|
for edge in adjacency.get(node_id, []):
|
||||||
|
if not _edge_matches_attraction_rule(edge, rule):
|
||||||
|
continue
|
||||||
|
neighbor_id = _neighbor_for_direction(node_id, edge, rule.direction)
|
||||||
|
if not neighbor_id:
|
||||||
|
continue
|
||||||
|
next_depth = depth + 1
|
||||||
|
if seen_depth_by_node_id.get(neighbor_id, next_depth + 1) <= next_depth:
|
||||||
|
continue
|
||||||
|
neighbor = nodes_by_id.get(neighbor_id)
|
||||||
|
if not neighbor or not _rule_matches(
|
||||||
|
neighbor.data,
|
||||||
|
rule.node_filter,
|
||||||
|
empty_matches=True,
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
seen_depth_by_node_id[neighbor_id] = next_depth
|
||||||
|
queue.append((neighbor_id, next_depth))
|
||||||
|
if neighbor_id in seed_node_ids:
|
||||||
|
continue
|
||||||
|
key = (neighbor_id, definition.id)
|
||||||
|
existing = candidates.get(key)
|
||||||
|
candidate = _Candidate(
|
||||||
|
node_id=neighbor_id,
|
||||||
|
zone_id=definition.id,
|
||||||
|
reason="attraction",
|
||||||
|
depth=next_depth,
|
||||||
|
definition_order=definition_order,
|
||||||
|
)
|
||||||
|
if existing is None or candidate.depth < existing.depth:
|
||||||
|
candidates[key] = candidate
|
||||||
|
return sorted(candidates.values(), key=lambda candidate: (candidate.node_id, candidate.zone_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _node_records(nodes: Iterable[Mapping[str, Any]]) -> tuple[_NodeRecord, ...]:
|
||||||
|
records: list[_NodeRecord] = []
|
||||||
|
for order, element in enumerate(nodes):
|
||||||
|
data = _element_data(element)
|
||||||
|
node_id = str(data.get("id") or data.get("stableKey") or "")
|
||||||
|
if node_id:
|
||||||
|
records.append(_NodeRecord(id=node_id, data=data, order=order))
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_records(edges: Iterable[Mapping[str, Any]]) -> tuple[_EdgeRecord, ...]:
|
||||||
|
records: list[_EdgeRecord] = []
|
||||||
|
for order, element in enumerate(edges):
|
||||||
|
data = _element_data(element)
|
||||||
|
source = str(data.get("source") or "")
|
||||||
|
target = str(data.get("target") or "")
|
||||||
|
if not source or not target:
|
||||||
|
continue
|
||||||
|
edge_type = str(data.get("edgeType") or data.get("type") or data.get("canonicalType") or "")
|
||||||
|
edge_id = str(data.get("id") or data.get("stableKey") or f"edge:{source}:{target}:{order}")
|
||||||
|
records.append(
|
||||||
|
_EdgeRecord(
|
||||||
|
id=edge_id,
|
||||||
|
source=source,
|
||||||
|
target=target,
|
||||||
|
edge_type=edge_type,
|
||||||
|
data=data,
|
||||||
|
order=order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(records)
|
||||||
|
|
||||||
|
|
||||||
|
def _element_data(element: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||||
|
data = element.get("data")
|
||||||
|
if isinstance(data, Mapping):
|
||||||
|
return data
|
||||||
|
return element
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_matches_attraction_rule(edge: _EdgeRecord, rule: ZoneAttractionRule) -> bool:
|
||||||
|
if rule.edge_type and rule.edge_type != "*" and edge.edge_type != rule.edge_type:
|
||||||
|
return False
|
||||||
|
return _rule_matches(edge.data, rule.edge_filter, empty_matches=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _neighbor_for_direction(node_id: str, edge: _EdgeRecord, direction: str) -> str:
|
||||||
|
if direction in {"out", "both"} and edge.source == node_id:
|
||||||
|
return edge.target
|
||||||
|
if direction in {"in", "both"} and edge.target == node_id:
|
||||||
|
return edge.source
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_matches(data: Mapping[str, Any], rule: Mapping[str, Any], *, empty_matches: bool) -> bool:
|
||||||
|
if not rule:
|
||||||
|
return empty_matches
|
||||||
|
if "all" in rule:
|
||||||
|
nested = rule.get("all")
|
||||||
|
return isinstance(nested, Iterable) and all(
|
||||||
|
isinstance(child, Mapping) and _rule_matches(data, child, empty_matches=False)
|
||||||
|
for child in nested
|
||||||
|
)
|
||||||
|
if "any" in rule:
|
||||||
|
nested = rule.get("any")
|
||||||
|
return isinstance(nested, Iterable) and any(
|
||||||
|
isinstance(child, Mapping) and _rule_matches(data, child, empty_matches=False)
|
||||||
|
for child in nested
|
||||||
|
)
|
||||||
|
if "rules" in rule:
|
||||||
|
nested = rule.get("rules")
|
||||||
|
return isinstance(nested, Iterable) and all(
|
||||||
|
isinstance(child, Mapping) and _rule_matches(data, child, empty_matches=False)
|
||||||
|
for child in nested
|
||||||
|
)
|
||||||
|
|
||||||
|
field = str(rule.get("field") or "")
|
||||||
|
op = str(rule.get("op") or "equals")
|
||||||
|
expected = rule.get("value")
|
||||||
|
actual = _field_value(data, field)
|
||||||
|
if op == "equals":
|
||||||
|
return actual == expected
|
||||||
|
if op == "in":
|
||||||
|
if isinstance(expected, str) or not isinstance(expected, Iterable):
|
||||||
|
return actual == expected
|
||||||
|
return actual in expected
|
||||||
|
if op == "exists":
|
||||||
|
return actual is not None and actual != "" and actual != []
|
||||||
|
raise ValueError(f"Unsupported zone rule operator: {op}")
|
||||||
|
|
||||||
|
|
||||||
|
def _field_value(data: Mapping[str, Any], field: str) -> Any:
|
||||||
|
if not field:
|
||||||
|
return None
|
||||||
|
current: Any = data
|
||||||
|
for part in field.split("."):
|
||||||
|
if not isinstance(current, Mapping) or part not in current:
|
||||||
|
return None
|
||||||
|
current = current[part]
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_zone_candidates(candidates: Iterable[_Candidate]) -> list[_Candidate]:
|
||||||
|
by_zone_id: dict[str, _Candidate] = {}
|
||||||
|
for candidate in candidates:
|
||||||
|
existing = by_zone_id.get(candidate.zone_id)
|
||||||
|
if existing is None or (candidate.depth, candidate.definition_order) < (
|
||||||
|
existing.depth,
|
||||||
|
existing.definition_order,
|
||||||
|
):
|
||||||
|
by_zone_id[candidate.zone_id] = candidate
|
||||||
|
return list(by_zone_id.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _choose_candidate(candidates: Iterable[_Candidate], height_by_zone_id: Mapping[str, int]) -> _Candidate:
|
||||||
|
return sorted(
|
||||||
|
candidates,
|
||||||
|
key=lambda candidate: (
|
||||||
|
-height_by_zone_id.get(candidate.zone_id, 0),
|
||||||
|
candidate.definition_order,
|
||||||
|
candidate.zone_id,
|
||||||
|
candidate.depth,
|
||||||
|
),
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_or_empty(value: Any) -> Mapping[str, Any]:
|
||||||
|
return value if isinstance(value, Mapping) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _mapping_or_none(value: Any) -> Mapping[str, Any] | None:
|
||||||
|
return value if isinstance(value, Mapping) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_string(value: Any) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
return str(value)
|
||||||
231
tests/test_zone_view.py
Normal file
231
tests/test_zone_view.py
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from railiance_fabric.zone_view import ZoneDefinition, resolve_zones
|
||||||
|
|
||||||
|
|
||||||
|
def _node(node_id: str, **data: object) -> dict[str, dict[str, object]]:
|
||||||
|
return {"data": {"id": node_id, **data}}
|
||||||
|
|
||||||
|
|
||||||
|
def _edge(edge_id: str, source: str, target: str, edge_type: str, **data: object) -> dict[str, dict[str, object]]:
|
||||||
|
return {
|
||||||
|
"data": {
|
||||||
|
"id": edge_id,
|
||||||
|
"source": source,
|
||||||
|
"target": target,
|
||||||
|
"edgeType": edge_type,
|
||||||
|
**data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_definition_round_trips() -> None:
|
||||||
|
definition = ZoneDefinition.from_dict(
|
||||||
|
{
|
||||||
|
"id": "prod",
|
||||||
|
"label": "Production",
|
||||||
|
"membership": {
|
||||||
|
"rules": [
|
||||||
|
{"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||||
|
{"field": "kind", "op": "in", "value": ["service", "endpoint"]},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"attraction": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"edge_type": "routes_to_service",
|
||||||
|
"direction": "out",
|
||||||
|
"depth": 2,
|
||||||
|
"node_filter": {"field": "kind", "op": "exists"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"layout": {"algorithm": "cose", "options": {"animate": False}},
|
||||||
|
"presentation": {"height": 30, "color": "#be123c", "opacity": 0.2, "blur_below": True},
|
||||||
|
"collapse": {"enabled": True, "label": "Production Zone"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert definition.id == "prod"
|
||||||
|
assert definition.label == "Production"
|
||||||
|
assert definition.attraction_rules[0].edge_type == "routes_to_service"
|
||||||
|
assert definition.attraction_rules[0].depth == 2
|
||||||
|
assert definition.layout.algorithm == "cose"
|
||||||
|
assert definition.presentation.height == 30
|
||||||
|
assert definition.collapse.enabled is True
|
||||||
|
|
||||||
|
round_tripped = ZoneDefinition.from_dict(definition.to_dict())
|
||||||
|
|
||||||
|
assert round_tripped == definition
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_assigns_seed_nodes_and_boundary_edges() -> None:
|
||||||
|
resolution = resolve_zones(
|
||||||
|
nodes=[
|
||||||
|
_node("svc.prod", deploymentEnvironment="prod"),
|
||||||
|
_node("svc.test", deploymentEnvironment="test"),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
_edge("edge.prod-test", "svc.prod", "svc.test", "routes_to_service"),
|
||||||
|
],
|
||||||
|
zone_definitions=[
|
||||||
|
{
|
||||||
|
"id": "prod",
|
||||||
|
"label": "prod",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||||
|
"presentation": {"height": 20},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "test",
|
||||||
|
"label": "test",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "test"},
|
||||||
|
"presentation": {"height": 10},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.node_assignments["svc.prod"].zone_id == "prod"
|
||||||
|
assert resolution.node_assignments["svc.prod"].reason == "seed"
|
||||||
|
assert resolution.zone_by_id("prod").seed_node_ids == ("svc.prod",)
|
||||||
|
assert resolution.zone_by_id("test").seed_node_ids == ("svc.test",)
|
||||||
|
assert resolution.boundary_edges[0].edge_id == "edge.prod-test"
|
||||||
|
assert resolution.boundary_edges[0].source_zone_id == "prod"
|
||||||
|
assert resolution.boundary_edges[0].target_zone_id == "test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_attracts_nodes_by_edge_type_direction_and_depth() -> None:
|
||||||
|
resolution = resolve_zones(
|
||||||
|
nodes=[
|
||||||
|
_node("seed", deploymentEnvironment="prod", kind="service"),
|
||||||
|
_node("near", kind="endpoint"),
|
||||||
|
_node("far", kind="endpoint"),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
_edge("edge.seed-near", "seed", "near", "routes_to_service"),
|
||||||
|
_edge("edge.near-far", "near", "far", "routes_to_service"),
|
||||||
|
],
|
||||||
|
zone_definitions=[
|
||||||
|
{
|
||||||
|
"id": "prod",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||||
|
"attraction": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"edge_type": "routes_to_service",
|
||||||
|
"direction": "out",
|
||||||
|
"depth": 1,
|
||||||
|
"node_filter": {"field": "kind", "op": "exists"},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.node_assignments["near"].zone_id == "prod"
|
||||||
|
assert resolution.node_assignments["near"].reason == "attraction"
|
||||||
|
assert resolution.node_assignments["near"].depth == 1
|
||||||
|
assert "far" not in resolution.node_assignments
|
||||||
|
assert resolution.zone_by_id("prod").internal_edge_ids == ("edge.seed-near",)
|
||||||
|
assert resolution.zone_by_id("prod").boundary_edge_ids == ("edge.near-far",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_keeps_seed_membership_over_attraction() -> None:
|
||||||
|
resolution = resolve_zones(
|
||||||
|
nodes=[
|
||||||
|
_node("seed", deploymentEnvironment="prod", kind="service"),
|
||||||
|
_node("tenant", deploymentEnvironment="tenant", kind="endpoint"),
|
||||||
|
],
|
||||||
|
edges=[
|
||||||
|
_edge("edge.seed-tenant", "seed", "tenant", "routes_to_service"),
|
||||||
|
],
|
||||||
|
zone_definitions=[
|
||||||
|
{
|
||||||
|
"id": "prod",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||||
|
"attraction": {
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"edge_type": "routes_to_service",
|
||||||
|
"direction": "out",
|
||||||
|
"depth": 1,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"presentation": {"height": 100},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tenant",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "tenant"},
|
||||||
|
"presentation": {"height": 0},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolution.node_assignments["tenant"].zone_id == "tenant"
|
||||||
|
assert resolution.node_assignments["tenant"].reason == "seed"
|
||||||
|
assert "ZONE_SEED_OVERRIDES_ATTRACTION" in {
|
||||||
|
diagnostic.code for diagnostic in resolution.diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_uses_height_then_definition_order_for_overlapping_membership() -> None:
|
||||||
|
height_resolution = resolve_zones(
|
||||||
|
nodes=[_node("shared", deploymentEnvironment="prod", owner="lord")],
|
||||||
|
edges=[],
|
||||||
|
zone_definitions=[
|
||||||
|
{
|
||||||
|
"id": "lower",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||||
|
"presentation": {"height": 10},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "higher",
|
||||||
|
"membership": {"field": "owner", "op": "equals", "value": "lord"},
|
||||||
|
"presentation": {"height": 20},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert height_resolution.node_assignments["shared"].zone_id == "higher"
|
||||||
|
assert "ZONE_NODE_SEEDED_BY_MULTIPLE_ZONES" in {
|
||||||
|
diagnostic.code for diagnostic in height_resolution.diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
order_resolution = resolve_zones(
|
||||||
|
nodes=[_node("shared", deploymentEnvironment="prod", owner="lord")],
|
||||||
|
edges=[],
|
||||||
|
zone_definitions=[
|
||||||
|
{
|
||||||
|
"id": "first",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||||
|
"presentation": {"height": 10},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "second",
|
||||||
|
"membership": {"field": "owner", "op": "equals", "value": "lord"},
|
||||||
|
"presentation": {"height": 10},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order_resolution.node_assignments["shared"].zone_id == "first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolver_serializes_resolution() -> None:
|
||||||
|
resolution = resolve_zones(
|
||||||
|
nodes=[_node("svc", deploymentEnvironment="prod")],
|
||||||
|
edges=[],
|
||||||
|
zone_definitions=[
|
||||||
|
{
|
||||||
|
"id": "prod",
|
||||||
|
"membership": {"field": "deploymentEnvironment", "op": "equals", "value": "prod"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
serialized = resolution.to_dict()
|
||||||
|
|
||||||
|
assert serialized["zones"][0]["id"] == "prod"
|
||||||
|
assert serialized["node_assignments"]["svc"]["zone_id"] == "prod"
|
||||||
|
assert serialized["node_assignments"]["svc"]["reason"] == "seed"
|
||||||
@@ -34,7 +34,7 @@ step.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0022-T01
|
id: RAIL-FAB-WP-0022-T01
|
||||||
status: in_progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "636b14f7-e5c7-4d5d-acda-40f0c270cd29"
|
state_hub_task_id: "636b14f7-e5c7-4d5d-acda-40f0c270cd29"
|
||||||
```
|
```
|
||||||
@@ -55,11 +55,17 @@ but it does not need a persistence store in this task.
|
|||||||
Expected result: a small typed model or schema with tests for construction and
|
Expected result: a small typed model or schema with tests for construction and
|
||||||
basic serialization.
|
basic serialization.
|
||||||
|
|
||||||
|
Result: Added `railiance_fabric.zone_view` dataclasses for zone definitions,
|
||||||
|
attraction rules, layout, presentation, collapse settings, resolved zone
|
||||||
|
instances, node assignments, boundary edges, diagnostics, and serializable
|
||||||
|
resolution output. Covered definition round-trip and resolution serialization in
|
||||||
|
`tests/test_zone_view.py`.
|
||||||
|
|
||||||
## Task 2: Implement A Pure Zone Resolver
|
## Task 2: Implement A Pure Zone Resolver
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: RAIL-FAB-WP-0022-T02
|
id: RAIL-FAB-WP-0022-T02
|
||||||
status: in_progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "d0ca41d2-f7c4-4409-8799-4c0099192af5"
|
state_hub_task_id: "d0ca41d2-f7c4-4409-8799-4c0099192af5"
|
||||||
```
|
```
|
||||||
@@ -78,6 +84,13 @@ The first resolver should support a conservative rule subset:
|
|||||||
Expected result: resolver unit tests that prove deterministic node assignment,
|
Expected result: resolver unit tests that prove deterministic node assignment,
|
||||||
seed-vs-attraction precedence, conflict reporting, and depth-limited attraction.
|
seed-vs-attraction precedence, conflict reporting, and depth-limited attraction.
|
||||||
|
|
||||||
|
Result: Implemented `resolve_zones()` as a pure resolver over Cytoscape-style or
|
||||||
|
raw node/edge mappings. It supports `equals`, `in`, `exists`, `all`, `any`,
|
||||||
|
membership `rules`, edge-type/direction/depth attraction, seed precedence,
|
||||||
|
height/order conflict resolution, single-zone node assignments, boundary edge
|
||||||
|
summaries, and conflict diagnostics. Covered the core behavior in
|
||||||
|
`tests/test_zone_view.py`.
|
||||||
|
|
||||||
## Task 3: Back The Existing Overlays With Zone Definitions
|
## Task 3: Back The Existing Overlays With Zone Definitions
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|||||||
Reference in New Issue
Block a user