Files
2026-07-13 13:17:40 +08:00

137 lines
5.5 KiB
Python

import collections
from dataclasses import dataclass, field
from typing import Dict, Iterator, List, Optional, Set, Tuple, Type
from ray.data._internal.logical.interfaces import Rule
from ray.util.annotations import DeveloperAPI
@DeveloperAPI
class Ruleset:
"""A collection of rules to apply to a plan.
This is a utility class to ensure that, if rules depend on each other, they're
applied in a correct order.
"""
@dataclass(frozen=True)
class _Node:
rule: Type[Rule]
dependents: List["Ruleset._Node"] = field(default_factory=list)
def __init__(self, rules: Optional[List[Type[Rule]]] = None):
if rules is None:
rules = []
self._rules = list(rules)
def add(self, rule: Type[Rule]):
if rule in self._rules:
raise ValueError(f"Rule {rule} already in ruleset")
self._rules.append(rule)
if self._contains_cycle():
raise ValueError("Cannot add rule that would create a cycle")
def remove(self, rule: Type[Rule]):
if rule not in self._rules:
raise ValueError(f"Rule {rule} not found in ruleset")
self._rules.remove(rule)
def __iter__(self) -> Iterator[Type[Rule]]:
"""Iterate over the rules in this ruleset.
This method yields rules in dependency order. For example, if B depends on A,
then this method yields A before B. Each rule is yielded exactly once, and a
rule is only yielded once *all* of its dependencies have been yielded (so a
rule that several others must precede is not emitted early or duplicated).
Insertion order breaks ties among rules that are ready at the same time.
"""
order, _ = self._topological_order()
for node in order:
yield node.rule
def _topological_order(self) -> Tuple[List["Ruleset._Node"], int]:
"""Order the nodes by dependency using Kahn's algorithm.
Returns the topologically-ordered nodes and the total node count.
A node is enqueued the moment its in-degree (count of not-yet-emitted
dependencies) hits zero; since an in-degree only decreases and we
enqueue solely on the zero-crossing, each node is emitted exactly once.
Insertion order breaks ties among nodes that are ready together.
Nodes that participate in a cycle never reach in-degree zero, so they
are absent from the result -- i.e. ``len(order) < total`` exactly when
the graph contains a cycle.
"""
nodes, indegree = self._build_graph()
queue = collections.deque(n for n in nodes if indegree[id(n)] == 0)
order: List["Ruleset._Node"] = []
while queue:
node = queue.popleft()
order.append(node)
for dep in node.dependents:
indegree[id(dep)] -= 1
if indegree[id(dep)] == 0:
queue.append(dep)
return order, len(nodes)
def _build_graph(
self,
) -> Tuple[List["Ruleset._Node"], Dict[int, int]]:
"""Build the dependency DAG.
Returns the nodes (one per rule, in insertion order) and their
in-degrees -- the number of rules that must be applied before each.
The in-degree map is keyed by node identity (``id``) rather than rule
type so that distinct nodes never share a counter, and is computed as
the edges are added (every incoming edge bumps the target's in-degree)
rather than re-derived by a second traversal. A node whose in-degree
is zero is a root.
"""
rule_to_node: Dict[Type[Rule], "Ruleset._Node"] = {
rule: Ruleset._Node(rule) for rule in self._rules
}
indegree: Dict[int, int] = {id(node): 0 for node in rule_to_node.values()}
# De-duplicate edges. The same ordering can be declared from both ends
# -- rule A lists B in ``dependencies()`` while B lists A in
# ``dependents()`` -- which would otherwise add the edge twice,
# double-counting the in-degree and duplicating ``dependents`` entries.
seen_edges: Set[Tuple[int, int]] = set()
def add_edge(before: "Ruleset._Node", after: "Ruleset._Node") -> None:
"""Record that ``before`` must be applied before ``after``."""
edge = (id(before), id(after))
if edge in seen_edges:
return
seen_edges.add(edge)
before.dependents.append(after)
indegree[id(after)] += 1
for rule in self._rules:
node = rule_to_node[rule]
# Rules that must be applied *before* this rule: dependency -> node.
for dependency in rule.dependencies():
if dependency in rule_to_node:
add_edge(rule_to_node[dependency], node)
# Rules that must be applied *after* this rule: node -> dependent.
for dependent in rule.dependents():
if dependent in rule_to_node:
add_edge(node, rule_to_node[dependent])
return list(rule_to_node.values()), indegree
def _contains_cycle(self) -> bool:
# Kahn's traversal drops any node stuck in a cycle (its in-degree never
# reaches zero), so a shortfall between the ordered nodes and the total
# means a cycle exists. This correctly flags a graph that mixes an
# acyclic component with a disjoint cycle -- a plain "does any root
# exist?" check would be fooled by the acyclic root.
order, total = self._topological_order()
return len(order) != total