torch-flowgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,282 @@
1
+ # SPDX-FileCopyrightText: 2026 European Centre for Medium-Range Weather Forecasts (ECMWF)
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Clustering strategies for computation graphs.
5
+
6
+ This module provides:
7
+
8
+ * :class:`Clustering` — abstract base class for all clustering strategies.
9
+ * :class:`ClanClustering` — re-clusters using the McCreary clan algorithm.
10
+ * :class:`LevelClustering` — shows only cluster boxes at a specific depth.
11
+ * :class:`NameClustering` — shows only cluster boxes with specific names.
12
+
13
+ All concrete classes implement :meth:`~Clustering.build_clusters`, which
14
+ receives a :class:`~torch_flowgraph.graph.GraphBase` and returns a new
15
+ :class:`~torch_flowgraph.clusters.Cluster` root. Use
16
+ :meth:`~torch_flowgraph.graph.GraphBase.recluster` to apply a strategy::
17
+
18
+ from torch_flowgraph.clustering import LevelClustering
19
+ shallow = graph.recluster(LevelClustering(level=1))
20
+
21
+ Backward-compatibility shim
22
+ ----------------------------
23
+ :func:`apply_clan_clustering` is kept as a convenience wrapper so that
24
+ existing code does not break::
25
+
26
+ from torch_flowgraph import apply_clan_clustering
27
+ clan_graph = apply_clan_clustering(graph)
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from abc import ABC
33
+ from abc import abstractmethod
34
+
35
+ from .clusters import Cluster
36
+
37
+
38
+ class Clustering(ABC):
39
+ """Abstract base class for graph reclustering strategies.
40
+
41
+ Subclasses implement :meth:`build_clusters`, which returns a new
42
+ :class:`~torch_flowgraph.clusters.Cluster` root built from *graph*.
43
+ The graph's nodes and edges are *not* modified; only the cluster
44
+ hierarchy changes.
45
+ """
46
+
47
+ @abstractmethod
48
+ def build_clusters(self, graph) -> Cluster:
49
+ """Build and return a new cluster root for *graph*.
50
+
51
+ Parameters
52
+ ----------
53
+ graph:
54
+ Source :class:`~torch_flowgraph.graph.GraphBase`.
55
+
56
+ Returns
57
+ -------
58
+ Cluster
59
+ Root of the new cluster hierarchy. The returned tree must
60
+ reference only node-ids that exist in *graph*.
61
+ """
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Clan clustering
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ class ClanClustering(Clustering):
70
+ """Re-cluster using the McCreary clan-based graph-parsing algorithm.
71
+
72
+ The resulting cluster hierarchy reflects the *dataflow* decomposition
73
+ of the graph (linear chains, independent sub-graphs, primitives) rather
74
+ than the original ``nn.Module`` code hierarchy.
75
+
76
+ Clan kinds assigned to clusters: ``LINEAR``, ``INDEPENDENT``,
77
+ ``PRIMITIVE``.
78
+
79
+ Trivial clusters are dissolved after parsing: single-node clusters
80
+ have their node promoted to the parent, and single-child wrapper
81
+ clusters (with no own nodes) are replaced by their only child.
82
+ """
83
+
84
+ def build_clusters(self, graph) -> Cluster:
85
+ from .parsing import Parser
86
+
87
+ G = graph.to_op_networkx()
88
+ parser = Parser(G)
89
+ parse_tree = parser.parse()
90
+ root = _parse_tree_to_cluster(parse_tree, parent=None)
91
+ root.update_cluster_nodes()
92
+ _dissolve_leaf_clusters(root)
93
+ return root
94
+
95
+
96
+ def _parse_tree_to_cluster(node, parent) -> Cluster:
97
+ """Convert a :class:`~torch_flowgraph.parsing.ParseTree` node to a Cluster."""
98
+ from .parsing import Kind
99
+
100
+ kind_name = node.clan.kind.name if node.clan.kind != Kind.UNDEF else "UNKNOWN"
101
+ cluster = Cluster(name=kind_name, parent=parent, nodes=list(node.clan.nodes))
102
+ cluster.children = [_parse_tree_to_cluster(child, cluster) for child in node.children]
103
+ return cluster
104
+
105
+
106
+ def _dissolve_leaf_clusters(cluster):
107
+ """Remove trivial clusters, attaching their contents to the parent.
108
+
109
+ A cluster is trivial when it contains only a single node (no
110
+ sub-clusters) or only a single sub-cluster (no direct nodes).
111
+ Runs repeatedly until no more trivial clusters remain.
112
+ """
113
+ changed = True
114
+ while changed:
115
+ changed = False
116
+ kept = []
117
+ for child in cluster.children:
118
+ _dissolve_leaf_clusters(child)
119
+ if not child.children and len(child.nodes) <= 1:
120
+ # Single-node (or empty) cluster — promote nodes to parent.
121
+ cluster.nodes.extend(child.nodes)
122
+ changed = True
123
+ elif len(child.children) == 1 and not child.nodes:
124
+ # Single-child wrapper with no own nodes — replace with the child.
125
+ grandchild = child.children[0]
126
+ grandchild.parent = cluster
127
+ kept.append(grandchild)
128
+ changed = True
129
+ else:
130
+ kept.append(child)
131
+ cluster.children = kept
132
+
133
+
134
+ # ---------------------------------------------------------------------------
135
+ # Selective clustering (level / name filters)
136
+ # ---------------------------------------------------------------------------
137
+
138
+
139
+ class _SelectiveClustering(Clustering):
140
+ """Base for strategies that select a subset of existing cluster boxes.
141
+
142
+ Matching clusters are kept as labeled boxes. All other clusters are
143
+ dropped: their nodes are absorbed into the flat root so the complete
144
+ node set is still rendered — just without grouping boxes.
145
+ """
146
+
147
+ @abstractmethod
148
+ def _find_matching(self, root_cluster: Cluster) -> list[Cluster]:
149
+ """Return clusters from *root_cluster*'s tree that should be drawn."""
150
+
151
+ def build_clusters(self, graph) -> Cluster:
152
+ matching = self._find_matching(graph.clusters)
153
+
154
+ # Collect all node-ids claimed by matching cluster subtrees.
155
+ matching_nodes: set[int] = set()
156
+ for c in matching:
157
+ matching_nodes.update(c.all_nodes)
158
+
159
+ # Root gets every node *not* in a matching subtree (shown ungrouped).
160
+ root = Cluster(name=None)
161
+ root.nodes = [n for n in graph.nodes if n not in matching_nodes]
162
+ root.children = []
163
+
164
+ for c in matching:
165
+ child = Cluster(name=c.name, nodes=list(c.all_nodes), parent=root)
166
+ root.children.append(child)
167
+
168
+ return root
169
+
170
+
171
+ class LevelClustering(_SelectiveClustering):
172
+ """Show only cluster boxes at one or more specific depths.
173
+
174
+ All nodes remain in the graph; those not inside a matching cluster
175
+ are rendered without a surrounding box.
176
+
177
+ Parameters
178
+ ----------
179
+ level : int or collection of int
180
+ Target cluster level(s), 0-indexed from the outermost real cluster.
181
+ ``0`` means the top-level cluster boxes (the root's direct children),
182
+ ``1`` their children, etc. The nameless structural root is not a
183
+ selectable level. Pass a single ``int`` or any iterable of ``int``
184
+ values to select multiple levels simultaneously.
185
+ """
186
+
187
+ def __init__(self, level) -> None:
188
+ if isinstance(level, int):
189
+ self.levels: frozenset[int] = frozenset([level])
190
+ else:
191
+ self.levels = frozenset(level)
192
+
193
+ def _find_matching(self, root_cluster: Cluster) -> list[Cluster]:
194
+ matching: list[Cluster] = []
195
+ # User-facing level L corresponds to internal cluster depth L + 1,
196
+ # since the root (depth 0) is a nameless structural container.
197
+ target_depths = frozenset(level + 1 for level in self.levels)
198
+ max_depth = max(target_depths) if target_depths else 0
199
+
200
+ def _walk(c: Cluster) -> None:
201
+ if c.depth in target_depths:
202
+ matching.append(c)
203
+ # Don't recurse: the whole subtree is kept in this box.
204
+ elif c.depth < max_depth:
205
+ for child in c.children:
206
+ _walk(child)
207
+
208
+ _walk(root_cluster)
209
+ return matching
210
+
211
+
212
+ class NameClustering(_SelectiveClustering):
213
+ """Show only cluster boxes whose name is in *names*.
214
+
215
+ All nodes remain in the graph; nodes not inside a named cluster are
216
+ rendered without a surrounding box.
217
+
218
+ Parameters
219
+ ----------
220
+ names : collection of str
221
+ Cluster names to keep. When a matching cluster is found, its
222
+ children are not inspected (the whole subtree is included in the box).
223
+ """
224
+
225
+ def __init__(self, names) -> None:
226
+ self.names: frozenset[str] = frozenset(names)
227
+
228
+ def _find_matching(self, root_cluster: Cluster) -> list[Cluster]:
229
+ matching: list[Cluster] = []
230
+
231
+ def _walk(c: Cluster) -> None:
232
+ if c.name in self.names:
233
+ matching.append(c)
234
+ else:
235
+ for child in c.children:
236
+ _walk(child)
237
+
238
+ _walk(root_cluster)
239
+ return matching
240
+
241
+
242
+ # ---------------------------------------------------------------------------
243
+ # No-op clustering — clears all cluster boxes
244
+ # ---------------------------------------------------------------------------
245
+
246
+
247
+ class NoClustering(Clustering):
248
+ """Remove all cluster boxes, rendering every node at the top level.
249
+
250
+ The returned cluster hierarchy is a single flat root containing all
251
+ nodes and no children. The graph itself is unchanged; only the cluster
252
+ boxes disappear from the rendered output.
253
+ """
254
+
255
+ def build_clusters(self, graph) -> Cluster:
256
+ root = Cluster(name=None)
257
+ root.nodes = list(graph.nodes)
258
+ return root
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Backward-compatibility shim
263
+ # ---------------------------------------------------------------------------
264
+
265
+
266
+ def apply_clan_clustering(graph):
267
+ """Re-cluster *graph* using the McCreary clan algorithm.
268
+
269
+ Convenience wrapper around ``graph.recluster(ClanClustering())``.
270
+ Kept for backward compatibility with existing code.
271
+
272
+ Parameters
273
+ ----------
274
+ graph:
275
+ Source :class:`~torch_flowgraph.graph.GraphBase`.
276
+
277
+ Returns
278
+ -------
279
+ GraphBase
280
+ New graph with clan-derived cluster hierarchy.
281
+ """
282
+ return graph.recluster(ClanClustering())
@@ -0,0 +1,209 @@
1
+ # SPDX-FileCopyrightText: 2026 European Centre for Medium-Range Weather Forecasts (ECMWF)
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Cluster data structure for hierarchical graph grouping.
5
+
6
+ A :class:`Cluster` represents a named group of graph nodes, arranged in a
7
+ tree via parent/children references. Clusters are used to generate Graphviz
8
+ ``subgraph cluster_*`` blocks and to control the collapsing/expansion of
9
+ module hierarchies during rendering.
10
+ """
11
+
12
+ from functools import cached_property
13
+
14
+
15
+ class Cluster:
16
+ """A named, hierarchical group of graph nodes.
17
+
18
+ Clusters form a tree: each cluster has a *parent* and zero or more
19
+ *children* (sub-clusters). The *nodes* list holds the integer node-ids
20
+ that belong directly to this cluster (not to its children).
21
+
22
+ Attributes
23
+ ----------
24
+ name : str or None
25
+ Human-readable cluster name shown in Graphviz DOT output as the
26
+ ``label`` attribute.
27
+ nodes : list[int]
28
+ Integer node-ids that are direct members of this cluster.
29
+ children : list[Cluster]
30
+ Sub-clusters nested inside this cluster.
31
+ parent : Cluster or None
32
+ Parent cluster, or ``None`` for the root.
33
+ depth : int
34
+ Depth of this cluster in the tree (set by :class:`ModelGraph`
35
+ during graph finalisation; ``-1`` until then).
36
+ visible : bool or None
37
+ Explicit visibility override. ``None`` means visibility is
38
+ determined automatically by :meth:`show_cluster`.
39
+ kwargs : dict
40
+ Additional Graphviz DOT attributes passed through to
41
+ :attr:`dot_attributes`.
42
+ """
43
+
44
+ def __init__(self, *, name=None, parent=None, visible=None, nodes=None, children=None, **kwargs):
45
+ """Initialise a Cluster.
46
+
47
+ Parameters
48
+ ----------
49
+ name:
50
+ Display label for the cluster in DOT output.
51
+ parent:
52
+ Parent :class:`Cluster`, or ``None`` for the root.
53
+ visible:
54
+ Explicit visibility flag. When ``None`` the cluster is always
55
+ shown (see :meth:`show_cluster`).
56
+ nodes:
57
+ Initial list of direct member node-ids. Defaults to ``[]``.
58
+ children:
59
+ Initial list of child :class:`Cluster` instances. Defaults to
60
+ ``[]``.
61
+ **kwargs:
62
+ Additional Graphviz DOT attributes (e.g. ``bgcolor``,
63
+ ``pencolor``).
64
+ """
65
+ self.name = name
66
+ self.nodes = [] if nodes is None else nodes
67
+ self.children = [] if children is None else children
68
+ self.parent = parent
69
+ self.depth = -1
70
+ self.visible = visible
71
+ self.is_repeated = False
72
+ self.kwargs = kwargs
73
+
74
+ @property
75
+ def dot_attributes(self):
76
+ """Graphviz DOT attribute dictionary for this cluster's subgraph block.
77
+
78
+ Starts from any extra keyword arguments supplied at construction time
79
+ and adds a ``"label"`` key when :attr:`name` is set.
80
+
81
+ Returns
82
+ -------
83
+ dict
84
+ Mapping of DOT attribute name to value.
85
+ """
86
+ attr = self.kwargs.copy()
87
+ if self.name is not None:
88
+ attr["label"] = self.name
89
+ return attr
90
+
91
+ def __len__(self):
92
+ """Return the number of direct member nodes."""
93
+ return len(self.nodes)
94
+
95
+ def __repr__(self):
96
+ return f"Cluster({self.name})"
97
+
98
+ def as_dict(self):
99
+ """Serialise the cluster tree to a JSON-compatible dictionary.
100
+
101
+ Returns
102
+ -------
103
+ dict
104
+ Dictionary with ``"name"``, ``"nodes"``, and ``"children"``
105
+ keys. Children are recursively serialised.
106
+ """
107
+ return {
108
+ "name": self.name,
109
+ "nodes": self.nodes,
110
+ "children": [c.as_dict() for c in self.children],
111
+ }
112
+
113
+ @classmethod
114
+ def from_dict(cls, data, parent):
115
+ """Deserialise a cluster tree from a dictionary.
116
+
117
+ Handles both the current ``"children"`` key and the legacy
118
+ ``"clusters"`` key for backwards compatibility.
119
+
120
+ Parameters
121
+ ----------
122
+ data:
123
+ Dictionary as produced by :meth:`as_dict`.
124
+ parent:
125
+ Parent :class:`Cluster` to assign to the newly created cluster
126
+ (``None`` for the root).
127
+
128
+ Returns
129
+ -------
130
+ Cluster
131
+ Fully reconstructed cluster tree.
132
+ """
133
+ data = data.copy()
134
+ children = data.pop("children", data.get("clusters", []))
135
+ cluster = cls(parent=parent, **data)
136
+ cluster.children = [cls.from_dict(c, cluster) for c in children]
137
+ return cluster
138
+
139
+ @cached_property
140
+ def all_nodes(self):
141
+ """Set of all node-ids contained anywhere in this cluster subtree.
142
+
143
+ Computed once and cached. Includes nodes from this cluster and all
144
+ descendant clusters.
145
+
146
+ Returns
147
+ -------
148
+ set[int]
149
+ """
150
+ result = set()
151
+
152
+ def _all_nodes(c):
153
+ for n in c.nodes:
154
+ result.add(n)
155
+ for c in c.children:
156
+ _all_nodes(c)
157
+
158
+ _all_nodes(self)
159
+ return result
160
+
161
+ def show_cluster(self, depth):
162
+ """Determine whether this cluster should be rendered as a subgraph.
163
+
164
+ Returns :attr:`visible` when it is explicitly set, otherwise always
165
+ returns ``True``.
166
+
167
+ Parameters
168
+ ----------
169
+ depth:
170
+ Current rendering depth (not used in the default implementation
171
+ but available for subclasses).
172
+
173
+ Returns
174
+ -------
175
+ bool
176
+ """
177
+ if self.visible is not None:
178
+ return self.visible
179
+
180
+ return True
181
+
182
+ def update_cluster_nodes(self):
183
+ """Redistribute nodes so each node appears in exactly one cluster.
184
+
185
+ Starting from the full set of nodes in this subtree (:attr:`all_nodes`),
186
+ recursively assigns each node to the deepest cluster that contains it,
187
+ removing it from all ancestor clusters' :attr:`nodes` lists.
188
+
189
+ Returns
190
+ -------
191
+ Cluster
192
+ *self*, mutated in-place.
193
+ """
194
+ nodes = set(self.all_nodes)
195
+
196
+ def _update_cluster_nodes(c):
197
+ nonlocal nodes
198
+
199
+ for c in c.children:
200
+ _update_cluster_nodes(c)
201
+
202
+ c.nodes = list(set(c.nodes).intersection(nodes))
203
+ nodes = nodes.difference(c.nodes)
204
+
205
+ return c
206
+
207
+ _update_cluster_nodes(self)
208
+
209
+ return self
torch_flowgraph/dot.py ADDED
@@ -0,0 +1,83 @@
1
+ # SPDX-FileCopyrightText: 2026 European Centre for Medium-Range Weather Forecasts (ECMWF)
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """High-level DOT export helper for :class:`ModelGraph`.
5
+
6
+ This module provides :func:`save_dot`, a convenience wrapper around
7
+ :func:`~torch_flowgraph.plotting.plot_graph` that accepts a
8
+ :class:`~torch_flowgraph.model.graph.ModelGraph` and writes a Graphviz DOT
9
+ file suitable for rendering with ``dot -Tpdf``.
10
+ """
11
+
12
+ from torch_flowgraph.clusters import Cluster
13
+ from torch_flowgraph.plotting import plot_graph
14
+
15
+
16
+ def save_dot(
17
+ graph,
18
+ path,
19
+ graph_attributes=None,
20
+ clusters=None,
21
+ depth=None,
22
+ links=False,
23
+ show_clusters=True,
24
+ highlight_fn=None,
25
+ cluster_bg=True,
26
+ ):
27
+ """Write a :class:`ModelGraph` to a Graphviz DOT file.
28
+
29
+ Optionally replaces the graph's cluster hierarchy with *clusters* and/or
30
+ collapses the graph to *depth* levels before rendering.
31
+
32
+ Parameters
33
+ ----------
34
+ graph:
35
+ :class:`~torch_flowgraph.model.graph.ModelGraph` instance to export.
36
+ path:
37
+ Destination file path for the ``.dot`` output.
38
+ graph_attributes:
39
+ Optional dictionary of top-level Graphviz graph attributes (e.g.
40
+ ``{"rankdir": "LR"}``).
41
+ clusters:
42
+ Controls subgraph box rendering:
43
+
44
+ * ``None`` (default) — use the graph's own cluster hierarchy.
45
+ * A :class:`~torch_flowgraph.clusters.Cluster` instance — re-cluster
46
+ the graph with this hierarchy before rendering.
47
+ * ``[]`` (empty list) — suppress all subgraph boxes.
48
+ depth:
49
+ Maximum cluster depth to render. When given, the graph is collapsed
50
+ to *depth* levels via
51
+ :meth:`~torch_flowgraph.model.graph.ModelGraph.collapsed`.
52
+ links:
53
+ When ``True``, function nodes include ``URL`` attributes linking to
54
+ the PyTorch documentation. Defaults to ``False``.
55
+ show_clusters:
56
+ When ``False``, all cluster subgraph blocks are rendered as invisible
57
+ so they guide the layout engine without drawing visible boxes.
58
+ Forwarded to :func:`~torch_flowgraph.plotting.plot_graph`.
59
+ Defaults to ``True``.
60
+ cluster_bg:
61
+ When ``False``, cluster boxes are drawn without a background fill.
62
+ Forwarded to :func:`~torch_flowgraph.plotting.plot_graph`.
63
+ Defaults to ``True``.
64
+ """
65
+ # Re-cluster only when a real Cluster object is provided.
66
+ if isinstance(clusters, Cluster) and clusters is not graph.clusters:
67
+ graph = graph.use_clusters(clusters)
68
+
69
+ graph = graph.collapsed(depth) if depth is not None else graph
70
+
71
+ G = graph.to_op_networkx(links=links)
72
+ # None → show own clusters (skip root wrapper, use children directly);
73
+ # [] → no boxes; Cluster already applied above.
74
+ display_clusters = graph.clusters.children if clusters is None else clusters
75
+ plot_graph(
76
+ path,
77
+ G,
78
+ display_clusters,
79
+ graph_attributes=graph_attributes,
80
+ cluster_bg=cluster_bg,
81
+ show_clusters=show_clusters,
82
+ highlight_fn=highlight_fn,
83
+ )