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 ADDED
@@ -0,0 +1,4 @@
1
+ from . import _rxgraph
2
+ from .plugin import export_api
3
+
4
+ export_api(globals(), _rxgraph)
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: ...
@@ -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