rxgraph 0.13.0__cp315-cp315-win_amd64.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.
- rxgraph/__init__.py +4 -0
- rxgraph/__init__.pyi +310 -0
- rxgraph/_graph_tables.py +219 -0
- rxgraph/_rxgraph.cp315-win_amd64.pyd +0 -0
- rxgraph/plugin.py +953 -0
- rxgraph/py.typed +0 -0
- rxgraph-0.13.0.dist-info/METADATA +277 -0
- rxgraph-0.13.0.dist-info/RECORD +10 -0
- rxgraph-0.13.0.dist-info/WHEEL +4 -0
- rxgraph-0.13.0.dist-info/sboms/rxgraph-python.cyclonedx.json +5255 -0
rxgraph/__init__.py
ADDED
rxgraph/__init__.pyi
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from collections.abc import Hashable, Iterable, Mapping
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from os import PathLike
|
|
4
|
+
from typing import Any, Literal, Self, TypeVar
|
|
5
|
+
|
|
6
|
+
from polars import DataFrame, Expr, LazyFrame, col as col, lit as lit
|
|
7
|
+
|
|
8
|
+
Node = TypeVar("Node", bound=Hashable)
|
|
9
|
+
GraphId = int | str
|
|
10
|
+
|
|
11
|
+
def rayon_thread_count() -> int:
|
|
12
|
+
"""Return the Rayon worker thread count used by rxgraph."""
|
|
13
|
+
...
|
|
14
|
+
|
|
15
|
+
class Graph:
|
|
16
|
+
"""Arrow-backed directed graph."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
nodes: DataFrame | list[tuple[str, DataFrame]],
|
|
21
|
+
edges: DataFrame | list[tuple[str, DataFrame]],
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Build a graph from node and edge tables.
|
|
24
|
+
|
|
25
|
+
Nodes require ``id`` and edges require ``id``, ``src``, and ``dest``.
|
|
26
|
+
All ID columns must be either UInt64 or string. Optional ``type``
|
|
27
|
+
columns must be string when present.
|
|
28
|
+
"""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def from_edges(
|
|
33
|
+
cls,
|
|
34
|
+
edges: Iterable[tuple[Node, Node] | tuple[Node, Node, Mapping[str, Any]]],
|
|
35
|
+
*,
|
|
36
|
+
nodes: Iterable[Node | tuple[Node, Mapping[str, Any]]] | None = None,
|
|
37
|
+
) -> Self:
|
|
38
|
+
"""Build a directed graph from Python node labels and edge tuples."""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_lazy(cls, nodes: LazyFrame, edges: LazyFrame) -> Self:
|
|
43
|
+
"""Build a graph from Polars ``LazyFrame``s, deferring payload columns.
|
|
44
|
+
|
|
45
|
+
Only identity/topology columns (``id`` for nodes; ``id``/``src``/``dest``
|
|
46
|
+
for edges, plus optional ``type``) are collected at construction. Payload
|
|
47
|
+
columns are pulled lazily at :meth:`search` time, projected to only the
|
|
48
|
+
columns the kernel references, bounding resident payload memory.
|
|
49
|
+
"""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_parquet(
|
|
54
|
+
cls,
|
|
55
|
+
nodes: str | PathLike[str],
|
|
56
|
+
edges: str | PathLike[str],
|
|
57
|
+
*,
|
|
58
|
+
payloads: Literal["eager", "lazy"] = "eager",
|
|
59
|
+
) -> Self:
|
|
60
|
+
"""Build a file-backed graph from Parquet node and edge tables."""
|
|
61
|
+
...
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def node_count(self) -> int: ...
|
|
65
|
+
@property
|
|
66
|
+
def edge_count(self) -> int: ...
|
|
67
|
+
def node_id(self, label: Hashable) -> GraphId:
|
|
68
|
+
"""Return the graph ID used by the engine for a node label."""
|
|
69
|
+
...
|
|
70
|
+
def search(
|
|
71
|
+
self,
|
|
72
|
+
*,
|
|
73
|
+
start_nodes: Iterable[Hashable],
|
|
74
|
+
visit: Expr | str | None = None,
|
|
75
|
+
next_state: Mapping[str, Expr | str] | None = None,
|
|
76
|
+
stop: Expr | str | None = None,
|
|
77
|
+
initial_state: Mapping[str, Any] | None = None,
|
|
78
|
+
kernel: str | None = None,
|
|
79
|
+
params: Mapping[str, Any] | None = None,
|
|
80
|
+
columns: Iterable[str] | None = None,
|
|
81
|
+
max_depth: int | None = None,
|
|
82
|
+
max_paths: int | None = None,
|
|
83
|
+
strategy: Literal["dfs", "bfs"] = "dfs",
|
|
84
|
+
parallel: bool | Literal["auto", "off", "on"] = True,
|
|
85
|
+
intermediate_states: bool = False,
|
|
86
|
+
progress: bool = False,
|
|
87
|
+
max_visits_per_node: int = 1,
|
|
88
|
+
) -> SearchResult:
|
|
89
|
+
"""Run a stateful traversal.
|
|
90
|
+
|
|
91
|
+
Two mutually exclusive modes:
|
|
92
|
+
|
|
93
|
+
* DSL: ``visit``/``next_state``/``stop``/``initial_state`` Polars expressions.
|
|
94
|
+
* Named native kernel: ``kernel`` (registered Rust kernel name) and ``params``.
|
|
95
|
+
The DSL args must not be set in this mode.
|
|
96
|
+
|
|
97
|
+
``params`` node-label values under the conventional keys
|
|
98
|
+
``target``/``source``/``start``/``node`` are translated to engine IDs when
|
|
99
|
+
the value is a known node label; other values pass through unchanged.
|
|
100
|
+
|
|
101
|
+
``columns`` lists payload columns to load for ``from_lazy`` before a
|
|
102
|
+
legacy named kernel runs. File-backed ``from_parquet`` graphs use typed
|
|
103
|
+
native kernels and do not need ``columns``.
|
|
104
|
+
"""
|
|
105
|
+
...
|
|
106
|
+
def search_batches(
|
|
107
|
+
self,
|
|
108
|
+
*,
|
|
109
|
+
start_nodes: Iterable[Hashable],
|
|
110
|
+
kernel: str,
|
|
111
|
+
params: Mapping[str, Any] | None = None,
|
|
112
|
+
columns: Iterable[str] | None = None,
|
|
113
|
+
max_depth: int | None = None,
|
|
114
|
+
max_paths: int | None = None,
|
|
115
|
+
strategy: Literal["dfs", "bfs"] = "dfs",
|
|
116
|
+
parallel: bool | Literal["auto", "off", "on"] = True,
|
|
117
|
+
intermediate_states: bool = False,
|
|
118
|
+
progress: bool = False,
|
|
119
|
+
max_visits_per_node: int = 1,
|
|
120
|
+
batch_size: int = 1024,
|
|
121
|
+
) -> SearchStream: ...
|
|
122
|
+
def search_paths(
|
|
123
|
+
self,
|
|
124
|
+
*,
|
|
125
|
+
start_nodes: Iterable[Hashable],
|
|
126
|
+
visit: Expr | str | None = None,
|
|
127
|
+
next_state: Mapping[str, Expr | str] | None = None,
|
|
128
|
+
stop: Expr | str | None = None,
|
|
129
|
+
initial_state: Mapping[str, Any] | None = None,
|
|
130
|
+
kernel: str | None = None,
|
|
131
|
+
params: Mapping[str, Any] | None = None,
|
|
132
|
+
columns: Iterable[str] | None = None,
|
|
133
|
+
max_depth: int | None = None,
|
|
134
|
+
max_paths: int | None = None,
|
|
135
|
+
strategy: Literal["dfs", "bfs"] = "dfs",
|
|
136
|
+
parallel: bool | Literal["auto", "off", "on"] = True,
|
|
137
|
+
intermediate_states: bool = False,
|
|
138
|
+
progress: bool = False,
|
|
139
|
+
max_visits_per_node: int = 1,
|
|
140
|
+
) -> SearchResult: ...
|
|
141
|
+
def search_first(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
start_nodes: Iterable[Hashable],
|
|
145
|
+
visit: Expr | str | None = None,
|
|
146
|
+
next_state: Mapping[str, Expr | str] | None = None,
|
|
147
|
+
stop: Expr | str | None = None,
|
|
148
|
+
initial_state: Mapping[str, Any] | None = None,
|
|
149
|
+
kernel: str | None = None,
|
|
150
|
+
params: Mapping[str, Any] | None = None,
|
|
151
|
+
columns: Iterable[str] | None = None,
|
|
152
|
+
max_depth: int | None = None,
|
|
153
|
+
intermediate_states: bool = False,
|
|
154
|
+
progress: bool = False,
|
|
155
|
+
max_visits_per_node: int = 1,
|
|
156
|
+
) -> SearchPath | None: ...
|
|
157
|
+
def bfs(self, start: Hashable, max_depth: int | None = None) -> list[Any]:
|
|
158
|
+
"""Return nodes reachable from ``start`` in breadth-first order."""
|
|
159
|
+
...
|
|
160
|
+
def dfs(self, start: Hashable, max_depth: int | None = None) -> list[Any]:
|
|
161
|
+
"""Return nodes reachable from ``start`` in depth-first pre-order."""
|
|
162
|
+
...
|
|
163
|
+
def reachable_nodes(self, start: Hashable) -> list[Any]:
|
|
164
|
+
"""Return all nodes reachable from ``start``."""
|
|
165
|
+
...
|
|
166
|
+
def ancestors_many(self, targets: Iterable[Hashable]) -> list[Any]:
|
|
167
|
+
"""Return nodes that can reach any target, excluding targets, in BFS order."""
|
|
168
|
+
...
|
|
169
|
+
def descendants_many(self, sources: Iterable[Hashable]) -> list[Any]:
|
|
170
|
+
"""Return nodes reachable from any source, excluding sources, in BFS order."""
|
|
171
|
+
...
|
|
172
|
+
def shortest_path(self, source: Hashable, target: Hashable) -> list[Any] | None:
|
|
173
|
+
"""Return an unweighted directed shortest path, if one exists."""
|
|
174
|
+
...
|
|
175
|
+
def out_degrees(self) -> list[int]:
|
|
176
|
+
"""Return out-degree for each node in node insertion order."""
|
|
177
|
+
...
|
|
178
|
+
def in_degrees(self) -> list[int]:
|
|
179
|
+
"""Return in-degree for each node in node insertion order."""
|
|
180
|
+
...
|
|
181
|
+
def degrees(self) -> list[int]:
|
|
182
|
+
"""Return total directed degree for each node in node insertion order."""
|
|
183
|
+
...
|
|
184
|
+
def weakly_connected_components(self) -> list[list[Any]]:
|
|
185
|
+
"""Return weakly connected components, ignoring edge direction."""
|
|
186
|
+
...
|
|
187
|
+
|
|
188
|
+
class DiGraph(Graph):
|
|
189
|
+
"""Arrow-backed bidirectional graph."""
|
|
190
|
+
|
|
191
|
+
def __init__(
|
|
192
|
+
self,
|
|
193
|
+
nodes: DataFrame | list[tuple[str, DataFrame]],
|
|
194
|
+
edges: DataFrame | list[tuple[str, DataFrame]],
|
|
195
|
+
) -> None:
|
|
196
|
+
"""Build a bidirectional graph from node and edge tables.
|
|
197
|
+
|
|
198
|
+
Each input edge is available in both directions. Reverse rows receive
|
|
199
|
+
synthetic engine IDs, but traversal path edges are mapped back to the
|
|
200
|
+
original input edge IDs.
|
|
201
|
+
"""
|
|
202
|
+
...
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_edges(
|
|
206
|
+
cls,
|
|
207
|
+
edges: Iterable[tuple[Node, Node] | tuple[Node, Node, Mapping[str, Any]]],
|
|
208
|
+
*,
|
|
209
|
+
nodes: Iterable[Node | tuple[Node, Mapping[str, Any]]] | None = None,
|
|
210
|
+
) -> Self:
|
|
211
|
+
"""Build a bidirectional graph from Python node labels and edge tuples."""
|
|
212
|
+
...
|
|
213
|
+
|
|
214
|
+
class Kernel:
|
|
215
|
+
"""Traversal kernel built from Polars expressions."""
|
|
216
|
+
|
|
217
|
+
visit: Expr | str
|
|
218
|
+
next_state: dict[str, Expr | str]
|
|
219
|
+
stop: Expr | str
|
|
220
|
+
initial_state: dict[str, Any]
|
|
221
|
+
|
|
222
|
+
def __init__(
|
|
223
|
+
self,
|
|
224
|
+
visit: Expr | str | None = None,
|
|
225
|
+
next_state: Mapping[str, Expr | str] | None = None,
|
|
226
|
+
stop: Expr | str | None = None,
|
|
227
|
+
initial_state: Mapping[str, Any] | None = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Create a kernel.
|
|
230
|
+
|
|
231
|
+
``visit`` decides whether an edge is accepted, ``next_state`` computes
|
|
232
|
+
state updates for accepted edges, and ``stop`` decides whether an
|
|
233
|
+
accepted edge materializes a path. Defaults accept every edge, leave
|
|
234
|
+
state unchanged, never stop, and start with empty state.
|
|
235
|
+
"""
|
|
236
|
+
...
|
|
237
|
+
|
|
238
|
+
class Traversal:
|
|
239
|
+
"""Low-level traversal configuration for the native engine."""
|
|
240
|
+
|
|
241
|
+
def __init__(
|
|
242
|
+
self,
|
|
243
|
+
kernel: Kernel,
|
|
244
|
+
start_nodes: list[Hashable],
|
|
245
|
+
max_depth: int,
|
|
246
|
+
max_paths: int,
|
|
247
|
+
strategy: Literal["dfs", "bfs"] = "dfs",
|
|
248
|
+
parallel: bool | Literal["auto", "off", "on"] = True,
|
|
249
|
+
intermediate_states: bool = False,
|
|
250
|
+
progress: bool = False,
|
|
251
|
+
) -> None:
|
|
252
|
+
"""Create a traversal.
|
|
253
|
+
|
|
254
|
+
``strategy`` is ``"dfs"`` or ``"bfs"``. ``parallel`` enables or disables
|
|
255
|
+
rxgraph parallel traversal. ``intermediate_states`` stores per-node
|
|
256
|
+
state history on returned paths.
|
|
257
|
+
"""
|
|
258
|
+
...
|
|
259
|
+
|
|
260
|
+
class SearchStats:
|
|
261
|
+
"""Counters collected while searching."""
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def start_nodes(self) -> int: ...
|
|
265
|
+
@property
|
|
266
|
+
def path_entries(self) -> int: ...
|
|
267
|
+
@property
|
|
268
|
+
def evaluated_edges(self) -> int: ...
|
|
269
|
+
@property
|
|
270
|
+
def parallel_edges(self) -> int: ...
|
|
271
|
+
@property
|
|
272
|
+
def accepted_edges(self) -> int: ...
|
|
273
|
+
@property
|
|
274
|
+
def stopped_paths(self) -> int: ...
|
|
275
|
+
@property
|
|
276
|
+
def rejected_edges(self) -> int: ...
|
|
277
|
+
@property
|
|
278
|
+
def skipped_revisits(self) -> int: ...
|
|
279
|
+
@property
|
|
280
|
+
def max_depth(self) -> int: ...
|
|
281
|
+
@property
|
|
282
|
+
def materialized_node_payloads(self) -> int: ...
|
|
283
|
+
@property
|
|
284
|
+
def materialized_edge_payloads(self) -> int: ...
|
|
285
|
+
|
|
286
|
+
@dataclass(slots=True)
|
|
287
|
+
class SearchPath:
|
|
288
|
+
"""One stopped path returned by a traversal."""
|
|
289
|
+
|
|
290
|
+
nodes: list[Any]
|
|
291
|
+
edges: list[Any]
|
|
292
|
+
state: dict[str, Any]
|
|
293
|
+
intermediate_states: list[dict[str, Any]] | None = None
|
|
294
|
+
|
|
295
|
+
@dataclass(slots=True)
|
|
296
|
+
class SearchResult:
|
|
297
|
+
"""Paths and stats returned by :meth:`Graph.search`."""
|
|
298
|
+
|
|
299
|
+
paths: list[SearchPath]
|
|
300
|
+
stats: SearchStats
|
|
301
|
+
|
|
302
|
+
class SearchStream:
|
|
303
|
+
"""Native path batches; use close() or a context manager for early termination."""
|
|
304
|
+
def __iter__(self) -> SearchStream: ...
|
|
305
|
+
def __next__(self) -> list[SearchPath]: ...
|
|
306
|
+
@property
|
|
307
|
+
def stats(self) -> SearchStats: ...
|
|
308
|
+
def close(self) -> None: ...
|
|
309
|
+
def __enter__(self) -> SearchStream: ...
|
|
310
|
+
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None: ...
|
rxgraph/_graph_tables.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from collections.abc import Hashable, Iterable, Mapping
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Any, cast
|
|
4
|
+
|
|
5
|
+
import polars as pl
|
|
6
|
+
|
|
7
|
+
ID_COL = "id"
|
|
8
|
+
SRC_COL = "src"
|
|
9
|
+
DEST_COL = "dest"
|
|
10
|
+
NODE_RESERVED_ATTRS = frozenset({ID_COL})
|
|
11
|
+
EDGE_RESERVED_ATTRS = frozenset({ID_COL, SRC_COL, DEST_COL})
|
|
12
|
+
REVERSE_EDGE_SUFFIX = "__rev"
|
|
13
|
+
UINT64_MAX = (1 << 64) - 1
|
|
14
|
+
|
|
15
|
+
NodeInput = Hashable | tuple[Hashable, Mapping[str, Any]]
|
|
16
|
+
EdgeInput = tuple[Hashable, Hashable] | tuple[Hashable, Hashable, Mapping[str, Any]]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class GraphTables:
|
|
21
|
+
nodes: pl.DataFrame
|
|
22
|
+
edges: pl.DataFrame
|
|
23
|
+
label_to_id: dict[Hashable, int]
|
|
24
|
+
id_to_label: list[Hashable]
|
|
25
|
+
edge_id_to_label: dict[Hashable, Hashable] | None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def normalize_table(value: Any) -> Any:
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
if len(value) != 1:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"rxgraph expects one node DataFrame and one edge DataFrame"
|
|
33
|
+
)
|
|
34
|
+
return value[0][1]
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_labeled_tables(
|
|
39
|
+
edges: Iterable[EdgeInput],
|
|
40
|
+
nodes: Iterable[NodeInput] | None,
|
|
41
|
+
*,
|
|
42
|
+
bidirectional: bool = False,
|
|
43
|
+
) -> GraphTables:
|
|
44
|
+
builder = _LabeledTableBuilder(track_edge_labels=bidirectional)
|
|
45
|
+
|
|
46
|
+
if nodes is not None:
|
|
47
|
+
for node in nodes:
|
|
48
|
+
label, attrs = _parse_node(node)
|
|
49
|
+
builder.add_node(label, attrs)
|
|
50
|
+
|
|
51
|
+
for edge_id, edge in enumerate(edges):
|
|
52
|
+
src, dest, attrs = _parse_edge(edge)
|
|
53
|
+
builder.add_edge(src, dest, attrs, edge_id)
|
|
54
|
+
if bidirectional:
|
|
55
|
+
builder.add_edge(dest, src, attrs, edge_id)
|
|
56
|
+
|
|
57
|
+
return builder.build()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_bidirectional_edges(
|
|
61
|
+
edges: Any,
|
|
62
|
+
) -> tuple[pl.DataFrame, dict[Hashable, Hashable]]:
|
|
63
|
+
edge_table = normalize_table(edges)
|
|
64
|
+
if not isinstance(edge_table, pl.DataFrame):
|
|
65
|
+
edge_table = pl.DataFrame(edge_table)
|
|
66
|
+
|
|
67
|
+
edge_ids = edge_table[ID_COL].to_list()
|
|
68
|
+
reverse_ids = _reverse_edge_ids(edge_ids, edge_table.schema[ID_COL])
|
|
69
|
+
reverse_table = edge_table.with_columns(
|
|
70
|
+
pl.Series(ID_COL, reverse_ids, dtype=edge_table.schema[ID_COL]),
|
|
71
|
+
pl.col(DEST_COL).alias(SRC_COL),
|
|
72
|
+
pl.col(SRC_COL).alias(DEST_COL),
|
|
73
|
+
)
|
|
74
|
+
edge_id_to_label = {edge_id: edge_id for edge_id in edge_ids}
|
|
75
|
+
edge_id_to_label.update(
|
|
76
|
+
{
|
|
77
|
+
reverse_id: edge_id
|
|
78
|
+
for reverse_id, edge_id in zip(reverse_ids, edge_ids, strict=True)
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
return pl.concat([edge_table, reverse_table], how="vertical"), edge_id_to_label
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _LabeledTableBuilder:
|
|
85
|
+
label_to_id: dict[Hashable, int]
|
|
86
|
+
id_to_label: list[Hashable]
|
|
87
|
+
node_attrs: list[dict[str, Any]]
|
|
88
|
+
edge_srcs: list[int]
|
|
89
|
+
edge_dests: list[int]
|
|
90
|
+
edge_attrs: list[dict[str, Any]]
|
|
91
|
+
edge_id_to_label: dict[Hashable, Hashable] | None
|
|
92
|
+
|
|
93
|
+
def __init__(self, *, track_edge_labels: bool) -> None:
|
|
94
|
+
self.label_to_id = {}
|
|
95
|
+
self.id_to_label = []
|
|
96
|
+
self.node_attrs = []
|
|
97
|
+
self.edge_srcs = []
|
|
98
|
+
self.edge_dests = []
|
|
99
|
+
self.edge_attrs = []
|
|
100
|
+
self.edge_id_to_label = {} if track_edge_labels else None
|
|
101
|
+
|
|
102
|
+
def add_node(self, label: Hashable, attrs: Mapping[str, Any] | None = None) -> int:
|
|
103
|
+
if label in self.label_to_id:
|
|
104
|
+
if attrs:
|
|
105
|
+
self.node_attrs[self.label_to_id[label]].update(_attrs(attrs, "node"))
|
|
106
|
+
return self.label_to_id[label]
|
|
107
|
+
|
|
108
|
+
node_id = len(self.id_to_label)
|
|
109
|
+
self.label_to_id[label] = node_id
|
|
110
|
+
self.id_to_label.append(label)
|
|
111
|
+
self.node_attrs.append(_attrs(attrs or {}, "node"))
|
|
112
|
+
return node_id
|
|
113
|
+
|
|
114
|
+
def add_edge(
|
|
115
|
+
self,
|
|
116
|
+
src: Hashable,
|
|
117
|
+
dest: Hashable,
|
|
118
|
+
attrs: Mapping[str, Any] | None,
|
|
119
|
+
label: Hashable,
|
|
120
|
+
) -> None:
|
|
121
|
+
edge_id = len(self.edge_srcs)
|
|
122
|
+
self.edge_srcs.append(self.add_node(src))
|
|
123
|
+
self.edge_dests.append(self.add_node(dest))
|
|
124
|
+
self.edge_attrs.append(_attrs(attrs or {}, "edge"))
|
|
125
|
+
if self.edge_id_to_label is not None:
|
|
126
|
+
self.edge_id_to_label[edge_id] = label
|
|
127
|
+
|
|
128
|
+
def build(self) -> GraphTables:
|
|
129
|
+
node_data = _rows_to_columns(self.node_attrs)
|
|
130
|
+
node_data[ID_COL] = list(range(len(self.id_to_label)))
|
|
131
|
+
edge_data = _rows_to_columns(self.edge_attrs)
|
|
132
|
+
edge_data[ID_COL] = list(range(len(self.edge_srcs)))
|
|
133
|
+
edge_data[SRC_COL] = self.edge_srcs
|
|
134
|
+
edge_data[DEST_COL] = self.edge_dests
|
|
135
|
+
|
|
136
|
+
return GraphTables(
|
|
137
|
+
nodes=pl.DataFrame(node_data, schema_overrides={ID_COL: pl.UInt64}),
|
|
138
|
+
edges=pl.DataFrame(
|
|
139
|
+
edge_data,
|
|
140
|
+
schema_overrides={
|
|
141
|
+
ID_COL: pl.UInt64,
|
|
142
|
+
SRC_COL: pl.UInt64,
|
|
143
|
+
DEST_COL: pl.UInt64,
|
|
144
|
+
},
|
|
145
|
+
),
|
|
146
|
+
label_to_id=self.label_to_id,
|
|
147
|
+
id_to_label=self.id_to_label,
|
|
148
|
+
edge_id_to_label=self.edge_id_to_label,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_node(node: NodeInput) -> tuple[Hashable, Mapping[str, Any] | None]:
|
|
153
|
+
if isinstance(node, tuple) and len(node) == 2 and isinstance(node[1], Mapping):
|
|
154
|
+
return node[0], cast(Mapping[str, Any], node[1])
|
|
155
|
+
return node, None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_edge(edge: EdgeInput) -> tuple[Hashable, Hashable, Mapping[str, Any] | None]:
|
|
159
|
+
if len(edge) == 2:
|
|
160
|
+
return edge[0], edge[1], None
|
|
161
|
+
attrs = cast(tuple[Hashable, Hashable, Any], edge)[2]
|
|
162
|
+
if isinstance(attrs, Mapping):
|
|
163
|
+
return edge[0], edge[1], cast(Mapping[str, Any], attrs)
|
|
164
|
+
raise ValueError("edges must be (src, dest) or (src, dest, attrs) tuples")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _attrs(attrs: Mapping[str, Any], kind: str) -> dict[str, Any]:
|
|
168
|
+
reserved = NODE_RESERVED_ATTRS if kind == "node" else EDGE_RESERVED_ATTRS
|
|
169
|
+
overlap = reserved.intersection(attrs)
|
|
170
|
+
if overlap:
|
|
171
|
+
names = ", ".join(sorted(overlap))
|
|
172
|
+
raise ValueError(f"{kind} attributes cannot use reserved keys: {names}")
|
|
173
|
+
return dict(attrs)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _rows_to_columns(rows: list[dict[str, Any]]) -> dict[str, list[Any]]:
|
|
177
|
+
keys = sorted(
|
|
178
|
+
{key for row in rows for key, value in row.items() if value is not None}
|
|
179
|
+
)
|
|
180
|
+
return {key: [row.get(key) for row in rows] for key in keys}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _reverse_edge_ids(edge_ids: list[Any], dtype: pl.DataType) -> list[Any]:
|
|
184
|
+
used = set(edge_ids)
|
|
185
|
+
if dtype.is_integer():
|
|
186
|
+
return _reverse_integer_edge_ids(edge_ids, used)
|
|
187
|
+
if dtype == pl.String:
|
|
188
|
+
return _reverse_string_edge_ids(edge_ids, used)
|
|
189
|
+
raise ValueError("DiGraph edge ids must be integers or strings")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _reverse_integer_edge_ids(edge_ids: list[Any], used: set[Any]) -> list[int]:
|
|
193
|
+
max_id = max((edge_id for edge_id in edge_ids if edge_id is not None), default=-1)
|
|
194
|
+
next_id = max_id + 1
|
|
195
|
+
if next_id + len(edge_ids) - 1 <= UINT64_MAX:
|
|
196
|
+
return list(range(next_id, next_id + len(edge_ids)))
|
|
197
|
+
|
|
198
|
+
reverse_ids: list[int] = []
|
|
199
|
+
candidate = 0
|
|
200
|
+
for _ in edge_ids:
|
|
201
|
+
while candidate in used:
|
|
202
|
+
candidate += 1
|
|
203
|
+
reverse_ids.append(candidate)
|
|
204
|
+
used.add(candidate)
|
|
205
|
+
return reverse_ids
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _reverse_string_edge_ids(edge_ids: list[Any], used: set[Any]) -> list[str]:
|
|
209
|
+
reverse_ids: list[str] = []
|
|
210
|
+
for edge_id in edge_ids:
|
|
211
|
+
base = f"{edge_id}{REVERSE_EDGE_SUFFIX}"
|
|
212
|
+
candidate = base
|
|
213
|
+
suffix = 2
|
|
214
|
+
while candidate in used:
|
|
215
|
+
candidate = f"{base}_{suffix}"
|
|
216
|
+
suffix += 1
|
|
217
|
+
reverse_ids.append(candidate)
|
|
218
|
+
used.add(candidate)
|
|
219
|
+
return reverse_ids
|
|
Binary file
|