treegraphduals 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,30 @@
1
+ """Trees, their duals, graphs, and time series.
2
+
3
+ An extension of *The Horizontal Tunnelability Graph is Dual to Level Set Trees*
4
+ (Khan, University of Nevada, Reno, 2023).
5
+
6
+ The library is organized into subpackages, imported explicitly so that a bare
7
+ ``import treegraphduals`` stays free of plotting dependencies:
8
+
9
+ - :mod:`treegraphduals.core` -- tree and graph data structures
10
+ - :mod:`treegraphduals.timeseries` -- time series analysis and tree conversions
11
+ - :mod:`treegraphduals.visualizations` -- plotting (requires matplotlib)
12
+
13
+ Examples
14
+ --------
15
+ >>> from treegraphduals.core import Tree
16
+ >>> tree = Tree(n_nodes=3, root=0)
17
+ >>> tree.add_edge(0, 1)
18
+ >>> tree.add_edge(0, 2)
19
+ >>> tree.n_edges
20
+ 2
21
+ """
22
+
23
+ from importlib.metadata import PackageNotFoundError, version
24
+
25
+ try:
26
+ __version__ = version("treegraphduals")
27
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
28
+ __version__ = "0.0.0+unknown"
29
+
30
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ """LangGraph-based tooling: verification assistant and experiment-exploration loop.
2
+
3
+ Secondary to the core research; see the ``agents`` optional dependency group.
4
+ """
@@ -0,0 +1,28 @@
1
+ """Core package initialization."""
2
+
3
+ from .base_graph import BaseGraph, GraphRepresentation
4
+ from .binary_tree import BinaryTree
5
+ from .dag import DAG
6
+ from .erdos_renyi import ErdosRenyi
7
+ from .forest import Forest
8
+ from .galton_watson import GaltonWatsonTree
9
+ from .graph import Graph
10
+ from .multigraph import Multigraph
11
+ from .polytree import Polytree
12
+ from .real_tree import RealTree
13
+ from .tree import Tree
14
+
15
+ __all__ = [
16
+ "DAG",
17
+ "BaseGraph",
18
+ "BinaryTree",
19
+ "ErdosRenyi",
20
+ "Forest",
21
+ "GaltonWatsonTree",
22
+ "Graph",
23
+ "GraphRepresentation",
24
+ "Multigraph",
25
+ "Polytree",
26
+ "RealTree",
27
+ "Tree",
28
+ ]
@@ -0,0 +1,303 @@
1
+ """
2
+ Generic representation and base graph classes with multi-library compatibility.
3
+
4
+ Supports NetworkX, igraph, numpy arrays, scipy sparse matrices, and other representations.
5
+ BaseGraph(ABC)
6
+ ├── DAG
7
+ │ └── Polytree
8
+ ├── Tree
9
+ │ └── BinaryTree
10
+ │ ├── GaltonWatsonTree
11
+ │ └── Forest
12
+ ├── GeneralGraph
13
+ │ ├── Multigraph
14
+ │ └── ErdosRenyi
15
+ └── RealTree (separate - different math)
16
+ """
17
+
18
+ from abc import ABC, abstractmethod
19
+ from typing import Any
20
+
21
+ import numpy as np
22
+
23
+
24
+ class GraphRepresentation:
25
+ """
26
+ Lightweight internal representation of a graph.
27
+
28
+ Stores graph data in an efficient format and provides conversions
29
+ to networkx, igraph, and scipy graph libraries and various graph representations.
30
+ """
31
+
32
+ def __init__(self, n_nodes: int):
33
+ self.n_nodes = n_nodes
34
+ self.edges: list[tuple[int, int]] = []
35
+ self.edge_attrs: dict[tuple[int, int], dict[str, Any]] = {}
36
+ self.node_attrs: dict[int, dict[str, Any]] = {i: {} for i in range(n_nodes)}
37
+
38
+ # Cache for converted representations
39
+ self._networkx_cache = None
40
+ self._igraph_cache = None
41
+ self._adjacency_cache = None
42
+ self._dirty = False # Track if graph has been modified
43
+
44
+ def add_edge(self, u: int, v: int, **attrs):
45
+ """Add an edge with optional attributes."""
46
+ edge = (u, v)
47
+ if edge not in self.edges:
48
+ self.edges.append(edge)
49
+ self.edge_attrs[edge] = attrs
50
+ self._invalidate_caches()
51
+
52
+ def add_node_attr(self, node: int, key: str, value: Any):
53
+ """Add attribute to a node."""
54
+ self.node_attrs[node][key] = value
55
+ self._invalidate_caches()
56
+
57
+ def _invalidate_caches(self):
58
+ """Invalidate cached representations when graph is modified."""
59
+ self._networkx_cache = None
60
+ self._igraph_cache = None
61
+ self._adjacency_cache = None
62
+ self._dirty = True
63
+
64
+ def to_networkx(self, directed: bool = True):
65
+ """Convert to NetworkX graph."""
66
+ if self._networkx_cache is not None and not self._dirty:
67
+ return self._networkx_cache
68
+
69
+ try:
70
+ import networkx as nx
71
+ except ImportError:
72
+ raise ImportError(
73
+ "NetworkX is required for this conversion. Install with: pip install networkx or per your package manager's syntax"
74
+ )
75
+
76
+ G = nx.DiGraph() if directed else nx.Graph()
77
+ G.add_nodes_from(range(self.n_nodes))
78
+
79
+ # Add node attributes
80
+ for node, attrs in self.node_attrs.items():
81
+ for key, value in attrs.items():
82
+ G.nodes[node][key] = value
83
+
84
+ # Add edges with attributes
85
+ for u, v in self.edges:
86
+ attrs = self.edge_attrs.get((u, v), {})
87
+ G.add_edge(u, v, **attrs)
88
+
89
+ self._networkx_cache = G
90
+ self._dirty = False
91
+ return G
92
+
93
+ def to_igraph(self, directed: bool = True):
94
+ """Convert to igraph graph."""
95
+ if self._igraph_cache is not None and not self._dirty:
96
+ return self._igraph_cache
97
+
98
+ try:
99
+ import igraph as ig
100
+ except ImportError:
101
+ raise ImportError(
102
+ "igraph is required for this conversion. Install with: pip install igraph or per your package manager's syntax"
103
+ )
104
+
105
+ g = ig.Graph(n=self.n_nodes, directed=directed)
106
+
107
+ # Add edges
108
+ if self.edges:
109
+ g.add_edges(self.edges)
110
+
111
+ # Add edge attributes
112
+ for edge_idx, (u, v) in enumerate(self.edges):
113
+ attrs = self.edge_attrs.get((u, v), {})
114
+ for key, value in attrs.items():
115
+ if key not in g.es.attributes():
116
+ g.es[key] = [None] * g.ecount()
117
+ g.es[edge_idx][key] = value
118
+
119
+ # Add node attributes
120
+ for node, attrs in self.node_attrs.items():
121
+ for key, value in attrs.items():
122
+ if key not in g.vs.attributes():
123
+ g.vs[key] = [None] * g.vcount()
124
+ g.vs[node][key] = value
125
+
126
+ self._igraph_cache = g
127
+ self._dirty = False
128
+ return g
129
+
130
+ def to_adjacency_matrix(
131
+ self, weighted: bool = False, weight_attr: str = "length"
132
+ ) -> np.ndarray:
133
+ """
134
+ Convert to adjacency matrix (numpy array).
135
+
136
+ Parameters
137
+ ----------
138
+ weighted : bool
139
+ If True, use edge weights. If False, binary adjacency.
140
+ weight_attr : str
141
+ Edge attribute to use for weights.
142
+ """
143
+ if not weighted and self._adjacency_cache is not None:
144
+ return self._adjacency_cache
145
+
146
+ adj = np.zeros((self.n_nodes, self.n_nodes))
147
+
148
+ for u, v in self.edges:
149
+ if weighted and weight_attr in self.edge_attrs.get((u, v), {}):
150
+ adj[u, v] = self.edge_attrs[(u, v)][weight_attr]
151
+ else:
152
+ adj[u, v] = 1
153
+
154
+ if not weighted:
155
+ self._adjacency_cache = adj
156
+
157
+ return adj
158
+
159
+ def to_sparse_matrix(self, weighted: bool = False, weight_attr: str = "length"):
160
+ """Convert to scipy sparse matrix (CSR format)."""
161
+ try:
162
+ from scipy.sparse import csr_matrix
163
+ except ImportError:
164
+ raise ImportError(
165
+ "scipy is required for sparse matrices. Install with: pip install scipy or per your package manager's syntax"
166
+ )
167
+
168
+ adj = self.to_adjacency_matrix(weighted=weighted, weight_attr=weight_attr)
169
+ return csr_matrix(adj)
170
+
171
+ @classmethod
172
+ def from_networkx(cls, G):
173
+ """Create from NetworkX graph."""
174
+ n_nodes = G.number_of_nodes()
175
+ graph_rep = cls(n_nodes)
176
+
177
+ # Add edges with attributes
178
+ for u, v, attrs in G.edges(data=True):
179
+ graph_rep.add_edge(u, v, **attrs)
180
+
181
+ # Add node attributes
182
+ for node, attrs in G.nodes(data=True):
183
+ for key, value in attrs.items():
184
+ graph_rep.add_node_attr(node, key, value)
185
+
186
+ return graph_rep
187
+
188
+ @classmethod
189
+ def from_igraph(cls, g):
190
+ """Create from igraph graph."""
191
+ n_nodes = g.vcount()
192
+ graph_rep = cls(n_nodes)
193
+
194
+ # Add edges with attributes
195
+ for edge in g.es:
196
+ u, v = edge.tuple
197
+ attrs = {key: edge[key] for key in edge.attributes()}
198
+ graph_rep.add_edge(u, v, **attrs)
199
+
200
+ # Add node attributes
201
+ for node_idx in range(n_nodes):
202
+ vertex = g.vs[node_idx]
203
+ for key in vertex.attributes():
204
+ graph_rep.add_node_attr(node_idx, key, vertex[key])
205
+
206
+ return graph_rep
207
+
208
+ @classmethod
209
+ def from_adjacency_matrix(cls, adj: np.ndarray, weighted: bool = False):
210
+ """Create from adjacency matrix."""
211
+ n_nodes = adj.shape[0]
212
+ graph_rep = cls(n_nodes)
213
+
214
+ rows, cols = np.nonzero(adj)
215
+ for u, v in zip(rows, cols):
216
+ if weighted:
217
+ graph_rep.add_edge(int(u), int(v), weight=float(adj[u, v]))
218
+ else:
219
+ graph_rep.add_edge(int(u), int(v))
220
+
221
+ return graph_rep
222
+
223
+
224
+ class BaseGraph(ABC):
225
+ """
226
+ Abstract base class for all graph structures.
227
+
228
+ This provides a unified interface while maintaining compatibility
229
+ with multiple graph libraries.
230
+ """
231
+
232
+ def __init__(self, n_nodes: int = 0):
233
+ self._graph = GraphRepresentation(n_nodes)
234
+
235
+ @property
236
+ def n_nodes(self) -> int:
237
+ """Number of nodes in the graph."""
238
+ return self._graph.n_nodes
239
+
240
+ @property
241
+ def n_edges(self) -> int:
242
+ """Number of edges in the graph."""
243
+ return len(self._graph.edges)
244
+
245
+ def add_edge(self, u: int, v: int, **attrs):
246
+ """Add an edge with optional attributes."""
247
+ self._graph.add_edge(u, v, **attrs)
248
+
249
+ def add_node_attr(self, node: int, key: str, value: Any):
250
+ """Add attribute to a node."""
251
+ self._graph.add_node_attr(node, key, value)
252
+
253
+ # Conversion methods
254
+ def to_networkx(self, directed: bool = True):
255
+ """Export to NetworkX graph."""
256
+ return self._graph.to_networkx(directed=directed)
257
+
258
+ def to_igraph(self, directed: bool = True):
259
+ """Export to igraph graph."""
260
+ return self._graph.to_igraph(directed=directed)
261
+
262
+ def to_adjacency_matrix(
263
+ self, weighted: bool = False, weight_attr: str = "length"
264
+ ) -> np.ndarray:
265
+ """Export to numpy adjacency matrix."""
266
+ return self._graph.to_adjacency_matrix(
267
+ weighted=weighted, weight_attr=weight_attr
268
+ )
269
+
270
+ def to_sparse_matrix(self, weighted: bool = False, weight_attr: str = "length"):
271
+ """Export to scipy sparse matrix."""
272
+ return self._graph.to_sparse_matrix(weighted=weighted, weight_attr=weight_attr)
273
+
274
+ @classmethod
275
+ def from_networkx(cls, G):
276
+ """Create from NetworkX graph."""
277
+ instance = cls(G.number_of_nodes())
278
+ instance._graph = GraphRepresentation.from_networkx(G)
279
+ return instance
280
+
281
+ @classmethod
282
+ def from_igraph(cls, g):
283
+ """Create from igraph graph."""
284
+ instance = cls(g.vcount())
285
+ instance._graph = GraphRepresentation.from_igraph(g)
286
+ return instance
287
+
288
+ @classmethod
289
+ def from_adjacency_matrix(cls, adj: np.ndarray, weighted: bool = False):
290
+ """Create from adjacency matrix."""
291
+ instance = cls(adj.shape[0])
292
+ instance._graph = GraphRepresentation.from_adjacency_matrix(adj, weighted)
293
+ return instance
294
+
295
+ @abstractmethod
296
+ def validate(self) -> bool:
297
+ """Validate that the graph satisfies structural constraints."""
298
+
299
+ def __repr__(self):
300
+ """Return a summary of the graph's node and edge counts."""
301
+ return (
302
+ f"{self.__class__.__name__}(n_nodes={self.n_nodes}, n_edges={self.n_edges})"
303
+ )
@@ -0,0 +1,286 @@
1
+ """
2
+ Binary tree data structure with multi-library compatibility.
3
+
4
+ Extends Tree with binary-specific constraints and operations.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+ from .tree import Tree
10
+
11
+
12
+ class BinaryTree(Tree):
13
+ """
14
+ Binary tree where each node has at most 2 children.
15
+
16
+ Maintains explicit left/right child tracking for binary-specific operations.
17
+ Also stores whether the tree is 'planted' (root has degree 1).
18
+ """
19
+
20
+ def __init__(self, n_nodes: int = 0, root: int | None = None, planted: bool = True):
21
+ super().__init__(n_nodes, root)
22
+
23
+ # Binary tree specific structures
24
+ self.left_child: np.ndarray = np.full(n_nodes, -1, dtype=np.int32)
25
+ self.right_child: np.ndarray = np.full(n_nodes, -1, dtype=np.int32)
26
+ self.planted = planted # Whether root has degree 1
27
+
28
+ def add_edge(
29
+ self,
30
+ parent: int,
31
+ child: int,
32
+ side: str | None = None,
33
+ length: float = 1.0,
34
+ **attrs,
35
+ ):
36
+ """
37
+ Add an edge from parent to child.
38
+
39
+ Parameters
40
+ ----------
41
+ parent : int
42
+ Parent node index
43
+ child : int
44
+ Child node index
45
+ side : str, optional
46
+ 'left' or 'right'. If None, will be determined automatically.
47
+ length : float
48
+ Edge length
49
+ **attrs
50
+ Additional edge attributes
51
+ """
52
+ # Validate binary constraint
53
+ if len(self.children[parent]) >= 2:
54
+ raise ValueError(
55
+ f"Node {parent} already has 2 children. Binary tree constraint violated."
56
+ )
57
+
58
+ # Determine side if not specified
59
+ if side is None:
60
+ if self.left_child[parent] == -1:
61
+ side = "left"
62
+ elif self.right_child[parent] == -1:
63
+ side = "right"
64
+ else:
65
+ raise ValueError(f"Node {parent} already has 2 children")
66
+
67
+ # Update left/right tracking
68
+ if side == "left":
69
+ if self.left_child[parent] != -1:
70
+ raise ValueError(f"Node {parent} already has a left child")
71
+ self.left_child[parent] = child
72
+ elif side == "right":
73
+ if self.right_child[parent] != -1:
74
+ raise ValueError(f"Node {parent} already has a right child")
75
+ self.right_child[parent] = child
76
+ else:
77
+ raise ValueError(f"side must be 'left' or 'right', got {side}")
78
+
79
+ # Call parent add_edge
80
+ super().add_edge(parent, child, length=length, **attrs)
81
+
82
+ # Store side as edge attribute
83
+ self._graph.edge_attrs[(parent, child)]["side"] = side
84
+
85
+ def get_left_child(self, node: int) -> int:
86
+ """Get left child of node. Returns -1 if no left child."""
87
+ return int(self.left_child[node])
88
+
89
+ def get_right_child(self, node: int) -> int:
90
+ """Get right child of node. Returns -1 if no right child."""
91
+ return int(self.right_child[node])
92
+
93
+ def is_left_child(self, node: int) -> bool:
94
+ """Check if node is a left child of its parent."""
95
+ if self.is_root(node):
96
+ return False
97
+ parent = self.parent[node]
98
+ return self.left_child[parent] == node
99
+
100
+ def is_right_child(self, node: int) -> bool:
101
+ """Check if node is a right child of its parent."""
102
+ if self.is_root(node):
103
+ return False
104
+ parent = self.parent[node]
105
+ return self.right_child[parent] == node
106
+
107
+ def get_sibling(self, node: int) -> int:
108
+ """Get sibling of node. Returns -1 if no sibling or node is root."""
109
+ if self.is_root(node):
110
+ return -1
111
+
112
+ parent = self.parent[node]
113
+ if self.is_left_child(node):
114
+ return self.right_child[parent]
115
+ else:
116
+ return self.left_child[parent]
117
+
118
+ def depth_first_search_lr(self, start: int | None = None) -> list[int]:
119
+ """
120
+ Depth-first search with explicit left-right ordering.
121
+
122
+ Returns nodes in order: leftmost leaf → root → rightmost leaf
123
+ (standard DFS for binary trees).
124
+ """
125
+ if start is None:
126
+ start = self.root
127
+
128
+ order = []
129
+
130
+ def dfs(node):
131
+ if node == -1:
132
+ return
133
+ dfs(self.left_child[node])
134
+ order.append(node)
135
+ dfs(self.right_child[node])
136
+
137
+ dfs(start)
138
+ return order
139
+
140
+ def get_leaves_lr(self) -> list[int]:
141
+ """Get leaves in left-to-right order."""
142
+ return [node for node in self.depth_first_search_lr() if self.is_leaf(node)]
143
+
144
+ def validate(self) -> bool:
145
+ """
146
+ Validate binary tree structure.
147
+
148
+ Checks Tree constraints plus:
149
+ - Each node has at most 2 children
150
+ - Left/right child arrays are consistent with children lists
151
+ """
152
+ # Check parent tree validation
153
+ if not super().validate():
154
+ return False
155
+
156
+ # Check binary constraint
157
+ for node in range(self.n_nodes):
158
+ if len(self.children[node]) > 2:
159
+ return False
160
+
161
+ # Check consistency of left/right with children list
162
+ left = self.left_child[node]
163
+ right = self.right_child[node]
164
+ children_set = set(self.children[node])
165
+
166
+ expected_children = set()
167
+ if left != -1:
168
+ expected_children.add(left)
169
+ if right != -1:
170
+ expected_children.add(right)
171
+
172
+ if children_set != expected_children:
173
+ return False
174
+
175
+ return True
176
+
177
+ @classmethod
178
+ def from_parent_array(
179
+ cls,
180
+ parent_array: np.ndarray,
181
+ edge_lengths: np.ndarray | None = None,
182
+ left_right_order: list[tuple[int, str]] | None = None,
183
+ root: int | None = None,
184
+ planted: bool = True,
185
+ ) -> "BinaryTree":
186
+ """
187
+ Create binary tree from parent array.
188
+
189
+ Parameters
190
+ ----------
191
+ parent_array : np.ndarray
192
+ Parent array where parent_array[i] is parent of node i
193
+ edge_lengths : np.ndarray, optional
194
+ Edge lengths
195
+ left_right_order : List[Tuple[int, str]], optional
196
+ List of (node, side) tuples specifying whether each child is 'left' or 'right'.
197
+ If None, will assign left/right based on index order.
198
+ root : int, optional
199
+ Root node index
200
+ planted : bool
201
+ Whether the tree is planted (root has degree 1)
202
+
203
+ Returns
204
+ -------
205
+ BinaryTree instance
206
+ """
207
+ n_nodes = len(parent_array)
208
+
209
+ # Find root if not specified
210
+ if root is None:
211
+ root_candidates = np.where(
212
+ (parent_array == -1) | (parent_array == np.arange(n_nodes))
213
+ )[0]
214
+ if len(root_candidates) == 0:
215
+ raise ValueError("No root found in parent array")
216
+ root = int(root_candidates[0])
217
+
218
+ tree = cls(n_nodes=n_nodes, root=root, planted=planted)
219
+
220
+ # Build mapping of which children are left/right
221
+ if left_right_order is None:
222
+ # Default: first child is left, second is right (by index)
223
+ parent_children = {i: [] for i in range(n_nodes)}
224
+ for child in range(n_nodes):
225
+ if child != root and parent_array[child] != child:
226
+ parent = int(parent_array[child])
227
+ parent_children[parent].append(child)
228
+
229
+ # Sort children by index for consistent left/right assignment
230
+ for children in parent_children.values():
231
+ children.sort()
232
+
233
+ # Add edges
234
+ for child in range(n_nodes):
235
+ if child != root and parent_array[child] != child:
236
+ parent = int(parent_array[child])
237
+ length = 1.0 if edge_lengths is None else float(edge_lengths[child])
238
+
239
+ # Determine side
240
+ if left_right_order is not None:
241
+ # Use provided left/right info
242
+ side = next((s for n, s in left_right_order if n == child), None)
243
+ if side is None:
244
+ # Guess based on existing children
245
+ side = "left" if tree.left_child[parent] == -1 else "right"
246
+ else:
247
+ # Use index-based ordering
248
+ children_of_parent = parent_children[parent]
249
+ idx = children_of_parent.index(child)
250
+ side = "left" if idx == 0 else "right"
251
+
252
+ tree.add_edge(parent, child, side=side, length=length)
253
+
254
+ return tree
255
+
256
+ def to_parent_array_with_sides(
257
+ self,
258
+ ) -> tuple[np.ndarray, np.ndarray, list[tuple[int, str]]]:
259
+ """
260
+ Export as parent array with left/right information.
261
+
262
+ Returns
263
+ -------
264
+ parent_array : np.ndarray
265
+ edge_lengths : np.ndarray
266
+ left_right_info : List[Tuple[int, str]]
267
+ List of (node, side) tuples
268
+ """
269
+ parent_arr, lengths = super().to_parent_array()
270
+
271
+ left_right_info = []
272
+ for node in range(self.n_nodes):
273
+ if not self.is_root(node):
274
+ side = "left" if self.is_left_child(node) else "right"
275
+ left_right_info.append((node, side))
276
+
277
+ return parent_arr, lengths, left_right_info
278
+
279
+ def __repr__(self):
280
+ """Return a summary of the tree's size, leaf count, depth, and plantedness."""
281
+ leaves = len(self.get_leaves())
282
+ max_depth = np.max(self.get_depth()) if self.n_nodes > 0 else 0
283
+ return (
284
+ f"BinaryTree(n_nodes={self.n_nodes}, n_leaves={leaves}, "
285
+ f"max_depth={max_depth}, planted={self.planted})"
286
+ )