diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c35bf98..1f71f02 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,14 @@ repos: rev: v2.1.0 hooks: - id: mypy # Static type checker for Python - additional_dependencies: [daft>=0.7.5] + additional_dependencies: [daft>=0.7.5, typing_extensions] + # Scope to the library, matching [tool.mypy] files in pyproject.toml. This + # hook passes filenames explicitly, which overrides that setting, so the + # exclude has to be repeated here. Tests build graphs from daft expressions + # (e.g. `col("id") != 3`), which daft's stubs type as `bool` rather than + # `Expression`, so type-checking them reports dozens of false positives that + # would drown real errors. + exclude: ^tests/ # Check that uv.lock is up to date with pyproject.toml - repo: https://github.com/astral-sh/uv-pre-commit diff --git a/.ruff.toml b/.ruff.toml index 3825d45..05fdfbc 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -46,7 +46,7 @@ ignore = [ preview = true [lint.isort] -known-first-party = ["daft_ext_template"] +known-first-party = ["daft_graph"] known-third-party = ["daft"] required-imports = ["from __future__ import annotations"] diff --git a/README.md b/README.md index 3711888..215cbe1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,100 @@ # daft-graph Graph processing algorithms using Daft under the hood. + +`daft-graph` brings GraphFrames style graph operations to the [Daft](https://docs.daft.ai) +DataFrame engine. A graph is two DataFrames, a vertex table keyed by `id` and an +edge table keyed by `src`/`dst`, and every algorithm returns an ordinary DataFrame +that composes with the rest of a Daft pipeline. It runs on the native runner locally +and on Ray when distributed. + +## Install + +```bash +uv sync +``` + +Requires Python 3.10 to 3.13. The core install depends on `daft` alone. The single +node solves (connected components `strategy="local"` and `svd_plus_plus`) use numpy +and scipy from the optional `local` extra: + +```bash +uv sync --extra local # or: pip install 'daft-graph[local]' +``` + +## Quick start + +```python +import daft +from daft_graph import UndirectedGraph, connected_components + +edges = daft.from_pydict({"src": [0, 1, 3], "dst": [1, 2, 4]}) +graph = UndirectedGraph(edges) # vertices derived from the endpoints + +connected_components(graph).show() +``` + +Graphs come in two flavors and the type carries the direction semantics, so an +algorithm declares which one it needs. `pagerank` takes a `DirectedGraph`; +`connected_components` accepts either. + +```python +from daft_graph import DirectedGraph, pagerank + +g = DirectedGraph(edges) +pagerank(g).show() # directed +connected_components(g).show() # undirected semantics either way +pagerank(g.reverse()).show() # every edge flipped +``` + +Convert between flavors with `g.as_undirected()` and `g.as_directed()`. Traversal +follows the graph's own semantics, so there is no `directed` keyword; pass an +`UndirectedGraph` (or call `as_undirected()`) to walk edges both ways. + +## API + +### Graph model + +- `DirectedGraph(edges, vertices=None, *, src_col="src", dst_col="dst", id_col="id", validate=False)` +- `UndirectedGraph(edges, vertices=None, *, src_col="src", dst_col="dst", id_col="id", validate=False)` +- `Graph` - the abstract base, for type annotations and isinstance checks + +Base methods: `degrees`, `triplets`, `filter_vertices`, `filter_edges`, +`drop_isolated_vertices`, `degree_by_type`, `num_vertices`, `num_edges`, `bfs_paths`. +`DirectedGraph` adds `in_degrees`, `out_degrees`, `reverse`, `as_undirected`, `find`. +`UndirectedGraph` adds `as_directed`. Transforms return the caller's flavor. + +### Algorithms + +| Group | Functions | +|---|---| +| Connected components | `connected_components`, `strongly_connected_components` | +| Centrality | `pagerank` (+ personalized), `parallel_personalized_pagerank` | +| Traversal | `bfs`, `bfs_paths`, `shortest_paths`, `all_shortest_paths`, `all_paths` | +| Community | `label_propagation`, `power_iteration_clustering` | +| Motif | `find` (GraphFrames style DSL) | +| Message passing | `aggregate_messages`, `pregel` | +| Other | `triangle_count`, `k_core`, `has_cycle`, `vertices_on_cycles`, `maximal_independent_set`, `random_walks`, `svd_plus_plus`, `hyper_anf` | +| Id indexing | `reindex`, `restore_ids` for arbitrary (non int) ids | +| Edge utils | `canonicalize`, `symmetrize`, `dedupe_edges`, `drop_self_loops`, `to_edges`, `validate_edges` | + +Which flavor each algorithm takes, and full examples, are in [`docs/usage.md`](docs/usage.md). + +## Design notes + +- Iterative algorithms run on `iterate_to_fixed_point`, which materializes the state + between rounds to truncate the Daft logical plan. This is the analog of GraphFrames + checkpointing and is what keeps the iterative joins from growing an unbounded plan. +- Connected components ports the large star and small star contraction algorithm + (Kiveris et al. 2014), the same method Spark GraphFrames uses by default, with an + optional `scipy.sparse.csgraph` single node solve for small edge sets. +- Correctness is validated against igraph (connected components) and networkx + (PageRank and others), which are test only dependencies. + +## Development + +```bash +uv sync # install with dev group +uv run pytest tests/ -v # run the suite +uv run pre-commit run --all-files # ruff + mypy style checks +``` diff --git a/benchmarks/bench_cc.py b/benchmarks/bench_cc.py new file mode 100644 index 0000000..70fcfc7 --- /dev/null +++ b/benchmarks/bench_cc.py @@ -0,0 +1,91 @@ +"""Benchmark connected components on synthetic random graphs. + +Reports the star contraction round count and wall time on the active Daft +runner. Uses a few internal helpers (prefixed ``_``) so it can report the round +count, which the public ``connected_components`` does not expose. + +Requires the optional ``local`` extra for numpy (``uv sync --extra local`` or +``pip install 'daft-graph[local]'``). + +Examples: + uv run python benchmarks/bench_cc.py --edges 1000000 + DAFT_RUNNER=ray uv run python benchmarks/bench_cc.py --edges 10000000 +""" + +from __future__ import annotations + +import argparse +import time + +import daft +import numpy as np + +from daft_graph.algorithms.connected_components import ( + _assign_components, + _attach_isolated, + _canonical_equal, + _propagate_min_labels, + _star_step, +) +from daft_graph.edges import canonicalize +from daft_graph.graph import UndirectedGraph +from daft_graph.iterate import iterate_to_fixed_point +from daft_graph.schema import COMPONENT, DST, SRC + + +def _generate_edges(n_nodes: int, n_edges: int, seed: int) -> daft.DataFrame: + rng = np.random.default_rng(seed) + src = rng.integers(0, n_nodes, size=n_edges) + dst = rng.integers(0, n_nodes, size=n_edges) + return daft.from_pydict({SRC: src.tolist(), DST: dst.tolist()}) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="daft-graph connected components benchmark") + parser.add_argument("--edges", type=int, default=1_000_000) + parser.add_argument("--nodes", type=int, default=None) + parser.add_argument("--seed", type=int, default=0) + parser.add_argument("--max-iters", type=int, default=30) + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + n_nodes = args.nodes if args.nodes is not None else max(2, args.edges // 5) + + graph = UndirectedGraph(_generate_edges(n_nodes, args.edges, args.seed)) + + start = time.perf_counter() + edges = canonicalize(graph.edges) + final_edges, star_rounds = iterate_to_fixed_point(edges, _star_step, _canonical_equal, max_iters=args.max_iters) + after_star = time.perf_counter() + + assignments = _assign_components(final_edges) + assignments = _propagate_min_labels( + final_edges, assignments, max_iters=args.max_iters, materialize_every=1, checkpoint_dir=None + ) + result = _attach_isolated(graph.vertices, assignments).collect() + end = time.perf_counter() + + n_vertices = result.count_rows() + n_components = result.select(COMPONENT).distinct().count_rows() + + rows = [ + ("requested edges", f"{args.edges:,}"), + ("requested nodes", f"{n_nodes:,}"), + ("vertices", f"{n_vertices:,}"), + ("components", f"{n_components:,}"), + ("star rounds", f"{star_rounds:,}"), + ("star loop seconds", f"{after_star - start:.2f}"), + ("assign + label seconds", f"{end - after_star:.2f}"), + ("total seconds", f"{end - start:.2f}"), + ] + print("daft-graph connected components benchmark") + print(f"{'metric':<26}{'value':>16}") + print("-" * 42) + for name, value in rows: + print(f"{name:<26}{value:>16}") + + +if __name__ == "__main__": + main() diff --git a/daft_graph/__init__.py b/daft_graph/__init__.py index c6ed8b3..aea14e3 100644 --- a/daft_graph/__init__.py +++ b/daft_graph/__init__.py @@ -1,6 +1,88 @@ -from __future__ import annotations +"""daft-graph: distributed graph operations for the Daft DataFrame engine on Ray. -import daft +Construct a :class:`DirectedGraph` or an :class:`UndirectedGraph`. ``Graph`` is +the abstract base, exported for type annotations and isinstance checks. +Example: + >>> import daft + >>> from daft_graph import UndirectedGraph, connected_components + >>> edges = daft.from_pydict({"src": [0, 1, 3], "dst": [1, 2, 4]}) + >>> components = connected_components(UndirectedGraph(edges)) +""" -__all__ = ["greet"] +from importlib.metadata import PackageNotFoundError, version + +from daft_graph.algorithms.all_paths import all_paths +from daft_graph.algorithms.bfs import all_shortest_paths, bfs, bfs_paths +from daft_graph.algorithms.connected_components import connected_components +from daft_graph.algorithms.cycles import has_cycle, vertices_on_cycles +from daft_graph.algorithms.hyper_anf import hyper_anf +from daft_graph.algorithms.k_core import k_core +from daft_graph.algorithms.label_propagation import label_propagation +from daft_graph.algorithms.maximal_independent_set import maximal_independent_set +from daft_graph.algorithms.pagerank import pagerank, parallel_personalized_pagerank +from daft_graph.algorithms.power_iteration_clustering import power_iteration_clustering +from daft_graph.algorithms.random_walks import random_walks +from daft_graph.algorithms.shortest_paths import shortest_paths +from daft_graph.algorithms.strongly_connected_components import ( + strongly_connected_components, +) +from daft_graph.algorithms.svd_plus_plus import SvdPlusPlusResult, svd_plus_plus +from daft_graph.algorithms.triangle_count import triangle_count +from daft_graph.edges import ( + canonicalize, + dedupe_edges, + drop_self_loops, + symmetrize, + to_edges, + validate_edges, +) +from daft_graph.graph import DirectedGraph, Graph, UndirectedGraph +from daft_graph.indexing import ORIGINAL, IndexedGraph, reindex, restore_ids +from daft_graph.message_passing import aggregate_messages, pregel +from daft_graph.motif import find + +try: + __version__ = version("daft-graph") +except PackageNotFoundError: # pragma: no cover + __version__ = "0.0.0" + +__all__ = [ + "ORIGINAL", + "DirectedGraph", + "Graph", + "IndexedGraph", + "SvdPlusPlusResult", + "UndirectedGraph", + "__version__", + "aggregate_messages", + "all_paths", + "all_shortest_paths", + "bfs", + "bfs_paths", + "canonicalize", + "connected_components", + "dedupe_edges", + "drop_self_loops", + "find", + "has_cycle", + "hyper_anf", + "k_core", + "label_propagation", + "maximal_independent_set", + "pagerank", + "parallel_personalized_pagerank", + "power_iteration_clustering", + "pregel", + "random_walks", + "reindex", + "restore_ids", + "shortest_paths", + "strongly_connected_components", + "svd_plus_plus", + "symmetrize", + "to_edges", + "triangle_count", + "validate_edges", + "vertices_on_cycles", +] diff --git a/daft_graph/_compare.py b/daft_graph/_compare.py new file mode 100644 index 0000000..ce5db26 --- /dev/null +++ b/daft_graph/_compare.py @@ -0,0 +1,24 @@ +"""Internal helper for comparing DataFrame row sets during iteration.""" + +from __future__ import annotations + +from daft import DataFrame, Expression + +from daft_graph.iterate import bound_partitions + + +def rows_equal(a: DataFrame, b: DataFrame, on: list[str | Expression]) -> bool: + """True when ``a`` and ``b`` hold the same rows over the columns ``on``. + + Uses anti join counts so the comparison stays inside the Daft engine instead + of materializing rows into Python. Both sides are capped with + :func:`daft_graph.iterate.bound_partitions` first, a plan rewrite that costs + no execution: this runs every iteration, so collecting here would add a + distributed round trip per round, while leaving the inputs uncapped would let + the anti join inherit a large partition count from the caller's shuffles. + """ + a2 = bound_partitions(a) + b2 = bound_partitions(b) + left = a2.join(b2, on=on, how="anti").count_rows() + right = b2.join(a2, on=on, how="anti").count_rows() + return left == 0 and right == 0 diff --git a/daft_graph/_optional.py b/daft_graph/_optional.py new file mode 100644 index 0000000..31c025c --- /dev/null +++ b/daft_graph/_optional.py @@ -0,0 +1,72 @@ +"""Guarded imports for the optional ``local`` extra. + +The distributed algorithms are pure Daft, so ``numpy`` and ``scipy`` are not core +runtime dependencies. They back only the single node solves, namely the +``strategy="local"`` path of connected components and the ``svd_plus_plus`` +factorization. + +These helpers check availability and raise an actionable message. The caller then +does an ordinary ``import numpy as np`` inside the function body, which keeps the +import lazy while leaving the module properly typed for mypy. +""" + +from __future__ import annotations + +_EXTRA = "daft-graph[local]" + +_HINT = ( + "{package} is required for {feature}, but it is not installed. It ships in the " + "optional 'local' extra, so install {extra} (for example 'uv sync --extra local' " + "or 'pip install {extra}'). The distributed code paths do not need it." +) + + +def require_numpy(feature: str) -> None: + """Check that ``numpy`` is importable. + + Args: + feature: Human readable name of the caller, used in the error message. + + Raises: + ImportError: If ``numpy`` is not installed. + """ + try: + import numpy # noqa: F401 + except ImportError as exc: + raise ImportError(_HINT.format(package="numpy", feature=feature, extra=_EXTRA)) from exc + + +def require_scipy(feature: str) -> None: + """Check that ``scipy`` and the sparse graph routines are importable. + + Args: + feature: Human readable name of the caller, used in the error message. + + Raises: + ImportError: If ``scipy`` is not installed. + """ + try: + import scipy.sparse.csgraph # noqa: F401 + except ImportError as exc: + raise ImportError(_HINT.format(package="scipy", feature=feature, extra=_EXTRA)) from exc + + +def has_local_extra() -> bool: + """Whether the optional ``local`` extra is installed. + + Lets ``strategy="auto"`` mean the best *available* strategy, so a core only + install silently prefers the distributed path instead of raising. An explicit + ``strategy="local"`` still raises, because the caller asked for it by name. + + Returns: + True when both ``numpy`` and ``scipy`` can be imported. + """ + from importlib.util import find_spec + + # A never-raise probe. `find_spec` on a dotted path imports the parent + # packages, so a broken (not merely absent) scipy install can raise arbitrary + # exceptions; treat any of them as "not available" so auto still falls back. + try: + return find_spec("numpy") is not None and find_spec("scipy.sparse.csgraph") is not None + except Exception: # noqa: BLE001 - availability probe must never raise + return False diff --git a/daft_graph/algorithms/__init__.py b/daft_graph/algorithms/__init__.py new file mode 100644 index 0000000..7a29f9b --- /dev/null +++ b/daft_graph/algorithms/__init__.py @@ -0,0 +1 @@ +"""Graph algorithms built on Daft DataFrames.""" diff --git a/daft_graph/algorithms/_traversal.py b/daft_graph/algorithms/_traversal.py new file mode 100644 index 0000000..8ee0108 --- /dev/null +++ b/daft_graph/algorithms/_traversal.py @@ -0,0 +1,161 @@ +"""Shared frontier traversal helpers for BFS based algorithms.""" + +from __future__ import annotations + +from collections import defaultdict + +import daft +from daft import DataFrame, Expression, col, lit + +from daft_graph.graph import Graph +from daft_graph.iterate import bound_partitions +from daft_graph.schema import DST, ID, SRC + +#: Working columns of the level frame returned by :func:`bfs_levels`. +DIST = "__dist" +PRED = "__pred" + + +def prepare_edges(graph: Graph, *, edge_filter: Expression | None = None) -> DataFrame: + """Project graph edges to ``(src, dst)``, applying ``edge_filter`` first. + + The filter runs before the projection so it can reference any edge attribute + column, and the orientation is applied afterwards so an undirected graph + symmetrizes only the edges that survived the filter. + + Args: + graph: The graph to read edges from. Its flavor decides the orientation. + edge_filter: Optional predicate applied to the edges before projection. + + Returns: + A two column ``(src, dst)`` DataFrame oriented for traversal. + """ + edges = graph.edges + if edge_filter is not None: + edges = edges.where(edge_filter) + edges = edges.select(SRC, DST) + return graph.orient(edges) + + +def bfs_levels( + edges: DataFrame, + sources: DataFrame, + *, + max_hops: int, + targets: DataFrame | None = None, +) -> tuple[DataFrame | None, int | None]: + """Multi source BFS that keeps the frontier inside Daft. + + The frontier, the visited set, and the predecessor records are all + DataFrames, so no hop pulls the frontier's adjacency into the driver. Only + scalar row counts cross back per round. This is what lets the traversal + algorithms scale past what fits in driver memory during the search itself; + whatever they return to Python afterwards is their own bound to document. + + Args: + edges: Materialized ``(src, dst)`` edge frame, already oriented. + sources: One column ``id`` frame of starting vertices. + max_hops: Maximum number of hops to expand. + targets: Optional one column ``id`` frame. When given the search stops at + the first hop that reaches any of them. + + Returns: + ``(levels, depth)``. ``levels`` holds one row per discovered + ``(id, __dist, __pred)`` triple, meaning ``id`` sits at distance + ``__dist`` and ``__pred`` is one of its shortest path predecessors; it is + None when nothing was discovered. ``depth`` is the hop count at which a + target was first reached, or None if none was (or no ``targets`` given). + """ + visited = bound_partitions(sources.select(col(ID)).distinct()).collect() + frontier = visited + levels: DataFrame | None = None + for hop in range(1, max_hops + 1): + if frontier.count_rows() == 0: + break + # One hop, entirely as a Daft join: frontier -> its out neighbors. + step = ( + frontier.join(edges, left_on=ID, right_on=SRC, how="inner") + .select(col(DST).alias(ID), col(SRC).alias(PRED)) + .distinct() + ) + # Keep only vertices not already at a shorter distance, so every + # surviving (id, pred) pair is a genuine shortest path predecessor. + fresh = step.join(visited, on=ID, how="anti").collect() + if fresh.count_rows() == 0: + break + layer = fresh.with_column(DIST, lit(hop)) + # visited and levels are carried across hops via union_all (which sums + # partition counts) and re-collected, then joined against the next hop, so + # bound them or the frontier join grid compounds every hop. + levels = layer if levels is None else bound_partitions(levels.union_all(layer)).collect() + new_ids = fresh.select(col(ID)).distinct().collect() + visited = bound_partitions(visited.union_all(new_ids)).collect() + if targets is not None and new_ids.join(targets, on=ID, how="semi").count_rows() > 0: + return (levels.collect(), hop) + frontier = new_ids + return (levels.collect() if levels is not None else None, None) + + +def min_predecessor(levels: DataFrame, vertex: int, dist: int) -> int: + """Smallest shortest path predecessor of ``vertex`` at distance ``dist``. + + A single row point lookup, so path reconstruction costs one small query per + hop rather than collecting the whole predecessor map. + """ + row = ( + levels.where((col(ID) == lit(vertex)) & (col(DIST) == lit(dist))) + .agg(col(PRED).min().alias(PRED)) + .collect() + .to_pydict() + ) + return int(row[PRED][0]) + + +def prune_to_dag(levels: DataFrame, reached: DataFrame, depth: int) -> tuple[dict[int, list[int]], set[int]]: + """Collect only the shortest path sub DAG that leads to ``reached``. + + Walks the predecessor records backwards one level at a time, in Daft, keeping + just the vertices that lie on a shortest path to a reached target. The result + is what path enumeration actually needs, and is far smaller than the full + visited set that a naive collect would pull in. + + Args: + levels: The level frame from :func:`bfs_levels`. + reached: One column ``id`` frame of targets hit at ``depth``. + depth: The shortest distance at which a target was reached. + + Returns: + ``(preds, origins)``: ``preds`` maps each vertex on the sub DAG to its + sorted shortest path predecessors, and ``origins`` is the set of distance + zero vertices the paths start from. + """ + preds: dict[int, list[int]] = defaultdict(list) + layer = reached.select(col(ID)).distinct().collect() + for dist in range(depth, 0, -1): + step = levels.where(col(DIST) == lit(dist)).join(layer, on=ID, how="semi").collect() + rows = step.select(col(ID), col(PRED)).distinct().collect().to_pydict() + for vertex, pred in zip(rows[ID], rows[PRED]): + preds[int(vertex)].append(int(pred)) + layer = step.select(col(PRED).alias(ID)).distinct().collect() + origins = {int(x) for x in layer.select(col(ID)).collect().to_pydict()[ID]} + return ({v: sorted(set(ps)) for v, ps in preds.items()}, origins) + + +def neighbors(edges: DataFrame, sources: list[int]) -> dict[int, list[int]]: + """Out neighbors of each source vertex, fetched with a single Daft join. + + Pulls the adjacency of ``sources`` into the driver, so callers must bound + ``sources`` themselves. Prefer :func:`bfs_levels` for anything whose frontier + grows with the graph. + """ + rows = ( + edges.join(daft.from_pydict({SRC: sources}), on=SRC, how="inner") + .select(SRC, DST) + .distinct() + .collect() + .to_pydict() + ) + adjacency: dict[int, list[int]] = defaultdict(list) + for s, d in zip(rows[SRC], rows[DST]): + adjacency[int(s)].append(int(d)) + return adjacency diff --git a/daft_graph/algorithms/all_paths.py b/daft_graph/algorithms/all_paths.py new file mode 100644 index 0000000..54745ac --- /dev/null +++ b/daft_graph/algorithms/all_paths.py @@ -0,0 +1,81 @@ +"""All simple paths between two vertices, bounded by length. + +Enumerates every simple path (no repeated vertex) from source to target with at +most ``max_path_length`` edges, extending partial paths one hop at a time and +using Daft to look up the frontier's out neighbors. The number of simple paths +can grow exponentially, so ``max_path_length`` and ``max_paths`` bound the search. +""" + +from __future__ import annotations + +from daft import Expression + +from daft_graph.algorithms._traversal import neighbors, prepare_edges +from daft_graph.graph import Graph +from daft_graph.iterate import collect_bounded + + +def all_paths( + graph: Graph, + source: int, + target: int, + *, + max_path_length: int = 5, + edge_filter: Expression | None = None, + max_paths: int = 1_000_000, +) -> list[list[int]]: + """Return all simple paths from source to target with at most N edges. + + Args: + graph: The graph to search. + source: Starting vertex id. + target: Goal vertex id. + max_path_length: Maximum number of edges in a returned path. + edge_filter: Optional predicate over the edge columns; only matching edges + are traversed. + max_paths: Guard against exponential blow up; raises if the working set of + partial paths exceeds this. + + Returns: + A sorted list of paths, each a list of vertex ids from source to target. + If source == target the single trivial path ``[source]`` is returned. + + Note: + The search is driver mediated: each hop pulls the current frontier's + adjacency into the driver process, and the returned paths are Python + objects. Frontier width is not bounded by ``max_path_length``, so on a + large well connected graph the frontier can reach a sizeable fraction of + the graph within a few hops. Keep the hop count tight, narrow the search + with ``edge_filter``, or use :func:`daft_graph.shortest_paths` (which + stays in Daft) for whole graph distances. + + Raises: + ValueError: if the partial path frontier exceeds ``max_paths``. + """ + if source == target: + return [[source]] + edges = collect_bounded(prepare_edges(graph, edge_filter=edge_filter)) + + frontier: list[list[int]] = [[source]] + results: list[tuple[int, ...]] = [] + for _ in range(max_path_length): + if not frontier: + break + if len(frontier) > max_paths: + raise ValueError( + f"partial path frontier exceeded max_paths={max_paths}; reduce max_path_length or raise max_paths" + ) + endpoints = sorted({path[-1] for path in frontier}) + adjacency = neighbors(edges, endpoints) + nxt: list[list[int]] = [] + for path in frontier: + for neighbor in adjacency.get(path[-1], []): + if neighbor in path: + continue + extended = path + [neighbor] + if neighbor == target: + results.append(tuple(extended)) + else: + nxt.append(extended) + frontier = nxt + return [list(path) for path in sorted(set(results))] diff --git a/daft_graph/algorithms/bfs.py b/daft_graph/algorithms/bfs.py new file mode 100644 index 0000000..9a86ba8 --- /dev/null +++ b/daft_graph/algorithms/bfs.py @@ -0,0 +1,322 @@ +"""Breadth first search and shortest path enumeration on Daft DataFrames. + +``bfs`` returns one shortest path between two vertices, ``all_shortest_paths`` +returns every shortest path, and ``bfs_paths`` is the GraphFrames style search +between two vertex sets. All expand the frontier inside Daft (see +:func:`daft_graph.algorithms._traversal.bfs_levels`), so the search itself does +not pull the graph into the driver; only the resulting paths do, bounded by +``max_paths``. All accept an optional ``edge_filter`` predicate over the edge +columns. ``bfs`` breaks ties toward the smaller predecessor id for determinism. +""" + +from __future__ import annotations + +from typing import Any + +import daft +from daft import DataFrame, Expression, col, lit +from daft.functions import to_struct + +from daft_graph.algorithms._traversal import ( + DIST, + bfs_levels, + min_predecessor, + prepare_edges, + prune_to_dag, +) +from daft_graph.graph import Graph +from daft_graph.iterate import collect_bounded +from daft_graph.schema import DST, ID, SRC + + +def bfs( + graph: Graph, + source: int, + target: int, + *, + max_path_length: int = 10, + edge_filter: Expression | None = None, +) -> list[int] | None: + """Return a shortest path of vertex ids from source to target, or None. + + Args: + graph: The graph to search. + source: Starting vertex id. + target: Goal vertex id. + max_path_length: Maximum number of hops to explore. + edge_filter: Optional predicate over the edge columns; only matching edges + are traversed. + + Returns: + The list of vertex ids on a shortest path (both ends inclusive), or None + if the target is not reachable within ``max_path_length`` hops. + + Note: + The frontier is expanded inside Daft, so the search does not pull the + graph into the driver. Only the returned path itself crosses back, one + small lookup per hop, bounded by ``max_path_length``. + """ + if source == target: + return [source] + edges = collect_bounded(prepare_edges(graph, edge_filter=edge_filter)) + levels, depth = bfs_levels( + edges, + daft.from_pydict({ID: [source]}), + max_hops=max_path_length, + targets=daft.from_pydict({ID: [target]}), + ) + if depth is None or levels is None: + return None + # Walk the predecessor records back from the target, taking the smallest + # predecessor at each hop so the chosen path is deterministic. + path = [target] + current = target + for dist in range(depth, 0, -1): + current = min_predecessor(levels, current, dist) + path.append(current) + return list(reversed(path)) + + +def all_shortest_paths( + graph: Graph, + source: int, + target: int, + *, + max_path_length: int = 10, + edge_filter: Expression | None = None, + max_paths: int = 1_000_000, +) -> list[list[int]]: + """Return every shortest path from source to target. + + Expands the frontier layer by layer and returns all paths that first reach + the target (which therefore all have the shortest length). Returns an empty + list if the target is unreachable within ``max_path_length`` hops, or + ``[[source]]`` when source == target. + + Note: + The frontier is expanded inside Daft, and only the shortest path sub DAG + is brought back to enumerate paths from. The returned paths are Python + lists, so ``max_paths`` bounds what crosses into the driver. + + Raises: + ValueError: if the number of shortest paths exceeds ``max_paths`` + (a guard against exponential blow up on dense graphs). + """ + if source == target: + return [[source]] + edges = collect_bounded(prepare_edges(graph, edge_filter=edge_filter)) + depth, reached, preds, origins = _shortest_path_dag( + edges, + daft.from_pydict({ID: [source]}), + daft.from_pydict({ID: [target]}), + max_path_length, + ) + if depth is None or reached is None: + return [] + reached_ids = sorted(int(x) for x in reached.select(col(ID)).collect().to_pydict()[ID]) + return [list(path) for path in _enumerate_paths(reached_ids, origins, preds, max_paths)] + + +_VID = "__vid" +_VSTRUCT = "__vstruct" +_TAIL = "__tail" +_HEAD = "__head" +_ESTRUCT = "__estruct" + + +def _vertex_struct_lookup(vertices: DataFrame) -> DataFrame: + """Map each vertex id to a struct of its full row, keyed by ``__vid``.""" + attrs = [c for c in vertices.column_names if c != ID] + fields = [col(ID), *[col(c) for c in attrs]] + return vertices.select(col(ID).alias(_VID), to_struct(*fields).alias(_VSTRUCT)) + + +def _edge_struct_lookup(edges: DataFrame, *, directed: bool) -> DataFrame: + """Map each traversable ordered pair to a struct of the edge's full row. + + Keyed by ``__tail`` (the vertex stepped from) and ``__head`` (stepped to). For + undirected search both orientations point to the original edge struct. + """ + attrs = [c for c in edges.column_names if c not in (SRC, DST)] + fields = [col(SRC), col(DST), *[col(c) for c in attrs]] + base = edges.select(col(SRC), col(DST), to_struct(*fields).alias(_ESTRUCT)) + fwd = base.select(col(SRC).alias(_TAIL), col(DST).alias(_HEAD), col(_ESTRUCT)) + if directed: + return fwd + rev = base.select(col(DST).alias(_TAIL), col(SRC).alias(_HEAD), col(_ESTRUCT)) + return fwd.union_all(rev) + + +def _shortest_path_dag( + edges: DataFrame, sources: DataFrame, targets: DataFrame, max_path_length: int +) -> tuple[int | None, DataFrame | None, dict[int, list[int]], set[int]]: + """Multi source BFS returning the shortest path sub DAG to any target. + + The search runs inside Daft via :func:`bfs_levels`, then only the sub DAG that + leads to a reached target is collected, so driver memory scales with the + answer rather than with the graph. + + Args: + edges: Materialized ``(src, dst)`` edge frame, already oriented. + sources: One column ``id`` frame of starting vertices. + targets: One column ``id`` frame of goal vertices. + max_path_length: Maximum hops to expand. + + Returns: + ``(depth, reached, preds, origins)``: ``depth`` is the shortest distance + from any source to any target (None if none was reached), ``reached`` is a + one column ``id`` frame of the targets at that distance, ``preds`` maps + each sub DAG vertex to its sorted predecessors, and ``origins`` is the set + of sources those paths start from. + """ + levels, depth = bfs_levels(edges, sources, max_hops=max_path_length, targets=targets) + if depth is None or levels is None: + return None, None, {}, set() + reached = ( + levels.where(col(DIST) == lit(depth)).select(col(ID)).distinct().join(targets, on=ID, how="semi").collect() + ) + preds, origins = prune_to_dag(levels, reached, depth) + return depth, reached, preds, origins + + +def _enumerate_paths( + reached: list[int], sources: set[int], preds: dict[int, list[int]], max_paths: int +) -> list[tuple[int, ...]]: + """Enumerate every shortest path id tuple from a source to a reached target.""" + memo: dict[int, list[tuple[int, ...]]] = {} + + def build(v: int) -> list[tuple[int, ...]]: + if v in memo: + return memo[v] + result: list[tuple[int, ...]] + if v in sources: + result = [(v,)] + else: + result = [] + for p in preds[v]: + for sub in build(p): + result.append((*sub, v)) + if len(result) > max_paths: + raise ValueError(f"shortest path count exceeded max_paths={max_paths}") + memo[v] = result + return result + + out: list[tuple[int, ...]] = [] + for target in reached: + out.extend(build(target)) + if len(out) > max_paths: + raise ValueError(f"shortest path count exceeded max_paths={max_paths}") + return sorted(out) + + +def _vertex_col(index: int, depth: int) -> str: + if index == 0: + return "from" + if index == depth: + return "to" + return f"v{index}" + + +def bfs_paths( + graph: Graph, + from_filter: Expression, + to_filter: Expression, + *, + max_path_length: int = 10, + edge_filter: Expression | None = None, + max_paths: int = 1_000_000, +) -> DataFrame: + """Shortest paths from a source vertex set to a target vertex set. + + This is the GraphFrames ``bfs`` analog. ``from_filter`` and ``to_filter`` are + predicates over the vertex columns that select the source and target vertex + sets; the search runs from all sources at once and returns every shortest + path to the nearest targets (all returned paths share the shortest length). + + Args: + graph: The graph to search. + from_filter: Predicate over vertex columns selecting the source vertices. + to_filter: Predicate over vertex columns selecting the target vertices. + max_path_length: Maximum number of hops to explore. + edge_filter: Optional predicate over the edge columns; only matching edges + are traversed and appear in the path. + max_paths: Guard against exponential path blow up on dense graphs. + + Returns: + A DataFrame with one row per shortest path. For paths of length ``d`` the + columns are ``from, e0, v1, e1, ..., to``: each vertex column holds a + struct of the vertex row, each edge column a struct of the edge row. + Parallel edges between the same pair multiply the matching rows. When any + source is also a target the global shortest length is 0, so only the + zero-hop overlap is returned (columns ``from`` and ``to``, the same + vertex) and other sources are not searched. An empty DataFrame with + columns ``from`` and ``to`` is returned when no target is reachable within + ``max_path_length``. + + Note: + The search is driver mediated: the matched source and target vertex sets + are collected into the driver, and each hop pulls the current frontier's + adjacency in as well. Frontier width is not bounded by ``max_path_length``, so on a + large well connected graph the frontier can reach a sizeable fraction of + the graph within a few hops. Keep the hop count tight, narrow the search + with ``edge_filter``, or use :func:`daft_graph.shortest_paths` (which + stays in Daft) for whole graph distances. + + Raises: + ValueError: if the number of shortest paths exceeds ``max_paths``. + """ + sources = graph.vertices.where(from_filter).select(col(ID)).distinct() + targets = graph.vertices.where(to_filter).select(col(ID)).distinct() + if sources.count_rows() == 0 or targets.count_rows() == 0: + return daft.from_pydict({"from": [], "to": []}) + + full_edges = graph.edges if edge_filter is None else graph.edges.where(edge_filter) + vlk = _vertex_struct_lookup(graph.vertices) + + overlap = sources.join(targets, on=ID, how="semi") + if overlap.count_rows() > 0: + result = overlap.select(col(ID).alias("__p0")) + result = result.join( + vlk.select(col(_VID).alias("__p0"), col(_VSTRUCT).alias("from")), + on="__p0", + how="left", + ).join( + vlk.select(col(_VID).alias("__p0"), col(_VSTRUCT).alias("to")), + on="__p0", + how="left", + ) + return result.select("from", "to") + + traversal = collect_bounded(graph.orient(full_edges.select(SRC, DST))) + depth, reached, preds, origins = _shortest_path_dag(traversal, sources, targets, max_path_length) + if depth is None or reached is None: + return daft.from_pydict({"from": [], "to": []}) + + reached_ids = sorted(int(x) for x in reached.select(col(ID)).collect().to_pydict()[ID]) + paths = _enumerate_paths(reached_ids, origins, preds, max_paths) + pcols: dict[str, Any] = {f"__p{i}": [path[i] for path in paths] for i in range(depth + 1)} + result = daft.from_pydict(pcols) + for i in range(depth + 1): + result = result.join( + vlk.select(col(_VID).alias(f"__p{i}"), col(_VSTRUCT).alias(_vertex_col(i, depth))), + on=f"__p{i}", + how="left", + ) + elk = _edge_struct_lookup(full_edges, directed=graph.is_directed) + for i in range(depth): + keys: list[str | Expression] = [f"__p{i}", f"__p{i + 1}"] + result = result.join( + elk.select( + col(_TAIL).alias(f"__p{i}"), + col(_HEAD).alias(f"__p{i + 1}"), + col(_ESTRUCT).alias(f"e{i}"), + ), + on=keys, + how="inner", + ) + + order = ["from"] + for i in range(depth): + order.append(f"e{i}") + order.append("to" if i == depth - 1 else f"v{i + 1}") + return result.select(*order) diff --git a/daft_graph/algorithms/connected_components.py b/daft_graph/algorithms/connected_components.py new file mode 100644 index 0000000..2b880f7 --- /dev/null +++ b/daft_graph/algorithms/connected_components.py @@ -0,0 +1,315 @@ +"""Connected components via large star and small star contraction. + +Ports the star contraction recipe from Kiveris et al., "Connected Components in +MapReduce and Beyond" (SoCC 2014), the same algorithm Spark GraphFrames uses by +default. Large star and small star passes alternate until the edge set stops +changing, then a global minimum label propagation pass guarantees every node in +a component adopts the single smallest id (exact parity with igraph). + +Two strategies are exposed. ``distributed`` runs the star contraction on Daft +and scales to the full edge set. ``local`` collects the (typically already +reduced) edge set and finishes with ``scipy.sparse.csgraph`` on one node. +``auto`` picks local below an edge count threshold and distributed above it, and +falls back to distributed when the optional ``local`` extra is absent. +""" + +from __future__ import annotations + +import os +from typing import Literal + +import daft +from daft import DataFrame, col +from daft.functions import list_agg, list_min, when + +from daft_graph._compare import rows_equal +from daft_graph._optional import has_local_extra, require_numpy, require_scipy +from daft_graph.edges import canonicalize, symmetrize +from daft_graph.graph import Graph +from daft_graph.iterate import bound_partitions, collect_bounded, iterate_to_fixed_point +from daft_graph.schema import COMPONENT, DST, ID, SRC, Strategy + +_NBRS = "nbrs" +_M = "m" +_NBR = "_nbr" +_NBR_MIN = "_nbr_min" +_DEFAULT_LOCAL_THRESHOLD = 2_000_000 + + +def _point_to_min(neighborhood: DataFrame) -> DataFrame: + """Set ``m = min(src, min(neighbors))`` for each grouped neighborhood.""" + with_min = neighborhood.with_column(_M, list_min(col(_NBRS))) + # The outer null guard covers groups whose neighbor list is empty after + # list_min; otherwise m is the smaller of src and the neighbor minimum. + return with_min.with_column( + _M, + when(col(_M).is_null(), col(SRC)).otherwise(when(col(SRC) < col(_M), col(SRC)).otherwise(col(_M))), + ) + + +def large_star(edges: DataFrame) -> DataFrame: + """Large star pass: point every node to the min over its full neighborhood. + + Emits edges ``(neighbor, m)`` only where ``neighbor > src``, pulling higher + id nodes toward low id hubs. + """ + undirected = symmetrize(edges) + neighborhood = undirected.groupby(SRC).agg(list_agg(col(DST)).alias(_NBRS)) + pointed = _point_to_min(neighborhood) + return ( + pointed.explode(_NBRS) + .where(col(_NBRS) > col(SRC)) + .select(col(_NBRS).alias(SRC), col(_M).alias(DST)) + .where(col(SRC) != col(DST)) + .distinct() + ) + + +def small_star(edges: DataFrame) -> DataFrame: + """Small star pass: orient edges so ``src < dst``, then point neighbors to the min. + + Merges the local minima discovered by :func:`large_star`. + """ + oriented = canonicalize(edges) + neighborhood = oriented.groupby(SRC).agg(list_agg(col(DST)).alias(_NBRS)) + pointed = _point_to_min(neighborhood) + return ( + pointed.explode(_NBRS).select(col(_NBRS).alias(SRC), col(_M).alias(DST)).where(col(SRC) != col(DST)).distinct() + ) + + +def _star_step(edges: DataFrame) -> DataFrame: + """One alternating round: large star followed by small star. + + The large star result is partition capped before the small star pass reads + it, so the two passes' shuffles do not stack into an ever growing grid (each + pass symmetrizes or unions, and Daft's shuffles inherit their input's + partition count). The cap is a plan rewrite, not a materialization, so it + adds no distributed round trip per round. + """ + return small_star(bound_partitions(large_star(edges))) + + +def _canonical_equal(prev: DataFrame, nxt: DataFrame) -> bool: + """True when two edge sets are identical after canonicalization.""" + return rows_equal(canonicalize(prev), canonicalize(nxt), [SRC, DST]) + + +def _assign_components(edges: DataFrame) -> DataFrame: + """Map each node to the minimum id it points to in the contracted edge set.""" + nodes = edges.select(col(SRC).alias(ID)).union_all(edges.select(col(DST).alias(ID))).distinct() + rep_map = edges.groupby(SRC).agg(col(DST).min().alias(COMPONENT)).select(col(SRC).alias(ID), col(COMPONENT)) + return ( + nodes.join(rep_map, on=ID, how="left") + .with_column( + COMPONENT, + when(col(COMPONENT).is_null(), col(ID)).otherwise(col(COMPONENT)), + ) + .select(ID, COMPONENT) + ) + + +def _propagate_min_labels( + edges: DataFrame, + assignments: DataFrame, + *, + max_iters: int, + materialize_every: int, + checkpoint_dir: str | None, +) -> DataFrame: + """Diffuse the minimum label across components for exact igraph parity. + + Star contraction can leave a component split across several local minima. + This pass repeatedly lowers each node's label to the minimum among its + neighbors until stable, so every node in a component adopts the single + global minimum id. + + Note: this phase collects the symmetrized contracted edge set on the driver. + After star contraction that set is typically small, but a very large + contracted graph will materialize here. + """ + adjacency = collect_bounded(symmetrize(edges)) + + def step(labels: DataFrame) -> DataFrame: + neighbor_labels = labels.select(col(ID).alias(DST), col(COMPONENT).alias(_NBR)) + # Cap the per-neighbor minimum before the second join so the two joins + # and the aggregation between them cannot stack their shuffle partitions. + # A plan rewrite, so it costs no extra execution per round. + nbr_min = bound_partitions( + adjacency.join(neighbor_labels, on=DST, how="left") + .groupby(SRC) + .agg(col(_NBR).min().alias(_NBR_MIN)) + .select(col(SRC).alias(ID), col(_NBR_MIN)) + ) + return ( + labels.join(nbr_min, on=ID, how="left") + .with_column( + COMPONENT, + when(col(_NBR_MIN).is_null(), col(COMPONENT)).otherwise( + when(col(COMPONENT) <= col(_NBR_MIN), col(COMPONENT)).otherwise(col(_NBR_MIN)) + ), + ) + .select(ID, COMPONENT) + ) + + final, _ = iterate_to_fixed_point( + assignments, + step, + lambda prev, nxt: rows_equal(prev, nxt, [ID, COMPONENT]), + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=checkpoint_dir, + ) + return final + + +def _attach_isolated(vertices: DataFrame, assignments: DataFrame) -> DataFrame: + """Give every vertex a component, mapping edge free vertices to themselves.""" + return ( + vertices.select(ID) + .distinct() + .join(assignments, on=ID, how="left") + .with_column( + COMPONENT, + when(col(COMPONENT).is_null(), col(ID)).otherwise(col(COMPONENT)), + ) + .select(ID, COMPONENT) + ) + + +def _distributed_connected_components( + graph: Graph, + *, + max_iters: int, + materialize_every: int, + checkpoint_dir: str | None, +) -> DataFrame: + """Connected components by distributed star contraction on Daft.""" + if graph.edges.count_rows() == 0: + return graph.vertices.select(col(ID), col(ID).alias(COMPONENT)).distinct() + star_ckpt = os.path.join(checkpoint_dir, "star") if checkpoint_dir else None + label_ckpt = os.path.join(checkpoint_dir, "labels") if checkpoint_dir else None + edges = canonicalize(graph.edges) + final_edges, _ = iterate_to_fixed_point( + edges, + _star_step, + _canonical_equal, + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=star_ckpt, + ) + assignments = _assign_components(final_edges) + assignments = _propagate_min_labels( + final_edges, + assignments, + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=label_ckpt, + ) + return _attach_isolated(graph.vertices, assignments) + + +def _local_scipy_components(graph: Graph, *, directed: bool, connection: str) -> DataFrame: + """Connected components on a single node via scipy.sparse.csgraph. + + Builds a sparse adjacency matrix from the collected edges, runs scipy's + component finder, then relabels each component by its minimum node id. + ``directed`` and ``connection`` select weak (undirected) or strong components. + + Raises: + ImportError: If the optional ``local`` extra is not installed. + """ + require_numpy("the local connected components solve") + require_scipy("the local connected components solve") + import numpy as np + from scipy.sparse import csr_matrix + from scipy.sparse.csgraph import connected_components as _scipy_cc + + vd = graph.vertices.select(ID).distinct().collect().to_pydict() + ed = graph.edges.select(SRC, DST).collect().to_pydict() + srcs = [int(x) for x in ed[SRC]] + dsts = [int(x) for x in ed[DST]] + node_ids = sorted({int(x) for x in vd[ID]} | set(srcs) | set(dsts)) + index = {nid: i for i, nid in enumerate(node_ids)} + n = len(node_ids) + rows = [index[s] for s in srcs] + cols = [index[d] for d in dsts] + data = np.ones(len(rows), dtype=np.int8) + adjacency = csr_matrix((data, (rows, cols)), shape=(n, n)) + _, labels = _scipy_cc(adjacency, directed=directed, connection=connection, return_labels=True) + rep_of_label: dict[int, int] = {} + for nid in node_ids: + lab = int(labels[index[nid]]) + if lab not in rep_of_label or nid < rep_of_label[lab]: + rep_of_label[lab] = nid + components = [rep_of_label[int(labels[index[nid]])] for nid in node_ids] + return daft.from_pydict({ID: node_ids, COMPONENT: components}) + + +def _local_connected_components(graph: Graph) -> DataFrame: + """Weakly connected components on a single node via scipy.""" + return _local_scipy_components(graph, directed=False, connection="weak") + + +def _resolve_strategy(strategy: Strategy, num_edges: int, local_threshold: int) -> Literal["local", "distributed"]: + """Resolve ``auto`` to ``local`` or ``distributed`` based on edge count. + + ``auto`` means the best available strategy, so it falls back to + ``distributed`` when the optional ``local`` extra is not installed rather + than failing. An explicit ``local`` is honored and raises later if the extra + is missing, because the caller asked for it by name. + """ + if strategy == "local": + return "local" + if strategy == "distributed": + return "distributed" + if strategy == "auto": + if num_edges <= local_threshold and has_local_extra(): + return "local" + return "distributed" + raise ValueError(f"unknown strategy {strategy!r}; expected 'auto', 'distributed', or 'local'") + + +def connected_components( + graph: Graph, + *, + strategy: Strategy = "auto", + max_iters: int = 30, + materialize_every: int = 1, + checkpoint_dir: str | None = None, + local_threshold: int = _DEFAULT_LOCAL_THRESHOLD, +) -> DataFrame: + """Compute weakly connected components as columns ``id`` and ``component``. + + Every vertex carries the smallest id in its component; edge free vertices + form singleton components. + + Args: + graph: The graph to analyze. Accepts either graph flavor; edges are treated as undirected either way. + strategy: ``auto`` (default), ``distributed``, or ``local``. ``auto`` + picks the local solve only when the edge count is under + ``local_threshold`` and the optional ``local`` extra is installed, + otherwise it runs distributed. + max_iters: Maximum rounds for each iterative phase (distributed only). + materialize_every: How often to truncate the Daft plan between rounds. + checkpoint_dir: Optional parquet checkpoint directory for long runs. + local_threshold: Edge count at or below which ``auto`` uses the local solve. + + Returns: + A DataFrame with one row per vertex: ``id`` and its ``component`` label. + """ + # Only count edges when it could change the decision. On a core only install + # `auto` is always distributed, so skip the count job entirely there. + if strategy == "auto" and not has_local_extra(): + resolved: Literal["local", "distributed"] = "distributed" + else: + num_edges = graph.num_edges() if strategy == "auto" else 0 + resolved = _resolve_strategy(strategy, num_edges, local_threshold) + if resolved == "local": + return _local_connected_components(graph) + return _distributed_connected_components( + graph, + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=checkpoint_dir, + ) diff --git a/daft_graph/algorithms/cycles.py b/daft_graph/algorithms/cycles.py new file mode 100644 index 0000000..7ae7e59 --- /dev/null +++ b/daft_graph/algorithms/cycles.py @@ -0,0 +1,40 @@ +"""Cycle detection on Daft DataFrames. + +Reports the vertices that lie on a directed cycle, and hence whether the graph +is acyclic. A vertex is on a cycle iff it belongs to a strongly connected +component with more than one vertex, or it has a self loop. Full cycle +enumeration (listing every cycle as a path) is intentionally not provided: the +number of cycles can be exponential. Use ``strongly_connected_components`` for +the component structure. +""" + +from __future__ import annotations + +from daft import DataFrame, col, lit + +from daft_graph.algorithms.strongly_connected_components import ( + strongly_connected_components, +) +from daft_graph.graph import DirectedGraph +from daft_graph.schema import COMPONENT, DST, ID, SRC + +_SIZE = "_size" + + +def vertices_on_cycles(graph: DirectedGraph) -> DataFrame: + """Return the ids of vertices that lie on at least one directed cycle. + + Takes a :class:`DirectedGraph`; an undirected graph has no directed cycles. + + Returns a DataFrame with a single ``id`` column. + """ + scc = strongly_connected_components(graph) + big = scc.groupby(COMPONENT).agg(col(ID).count().alias(_SIZE)).where(col(_SIZE) >= lit(2)).select(COMPONENT) + in_cycle = scc.join(big, on=COMPONENT, how="semi").select(ID) + self_loops = graph.edges.where(col(SRC) == col(DST)).select(col(SRC).alias(ID)).distinct() + return in_cycle.union_all(self_loops).distinct() + + +def has_cycle(graph: DirectedGraph) -> bool: + """True if the graph contains at least one directed cycle.""" + return vertices_on_cycles(graph).count_rows() > 0 diff --git a/daft_graph/algorithms/hyper_anf.py b/daft_graph/algorithms/hyper_anf.py new file mode 100644 index 0000000..17ed73c --- /dev/null +++ b/daft_graph/algorithms/hyper_anf.py @@ -0,0 +1,133 @@ +"""Approximate neighborhood function via HyperLogLog (HyperANF). + +For each vertex and hop ``t``, estimates the number of vertices reachable within +``t`` hops. Each vertex keeps a HyperLogLog sketch of the vertices it has reached; +each hop a vertex merges (register wise max) its neighbors' sketches, and the +sketch cardinality estimates the reachable count. This is the scalable +approximation that the exact neighborhood function (count of reachable vertices) +would otherwise require materializing reachable sets for. + +Estimates carry HyperLogLog's relative error (about ``1.04 / sqrt(2**precision)``) +with linear counting for small cardinalities. Built on the message passing +primitive; the sketch merge is a UDF. +""" + +from __future__ import annotations + +import hashlib +import math + +import daft +from daft import DataFrame, Series, col, lit +from daft.functions import list_agg + +from daft_graph.graph import Graph +from daft_graph.iterate import bound_partitions, collect_bounded +from daft_graph.message_passing import MSG, aggregate_messages +from daft_graph.schema import DST, ID, SRC + +HOP = "hop" +APPROX_COUNT = "approx_count" +_HLL = "hll" +_LIST = daft.DataType.list(daft.DataType.int64()) + + +def _hash64(value: int) -> int: + masked = int(value) & ((1 << 64) - 1) + digest = hashlib.blake2b(masked.to_bytes(8, "little"), digest_size=8).digest() + return int.from_bytes(digest, "little") + + +def _alpha(m: int) -> float: + if m == 16: + return 0.673 + if m == 32: + return 0.697 + if m == 64: + return 0.709 + return 0.7213 / (1.0 + 1.079 / m) + + +def hyper_anf( + graph: Graph, + *, + max_hops: int = 10, + precision: int = 10, +) -> DataFrame: + """Estimate the neighborhood function per vertex via HyperLogLog. + + Args: + graph: The graph to analyze. + max_hops: Number of hops to expand (rows are produced for hops 0..max_hops). + precision: HyperLogLog precision ``p``; uses ``2**p`` registers. + + Returns: + A DataFrame ``[id, hop, approx_count]``: the estimated number of vertices + reachable from ``id`` within ``hop`` hops (``approx_count`` is a float). + """ + if not 4 <= precision <= 18: + raise ValueError(f"precision must be in [4, 18], got {precision}") + m = 1 << precision + bits = 64 - precision + alpha = _alpha(m) + + @daft.func.batch(return_dtype=_LIST) + def init_hll(ids: Series) -> list[list[int]]: + out: list[list[int]] = [] + for vid in ids.to_pylist(): + registers = [0] * m + h = _hash64(int(vid)) + idx = h >> bits + w = h & ((1 << bits) - 1) + registers[idx] = bits - w.bit_length() + 1 + out.append(registers) + return out + + @daft.func.batch(return_dtype=_LIST) + def merge_hll(own: Series, msgs: Series) -> list[list[int]]: + out: list[list[int]] = [] + for current, neighbor_sketches in zip(own.to_pylist(), msgs.to_pylist()): + acc = list(current) + for sketch in neighbor_sketches or []: + for j, value in enumerate(sketch): + acc[j] = max(acc[j], value) + out.append(acc) + return out + + @daft.func.batch(return_dtype=daft.DataType.float64()) + def estimate(sketches: Series) -> list[float]: + out: list[float] = [] + for registers in sketches.to_pylist(): + raw = alpha * m * m / sum(2.0 ** (-r) for r in registers) + zeros = registers.count(0) + if raw <= 2.5 * m and zeros > 0: + raw = m * math.log(m / zeros) + out.append(raw) + return out + + edges = graph.orient(graph.edges.select(SRC, DST)) + edges = collect_bounded(edges) + + current = graph.vertices.select(col(ID)).distinct().with_column(_HLL, init_hll(col(ID))).collect() + + def snapshot(state: DataFrame, hop: int) -> DataFrame: + return ( + state.with_column(HOP, lit(hop)) + .with_column(APPROX_COUNT, estimate(col(_HLL))) + .select(ID, HOP, APPROX_COUNT) + ) + + parts = [snapshot(current, 0)] + for hop in range(1, max_hops + 1): + msg = aggregate_messages(edges, current, to_src=col(f"dst_{_HLL}"), agg=lambda values: list_agg(values)) + # Bound the carried HLL state: it is joined against edges every hop and a + # plain collect keeps the join's inherited partition count, compounding. + current = collect_bounded( + current.join(msg, on=ID, how="left").with_column(_HLL, merge_hll(col(_HLL), col(MSG))).select(ID, _HLL) + ) + parts.append(snapshot(current, hop)) + + out = parts[0] + for part in parts[1:]: + out = out.union_all(part) + return bound_partitions(out) diff --git a/daft_graph/algorithms/k_core.py b/daft_graph/algorithms/k_core.py new file mode 100644 index 0000000..66b116d --- /dev/null +++ b/daft_graph/algorithms/k_core.py @@ -0,0 +1,80 @@ +"""k-core decomposition on Daft DataFrames. + +Computes each vertex's core number via the distributed local algorithm of +Montresor, Pellegrini, and Miorandi (2013): seed every vertex with its degree, +then repeatedly set its estimate to the h-index of its neighbors' estimates +until stable. Treats edges as undirected. Built on the message passing primitive. +""" + +from __future__ import annotations + +import daft +from daft import DataFrame, col, lit +from daft.functions import list_agg, when + +from daft_graph.edges import canonicalize, symmetrize +from daft_graph.graph import Graph +from daft_graph.iterate import collect_bounded +from daft_graph.message_passing import MSG, VALUE, pregel +from daft_graph.schema import DST, ID, SRC + +CORE = "core" + + +@daft.func.batch(return_dtype=daft.DataType.int64()) +def _h_index(neighbor_cores: daft.Series) -> list[int]: + """Largest k such that at least k neighbor estimates are >= k, per vertex.""" + out: list[int] = [] + for values in neighbor_cores.to_pylist(): + ordered = sorted(values or [], reverse=True) + h = 0 + for i, value in enumerate(ordered, start=1): + if value >= i: + h = i + else: + break + out.append(h) + return out + + +def k_core( + graph: Graph, + *, + max_iters: int = 100, + materialize_every: int = 1, + checkpoint_dir: str | None = None, +) -> DataFrame: + """Compute the core number of each vertex (undirected). + + Accepts either graph flavor; edges are treated as undirected either way. + + Args: + graph: The graph to analyze. + max_iters: Maximum relaxation rounds. + materialize_every: How often to truncate the Daft plan between rounds. + checkpoint_dir: Optional parquet checkpoint directory for long runs. + + Returns: + A DataFrame with one row per vertex: ``id`` and ``core``. + """ + all_v = graph.vertices.select(ID).distinct() + if graph.edges.count_rows() == 0: + return all_v.with_column(CORE, lit(0)).select(ID, CORE) + # Materialized once: this feeds both the degree init and the pregel step + # closure, which would otherwise replan the canonicalize+symmetrize every round. + undirected = collect_bounded(symmetrize(canonicalize(graph.edges))) + degree = undirected.groupby(SRC).agg(col(DST).count().alias(VALUE)).select(col(SRC).alias(ID), col(VALUE)) + init = all_v.join(degree, on=ID, how="left").with_column( + VALUE, when(col(VALUE).is_null(), lit(0)).otherwise(col(VALUE)) + ) + final = pregel( + undirected, + init, + to_src=col("dst_value"), + agg=lambda m: list_agg(m), + update=_h_index(col(MSG)), + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=checkpoint_dir, + ) + return final.select(col(ID), col(VALUE).alias(CORE)) diff --git a/daft_graph/algorithms/label_propagation.py b/daft_graph/algorithms/label_propagation.py new file mode 100644 index 0000000..eb272e3 --- /dev/null +++ b/daft_graph/algorithms/label_propagation.py @@ -0,0 +1,73 @@ +"""Community detection via synchronous label propagation (LPA). + +Built on the message passing primitive: each round every vertex collects its +neighbors' labels and adopts the most frequent one, breaking ties toward the +smallest label. Updates are synchronous and bounded by ``max_iters`` since LPA +can oscillate on bipartite structures, the same as the GraphFrames LPA. +""" + +from __future__ import annotations + +from collections import Counter + +import daft +from daft import DataFrame, col +from daft.functions import list_agg + +from daft_graph.edges import symmetrize +from daft_graph.graph import Graph +from daft_graph.iterate import collect_bounded +from daft_graph.message_passing import MSG, VALUE, pregel +from daft_graph.schema import ID, LABEL + + +@daft.func.batch(return_dtype=daft.DataType.int64()) +def _plurality(neighbor_labels: daft.Series, own: daft.Series) -> list[int]: + """Most frequent neighbor label per vertex (min on ties); own if no neighbors.""" + out: list[int] = [] + owns = own.to_pylist() + for labels, current in zip(neighbor_labels.to_pylist(), owns): + if not labels: + out.append(current) + else: + counts = Counter(labels) + best = max(counts.values()) + out.append(min(label for label, count in counts.items() if count == best)) + return out + + +def label_propagation( + graph: Graph, + *, + max_iters: int = 10, + materialize_every: int = 1, + checkpoint_dir: str | None = None, +) -> DataFrame: + """Assign a community label to each vertex via synchronous LPA. + + Args: + graph: The graph to analyze. Accepts either graph flavor; edges are treated as undirected either way. + max_iters: Maximum propagation rounds. LPA may oscillate, so this caps it. + materialize_every: How often to truncate the Daft plan between rounds. + checkpoint_dir: Optional parquet checkpoint directory for long runs. + + Returns: + A DataFrame with one row per vertex: ``id`` and its ``label``. + """ + base = graph.vertices.select(col(ID), col(ID).alias(VALUE)).distinct() + if graph.edges.count_rows() == 0: + return base.select(col(ID), col(VALUE).alias(LABEL)) + # Materialized before the pregel closure so the symmetrize is not replanned + # every round. + undirected = collect_bounded(symmetrize(graph.edges)) + final = pregel( + undirected, + base, + to_src=col("dst_value"), + agg=lambda m: list_agg(m), + update=_plurality(col(MSG), col(VALUE)), + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=checkpoint_dir, + ) + return final.select(col(ID), col(VALUE).alias(LABEL)) diff --git a/daft_graph/algorithms/maximal_independent_set.py b/daft_graph/algorithms/maximal_independent_set.py new file mode 100644 index 0000000..24152bb --- /dev/null +++ b/daft_graph/algorithms/maximal_independent_set.py @@ -0,0 +1,97 @@ +"""Maximal independent set on Daft DataFrames. + +Computes a maximal independent set deterministically: in each round every +undecided vertex that is a local minimum (a smaller id than all of its undecided +neighbors) joins the set, and its neighbors are excluded. This repeats until +every vertex is decided. Treats edges as undirected. The result is independent +(no two selected vertices are adjacent) and maximal (every unselected vertex has +a selected neighbor). +""" + +from __future__ import annotations + +import warnings + +from daft import DataFrame, col, lit +from daft.functions import when + +from daft_graph.edges import canonicalize, symmetrize +from daft_graph.graph import Graph +from daft_graph.iterate import collect_bounded +from daft_graph.schema import DST, ID, SRC + +SELECTED = "selected" +_STATUS = "_status" +_NMIN = "_nmin" +_IN = "_in" +_EX = "_ex" +_UNDECIDED, _IN_SET, _EXCLUDED = 0, 1, 2 + + +def maximal_independent_set(graph: Graph, *, max_iters: int = 1000) -> DataFrame: + """Compute a maximal independent set, as columns ``id`` and ``selected``. + + Accepts either graph flavor; edges are treated as undirected either way. + + Args: + graph: The graph to analyze. + max_iters: Maximum peeling rounds. One round decides every current local + minimum, so a graph whose ids increase along a long induced path needs + one round per vertex on it. A warning is emitted if the cap is reached, + because the result is then independent but not guaranteed maximal. + + Returns: + A DataFrame with one row per vertex: ``id`` and whether it was + ``selected`` into the set. + """ + all_v = graph.vertices.select(col(ID)).distinct() + if graph.edges.count_rows() == 0: + return all_v.with_column(SELECTED, lit(True)) + + undirected = collect_bounded(symmetrize(canonicalize(graph.edges))) + status = all_v.with_column(_STATUS, lit(_UNDECIDED)).collect() + + converged = False + for _ in range(max_iters): + undecided = status.where(col(_STATUS) == lit(_UNDECIDED)).select(ID).collect() + if undecided.count_rows() == 0: + converged = True + break + adj = undirected.join(undecided.select(col(ID).alias(SRC)), on=SRC, how="semi").join( + undecided.select(col(ID).alias(DST)), on=DST, how="semi" + ) + nbr_min = adj.groupby(SRC).agg(col(DST).min().alias(_NMIN)).select(col(SRC).alias(ID), col(_NMIN)) + joiners = ( + undecided.join(nbr_min, on=ID, how="left") + .where(col(_NMIN).is_null() | (col(ID) < col(_NMIN))) + .select(ID) + .collect() + ) + excluded = ( + undirected.join(joiners.select(col(ID).alias(SRC)), on=SRC, how="semi") + .select(col(DST).alias(ID)) + .distinct() + ) + status = collect_bounded( + status.join(joiners.select(col(ID), lit(1).alias(_IN)), on=ID, how="left") + .join(excluded.select(col(ID), lit(1).alias(_EX)), on=ID, how="left") + .with_column( + _STATUS, + when(col(_STATUS) != lit(_UNDECIDED), col(_STATUS)).otherwise( + when(~col(_IN).is_null(), lit(_IN_SET)).otherwise( + when(~col(_EX).is_null(), lit(_EXCLUDED)).otherwise(lit(_UNDECIDED)) + ) + ), + ) + .select(ID, _STATUS) + ) + + if not converged: + warnings.warn( + f"maximal_independent_set did not converge within {max_iters} rounds; " + "undecided vertices are reported as not selected, so the result is " + "independent but may not be maximal. Raise max_iters.", + UserWarning, + stacklevel=2, + ) + return status.select(col(ID), (col(_STATUS) == lit(_IN_SET)).alias(SELECTED)) diff --git a/daft_graph/algorithms/pagerank.py b/daft_graph/algorithms/pagerank.py new file mode 100644 index 0000000..3695ecf --- /dev/null +++ b/daft_graph/algorithms/pagerank.py @@ -0,0 +1,187 @@ +"""PageRank on Daft DataFrames. + +Power iteration that matches networkx semantics: a personalization vector ``p`` +drives both teleport and dangling redistribution, and ranks sum to one. With no +``source_ids`` the vector is uniform (standard PageRank); with ``source_ids`` it +concentrates on the seeds (personalized PageRank). The per round incoming +contribution is computed with the message passing primitive; the global dangling +mass and teleport are applied around it. +""" + +from __future__ import annotations + +import os + +import daft +from daft import DataFrame, col, lit +from daft.functions import when + +from daft_graph.graph import DirectedGraph +from daft_graph.iterate import collect_bounded, iterate_to_fixed_point +from daft_graph.message_passing import MSG, aggregate_messages +from daft_graph.schema import DST, ID, RANK, SRC + +_OD = "outdeg" +_P = "_p" +_DIFF = "_diff" +SOURCE = "source" + + +def _scalar_sum(df: DataFrame, column: str) -> float: + """Return the global sum of ``column``, treating empty input as 0.0.""" + rows = df.agg(col(column).sum().alias("_s")).collect().to_pydict()["_s"] + return float(rows[0]) if rows and rows[0] is not None else 0.0 + + +def _personalization(vertices: DataFrame, n: int, source_ids: list[int] | None) -> DataFrame: + """Build the personalization vector as columns ``id`` and ``_p`` summing to one. + + ``vertices`` is expected to be already collected; only its ``id`` column is read. + """ + if source_ids is None: + return vertices.with_column(_P, lit(1.0 / n)).select(ID, _P).collect() + if len(source_ids) == 0: + raise ValueError("source_ids must not be empty") + vertex_id_set = set(vertices.to_pydict()[ID]) + valid = sorted({int(s) for s in source_ids} & vertex_id_set) + if not valid: + raise ValueError("source_ids must contain at least one vertex in the graph") + seeds = daft.from_pydict({ID: valid}).with_column(_P, lit(1.0 / len(valid))) + return ( + vertices.join(seeds, on=ID, how="left") + .with_column(_P, when(col(_P).is_null(), lit(0.0)).otherwise(col(_P))) + .select(ID, _P) + .collect() + ) + + +def pagerank( + graph: DirectedGraph, + *, + damping: float = 0.85, + max_iters: int = 100, + tol: float = 1e-6, + source_ids: list[int] | None = None, + materialize_every: int = 1, + checkpoint_dir: str | None = None, +) -> DataFrame: + """Compute PageRank, returning columns ``id`` and ``rank`` that sum to one. + + Args: + graph: The directed graph to rank. + damping: Damping factor (networkx ``alpha``), typically 0.85. + max_iters: Maximum power iterations. + tol: Convergence threshold; iteration stops when the total L1 change + across all vertices falls below ``n * tol`` (same tol semantics as + networkx). + source_ids: If given, run personalized PageRank seeded uniformly on these + vertices instead of standard uniform PageRank. + materialize_every: How often to truncate the Daft plan between rounds. + checkpoint_dir: Optional parquet checkpoint directory for long runs. + + Returns: + A DataFrame with one row per vertex: ``id`` and its ``rank``. + + Raises: + ValueError: If ``source_ids`` is given but contains no vertex present in + the graph. + """ + vertices = graph.vertices.select(ID).distinct().collect() + n = vertices.count_rows() + if n == 0: + return vertices.with_column(RANK, lit(0.0)) + + pvec = _personalization(vertices, n, source_ids) + edges = collect_bounded(graph.edges.select(SRC, DST).distinct()) + if edges.count_rows() == 0: + return pvec.select(col(ID), col(_P).alias(RANK)) + + outdeg = collect_bounded(edges.groupby(SRC).agg(col(DST).count().alias(_OD)).select(col(SRC).alias(ID), col(_OD))) + dangling_ids = vertices.join(outdeg.select(col(ID)), on=ID, how="anti").collect() + init = vertices.with_column(RANK, lit(1.0 / n)) + + def step(ranks: DataFrame) -> DataFrame: + state = ranks.join(outdeg, on=ID, how="left") + incoming = aggregate_messages(edges, state, to_dst=col(f"src_{RANK}") / col(f"src_{_OD}")) + dangling_sum = _scalar_sum(ranks.join(dangling_ids, on=ID, how="inner"), RANK) + pcoef = damping * dangling_sum + (1.0 - damping) + return ( + vertices.join(incoming, on=ID, how="left") + .join(pvec, on=ID, how="inner") + .with_column( + RANK, + lit(damping) * when(col(MSG).is_null(), lit(0.0)).otherwise(col(MSG)) + lit(pcoef) * col(_P), + ) + .select(ID, RANK) + ) + + def converged(prev: DataFrame, nxt: DataFrame) -> bool: + diff = ( + prev.select(col(ID), col(RANK).alias("_prev")) + .join(nxt.select(col(ID), col(RANK).alias("_next")), on=ID, how="inner") + .with_column(_DIFF, (col("_prev") - col("_next")).abs()) + ) + return _scalar_sum(diff, _DIFF) < n * tol + + final, _ = iterate_to_fixed_point( + init, + step, + converged, + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=checkpoint_dir, + ) + return final + + +def parallel_personalized_pagerank( + graph: DirectedGraph, + source_ids: list[int], + *, + damping: float = 0.85, + max_iters: int = 100, + tol: float = 1e-6, + materialize_every: int = 1, + checkpoint_dir: str | None = None, +) -> DataFrame: + """Personalized PageRank computed separately for each source vertex. + + This is the GraphFrames ``parallelPersonalizedPageRank``: rather than one run + seeded on the whole set, it produces an independent personalized vector per + source. + + Args: + graph: The directed graph to rank. + source_ids: The source vertices; one personalized vector is produced each. + damping: Damping factor. + max_iters: Maximum power iterations per source. + tol: Convergence threshold per source. + materialize_every: How often to truncate the Daft plan between rounds. + checkpoint_dir: Optional parquet checkpoint directory. + + Returns: + A DataFrame ``[id, source, rank]``: ``rank`` is the PageRank of vertex + ``id`` in the run personalized to ``source``. + + Raises: + ValueError: If ``source_ids`` is empty. + """ + if not source_ids: + raise ValueError("source_ids must not be empty") + results: list[DataFrame] = [] + for source in source_ids: + source_ckpt = os.path.join(checkpoint_dir, str(source)) if checkpoint_dir else None + ranks = pagerank( + graph, + damping=damping, + max_iters=max_iters, + tol=tol, + source_ids=[source], + materialize_every=materialize_every, + checkpoint_dir=source_ckpt, + ) + results.append(ranks.with_column(SOURCE, lit(source)).select(ID, SOURCE, RANK)) + out = results[0] + for part in results[1:]: + out = out.union_all(part) + return out diff --git a/daft_graph/algorithms/power_iteration_clustering.py b/daft_graph/algorithms/power_iteration_clustering.py new file mode 100644 index 0000000..0f3e1d6 --- /dev/null +++ b/daft_graph/algorithms/power_iteration_clustering.py @@ -0,0 +1,117 @@ +"""Power iteration clustering (Lin and Cohen 2010) on Daft DataFrames. + +Runs power iteration on the row normalized affinity matrix to produce a one +dimensional embedding, then 1D k-means to assign clusters. Crucially the +iteration stops on the acceleration criterion (when the per step increment +stabilizes) rather than running to full convergence, since the stationary vector +carries no cluster structure. The matrix vector product is distributed via the +message passing primitive; the small 1D vector and the k-means run on the driver. +""" + +from __future__ import annotations + +import daft +from daft import col + +from daft_graph.edges import canonicalize, symmetrize +from daft_graph.graph import Graph +from daft_graph.iterate import collect_bounded +from daft_graph.message_passing import MSG, VALUE, aggregate_messages +from daft_graph.schema import DST, ID, SRC + +CLUSTER = "cluster" +_DEG = "deg" + + +def _kmeans_1d(values: list[float], k: int, iters: int) -> list[int]: + """Deterministic 1D k-means; returns a cluster index per input value.""" + n = len(values) + if k <= 1 or n == 0: + return [0] * n + unique = sorted(set(values)) + if len(unique) <= k: + rank = {v: i for i, v in enumerate(unique)} + return [rank[v] for v in values] + centroids = [unique[round(i * (len(unique) - 1) / (k - 1))] for i in range(k)] + assignment = [0] * n + for _ in range(iters): + nxt = [min(range(k), key=lambda c: abs(values[i] - centroids[c])) for i in range(n)] + if nxt == assignment: + break + assignment = nxt + for c in range(k): + members = [values[i] for i in range(n) if assignment[i] == c] + if members: + centroids[c] = sum(members) / len(members) + return assignment + + +def power_iteration_clustering( + graph: Graph, + k: int, + *, + max_iters: int = 100, + tol: float = 1e-5, + kmeans_iters: int = 50, +) -> daft.DataFrame: + """Cluster vertices via power iteration clustering. + + Args: + graph: The graph to cluster. Accepts either graph flavor; edges are treated as undirected either way. + k: Number of clusters. + max_iters: Maximum power iterations. + tol: Acceleration threshold for early stopping. + kmeans_iters: Maximum 1D k-means iterations. + + Returns: + A DataFrame ``[id, cluster]`` over the vertices that have edges. Vertices + with no incident edge are absent from the output rather than forming + singleton clusters. + + Raises: + ValueError: If ``k`` is less than 1. + + Note: + With the degree based initialization, perfectly symmetric communities + may not separate (the separating component is absent from a symmetric + start). This matches the known behavior of PIC with a symmetric init. + """ + if k < 1: + raise ValueError(f"k must be >= 1, got {k}") + undirected = collect_bounded(symmetrize(canonicalize(graph.edges))) + degree_rows = ( + undirected.groupby(SRC) + .agg(col(DST).count().alias(_DEG)) + .select(col(SRC).alias(ID), col(_DEG)) + .collect() + .to_pydict() + ) + degree = {int(i): int(d) for i, d in zip(degree_rows[ID], degree_rows[_DEG])} + ids = sorted(degree) + if not ids: + return daft.from_pydict({ID: ids, CLUSTER: []}) + + total = float(sum(degree.values())) + vector = {i: degree[i] / total for i in ids} + delta_prev: dict[int, float] | None = None + for _ in range(max_iters): + state = daft.from_pydict({ID: ids, VALUE: [vector[i] for i in ids]}) + msg = ( + aggregate_messages(undirected, state, to_src=col("dst_value"), agg=lambda m: m.sum()).collect().to_pydict() + ) + neighbor_sum = {int(i): float(s) for i, s in zip(msg[ID], msg[MSG])} + updated = {i: neighbor_sum.get(i, 0.0) / degree[i] for i in ids} + norm = sum(abs(x) for x in updated.values()) or 1.0 + updated = {i: x / norm for i, x in updated.items()} + delta = {i: updated[i] - vector[i] for i in ids} + if delta_prev is not None: + acceleration = sum(abs(delta[i] - delta_prev[i]) for i in ids) + if acceleration < tol: + vector = updated + break + delta_prev = delta + vector = updated + + values = [vector[i] for i in ids] + clusters = _kmeans_1d(values, k, kmeans_iters) + return daft.from_pydict({ID: ids, CLUSTER: [int(c) for c in clusters]}) diff --git a/daft_graph/algorithms/random_walks.py b/daft_graph/algorithms/random_walks.py new file mode 100644 index 0000000..ee8e976 --- /dev/null +++ b/daft_graph/algorithms/random_walks.py @@ -0,0 +1,63 @@ +"""Random walks on Daft DataFrames. + +Generates fixed length random walks, the input to walk based node embeddings such +as DeepWalk and node2vec. Walks are seeded for determinism. The adjacency is +collected to the driver, so this fits moderate graphs; a fully distributed walker +would be needed for billion edge graphs. Embedding training (skip-gram) is left +to a dedicated ML library. +""" + +from __future__ import annotations + +import random +from collections import defaultdict + +from daft_graph.graph import Graph +from daft_graph.schema import DST, ID, SRC + + +def random_walks( + graph: Graph, + *, + walk_length: int = 10, + num_walks: int = 1, + seed: int = 0, +) -> list[list[int]]: + """Generate random walks from every vertex. + + Args: + graph: The graph to walk. + walk_length: Maximum number of steps (edges) per walk. + num_walks: Number of walks started from each vertex. + seed: Seed for the walk randomness; the result is deterministic given it. + + Returns: + A list of walks, each a list of vertex ids beginning at the start vertex. + A walk stops early if it reaches a vertex with no out neighbor. + + Neighbor lists are sorted before walking, so the result depends only on + ``seed`` regardless of the edge row order Daft returns. + """ + edges = graph.orient(graph.edges.select(SRC, DST)) + rows = edges.distinct().collect().to_pydict() + adjacency: dict[int, list[int]] = defaultdict(list) + for s, d in zip(rows[SRC], rows[DST]): + adjacency[int(s)].append(int(d)) + for neighbors in adjacency.values(): + neighbors.sort() + + vertices = sorted(int(x) for x in graph.vertices.select(ID).distinct().collect().to_pydict()[ID]) + rng = random.Random(seed) + walks: list[list[int]] = [] + for source in vertices: + for _ in range(num_walks): + walk = [source] + current = source + for _ in range(walk_length): + options = adjacency.get(current) + if not options: + break + current = rng.choice(options) + walk.append(current) + walks.append(walk) + return walks diff --git a/daft_graph/algorithms/shortest_paths.py b/daft_graph/algorithms/shortest_paths.py new file mode 100644 index 0000000..094b37e --- /dev/null +++ b/daft_graph/algorithms/shortest_paths.py @@ -0,0 +1,85 @@ +"""Shortest path hop distances to landmark vertices. + +For each vertex, computes the unweighted distance to each landmark by running +min distance relaxation on the message passing primitive: every vertex +repeatedly takes one plus the minimum distance of its out neighbors, seeded with +zero at the landmark. This is the analog of GraphFrames shortestPaths and +demonstrates the pregel API. +""" + +from __future__ import annotations + +import daft +from daft import DataFrame, col, lit +from daft.functions import when + +from daft_graph.graph import Graph +from daft_graph.iterate import bound_partitions, collect_bounded +from daft_graph.message_passing import MSG, VALUE, pregel +from daft_graph.schema import DST, ID, SRC + +LANDMARK = "landmark" +DISTANCE = "distance" +_UNREACHABLE = 1 << 30 + + +def shortest_paths( + graph: Graph, + landmarks: list[int], + *, + max_iters: int = 100, + materialize_every: int = 1, + checkpoint_dir: str | None = None, +) -> DataFrame: + """Hop distance from each vertex to each landmark. + + Args: + graph: The graph to analyze. + landmarks: Vertex ids to measure distance to. + max_iters: Maximum relaxation rounds; bounds the largest distance found. + materialize_every: How often to truncate the Daft plan between rounds. + checkpoint_dir: Optional parquet checkpoint directory for long runs. + + Returns: + A DataFrame ``[id, landmark, distance]`` with one row per vertex that can + reach a landmark. Unreachable pairs are omitted. + + Raises: + ValueError: If ``landmarks`` is empty. + """ + if not landmarks: + raise ValueError("landmarks must not be empty") + if graph.edges.count_rows() == 0: + vertex_ids = set(graph.vertices.select(ID).distinct().collect().to_pydict()[ID]) + valid = [lm for lm in landmarks if lm in vertex_ids] + return daft.from_pydict({ID: valid, LANDMARK: valid, DISTANCE: [0] * len(valid)}) + # Materialized once: reused by every landmark's pregel run and every round + # within it, so leaving it lazy replans the orient landmarks x max_iters times. + edges = collect_bounded(graph.orient(graph.edges.select(SRC, DST))) + vertices = graph.vertices.select(ID).distinct().collect() + update = when(col(MSG).is_null(), col(VALUE)).otherwise( + when(col(VALUE) <= col(MSG), col(VALUE)).otherwise(col(MSG)) + ) + results: list[DataFrame] = [] + for landmark in landmarks: + init = vertices.with_column(VALUE, when(col(ID) == lit(landmark), lit(0)).otherwise(lit(_UNREACHABLE))) + final = pregel( + edges, + init, + to_src=col("dst_value") + lit(1), + agg=lambda m: m.min(), + update=update, + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=checkpoint_dir, + ) + results.append( + final.where(col(VALUE) < lit(_UNREACHABLE)) + .with_column(LANDMARK, lit(landmark)) + .select(ID, LANDMARK, col(VALUE).alias(DISTANCE)) + ) + out = results[0] + for r in results[1:]: + out = out.union_all(r) + # The fold sums each landmark's partition count; cap the result. Plan rewrite. + return bound_partitions(out) diff --git a/daft_graph/algorithms/strongly_connected_components.py b/daft_graph/algorithms/strongly_connected_components.py new file mode 100644 index 0000000..e855308 --- /dev/null +++ b/daft_graph/algorithms/strongly_connected_components.py @@ -0,0 +1,186 @@ +"""Strongly connected components on Daft DataFrames. + +Two vertices share a strongly connected component (SCC) when each can reach the +other following edge directions. Two strategies mirror connected_components: +``local`` collects the edges and uses scipy.sparse.csgraph; ``distributed`` runs +the coloring algorithm on the message passing primitive (forward max id color +propagation, then backward confirmation within each color). ``auto`` picks local +at or below an edge threshold. Each vertex is labelled by the smallest id in its +SCC, regardless of strategy. +""" + +from __future__ import annotations + +import os +import warnings + +from daft import DataFrame, Expression, col, lit +from daft.functions import when + +from daft_graph._optional import has_local_extra +from daft_graph.algorithms.connected_components import ( + _local_scipy_components, + _resolve_strategy, +) +from daft_graph.graph import DirectedGraph +from daft_graph.iterate import bound_partitions, collect_bounded +from daft_graph.message_passing import MSG as _MSG +from daft_graph.message_passing import VALUE, pregel +from daft_graph.schema import COMPONENT, DST, ID, SRC, Strategy + +_DEFAULT_LOCAL_THRESHOLD = 2_000_000 +_COLOR = "color" +_CSRC = "_csrc" +_CDST = "_cdst" +_MIN = "_min" + + +def _max_update() -> Expression: + """Expression setting value to max(value, msg), treating a null msg as value.""" + return when(col(_MSG).is_null(), col(VALUE)).otherwise( + when(col(VALUE) >= col(_MSG), col(VALUE)).otherwise(col(_MSG)) + ) + + +def _local_scc(graph: DirectedGraph) -> DataFrame: + """SCCs on a single node via scipy.sparse.csgraph, labelled by min id.""" + return _local_scipy_components(graph, directed=True, connection="strong") + + +def _distributed_scc( + graph: DirectedGraph, + *, + max_iters: int, + materialize_every: int, + checkpoint_dir: str | None, +) -> DataFrame: + """SCCs by the coloring algorithm on the message passing primitive. + + Each round assigns every vertex the max id that can reach it (its color), + restricts to same color edges, then confirms the vertices that can reach + their color's root. Confirmed vertices form an SCC and are removed; the loop + repeats on the rest. The final labels are remapped to the minimum id per SCC. + """ + active_v = graph.vertices.select(ID).distinct().collect() + active_e = collect_bounded(graph.edges.select(SRC, DST).distinct()) + parts: list[DataFrame] = [] + for outer in range(active_v.count_rows() + 1): + if active_v.count_rows() == 0: + break + fwd_ckpt = os.path.join(checkpoint_dir, f"scc_{outer}_forward") if checkpoint_dir else None + bwd_ckpt = os.path.join(checkpoint_dir, f"scc_{outer}_backward") if checkpoint_dir else None + colors = ( + pregel( + active_e, + active_v.with_column(VALUE, col(ID)), + to_dst=col("src_value"), + agg=lambda m: m.max(), + update=_max_update(), + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=fwd_ckpt, + ) + .select(col(ID), col(VALUE).alias(_COLOR)) + .collect() + ) + same_color_edges = ( + active_e.join( + colors.select(col(ID).alias(SRC), col(_COLOR).alias(_CSRC)), + on=SRC, + how="inner", + ) + .join( + colors.select(col(ID).alias(DST), col(_COLOR).alias(_CDST)), + on=DST, + how="inner", + ) + .where(col(_CSRC) == col(_CDST)) + .select(SRC, DST) + .collect() + ) + bwd_init = colors.select( + col(ID), + when(col(ID) == col(_COLOR), lit(1)).otherwise(lit(0)).alias(VALUE), + col(_COLOR), + ) + flags = pregel( + same_color_edges, + bwd_init, + to_src=col("dst_value"), + agg=lambda m: m.max(), + update=_max_update(), + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=bwd_ckpt, + ) + confirmed = flags.where(col(VALUE) == lit(1)).select(col(ID), col(_COLOR).alias(COMPONENT)).collect() + parts.append(confirmed) + # Bound the carried state each outer round: active_v/active_e are joined + # and fed to the inner pregels every round, and a plain collect keeps the + # growing partition count from the anti/semi joins, which then compounds + # through the pregel shuffles. Cap is a plan rewrite, no extra execution. + active_v = collect_bounded(active_v.join(confirmed.select(ID), on=ID, how="anti")) + active_e = collect_bounded( + active_e.join(active_v.select(col(ID).alias(SRC)), on=SRC, how="semi").join( + active_v.select(col(ID).alias(DST)), on=DST, how="semi" + ) + ) + if active_v.count_rows() > 0: + warnings.warn( + "strongly_connected_components did not fully partition the graph within the iteration bound", + stacklevel=2, + ) + if not parts: + return graph.vertices.select(col(ID), col(ID).alias(COMPONENT)).distinct() + out = parts[0] + for part in parts[1:]: + out = out.union_all(part) + # The fold sums each part's partition count; cap before the groupby shuffle. + out = bound_partitions(out) + # The coloring uses max id roots; remap to the min id in each component. + min_label = out.groupby(COMPONENT).agg(col(ID).min().alias(_MIN)) + return out.join(min_label, on=COMPONENT, how="inner").select(col(ID), col(_MIN).alias(COMPONENT)) + + +def strongly_connected_components( + graph: DirectedGraph, + *, + strategy: Strategy = "auto", + max_iters: int = 100, + materialize_every: int = 1, + checkpoint_dir: str | None = None, + local_threshold: int = _DEFAULT_LOCAL_THRESHOLD, +) -> DataFrame: + """Compute strongly connected components as columns ``id`` and ``component``. + + Every vertex carries the smallest id in its SCC; isolated vertices and those + in no cycle form singleton components. + + Args: + graph: The directed graph to analyze. + strategy: ``auto`` (default), ``distributed``, or ``local``. + max_iters: Maximum rounds per propagation phase (distributed only). + materialize_every: How often to truncate the Daft plan between rounds. + checkpoint_dir: Optional parquet checkpoint directory for long runs. + local_threshold: Edge count at or below which ``auto`` uses the local solve. + + Returns: + A DataFrame with one row per vertex: ``id`` and its ``component`` label. + """ + if graph.edges.count_rows() == 0: + return graph.vertices.select(col(ID), col(ID).alias(COMPONENT)).distinct() + # `auto` can only pick local when the optional extra is installed, so skip + # the extra edge count on a core only install, matching connected_components. + if strategy == "auto" and not has_local_extra(): + resolved = "distributed" + else: + num_edges = graph.num_edges() if strategy == "auto" else 0 + resolved = _resolve_strategy(strategy, num_edges, local_threshold) + if resolved == "local": + return _local_scc(graph) + return _distributed_scc( + graph, + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=checkpoint_dir, + ) diff --git a/daft_graph/algorithms/svd_plus_plus.py b/daft_graph/algorithms/svd_plus_plus.py new file mode 100644 index 0000000..1327942 --- /dev/null +++ b/daft_graph/algorithms/svd_plus_plus.py @@ -0,0 +1,127 @@ +"""SVD++ matrix factorization on a bipartite rating graph (Koren 2008). + +This is the GraphX ``svdPlusPlus`` algorithm: a recommender style factorization +of a user/item rating graph with global mean, per node biases, latent factors, +and an implicit feedback term. Edges are ``src`` (user) to ``dst`` (item) with a +rating column. It is recsys flavored rather than a structural graph algorithm; +the ratings are collected and trained with numpy SGD, which fits moderate rating +graphs. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass + +import daft +from daft import DataFrame, col + +from daft_graph._optional import require_numpy +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + +KIND = "kind" +BIAS = "bias" +FACTOR = "factor" +_RATING = "_r" + + +@dataclass(frozen=True) +class SvdPlusPlusResult: + """Learned SVD++ parameters and the final training RMSE. + + Attributes: + factors: DataFrame ``[id, kind, bias, factor]`` for every user and item. + global_mean: The global mean rating ``mu``. + rmse: Root mean squared error on the training ratings after fitting. + """ + + factors: DataFrame + global_mean: float + rmse: float + + +def svd_plus_plus( + graph: DirectedGraph, + *, + rating_column: str = "rating", + rank: int = 8, + epochs: int = 20, + learning_rate: float = 0.01, + regularization: float = 0.05, + seed: int = 0, +) -> SvdPlusPlusResult: + """Fit SVD++ on the user/item rating graph (``src`` users, ``dst`` items). + + Takes a :class:`DirectedGraph` because the rating edges are inherently + directional (``src`` is the user, ``dst`` is the item). Unlike pagerank or + SCC the body does not orient or symmetrize; the type documents the column + convention rather than an ``orient`` based behavior difference. + + Raises: + ImportError: If the optional ``local`` extra is not installed. + """ + require_numpy("svd_plus_plus") + import numpy as np + + rows = graph.edges.select(SRC, DST, col(rating_column).alias(_RATING)).collect().to_pydict() + user_ids = sorted({int(u) for u in rows[SRC]}) + item_ids = sorted({int(i) for i in rows[DST]}) + uidx = {u: i for i, u in enumerate(user_ids)} + iidx = {it: j for j, it in enumerate(item_ids)} + edges = [(uidx[int(u)], iidx[int(it)], float(r)) for u, it, r in zip(rows[SRC], rows[DST], rows[_RATING])] + if not edges: + return SvdPlusPlusResult( + factors=daft.from_pydict({ID: [], KIND: [], BIAS: [], FACTOR: []}), + global_mean=0.0, + rmse=0.0, + ) + n_users, n_items = len(user_ids), len(item_ids) + mu = sum(r for _, _, r in edges) / len(edges) + + rng = np.random.default_rng(seed) + bu = np.zeros(n_users) + bi = np.zeros(n_items) + p_user = rng.normal(0.0, 0.1, (n_users, rank)) + q_item = rng.normal(0.0, 0.1, (n_items, rank)) + y_item = rng.normal(0.0, 0.1, (n_items, rank)) + + rated: dict[int, list[int]] = defaultdict(list) + for u, it, _ in edges: + rated[u].append(it) + + lr, reg = learning_rate, regularization + for _ in range(epochs): + for u, it, r in edges: + items_u = rated[u] + scale = 1.0 / np.sqrt(len(items_u)) + implicit = scale * y_item[items_u].sum(axis=0) + pred = mu + bu[u] + bi[it] + q_item[it].dot(p_user[u] + implicit) + err = r - pred + bu[u] += lr * (err - reg * bu[u]) + bi[it] += lr * (err - reg * bi[it]) + q_old = q_item[it].copy() + p_user[u] += lr * (err * q_item[it] - reg * p_user[u]) + q_item[it] += lr * (err * (p_user[u] + implicit) - reg * q_item[it]) + y_item[items_u] += lr * (err * scale * q_old - reg * y_item[items_u]) + + squared_error = 0.0 + for u, it, r in edges: + items_u = rated[u] + implicit = (1.0 / np.sqrt(len(items_u))) * y_item[items_u].sum(axis=0) + pred = mu + bu[u] + bi[it] + q_item[it].dot(p_user[u] + implicit) + squared_error += (r - pred) ** 2 + rmse = float(np.sqrt(squared_error / len(edges))) + + factors_df = daft.from_pydict( + { + ID: user_ids + item_ids, + KIND: ["user"] * n_users + ["item"] * n_items, + BIAS: [float(b) for b in bu] + [float(b) for b in bi], + FACTOR: ( + [[float(x) for x in p_user[i]] for i in range(n_users)] + + [[float(x) for x in q_item[j]] for j in range(n_items)] + ), + } + ) + return SvdPlusPlusResult(factors=factors_df, global_mean=float(mu), rmse=rmse) diff --git a/daft_graph/algorithms/triangle_count.py b/daft_graph/algorithms/triangle_count.py new file mode 100644 index 0000000..6042bf3 --- /dev/null +++ b/daft_graph/algorithms/triangle_count.py @@ -0,0 +1,55 @@ +"""Triangle counting on Daft DataFrames. + +Counts the number of triangles each vertex participates in, treating edges as +undirected. Triangles are found by joining canonical edges (src < dst) into +paths ``a-b-c`` with ``a < b < c`` and keeping those whose closing edge +``(a, c)`` exists. Each triangle has a unique median vertex ``b``, so it is +counted exactly once. +""" + +from __future__ import annotations + +from daft import DataFrame, col, lit +from daft.functions import when + +from daft_graph.edges import canonicalize +from daft_graph.graph import Graph +from daft_graph.schema import DST, ID, SRC + +TRIANGLE_COUNT = "triangle_count" +_A = "a" +_B = "b" +_C = "c" +_ONE = "_one" + + +def triangle_count(graph: Graph) -> DataFrame: + """Count triangles per vertex (undirected). + + Accepts either graph flavor; edges are treated as undirected either way. + + Returns a DataFrame with one row per vertex: ``id`` and ``triangle_count``. + """ + all_v = graph.vertices.select(ID).distinct() + if graph.edges.count_rows() == 0: + return all_v.with_column(TRIANGLE_COUNT, lit(0)).select(ID, TRIANGLE_COUNT) + e = canonicalize(graph.edges) + paths = e.select(col(SRC).alias(_A), col(DST).alias(_B)).join( + e.select(col(SRC).alias(_B), col(DST).alias(_C)), on=_B, how="inner" + ) + triangles = paths.join(e.select(col(SRC).alias(_A), col(DST).alias(_C)), on=[_A, _C], how="semi") + members = ( + triangles.select(col(_A).alias(ID)) + .union_all(triangles.select(col(_B).alias(ID))) + .union_all(triangles.select(col(_C).alias(ID))) + .with_column(_ONE, lit(1)) + ) + counts = members.groupby(ID).agg(col(_ONE).sum().alias(TRIANGLE_COUNT)) + return ( + all_v.join(counts, on=ID, how="left") + .with_column( + TRIANGLE_COUNT, + when(col(TRIANGLE_COUNT).is_null(), lit(0)).otherwise(col(TRIANGLE_COUNT)), + ) + .select(ID, TRIANGLE_COUNT) + ) diff --git a/daft_graph/edges.py b/daft_graph/edges.py new file mode 100644 index 0000000..9e0fe3b --- /dev/null +++ b/daft_graph/edges.py @@ -0,0 +1,82 @@ +"""Edge list utilities for daft-graph. + +Edges are Daft DataFrames with two integer columns, ``src`` and ``dst``. These +helpers normalize, deduplicate, and reshape edge lists. They return lazy +DataFrames; materialization is the caller's responsibility (see +:mod:`daft_graph.iterate`). +""" + +from __future__ import annotations + +from daft import DataFrame, col +from daft.functions import when + +from daft_graph.schema import DST, SRC + + +def to_edges(df: DataFrame, src: str, dst: str) -> DataFrame: + """Project two columns of ``df`` into a canonical ``src``/``dst`` edge list. + + Args: + df: Any DataFrame holding a source and destination column. + src: Name of the column to use as ``src``. + dst: Name of the column to use as ``dst``. + + Returns: + A DataFrame with exactly the ``src`` and ``dst`` columns. + """ + return df.select(col(src).alias(SRC), col(dst).alias(DST)) + + +def validate_edges(edges: DataFrame) -> DataFrame: + """Return ``edges`` unchanged, or raise if it lacks ``src``/``dst`` columns. + + Args: + edges: The edge DataFrame to validate. + + Returns: + The same DataFrame, for convenient chaining. + + Raises: + ValueError: If either the ``src`` or ``dst`` column is missing. + """ + columns = set(edges.column_names) + missing = {SRC, DST} - columns + if missing: + raise ValueError(f"edges must have columns {SRC!r} and {DST!r}; missing {sorted(missing)}") + return edges + + +def drop_self_loops(edges: DataFrame) -> DataFrame: + """Drop edges whose endpoints are equal.""" + return edges.where(col(SRC) != col(DST)) + + +def dedupe_edges(edges: DataFrame) -> DataFrame: + """Remove duplicate ``(src, dst)`` rows.""" + return edges.select(SRC, DST).distinct() + + +def canonicalize(edges: DataFrame) -> DataFrame: + """Orient every edge so ``src <= dst``, drop self loops, and deduplicate. + + Produces a stable undirected representation: ``(a, b)`` and ``(b, a)`` + collapse to a single row. + """ + oriented = edges.select( + when(col(SRC) <= col(DST), col(SRC)).otherwise(col(DST)).alias(SRC), + when(col(SRC) <= col(DST), col(DST)).otherwise(col(SRC)).alias(DST), + ) + return oriented.where(col(SRC) != col(DST)).distinct() + + +def symmetrize(edges: DataFrame) -> DataFrame: + """Add the reverse of every edge, yielding an undirected adjacency list. + + Non ``src``/``dst`` columns (edge attributes) are preserved and carried onto + the reversed rows unchanged, so the result keeps the input's schema. + """ + attrs = [c for c in edges.column_names if c not in (SRC, DST)] + forward = edges.select(SRC, DST, *attrs) + backward = edges.select(col(DST).alias(SRC), col(SRC).alias(DST), *[col(c) for c in attrs]) + return forward.union_all(backward) diff --git a/daft_graph/graph.py b/daft_graph/graph.py new file mode 100644 index 0000000..758f1ed --- /dev/null +++ b/daft_graph/graph.py @@ -0,0 +1,390 @@ +"""The Graph abstraction over a vertices DataFrame and an edges DataFrame. + +``Graph`` is abstract. Construct a :class:`DirectedGraph` or an +:class:`UndirectedGraph` instead, so the type carries the direction semantics and +an algorithm can say which flavor it needs. Algorithms that walk edges call +:meth:`Graph.orient` on the edge frame they want to traverse, which returns it +unchanged for a directed graph and symmetrized for an undirected one, so one +implementation serves both flavors. :meth:`Graph.traversal_edges` is the +shorthand for orienting the whole edge set. + +Incoming column names are normalized on construction. The ``src_col``, +``dst_col``, and ``id_col`` arguments describe the frames handed in, not the +frames stored, so the rest of the library reads the canonical ``src``, ``dst``, +and ``id`` names from :mod:`daft_graph.schema`. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from daft import DataFrame, Expression, col + +from daft_graph.edges import symmetrize, validate_edges +from daft_graph.schema import DST, ID, SRC + +if TYPE_CHECKING: + # `typing.Self` is 3.11+, so import from typing_extensions for the 3.10 + # target. Guarded under TYPE_CHECKING, so there is no runtime import; every + # use is a return annotation kept as a string by `from __future__`. + from typing_extensions import Self + +_DEGREE = "degree" + + +def _rename(df: DataFrame, mapping: dict[str, str]) -> DataFrame: + """Rename columns per ``mapping``, leaving every other column untouched. + + Args: + df: The frame to rename columns on. + mapping: Incoming column name to canonical column name. Identity entries + are ignored. + + Returns: + ``df`` unchanged when no rename is needed, else a projection with the + renamed columns and all other columns preserved in their original order. + + Raises: + ValueError: If a source column is missing, or a rename would collide with + an existing column that is not itself being renamed away. + """ + pending = {src: dst for src, dst in mapping.items() if src != dst} + if not pending: + return df + columns = df.column_names + missing = [src for src in pending if src not in columns] + if missing: + raise ValueError(f"column(s) not found: {sorted(missing)}; have {sorted(columns)}") + collisions = [dst for dst in pending.values() if dst in columns and dst not in pending] + if collisions: + raise ValueError(f"renaming to {sorted(collisions)} would collide with existing column(s)") + return df.select(*[col(c).alias(pending.get(c, c)) for c in columns]) + + +def _derive_vertices(edges: DataFrame) -> DataFrame: + """Return the distinct endpoint ids of ``edges`` as a one column vertex frame.""" + endpoints = edges.select(col(SRC).alias(ID)).union_all(edges.select(col(DST).alias(ID))) + return endpoints.distinct() + + +class Graph(ABC): + """A graph backed by a vertices DataFrame and an edges DataFrame. + + Abstract. Use :class:`DirectedGraph` or :class:`UndirectedGraph`. + + Instances are immutable after construction, like the frozen dataclass this + replaced. The stored frames are reachable read only through the ``edges`` and + ``vertices`` properties; rebind nothing. + + Attributes: + vertices: DataFrame with a unique ``id`` column plus optional properties. + edges: DataFrame with ``src`` and ``dst`` columns plus optional properties. + """ + + __slots__ = ("_edges", "_vertices") + + _vertices: DataFrame + _edges: DataFrame + + def __setattr__(self, name: str, value: object) -> None: + """Block attribute assignment, so instances stay immutable.""" + raise AttributeError(f"{type(self).__name__} is immutable; cannot set {name!r}") + + def __init__( + self, + edges: DataFrame, + vertices: DataFrame | None = None, + *, + src_col: str = SRC, + dst_col: str = DST, + id_col: str = ID, + validate: bool = False, + ) -> None: + """Build a graph from an edge list and, optionally, a vertex list. + + Args: + edges: DataFrame holding the edges. + vertices: DataFrame holding the vertices. When None the distinct edge + endpoints are used. + src_col: Source column name in ``edges``. + dst_col: Destination column name in ``edges``. + id_col: Id column name in ``vertices``. Ignored when ``vertices`` is None. + validate: Run the semantic checks, namely that vertex ids are unique + and that every edge endpoint is a known vertex. Both scan the + data, so they are off by default. Structural column checks always + run because they only read the schema. + + Raises: + ValueError: If a required column is missing, or if ``validate`` is set + and a semantic check fails. + """ + if src_col == dst_col: + raise ValueError(f"src_col and dst_col must differ, got {src_col!r} for both") + normalized_edges = _rename(edges, {src_col: SRC, dst_col: DST}) + validate_edges(normalized_edges) + if vertices is None: + normalized_vertices = _derive_vertices(normalized_edges) + else: + normalized_vertices = _rename(vertices, {id_col: ID}) + if ID not in normalized_vertices.column_names: + raise ValueError(f"vertices must have an {ID!r} column") + object.__setattr__(self, "_edges", normalized_edges) + object.__setattr__(self, "_vertices", normalized_vertices) + if validate: + self._validate() + + def _validate(self) -> None: + """Check that vertex ids are unique and every edge endpoint is known. + + Raises: + ValueError: If a duplicate vertex id exists, or an edge references a + vertex that is not in the vertex set. + """ + ids = self._vertices.select(ID) + total = ids.count_rows() + if total != ids.distinct().count_rows(): + raise ValueError(f"vertices contain duplicate {ID!r} values") + endpoints = _derive_vertices(self._edges) + dangling = endpoints.join(ids, on=ID, how="anti").count_rows() + if dangling: + raise ValueError(f"{dangling} edge endpoint(s) are not in the vertex set") + + @property + def edges(self) -> DataFrame: + """The edge DataFrame, with canonical ``src`` and ``dst`` columns.""" + return self._edges + + @property + def vertices(self) -> DataFrame: + """The vertex DataFrame, with a canonical ``id`` column.""" + return self._vertices + + def _rebuild(self, *, vertices: DataFrame, edges: DataFrame) -> Self: + """Construct the same concrete class from already normalized frames.""" + return type(self)(edges, vertices) + + @property + @abstractmethod + def is_directed(self) -> bool: + """Whether edge direction is meaningful for this graph. + + True for :class:`DirectedGraph`, False for :class:`UndirectedGraph`. + """ + + def orient(self, edges: DataFrame) -> DataFrame: + """Orient an edge frame per this graph's direction semantics. + + The extension hook for algorithm authors: an algorithm that walks edges + calls this instead of branching on the graph's type, and it then works for + both flavors. A directed graph returns the frame unchanged; an undirected + graph returns it symmetrized, preserving edge attribute columns so the + schema is unchanged. + + It takes the frame rather than reading :attr:`edges` so a caller can + filter or project first and still get the orientation applied afterwards. + That ordering matters: filtering after symmetrizing would keep reversed + copies of edges the filter was meant to remove. + + Args: + edges: The edge frame to orient, usually derived from :attr:`edges`. + + Returns: + The frame oriented for traversal. + """ + return edges if self.is_directed else symmetrize(edges) + + def traversal_edges(self) -> DataFrame: + """The whole edge set, oriented per this graph's direction semantics. + + Shorthand for ``graph.orient(graph.edges)``. Use :meth:`orient` directly + when the edges need filtering or projecting first. + """ + return self.orient(self._edges) + + def degrees(self) -> DataFrame: + """Total degree per vertex, as columns ``id`` and ``degree``. + + Counts incident endpoint slots, so an edge contributes one to each of its + endpoints and a self loop contributes two. For a directed graph that is + the same number as in degree plus out degree. Only vertices touched by at + least one edge appear, matching GraphFrames. + """ + endpoints = self._edges.select(col(SRC).alias(ID)).union_all(self._edges.select(col(DST).alias(ID))) + return endpoints.groupby(ID).agg(col(ID).count().alias(_DEGREE)) + + def num_vertices(self) -> int: + """Return the number of distinct vertices.""" + return self._vertices.select(ID).distinct().count_rows() + + def num_edges(self) -> int: + """Return the number of edge rows.""" + return self._edges.count_rows() + + def triplets(self) -> DataFrame: + """Edges joined with their endpoint vertex attributes. + + Returns the edge columns plus ``src_`` and ``dst_`` for every + non id vertex attribute, so each row carries both endpoints' properties. + """ + vattrs = [c for c in self._vertices.column_names if c != ID] + src_v = self._vertices.select(col(ID).alias(SRC), *[col(c).alias(f"src_{c}") for c in vattrs]) + dst_v = self._vertices.select(col(ID).alias(DST), *[col(c).alias(f"dst_{c}") for c in vattrs]) + return self._edges.join(src_v, on=SRC, how="inner").join(dst_v, on=DST, how="inner") + + def filter_vertices(self, condition: Expression) -> Self: + """Keep vertices matching ``condition`` and drop edges touching removed ones.""" + kept = self._vertices.where(condition) + kept_ids = kept.select(ID) + edges = self._edges.join(kept_ids.select(col(ID).alias(SRC)), on=SRC, how="semi").join( + kept_ids.select(col(ID).alias(DST)), on=DST, how="semi" + ) + return self._rebuild(vertices=kept, edges=edges) + + def filter_edges(self, condition: Expression) -> Self: + """Keep edges matching ``condition``; all vertices are retained.""" + return self._rebuild(vertices=self._vertices, edges=self._edges.where(condition)) + + def drop_isolated_vertices(self) -> Self: + """Drop vertices that do not appear in any edge.""" + endpoints = _derive_vertices(self._edges) + kept = self._vertices.join(endpoints, on=ID, how="semi") + return self._rebuild(vertices=kept, edges=self._edges) + + def degree_by_type(self, type_column: str) -> DataFrame: + """Degree of each vertex broken down by edge type. + + Treats edges as undirected and counts incident edges of each type. + Returns columns ``id``, ````, and ``degree``. Useful for + labeled property graphs where edges carry a type or relationship column. + """ + endpoints = self._edges.select(col(SRC).alias(ID), col(type_column)).union_all( + self._edges.select(col(DST).alias(ID), col(type_column)) + ) + return endpoints.groupby([ID, type_column]).agg(col(ID).count().alias(_DEGREE)) + + def bfs_paths( + self, + from_filter: Expression, + to_filter: Expression, + *, + max_path_length: int = 10, + edge_filter: Expression | None = None, + max_paths: int = 1_000_000, + ) -> DataFrame: + """Shortest paths from a source vertex set to a target vertex set. + + The GraphFrames ``bfs`` analog. See + :func:`daft_graph.algorithms.bfs.bfs_paths` for the semantics and the + returned column shape. + """ + from daft_graph.algorithms.bfs import bfs_paths + + return bfs_paths( + self, + from_filter, + to_filter, + max_path_length=max_path_length, + edge_filter=edge_filter, + max_paths=max_paths, + ) + + +class DirectedGraph(Graph): + """A directed graph. Edges run from ``src`` to ``dst``. + + Example: + >>> import daft + >>> from daft_graph import DirectedGraph + >>> edges = daft.from_pydict({"src": [0, 1], "dst": [1, 2]}) + >>> g = DirectedGraph(edges) + """ + + __slots__ = () + + @property + def is_directed(self) -> bool: + """Directed traversal walks the edges as given.""" + return True + + def out_degrees(self) -> DataFrame: + """Out degree per source vertex, as columns ``id`` and ``degree``. + + Only vertices with at least one outgoing edge appear, matching GraphFrames. + """ + return self._edges.groupby(SRC).agg(col(DST).count().alias(_DEGREE)).select(col(SRC).alias(ID), col(_DEGREE)) + + def in_degrees(self) -> DataFrame: + """In degree per destination vertex, as columns ``id`` and ``degree``.""" + return self._edges.groupby(DST).agg(col(SRC).count().alias(_DEGREE)).select(col(DST).alias(ID), col(_DEGREE)) + + def degrees(self) -> DataFrame: + """Total degree (in plus out) per vertex, as columns ``id`` and ``degree``. + + Equivalent to the base implementation, kept explicit because in degree + plus out degree is how a directed graph's total degree is defined. + """ + combined = self.out_degrees().union_all(self.in_degrees()) + return combined.groupby(ID).agg(col(_DEGREE).sum().alias(_DEGREE)) + + def reverse(self) -> DirectedGraph: + """Return the graph with every edge's direction flipped. + + Edge attributes are preserved; only ``src`` and ``dst`` swap. + """ + attrs = [c for c in self._edges.column_names if c not in (SRC, DST)] + flipped = self._edges.select(col(DST).alias(SRC), col(SRC).alias(DST), *[col(c) for c in attrs]) + return DirectedGraph(flipped, self._vertices) + + def as_undirected(self) -> UndirectedGraph: + """Return an undirected view over the same vertices and edges. + + The edge rows are carried over as they are. Direction is dropped by the + undirected traversal and degree semantics, not by rewriting the edges. + """ + return UndirectedGraph(self._edges, self._vertices) + + def find(self, pattern: str) -> DataFrame: + """Find subgraphs matching a motif pattern. + + Motif patterns are directed, so this lives on the directed graph. See + :func:`daft_graph.motif.find` for the pattern syntax and return shape. + """ + from daft_graph.motif import find + + return find(self, pattern) + + +class UndirectedGraph(Graph): + """An undirected graph. Each edge row is one undirected edge. + + Edges are stored exactly as handed in, one row per edge, so ``num_edges`` and + ``degrees`` count each edge once. Direction is dropped at traversal time by + :meth:`traversal_edges`, which symmetrizes, rather than by duplicating the + stored rows. + + Example: + >>> import daft + >>> from daft_graph import UndirectedGraph + >>> edges = daft.from_pydict({"src": [0, 1], "dst": [1, 2]}) + >>> g = UndirectedGraph(edges) + """ + + __slots__ = () + + @property + def is_directed(self) -> bool: + """Undirected traversal walks both orientations of every edge.""" + return False + + def as_directed(self, src_col: str = SRC, dst_col: str = DST) -> DirectedGraph: + """Reinterpret this graph as directed, reading direction from the columns. + + Args: + src_col: Column of the stored edges to treat as the source. + dst_col: Column of the stored edges to treat as the destination. + + Returns: + A :class:`DirectedGraph` over the same vertices and edges. + """ + return DirectedGraph(self._edges, self._vertices, src_col=src_col, dst_col=dst_col) diff --git a/daft_graph/indexing.py b/daft_graph/indexing.py new file mode 100644 index 0000000..1e444a2 --- /dev/null +++ b/daft_graph/indexing.py @@ -0,0 +1,128 @@ +"""Vertex id indexing: relabel arbitrary vertex ids to contiguous ``int64``. + +The graph algorithms operate on integer vertex ids (``id``, ``src``, ``dst`` are +``int64``). GraphFrames hides this by remapping any id type (string, UUID, long) +to a packed long internally and mapping results back. ``reindex`` provides the +same convenience for daft-graph: it relabels a graph whose ids are strings (or +any sortable type) to contiguous ``int64`` ids ``0..n-1`` and returns a mapping +DataFrame, so an integer only algorithm can run on a string keyed graph. Use +``restore_ids`` to translate id columns of a result back to the original ids. + +The vertex id set is collected to the driver to assign contiguous, deterministic +ids (sorted original order). Vertex counts are typically far smaller than edge +counts, so this fits the same scale as the rest of the library; a fully +distributed relabel is future work. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Generic, TypeVar + +import daft +from daft import DataFrame, col + +from daft_graph.graph import Graph +from daft_graph.schema import DST, ID, SRC + +GraphT = TypeVar("GraphT", bound=Graph) + +ORIGINAL = "original_id" +_NEW = "__new_id" +_NEW_SRC = "__new_src" +_NEW_DST = "__new_dst" + + +@dataclass(frozen=True) +class IndexedGraph(Generic[GraphT]): + """A graph relabelled to contiguous ``int64`` ids, plus the id mapping. + + Generic in the graph flavor, so reindexing a :class:`DirectedGraph` yields an + ``IndexedGraph[DirectedGraph]`` and the directed methods stay available. + + Attributes: + graph: The relabelled graph; ``id``, ``src``, ``dst`` are ``int64`` in + ``0..n-1``. Same concrete class as the graph handed to + :func:`reindex`. + mapping: DataFrame ``[original_id, id]`` from each original id to its new + integer id. + """ + + graph: GraphT + mapping: DataFrame + + +def reindex(graph: GraphT) -> IndexedGraph[GraphT]: + """Relabel a graph's vertex ids to contiguous ``int64`` ids ``0..n-1``. + + Ids are assigned in sorted order of the original ids, so the result is + deterministic. Vertex and edge attribute columns are preserved. Edges whose + endpoints are not in the vertex set are dropped (they have no index), which + matches how GraphFrames joins edges to its indexed vertices. + + Args: + graph: The graph to relabel; its ``id`` column may hold any sortable type. + + Returns: + An :class:`IndexedGraph` with the relabelled graph and the id mapping. + The relabelled graph is the same concrete class as ``graph``. + + Raises: + ValueError: if the graph has a vertex or edge column whose name collides + with an internal working name (``__new_id``/``__new_src``/``__new_dst``). + """ + conflicts = sorted((set(graph.vertices.column_names) | set(graph.edges.column_names)) & {_NEW, _NEW_SRC, _NEW_DST}) + if conflicts: + raise ValueError(f"graph columns conflict with reindex internal names: {conflicts}") + originals = sorted(graph.vertices.select(ID).distinct().collect().to_pydict()[ID]) + new_ids = list(range(len(originals))) + mapping = daft.from_pydict({ORIGINAL: originals, ID: new_ids}) + + v_attrs = [c for c in graph.vertices.column_names if c != ID] + v_remap = daft.from_pydict({ORIGINAL: originals, _NEW: new_ids}) + new_vertices = graph.vertices.join(v_remap, left_on=ID, right_on=ORIGINAL, how="inner").select( + col(_NEW).alias(ID), *v_attrs + ) + + e_attrs = [c for c in graph.edges.column_names if c not in (SRC, DST)] + src_remap = daft.from_pydict({SRC: originals, _NEW_SRC: new_ids}) + dst_remap = daft.from_pydict({DST: originals, _NEW_DST: new_ids}) + new_edges = ( + graph.edges.join(src_remap, on=SRC, how="inner") + .join(dst_remap, on=DST, how="inner") + .select(col(_NEW_SRC).alias(SRC), col(_NEW_DST).alias(DST), *e_attrs) + ) + relabelled = type(graph)(new_edges, new_vertices) + return IndexedGraph(graph=relabelled, mapping=mapping) + + +def restore_ids(df: DataFrame, mapping: DataFrame, columns: list[str]) -> DataFrame: + """Translate integer id columns of ``df`` back to the original ids. + + Args: + df: A result DataFrame whose ``columns`` hold reindexed ``int64`` ids. + mapping: The ``[original_id, id]`` mapping from :func:`reindex`. + columns: Names of the columns in ``df`` to map back to original ids. + + Returns: + ``df`` with each named column's values replaced by the original ids, + preserving column order. Id values absent from ``mapping`` become null; + no error is raised for them. + + Raises: + ValueError: if a requested column is missing from ``df``, or if ``df`` has + a column that collides with the internal working name. + """ + missing = [c for c in columns if c not in df.column_names] + if missing: + raise ValueError(f"columns not found in df: {missing}") + if _NEW in df.column_names: + raise ValueError(f"df column {_NEW!r} conflicts with restore_ids internal name") + result = df + for column in columns: + order = result.column_names + lookup = mapping.select(col(ID).alias(column), col(ORIGINAL).alias(_NEW)) + result = result.join(lookup, on=column, how="left").select( + *[col(_NEW).alias(column) if name == column else col(name) for name in order] + ) + return result diff --git a/daft_graph/iterate.py b/daft_graph/iterate.py new file mode 100644 index 0000000..e7768ce --- /dev/null +++ b/daft_graph/iterate.py @@ -0,0 +1,208 @@ +"""Iteration engine for fixed point graph algorithms. + +The core job of this module is to truncate Daft's logical plan between rounds. +Iterative joins otherwise accumulate an ever growing plan and memory blows up, +the same failure Spark GraphFrames solves with checkpointing. Materializing the +state with ``.collect()`` starts a fresh plan; optionally the state is round +tripped through parquet for a stronger break on very long iterations. + +Materializing also has to bound the state's *partition count*. Each round's +joins and unions grow the partition count (``union_all`` sums it, so a self +referential step doubles it every round), and ``.collect()`` preserves that +count in the materialized result. On the native runner partitions are invisible, +but on the distributed (Ray/Flotilla) runner the count compounds - 200 -> 400 -> +800 -> ... -> thousands - and each shuffle then needs a partition-count-squared +number of pieces, which exhausts head node memory and stalls the job even on a +tiny graph. So every materialize coalesces the state back to a count scaled to +its row count and hard capped (see :data:`_MAX_PARTITIONS`). This mirrors what +GraphFrames does when it checkpoints and coalesces; the intra-round shuffles +still parallelize freely, only the carried-over state is bounded. + +Static inputs (adjacency, out degrees) should be materialized by the caller +before building the step closure, so they are not replanned every round. +""" + +from __future__ import annotations + +import os +import warnings +from collections.abc import Callable + +import daft +from daft import DataFrame + +StepFn = Callable[[DataFrame], DataFrame] +ConvergedFn = Callable[[DataFrame, DataFrame], bool] + +#: Rows per partition used to scale the coalesced state to its size. +_TARGET_ROWS_PER_PARTITION = 50_000 +#: Hard ceiling on the carried-over state's partition count, so an iterative +#: step can never compound partitions round over round no matter the runner. +_MAX_PARTITIONS = 256 +#: Ceiling applied to intermediate frames inside a single round. Daft's shuffles +#: inherit their input's partition count (a repartition with no explicit count +#: resolves to ``input_num_partitions``), so capping an intermediate caps every +#: shuffle downstream of it in that round. +_MAX_INTERMEDIATE_PARTITIONS = 16 + + +def _supports_partitioning(df: DataFrame) -> bool: + """True when the active runner exposes a partition count for ``df``. + + The native runner has no partitions: ``num_partitions`` returns None there and + ``into_partitions`` is a documented no-op that warns. Gating on this keeps the + single node path free of both the warning and the pointless plan node, while + the distributed runner (where the partition count is the whole problem) gets + the bounding. + """ + return df.num_partitions() is not None + + +def bound_partitions(df: DataFrame, cap: int = _MAX_INTERMEDIATE_PARTITIONS) -> DataFrame: + """Cap ``df``'s planned partition count without executing anything. + + ``DataFrame.num_partitions`` inspects the physical plan rather than running + it, and ``into_partitions`` only merges partitions, so this is a pure plan + rewrite: no job, no round trip. Use it on intermediate frames inside an + iterative step, where an extra ``collect`` would cost a distributed round + trip every round. + + Why it is needed: Daft resolves a shuffle's output partition count to the + repartition spec's count *or else the input's* count, and never lowers it. A + ``union_all`` sums its inputs' counts, so a step that symmetrizes or unions + doubles the count every round and each shuffle then needs a + count-squared number of pieces. Capping the intermediate breaks that chain. + + Args: + df: The frame to cap. May be lazy; nothing is materialized. + cap: Maximum partition count to allow through. + + Returns: + ``df`` unchanged when its planned count is unknown or already within + ``cap``, otherwise ``df`` coalesced to ``cap`` partitions. + """ + planned = df.num_partitions() + if planned is None or planned <= cap: + return df + return df.into_partitions(cap) + + +def iterate_to_fixed_point( + state: DataFrame, + step_fn: StepFn, + converged_fn: ConvergedFn, + *, + max_iters: int = 30, + materialize_every: int = 1, + checkpoint_dir: str | None = None, +) -> tuple[DataFrame, int]: + """Run ``step_fn`` until ``converged_fn`` is true or ``max_iters`` is reached. + + Between rounds the state is materialized to truncate the Daft logical plan, + which is what keeps iterative joins from growing an unbounded plan. This is + the analog of Spark GraphFrames checkpointing. + + With ``materialize_every > 1`` the state is collected only every N rounds, so + ``converged_fn`` may receive un-materialized frames on intermediate rounds + (its own aggregation still forces evaluation). If the loop does not converge + within ``max_iters`` a warning is emitted. + + Args: + state: The initial DataFrame state. + step_fn: Maps the current state to the next state. + converged_fn: Given ``(previous_state, next_state)``, returns True to stop. + max_iters: Maximum number of rounds before giving up. + materialize_every: Materialize the state every N rounds. Must be >= 1. + checkpoint_dir: If set, round trip the materialized state through parquet + here for a stronger plan break on very long iterations. + + Returns: + A tuple of ``(final_state, num_rounds_run)``. + + Raises: + ValueError: If ``max_iters`` or ``materialize_every`` is less than 1. + """ + if max_iters < 1: + raise ValueError(f"max_iters must be >= 1, got {max_iters}") + if materialize_every < 1: + raise ValueError(f"materialize_every must be >= 1, got {materialize_every}") + + current = collect_bounded(state) + rounds = 0 + converged = False + for i in range(max_iters): + nxt = step_fn(current) + if (i + 1) % materialize_every == 0: + nxt = _materialize(nxt, checkpoint_dir, i) + rounds += 1 + if converged_fn(current, nxt): + current = nxt + converged = True + break + current = nxt + if not converged: + warnings.warn( + f"iterate_to_fixed_point did not converge within {max_iters} rounds", + stacklevel=2, + ) + return current, rounds + + +def _bounded_partition_count(df: DataFrame) -> int: + """Partitions to coalesce a materialized state into: scaled to rows, capped. + + Called on an already-materialized frame, so ``count_rows`` is a cheap + metadata read rather than a job. The result is at least one partition, grows + one partition per :data:`_TARGET_ROWS_PER_PARTITION` rows, and never exceeds + :data:`_MAX_PARTITIONS` - which is what stops the round-over-round compounding. + """ + rows = df.count_rows() + if rows <= 0: + return 1 + scaled = (rows + _TARGET_ROWS_PER_PARTITION - 1) // _TARGET_ROWS_PER_PARTITION + return max(1, min(_MAX_PARTITIONS, scaled)) + + +def collect_bounded(df: DataFrame) -> DataFrame: + """Materialize ``df`` once, then present it at a size-scaled, capped count. + + Collects to truncate the plan, then returns ``into_partitions`` *lazily* on the + materialized result rather than re-collecting. This is deliberate: a second + ``.collect()`` returns a frame whose ``num_partitions`` reports 0 (a + materialized frame has no clustering spec), which would make every downstream + :func:`bound_partitions` check see 0, conclude "already small", and skip the + cap - silently disabling the whole mechanism in a loop. Returning the lazy + ``into_partitions`` keeps the reported count at the bound, so counts flow + correctly through the union_all/join chain that follows. The base is + materialized, so the coalesce reads cached partitions and does not replan. + + Use for loop state and once-collected static inputs (adjacency, degrees) that + are joined every round. For purely intermediate frames use + :func:`bound_partitions`, which needs no execution at all. + """ + collected = df.collect() + if not _supports_partitioning(collected): + return collected + return collected.into_partitions(_bounded_partition_count(collected)) + + +def _materialize(df: DataFrame, checkpoint_dir: str | None, round_index: int) -> DataFrame: + """Truncate the plan and present the state at a bounded partition count. + + Collects once to break the logical plan, then coalesces *lazily* (see + :func:`collect_bounded` for why re-collecting would zero the reported count + and disable downstream caps). The coalesce is fused into the next round's + execution over cached partitions, so bounding the state adds no distributed + round trip - which matters because this runs every round. + """ + collected = df.collect() + bounded = ( + collected.into_partitions(_bounded_partition_count(collected)) + if _supports_partitioning(collected) + else collected + ) + if checkpoint_dir is None: + return bounded + path = os.path.join(checkpoint_dir, f"round_{round_index}") + bounded.write_parquet(path, write_mode="overwrite") + return daft.read_parquet(path) diff --git a/daft_graph/message_passing.py b/daft_graph/message_passing.py new file mode 100644 index 0000000..81f3af4 --- /dev/null +++ b/daft_graph/message_passing.py @@ -0,0 +1,144 @@ +"""Message passing primitives: aggregate_messages (one round) and pregel (loop). + +These generalize the pattern the built in algorithms use: join the current +vertex state onto the edges to form triplets, send messages along edges, and +aggregate them at the receiving vertices. ``pregel`` runs this to a fixed point +on the shared iteration engine. This is the analog of the GraphFrames +``aggregateMessages`` and Pregel APIs. + +State is a vertex DataFrame keyed by ``id``. Its non id columns are exposed on +the triplets as ``src_`` and ``dst_``, so message expressions can read +both endpoints, e.g. ``col("src_value") / col("src_degree")``. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from daft import DataFrame, Expression, col + +from daft_graph._compare import rows_equal +from daft_graph.iterate import ConvergedFn, bound_partitions, iterate_to_fixed_point +from daft_graph.schema import DST, ID, SRC + +VALUE = "value" +MSG = "msg" + +AggFn = Callable[[Expression], Expression] + + +def _default_agg(msg: Expression) -> Expression: + return msg.sum() + + +def _triplets(edges: DataFrame, state: DataFrame) -> DataFrame: + """Join vertex state onto both endpoints, prefixing columns src_ and dst_. + + The result is partition capped: this is two chained joins, and Daft resolves a + shuffle's output partition count to its input's count without ever lowering + it, so an uncapped triplet frame would carry a growing count into every + message shuffle of every round. The cap is a plan rewrite, not an execution. + """ + state_cols = [c for c in state.column_names if c != ID] + reserved = [c for c in state_cols if c.startswith(("src_", "dst_"))] + if reserved: + raise ValueError(f"state columns must not start with 'src_' or 'dst_': {reserved}") + src_state = state.select(col(ID).alias(SRC), *[col(c).alias(f"src_{c}") for c in state_cols]) + dst_state = state.select(col(ID).alias(DST), *[col(c).alias(f"dst_{c}") for c in state_cols]) + joined = edges.join(src_state, on=SRC, how="inner").join(dst_state, on=DST, how="inner") + return bound_partitions(joined) + + +def aggregate_messages( + edges: DataFrame, + state: DataFrame, + *, + to_src: Expression | None = None, + to_dst: Expression | None = None, + agg: AggFn | None = None, +) -> DataFrame: + """One round of message passing along edges, aggregated at recipients. + + Args: + edges: Edge DataFrame with ``src`` and ``dst`` columns. + state: Vertex DataFrame keyed by ``id``; non id columns appear on the + triplets as ``src_`` and ``dst_``. + to_src: Message expression sent to each edge's source, or None. + to_dst: Message expression sent to each edge's destination, or None. + agg: Aggregator applied to the ``msg`` column per recipient (default sum). + + Returns: + A DataFrame ``[id, msg]`` with one row per vertex that received a message. + + Raises: + ValueError: If neither ``to_src`` nor ``to_dst`` is given. + """ + if to_src is None and to_dst is None: + raise ValueError("at least one of to_src or to_dst must be provided") + aggregator = agg or _default_agg + triplets = _triplets(edges, state) + parts: list[DataFrame] = [] + if to_dst is not None: + parts.append(triplets.select(col(DST).alias(ID), to_dst.alias(MSG))) + if to_src is not None: + parts.append(triplets.select(col(SRC).alias(ID), to_src.alias(MSG))) + combined = parts[0] + for part in parts[1:]: + combined = combined.union_all(part) + # union_all sums its inputs' partition counts, so cap before the aggregation + # shuffle inherits that sum. Plan rewrite only, no execution. + return bound_partitions(combined).groupby(ID).agg(aggregator(col(MSG)).alias(MSG)) + + +def pregel( + edges: DataFrame, + init_state: DataFrame, + *, + update: Expression, + to_src: Expression | None = None, + to_dst: Expression | None = None, + agg: AggFn | None = None, + max_iters: int = 20, + converged: ConvergedFn | None = None, + materialize_every: int = 1, + checkpoint_dir: str | None = None, +) -> DataFrame: + """Run message passing to a fixed point, updating a ``value`` column. + + Each round sends messages (built from ``src_``/``dst_`` triplet + columns), aggregates them into ``msg`` per vertex, then sets ``value`` to + ``update`` (an expression over ``value`` and ``msg``; ``msg`` is null for + vertices that received nothing). Other columns are carried unchanged. + + Args: + edges: Edge DataFrame with ``src`` and ``dst`` columns. + init_state: Initial vertex state ``[id, value, ...]``. + update: Expression over ``value`` and ``msg`` giving the new value. + to_src: Message expression sent to each edge's source, or None. + to_dst: Message expression sent to each edge's destination, or None. + agg: Aggregator for the message column (default sum). + max_iters: Maximum rounds. + converged: Optional ``(prev, next) -> bool``; default exact equality on + ``[id, value]``. + materialize_every: How often to truncate the Daft plan between rounds. + checkpoint_dir: Optional parquet checkpoint directory. + + Returns: + The final vertex state DataFrame, same schema as ``init_state``. + """ + extra_cols = [c for c in init_state.column_names if c not in (ID, VALUE)] + + def step(state: DataFrame) -> DataFrame: + msgs = bound_partitions(aggregate_messages(edges, state, to_src=to_src, to_dst=to_dst, agg=agg)) + return state.join(msgs, on=ID, how="left").select(col(ID), update.alias(VALUE), *[col(c) for c in extra_cols]) + + converged_fn = converged or (lambda prev, nxt: rows_equal(prev, nxt, [ID, VALUE])) + final, _ = iterate_to_fixed_point( + init_state, + step, + converged_fn, + max_iters=max_iters, + materialize_every=materialize_every, + checkpoint_dir=checkpoint_dir, + ) + return final diff --git a/daft_graph/motif.py b/daft_graph/motif.py new file mode 100644 index 0000000..3d783d8 --- /dev/null +++ b/daft_graph/motif.py @@ -0,0 +1,221 @@ +"""Motif finding for daft-graph: a GraphFrames style find() DSL. + +Patterns are chains of directed edge patterns and lone vertices separated by +semicolons, for example:: + + "(a)-[e]->(b); (b)-[e2]->(c); !(c)-[]->(a)" + +Vertices are written ``(name)`` and edges ``-[name]->``; empty names denote +anonymous (non output) elements. A leading ``!`` negates an edge pattern: the +match must not contain that edge. Repeated names bind to the same element. + +``find`` compiles the pattern to a chain of Daft joins and returns one row per +match, with a struct column per named vertex (the vertex row) and per named edge +(the edge row). Filter matches with struct field access, e.g. +``result.where(col("a")["id"] != col("c")["id"])``. + +Not supported in this version: variable length edges (``-[e*1..3]->``), +undirected or bidirectional motif edges, and self referential edge patterns +(``(a)-[e]->(a)``). Parallel edges (duplicate src/dst) multiply matches; dedupe +the edge set first if that is not wanted. Names starting with ``__`` are reserved. +""" + +from __future__ import annotations + +import itertools +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from daft import DataFrame, Expression, col +from daft.functions import to_struct + +from daft_graph.schema import DST, ID, SRC + +if TYPE_CHECKING: + from daft_graph.graph import DirectedGraph + +_EDGE_RE = re.compile(r"^\(\s*(\w*)\s*\)\s*-\s*\[\s*(\w*)\s*\]\s*->\s*\(\s*(\w*)\s*\)$") +_VERTEX_RE = re.compile(r"^\(\s*(\w+)\s*\)$") +_ANON_PREFIX = "__v" + + +@dataclass(frozen=True) +class EdgePattern: + """A directed edge pattern ``(src)-[edge]->(dst)``.""" + + src: str + dst: str + edge: str | None # None when the edge is anonymous + negated: bool = False + + +@dataclass(frozen=True) +class VertexPattern: + """A lone vertex pattern ``(name)``.""" + + name: str + + +Clause = EdgePattern | VertexPattern + + +def is_named(name: str) -> bool: + """True if ``name`` is a user provided (output) name, not anonymous.""" + return not name.startswith(_ANON_PREFIX) + + +def parse_motif(pattern: str) -> list[Clause]: + """Parse a motif pattern string into a list of clauses. + + Raises: + ValueError: on an unparseable clause, a named negated edge, or an empty + pattern. + """ + anon = itertools.count() + clauses: list[Clause] = [] + for raw in pattern.split(";"): + text = raw.strip() + if not text: + continue + negated = text.startswith("!") + if negated: + text = text[1:].strip() + edge_match = _EDGE_RE.match(text) + if edge_match: + src, edge, dst = ( + edge_match.group(1), + edge_match.group(2), + edge_match.group(3), + ) + if negated and edge: + raise ValueError(f"negated edges cannot be named: {raw!r}") + clauses.append( + EdgePattern( + src=src or f"{_ANON_PREFIX}{next(anon)}", + dst=dst or f"{_ANON_PREFIX}{next(anon)}", + edge=edge or None, + negated=negated, + ) + ) + continue + vertex_match = _VERTEX_RE.match(text) + if vertex_match: + if negated: + raise ValueError(f"negated lone vertex patterns are not supported: {raw!r}") + clauses.append(VertexPattern(name=vertex_match.group(1))) + continue + raise ValueError(f"could not parse motif clause: {raw!r}") + if not clauses: + raise ValueError("empty motif pattern") + return clauses + + +def find(graph: DirectedGraph, pattern: str) -> DataFrame: + """Find subgraphs matching a motif pattern. + + Args: + graph: The directed graph to search. Motif edge patterns are directed. + pattern: A motif pattern string (see module docstring). + + Returns: + A DataFrame with one row per match and one struct column per named vertex + (the vertex row) and per named edge (the edge row). + + Raises: + ValueError: if the pattern has no named elements, or a negated edge + references an unbound vertex. + """ + clauses = parse_motif(pattern) + edges, vertices = graph.edges, graph.vertices + v_attrs = [c for c in vertices.column_names if c != ID] + e_attrs = [c for c in edges.column_names if c not in (SRC, DST)] + + bindings: DataFrame | None = None + bound: set[str] = set() + output: list[tuple[str, str]] = [] + edge_specs: dict[str, tuple[str, str]] = {} + negations: list[tuple[str, str]] = [] + seen: set[str] = set() + + def emit(kind: str, name: str) -> None: + if is_named(name) and name not in seen: + seen.add(name) + output.append((kind, name)) + + for clause in clauses: + if isinstance(clause, VertexPattern): + if clause.name not in bound: + rel = vertices.select(col(ID).alias(clause.name)) + bindings = rel if bindings is None else bindings.join(rel, how="cross") + bound.add(clause.name) + emit("vertex", clause.name) + continue + if clause.negated: + negations.append((clause.src, clause.dst)) + continue + a, b, e = clause.src, clause.dst, clause.edge + if a == b: + raise ValueError(f"self referential edge patterns are not supported: ({a})-[]->({b})") + rel = edges.select(col(SRC).alias(a), col(DST).alias(b)) + if bindings is None: + bindings = rel + else: + shared: list[str | Expression] = [name for name in (a, b) if name in bound] + bindings = bindings.join(rel, on=shared, how="inner") if shared else bindings.join(rel, how="cross") + bound.update((a, b)) + emit("vertex", a) + if e is not None: + if e in edge_specs: + raise ValueError(f"edge name {e!r} used more than once in pattern") + edge_specs[e] = (a, b) + emit("edge", e) + emit("vertex", b) + + if bindings is None: + raise ValueError("motif pattern produced no bindings") + for a, b in negations: + if a not in bound or b not in bound: + raise ValueError(f"negated edge endpoints must be bound: ({a})-[]->({b})") + neg = edges.select(col(SRC).alias(a), col(DST).alias(b)) + bindings = bindings.join(neg, on=[a, b], how="anti") + + if not output: + raise ValueError("motif pattern has no named elements to return") + + result = bindings + for kind, name in output: + if kind == "edge": + a, b = edge_specs[name] + rel = edges.select( + col(SRC).alias(f"__e_{name}_src"), + col(DST).alias(f"__e_{name}_dst"), + *[col(c).alias(f"__e_{name}_{c}") for c in e_attrs], + ) + result = result.join( + rel, + left_on=[a, b], + right_on=[f"__e_{name}_src", f"__e_{name}_dst"], + how="inner", + ) + for kind, name in output: + if kind == "vertex": + rel = vertices.select( + col(ID).alias(f"__v_{name}_id"), + *[col(c).alias(f"__v_{name}_{c}") for c in v_attrs], + ) + result = result.join(rel, left_on=name, right_on=f"__v_{name}_id", how="inner") + + for kind, name in output: + if kind == "vertex": + fields = [col(f"__v_{name}_id").alias(ID)] + fields += [col(f"__v_{name}_{c}").alias(c) for c in v_attrs] + else: + fields = [ + col(f"__e_{name}_src").alias(SRC), + col(f"__e_{name}_dst").alias(DST), + ] + fields += [col(f"__e_{name}_{c}").alias(c) for c in e_attrs] + result = result.with_column(name, to_struct(*fields)) + + return result.select(*[name for _, name in output]) diff --git a/daft_graph/schema.py b/daft_graph/schema.py new file mode 100644 index 0000000..23a2ca1 --- /dev/null +++ b/daft_graph/schema.py @@ -0,0 +1,36 @@ +"""Column name constants and shared type aliases for daft-graph.""" + +from __future__ import annotations + +from typing import Literal + +# Vertex and edge column conventions, mirroring GraphFrames (id, src, dst). +ID = "id" +SRC = "src" +DST = "dst" + +# Algorithm output columns. +COMPONENT = "component" +LABEL = "label" +RANK = "rank" + +# Internal working columns used by edge utilities and the star algorithms. +U = "u" +V = "v" +REP = "rep" + +# Connected components execution strategy. +Strategy = Literal["auto", "distributed", "local"] + +__all__ = [ + "COMPONENT", + "DST", + "ID", + "LABEL", + "RANK", + "REP", + "SRC", + "Strategy", + "U", + "V", +] diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..bdd9600 --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,445 @@ +# daft-graph usage + +daft-graph provides graph operations over Daft DataFrames. Edges are a DataFrame +with `src` and `dst` integer columns; vertices are a DataFrame with an `id` +column. Everything runs on the Daft native runner locally or on Ray when +distributed. + +## Building a graph + +There are two graph classes. `DirectedGraph` treats `src` to `dst` as meaningful, +`UndirectedGraph` walks every edge both ways. `Graph` is the abstract base, used +for type annotations and isinstance checks, and cannot be constructed. + +Pick the class that matches your data. The type is what tells an algorithm which +semantics you meant, so `pagerank` accepts only a `DirectedGraph` while +`connected_components` accepts either. + +```python +import daft +from daft_graph import DirectedGraph, UndirectedGraph + +edges = daft.from_pydict({"src": [1, 2, 4], "dst": [2, 3, 5]}) + +# Vertices are derived from the edge endpoints when not supplied +graph = DirectedGraph(edges) + +# Or supply vertices explicitly (keeps isolated vertices) +vertices = daft.from_pydict({"id": [1, 2, 3, 4, 5, 99]}) +graph = DirectedGraph(edges, vertices) + +graph.num_vertices() # 6 +graph.num_edges() # 3 +graph.degrees() # DataFrame[id, degree] +graph.out_degrees() # directed only +``` + +Custom column names are read on ingest and normalized to `src`, `dst`, and `id`. + +```python +df = daft.from_pydict({"from": [1, 2], "to": [2, 3]}) +graph = DirectedGraph(df, src_col="from", dst_col="to") +graph.edges.column_names # ["src", "dst"] + +people = daft.from_pydict({"node": [1, 2, 3]}) +graph = DirectedGraph(df, people, src_col="from", dst_col="to", id_col="node") +``` + +Validation is opt in. Structural column checks always run because they only read +the schema, while the checks that scan data are behind `validate=True`. + +```python +DirectedGraph(edges, vertices, validate=True) # unique ids, no dangling endpoints +``` + +## Converting between flavors + +```python +directed = DirectedGraph(edges) + +directed.reverse() # DirectedGraph with every edge flipped +directed.as_undirected() # UndirectedGraph over the same rows + +undirected = UndirectedGraph(edges) +undirected.as_directed() # read src -> dst as given +undirected.as_directed(src_col="dst", dst_col="src") # or flip on the way out +``` + +An `UndirectedGraph` stores one row per edge, so `num_edges` and `degrees` count +each edge once. The symmetrization happens at traversal time, which is why a +single undirected edge gives each endpoint degree 1. + +## Connected components + +Weakly connected components labelled by the smallest id in each component. Edge +free vertices form singleton components. Output is a DataFrame `[id, component]`. + +```python +from daft_graph import connected_components + +graph = UndirectedGraph(edges) # or pass a DirectedGraph + +components = connected_components(graph) # strategy="auto" +components = connected_components(graph, strategy="distributed") +components = connected_components(graph, strategy="local") +``` + +Undirected semantics apply whichever flavor you pass, since the implementation +symmetrizes internally. + +- `distributed` runs large star and small star contraction on Daft and scales to + the full edge set. +- `local` collects the edge set and finishes with `scipy.sparse.csgraph` on one + node. Useful once deduplication has collapsed the edge count. +- `auto` (default) picks `local` at or below `local_threshold` edges *and* only + when the optional `local` extra is installed, else `distributed`. + +The `local` strategy needs `numpy` and `scipy`, which ship in the optional extra. +Install it with `uv sync --extra local` or `pip install 'daft-graph[local]'`. A +core install has `daft` alone, where `auto` quietly stays distributed and an +explicit `strategy="local"` raises an `ImportError` naming the extra. + +## Label propagation + +Community detection by synchronous label propagation. Output is `[id, label]`. + +```python +from daft_graph import label_propagation + +labels = label_propagation(graph, max_iters=10) +``` + +Updates are synchronous and bounded by `max_iters` because label propagation can +oscillate on bipartite structures. + +## PageRank + +PageRank matching networkx semantics, with ranks summing to one. Output is +`[id, rank]`. + +```python +from daft_graph import pagerank + +ranks = pagerank(graph, damping=0.85, tol=1e-6) + +# Personalized PageRank seeded on specific vertices +ranks = pagerank(graph, source_ids=[1, 2]) +``` + +## Strongly connected components + +SCCs of a directed graph, labelled by the smallest id in each component. Same +`strategy` options as connected components. + +```python +from daft_graph import strongly_connected_components + +sccs = strongly_connected_components(graph) # strategy="auto" +sccs = strongly_connected_components(graph, strategy="distributed") +``` + +## Triangle count + +Triangles per vertex (undirected). Output is `[id, triangle_count]`. + +```python +from daft_graph import triangle_count + +counts = triangle_count(graph) +``` + +## BFS and shortest paths + +`bfs` returns a shortest path between two vertices as a list of ids, or None if +unreachable within `max_path_length`. `shortest_paths` returns hop distances from +every vertex to each landmark as `[id, landmark, distance]`. + +```python +from daft_graph import bfs, shortest_paths + +path = bfs(graph, source=0, target=9) # [0, 3, 9] or None + +# to walk edges both ways, hand it an undirected graph +path = bfs(graph.as_undirected(), source=0, target=9) + +distances = shortest_paths(graph, landmarks=[0, 5]) +``` + +There is no `directed` keyword. Traversal follows the graph's own semantics, so +`bfs`, `bfs_paths`, `all_shortest_paths`, `all_paths`, `shortest_paths`, +`random_walks`, and `hyper_anf` all take direction from the class you built. + +## k-core + +Core number per vertex (undirected), as `[id, core]`. + +```python +from daft_graph import k_core + +cores = k_core(graph) +``` + +## Message passing + +Build custom vertex centric algorithms on the same primitive the library uses. +`aggregate_messages` runs one round; `pregel` runs to a fixed point. Vertex state +is `[id, value, ...]`; message expressions read `src_` and `dst_`. + +```python +from daft import col +from daft.functions import when +from daft_graph import pregel +from daft_graph.message_passing import MSG, VALUE + +# Propagate the minimum reachable id (undirected) +labels = pregel( + undirected_edges, + init_state, # [id, value] + to_src=col("dst_value"), + agg=lambda m: m.min(), + update=when(col(MSG).is_null(), col(VALUE)).otherwise( + when(col(VALUE) <= col(MSG), col(VALUE)).otherwise(col(MSG)) + ), +) +``` + +## Motif finding + +Match subgraph patterns with a GraphFrames style DSL. `find` returns one row per +match, with a struct column per named vertex (the vertex row) and per named edge +(the edge row). Filter with struct field access. + +`find` is a `DirectedGraph` method, since motif edge patterns are directed. + +```python +from daft import col + +# directed triangles +triangles = graph.find("(a)-[]->(b); (b)-[]->(c); (c)-[]->(a)") + +# edges without a reciprocal edge +one_way = graph.find("(a)-[e]->(b); !(b)-[]->(a)") + +# read fields off the struct columns +one_way.where(col("a")["id"] < col("b")["id"]).show() +``` + +Vertices are `(name)`, edges `-[name]->`, empty names are anonymous, a leading +`!` negates an edge, and repeated names bind to the same element. Variable +length and undirected motif edges are not supported in this version. + +## Cycle detection + +Both take a `DirectedGraph`; an undirected graph has no directed cycles. + +```python +from daft_graph import has_cycle, vertices_on_cycles + +has_cycle(graph) # bool +vertices_on_cycles(graph) # DataFrame[id] of vertices on a directed cycle +``` + +## All simple paths + +```python +from daft_graph import all_paths + +paths = all_paths(graph, source=0, target=9, max_path_length=5) # list[list[int]] +``` + +## Maximal independent set + +```python +from daft_graph import maximal_independent_set + +mis = maximal_independent_set(graph) # DataFrame[id, selected] +``` + +## Per source personalized PageRank + +```python +from daft_graph import parallel_personalized_pagerank + +# one personalized vector per source, as [id, source, rank] +vectors = parallel_personalized_pagerank(graph, source_ids=[0, 5]) +``` + +## All shortest paths and edge filters + +```python +from daft import col +from daft_graph import all_shortest_paths, bfs + +paths = all_shortest_paths(graph, source=0, target=9) # every shortest path + +# restrict traversal to edges matching a predicate +paths = all_shortest_paths(graph, 0, 9, edge_filter=col("type") == "follows") +one = bfs(graph, 0, 9, edge_filter=col("type") == "follows") +``` + +## BFS over vertex sets (GraphFrames style) + +`bfs_paths` is the GraphFrames `bfs` analog: it selects source and target vertex +sets with predicate expressions and returns one row per shortest path. Vertex +columns (`from`, `v1`, ..., `to`) hold a struct of the vertex row; edge columns +(`e0`, `e1`, ...) hold a struct of the edge row. + +```python +from daft import col +from daft_graph import bfs_paths + +# shortest paths from any active user to any admin user +paths = bfs_paths(graph, col("status") == "active", col("role") == "admin") + +# equivalently as a method; use an undirected graph to walk both ways +paths = graph.as_undirected().bfs_paths(col("id") == 0, col("id") == 9) + +# read attributes off the path structs +paths.select(col("from")["id"], col("to")["id"]).show() +``` + +All returned paths share the shortest length. When a source is also a target the +length is 0 and the columns are just `from` and `to`. An empty DataFrame means no +target was reachable within `max_path_length`. + +## Property graphs + +Model labeled property graphs with attribute columns on vertices and edges, then +query with `filter_vertices`, `filter_edges`, `triplets`, and `find`. For degree +broken down by edge type: + +```python +g.degree_by_type("type") # [id, type, degree] +``` + +## Vertex id indexing + +The algorithms operate on integer ids. To run them on a graph keyed by strings +(or any sortable id type), `reindex` relabels the graph to contiguous `int64` +ids and returns the mapping; `restore_ids` maps result id columns back. The +relabelled graph keeps the flavor it was given, so a `DirectedGraph` stays +directed. + +```python +from daft_graph import connected_components, reindex, restore_ids + +indexed = reindex(graph) # IndexedGraph(graph, mapping) +components = connected_components(indexed.graph) +result = restore_ids(components, indexed.mapping, ["id", "component"]) +``` + +`indexed.mapping` is a DataFrame `[original_id, id]`. Edges whose endpoints are +not in the vertex set are dropped (they have no index). + +## Random walks + +```python +from daft_graph import random_walks + +walks = random_walks(graph, walk_length=10, num_walks=5, seed=0) # list[list[int]] +``` + +Seeded and deterministic; the input to walk based node embeddings (embedding +training itself is left to a dedicated ML library). + +## Power iteration clustering + +```python +from daft_graph import power_iteration_clustering + +clusters = power_iteration_clustering(graph, k=3) # [id, cluster] +``` + +## Approximate neighborhood function + +```python +from daft_graph import hyper_anf + +nf = hyper_anf(graph, max_hops=6) # [id, hop, approx_count] via HyperLogLog +``` + +## SVD++ on rating graphs + +Edges are users (`src`) to items (`dst`) with a rating column, so this takes a +`DirectedGraph`. Needs the optional `local` extra for `numpy`. + +```python +from daft_graph import svd_plus_plus + +result = svd_plus_plus(graph, rating_column="rating", rank=8) +result.factors # [id, kind, bias, factor] +result.rmse # training RMSE +``` + +## Edge utilities + +```python +from daft_graph import ( + canonicalize, # orient src <= dst, drop self loops, dedupe + symmetrize, # add reverse of every edge + dedupe_edges, # remove duplicate (src, dst) + drop_self_loops, # remove src == dst + to_edges, # project two columns into src/dst + validate_edges, # raise if src/dst columns are missing +) +``` + +## Running on Ray + +Set the runner before launching, then use the same API: + +```bash +DAFT_RUNNER=ray python my_job.py +``` + +```python +import daft +daft.set_runner_ray() +``` + +## Reading from Iceberg + +Install Daft's `iceberg` extra (`pip install 'daft[iceberg]'`) and read an edge table +directly. daft-graph itself needs no extra for this: + +```python +import daft +from daft_graph import UndirectedGraph, connected_components + +# pass io_config by keyword; daft 0.7 inserted branch and tag ahead of it +edges = daft.read_iceberg(catalog.load_table("db.edges")) +components = connected_components(UndirectedGraph(edges)) +``` + +See `examples/cc_on_iceberg.py` for a runnable version backed by a local parquet +fixture. + +## Which flavor does an algorithm take + +| Accepts | Algorithms | +|---|---| +| `DirectedGraph` only | `pagerank`, `parallel_personalized_pagerank`, `strongly_connected_components`, `has_cycle`, `vertices_on_cycles`, `svd_plus_plus`, `find` | +| Either flavor, undirected semantics | `connected_components`, `label_propagation`, `triangle_count`, `k_core`, `maximal_independent_set`, `power_iteration_clustering` | +| Either flavor, traversal follows the type | `bfs`, `bfs_paths`, `all_shortest_paths`, `shortest_paths`, `all_paths`, `random_walks`, `hyper_anf` | +| No graph, DataFrames only | `aggregate_messages`, `pregel`, and the edge utilities | + +Passing an `UndirectedGraph` where a `DirectedGraph` is required is a type error +that mypy catches, which is the point of the split. + +## Scaling notes + +- Iterative algorithms run on `iterate_to_fixed_point`, which materializes the + state between rounds to truncate the Daft logical plan. This is the analog of + Spark GraphFrames checkpointing and is what keeps the iterative joins from + growing an unbounded plan. +- For very long runs, pass `checkpoint_dir=...` to round trip the state through + parquet between rounds. +- For connected components at large edge counts that collapse after + deduplication, prefer `strategy="local"` or a low `local_threshold`. That path + needs the `local` extra installed. +- Benchmark with `benchmarks/bench_cc.py --edges 1000000`. +- Edgeless graphs are handled: every vertex forms its own component or community, + and PageRank returns the uniform distribution. The exception is + `power_iteration_clustering`, which only returns vertices that have edges. + Iterative algorithms emit a warning if they reach `max_iters` without + converging. diff --git a/examples/cc_on_iceberg.py b/examples/cc_on_iceberg.py new file mode 100644 index 0000000..954b9f8 --- /dev/null +++ b/examples/cc_on_iceberg.py @@ -0,0 +1,62 @@ +"""Example: connected components over an edge table with daft-graph. + +Reads an edge table (here a local parquet fixture standing in for an Iceberg +table), computes connected components, and writes the labels back out. + +Run locally: + uv run python examples/cc_on_iceberg.py + +Run distributed on Ray by setting the runner before launching: + DAFT_RUNNER=ray uv run python examples/cc_on_iceberg.py + +To read a real Iceberg table instead of the parquet fixture, install the +``iceberg`` extra and use ``daft.read_iceberg(table)`` in place of +``daft.read_parquet`` below. Pass ``io_config`` by keyword, since Daft 0.7 +inserted ``branch`` and ``tag`` before it in the signature. + +Connected components applies undirected semantics, so this builds an +``UndirectedGraph``. Use ``DirectedGraph`` when direction matters, for example +for ``pagerank`` or ``strongly_connected_components``. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import daft + +from daft_graph import UndirectedGraph, connected_components +from daft_graph.schema import COMPONENT, DST, ID, SRC + + +def _write_edge_fixture(path: Path) -> None: + """Write a small two component edge table to parquet.""" + edges = daft.from_pydict({SRC: [1, 2, 3, 10, 11], DST: [2, 3, 1, 11, 12]}) + edges.write_parquet(str(path)) + + +def main() -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + edge_table = root / "edges" + _write_edge_fixture(edge_table) + + # In production: daft.read_iceberg(catalog.load_table("db.edges")) + # Pass io_config by keyword; Daft 0.7 inserted branch and tag ahead of it. + edges = daft.read_parquet(str(edge_table)) + graph = UndirectedGraph(edges) + + components = connected_components(graph).collect() + n_components = components.select(COMPONENT).distinct().count_rows() + components.write_parquet(str(root / "components")) + + result = components.to_pydict() + labels = dict(zip(result[ID], result[COMPONENT])) + print(f"vertices: {graph.num_vertices()} edges: {graph.num_edges()}") + print(f"components: {n_components}") + print(f"labels: {labels}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 3b4adab..f0cc9cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,15 @@ authors = [] maintainers = [] readme = "README.md" requires-python = ">=3.10,<3.14" +keywords = ["daft", "graph", "connected components", "pagerank", "graphframes"] + +[project.optional-dependencies] +# The single node solves (connected_components strategy="local", svd_plus_plus) +# use numpy and scipy. Keep them optional so the core install is daft only. +local = [ + "numpy>=1.24", + "scipy>=1.11" +] [project.urls] @@ -20,17 +29,39 @@ dev = [ "pre-commit==4.6.0", "pytest==9.1.1", "ruff==0.15.20", - "mypy==2.1.0" + "mypy==2.1.0", + # test-only: numpy/scipy exercise the local solves; igraph and networkx are + # correctness oracles. igraph is GPL and stays test only, never a runtime dep. + "numpy>=1.24", + "scipy>=1.11", + "igraph>=0.11", + "networkx>=3.0", + # type-check only: graph.py imports Self from here under TYPE_CHECKING for the + # 3.10 target. Declared explicitly rather than relying on transitive resolution. + "typing_extensions>=4.0" ] [tool.hatch.version] source = "vcs" [tool.mypy] -files = ["daft_graph/**/*.py", "tests/**/*.py"] +# The library is the type-checked surface. Tests build graphs from dynamic daft +# expressions (e.g. col("id") != 3) whose comparison operators daft does not type +# as Expression, which would drown real errors in false positives, so tests are +# validated by pytest rather than mypy. +files = ["daft_graph/**/*.py"] python_version = "3.10" warn_return_any = true warn_unused_configs = true +[[tool.mypy.overrides]] +module = ["scipy.*", "numpy.*"] +ignore_missing_imports = true +# Treat the optional numeric deps as Any without parsing their stubs. numpy's +# bundled stubs use 3.12 `type` statement syntax that mypy rejects under the 3.10 +# target, and these are only used lazily inside the local solves. +follow_imports = "skip" +follow_imports_for_stubs = true + [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/tests/data/README.md b/tests/data/README.md new file mode 100644 index 0000000..dc14d5e --- /dev/null +++ b/tests/data/README.md @@ -0,0 +1,14 @@ +# Test data + +## wiki-Vote.txt.gz + +The Wikipedia who-votes-on-whom network, used by `tests/test_wiki_vote.py` and +`tests/test_motif_dataset.py` as a larger-than-toy regression fixture (7,115 +vertices, 103,689 directed edges). + +- Source: Stanford Network Analysis Project (SNAP), + https://snap.stanford.edu/data/wiki-Vote.html +- Reference: J. Leskovec, D. Huttenlocher, J. Kleinberg. "Predicting Positive and + Negative Links in Online Social Networks." WWW 2010. +- Redistributed here unmodified (gzipped) so the suite runs without network + access. diff --git a/tests/data/wiki-Vote.txt.gz b/tests/data/wiki-Vote.txt.gz new file mode 100644 index 0000000..578cc24 Binary files /dev/null and b/tests/data/wiki-Vote.txt.gz differ diff --git a/tests/test_all_paths.py b/tests/test_all_paths.py new file mode 100644 index 0000000..77cf696 --- /dev/null +++ b/tests/test_all_paths.py @@ -0,0 +1,80 @@ +"""Tests for all_paths against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest +from daft import col + +from daft_graph.algorithms.all_paths import all_paths +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + return DirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}), + ) + + +def test_single_path() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + assert all_paths(g, 0, 3) == [[0, 1, 2, 3]] + + +def test_multiple_paths_diamond() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (0, 2), (1, 3), (2, 3)]) + assert all_paths(g, 0, 3) == [[0, 1, 3], [0, 2, 3]] + + +def test_max_length_bounds_search() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + assert all_paths(g, 0, 3, max_path_length=2) == [] + + +def test_source_equals_target() -> None: + g = _graph([0, 1], [(0, 1)]) + assert all_paths(g, 0, 0) == [[0]] + + +def test_no_repeated_vertices() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2), (2, 0)]) + assert all_paths(g, 0, 2) == [[0, 1, 2]] + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4]) +def test_matches_networkx(seed: int) -> None: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < 16: + u, v = rng.randint(0, 8), rng.randint(0, 8) + if u != v: + edges.add((u, v)) + edge_list = sorted(edges) + nodes = list(range(9)) + g = _graph(nodes, edge_list) + graph = nx.DiGraph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edge_list) + ours = {tuple(p) for p in all_paths(g, 0, 8, max_path_length=4)} + theirs = {tuple(p) for p in nx.all_simple_paths(graph, 0, 8, cutoff=4)} + assert ours == theirs + + +def test_edge_filter() -> None: + g = DirectedGraph( + vertices=daft.from_pydict({ID: [0, 1, 2]}), + edges=daft.from_pydict({SRC: [0, 1, 0], DST: [1, 2, 2], "type": ["a", "a", "b"]}), + ) + assert all_paths(g, 0, 2, edge_filter=col("type") == "a") == [[0, 1, 2]] + assert all_paths(g, 0, 2) == [[0, 1, 2], [0, 2]] + + +def test_max_paths_guard() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (0, 2), (1, 3), (2, 3)]) + with pytest.raises(ValueError): + all_paths(g, 0, 3, max_paths=1) diff --git a/tests/test_all_shortest_paths.py b/tests/test_all_shortest_paths.py new file mode 100644 index 0000000..34aca20 --- /dev/null +++ b/tests/test_all_shortest_paths.py @@ -0,0 +1,88 @@ +"""Tests for all_shortest_paths and bfs edge_filter against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest +from daft import col + +from daft_graph.algorithms.bfs import all_shortest_paths, bfs +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph( + node_ids: list[int], + edges: list[tuple[int, int]], + types: list[str] | None = None, +) -> DirectedGraph: + columns = {SRC: [u for u, _ in edges], DST: [v for _, v in edges]} + if types is not None: + columns["type"] = types + return DirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict(columns), + ) + + +def test_diamond_returns_both_paths() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (0, 2), (1, 3), (2, 3)]) + assert all_shortest_paths(g, 0, 3) == [[0, 1, 3], [0, 2, 3]] + + +def test_only_shortest_returned() -> None: + g = _graph([0, 1, 2, 3, 4], [(0, 1), (1, 3), (0, 2), (2, 4), (4, 3)]) + assert all_shortest_paths(g, 0, 3) == [[0, 1, 3]] + + +def test_source_equals_target() -> None: + g = _graph([0, 1], [(0, 1)]) + assert all_shortest_paths(g, 0, 0) == [[0]] + + +def test_unreachable_returns_empty() -> None: + g = _graph([0, 1, 2], [(0, 1)]) + assert all_shortest_paths(g, 0, 2) == [] + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4]) +def test_matches_networkx(seed: int) -> None: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < 16: + u, v = rng.randint(0, 8), rng.randint(0, 8) + if u != v: + edges.add((u, v)) + edge_list = sorted(edges) + nodes = list(range(9)) + g = _graph(nodes, edge_list) + graph = nx.DiGraph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edge_list) + for target in nodes: + ours = {tuple(p) for p in all_shortest_paths(g, 0, target, max_path_length=9)} + if nx.has_path(graph, 0, target): + theirs = {tuple(p) for p in nx.all_shortest_paths(graph, 0, target)} + assert ours == theirs + else: + assert ours == set() + + +def test_edge_filter_on_bfs() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2), (0, 2)], types=["a", "a", "b"]) + assert bfs(g, 0, 2) == [0, 2] + assert bfs(g, 0, 2, edge_filter=col("type") == "a") == [0, 1, 2] + + +def test_edge_filter_on_all_shortest_paths() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2), (0, 2)], types=["a", "a", "b"]) + assert all_shortest_paths(g, 0, 2, edge_filter=col("type") == "a") == [[0, 1, 2]] + + +def test_max_paths_guard() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (0, 2), (1, 3), (2, 3)]) + with pytest.raises(ValueError): + all_shortest_paths(g, 0, 3, max_paths=1) diff --git a/tests/test_bfs.py b/tests/test_bfs.py new file mode 100644 index 0000000..6c3e511 --- /dev/null +++ b/tests/test_bfs.py @@ -0,0 +1,75 @@ +"""Tests for bfs.""" + +from __future__ import annotations + +import itertools +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.bfs import bfs +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return DirectedGraph(vertices=vertices, edges=edges_df) + + +def test_directed_path() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + assert bfs(g, 0, 3) == [0, 1, 2, 3] + + +def test_source_equals_target() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2)]) + assert bfs(g, 2, 2) == [2] + + +def test_unreachable_directed_returns_none() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + assert bfs(g, 3, 0) is None + + +def test_undirected_reaches_backwards() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + assert bfs(g.as_undirected(), 3, 0) == [3, 2, 1, 0] + + +def test_max_path_length_cutoff() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + assert bfs(g, 0, 3, max_path_length=2) is None + + +def test_picks_shorter_branch() -> None: + g = _graph([0, 1, 2, 3, 4], [(0, 1), (1, 4), (0, 2), (2, 3), (3, 4)]) + assert bfs(g, 0, 4) == [0, 1, 4] + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_length_matches_networkx(seed: int) -> None: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < 20: + u, v = rng.randint(0, 9), rng.randint(0, 9) + if u != v: + edges.add((u, v)) + edge_list = sorted(edges) + g = _graph(list(range(10)), edge_list) + graph = nx.DiGraph() + graph.add_nodes_from(range(10)) + graph.add_edges_from(edge_list) + edge_set = set(edge_list) + for target in range(10): + ours = bfs(g, 0, target, max_path_length=20) + if ours is None: + assert not nx.has_path(graph, 0, target) + else: + assert len(ours) - 1 == nx.shortest_path_length(graph, 0, target) + assert ours[0] == 0 and ours[-1] == target + for a, b in itertools.pairwise(ours): + assert (a, b) in edge_set diff --git a/tests/test_bfs_paths.py b/tests/test_bfs_paths.py new file mode 100644 index 0000000..8669e50 --- /dev/null +++ b/tests/test_bfs_paths.py @@ -0,0 +1,163 @@ +"""Tests for bfs_paths (GraphFrames style breadth first search).""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest +from daft import col + +from daft_graph.algorithms.bfs import bfs_paths +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph( + node_ids: list[int], + edges: list[tuple[int, int]], + vattrs: dict | None = None, + eattrs: dict | None = None, +) -> DirectedGraph: + vcols: dict = {ID: node_ids} + if vattrs: + vcols.update(vattrs) + ecols: dict = {SRC: [u for u, _ in edges], DST: [v for _, v in edges]} + if eattrs: + ecols.update(eattrs) + return DirectedGraph(vertices=daft.from_pydict(vcols), edges=daft.from_pydict(ecols)) + + +def _id_paths(result: daft.DataFrame) -> list[tuple[int, ...]]: + """Extract the vertex id sequence of every path row, sorted.""" + cols = set(result.column_names) + ordered = ["from"] + i = 1 + while f"v{i}" in cols: + ordered.append(f"v{i}") + i += 1 + if "to" in cols: + ordered.append("to") + sel = result.select(*[col(c)["id"].alias(c) for c in ordered]).collect().to_pydict() + n = len(sel[ordered[0]]) + return sorted(tuple(int(sel[c][r]) for c in ordered) for r in range(n)) + + +def test_chain_single_path() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + result = bfs_paths(g, col(ID) == 0, col(ID) == 3) + assert result.column_names == ["from", "e0", "v1", "e1", "v2", "e2", "to"] + assert _id_paths(result) == [(0, 1, 2, 3)] + + +def test_diamond_returns_all_shortest() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (0, 2), (1, 3), (2, 3)]) + result = bfs_paths(g, col(ID) == 0, col(ID) == 3) + assert _id_paths(result) == [(0, 1, 3), (0, 2, 3)] + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_matches_networkx(seed: int) -> None: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < 25: + u, v = rng.randint(0, 11), rng.randint(0, 11) + if u != v: + edges.add((u, v)) + edge_list = sorted(edges) + nodes = list(range(12)) + g = _graph(nodes, edge_list) + graph = nx.DiGraph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edge_list) + + for s, t in [(0, 5), (1, 9), (3, 11), (2, 7)]: + result = bfs_paths(g, col(ID) == s, col(ID) == t) + if nx.has_path(graph, s, t): + expected = sorted(tuple(p) for p in nx.all_shortest_paths(graph, s, t)) + assert _id_paths(result) == expected + else: + assert result.count_rows() == 0 + + +def test_multi_source_multi_target() -> None: + g = _graph(list(range(6)), [(0, 1), (1, 2), (3, 4), (4, 5)]) + result = bfs_paths(g, (col(ID) == 0) | (col(ID) == 3), (col(ID) == 2) | (col(ID) == 5)) + assert _id_paths(result) == [(0, 1, 2), (3, 4, 5)] + + +def test_returns_only_nearest_target() -> None: + g = _graph(list(range(5)), [(0, 1), (1, 2), (2, 3), (3, 4)]) + # targets {2, 4}: 2 is nearer (dist 2) than 4 (dist 4), so only the path to 2 + result = bfs_paths(g, col(ID) == 0, (col(ID) == 2) | (col(ID) == 4)) + assert _id_paths(result) == [(0, 1, 2)] + + +def test_no_path_returns_empty() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (2, 3)]) + assert bfs_paths(g, col(ID) == 0, col(ID) == 3).count_rows() == 0 + + +def test_source_equals_target() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2)]) + result = bfs_paths(g, col(ID) == 0, col(ID) == 0) + assert set(result.column_names) == {"from", "to"} + rows = result.select(col("from")["id"].alias("f"), col("to")["id"].alias("t")).collect().to_pydict() + assert rows["f"] == [0] + assert rows["t"] == [0] + + +def test_mixed_overlap_returns_only_zero_hop() -> None: + # sources {0, 1}, targets {0, 2}: 0 is in both, so the global shortest length + # is 0 and only the zero-hop overlap is returned; the 1 -> 2 path is dropped + g = _graph([0, 1, 2], [(1, 2)]) + result = bfs_paths(g, (col(ID) == 0) | (col(ID) == 1), (col(ID) == 0) | (col(ID) == 2)) + assert set(result.column_names) == {"from", "to"} + rows = result.select(col("from")["id"].alias("f"), col("to")["id"].alias("t")).collect().to_pydict() + assert rows["f"] == [0] + assert rows["t"] == [0] + + +def test_edge_filter_changes_path() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2), (0, 2)], eattrs={"type": ["a", "a", "b"]}) + assert _id_paths(bfs_paths(g, col(ID) == 0, col(ID) == 2)) == [(0, 2)] + filtered = bfs_paths(g, col(ID) == 0, col(ID) == 2, edge_filter=col("type") == "a") + assert _id_paths(filtered) == [(0, 1, 2)] + + +def test_undirected_search() -> None: + g = _graph([0, 1, 2], [(1, 0), (2, 1)]) + assert bfs_paths(g, col(ID) == 0, col(ID) == 2).count_rows() == 0 + undirected = bfs_paths(g.as_undirected(), col(ID) == 0, col(ID) == 2) + assert _id_paths(undirected) == [(0, 1, 2)] + + +def test_structs_carry_attributes() -> None: + g = _graph([0, 1], [(0, 1)], vattrs={"name": ["a", "b"]}, eattrs={"w": [1.5]}) + result = bfs_paths(g, col(ID) == 0, col(ID) == 1) + rows = ( + result.select( + col("from")["name"].alias("fn"), + col("to")["name"].alias("tn"), + col("e0")["w"].alias("w"), + ) + .collect() + .to_pydict() + ) + assert rows["fn"] == ["a"] + assert rows["tn"] == ["b"] + assert rows["w"] == [1.5] + + +def test_graph_method_matches_function() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2)]) + from_function = _id_paths(bfs_paths(g, col(ID) == 0, col(ID) == 2)) + from_method = _id_paths(g.bfs_paths(col(ID) == 0, col(ID) == 2)) + assert from_function == from_method == [(0, 1, 2)] + + +def test_max_paths_guard() -> None: + g = _graph(list(range(5)), [(0, 1), (0, 2), (0, 3), (1, 4), (2, 4), (3, 4)]) + with pytest.raises(ValueError, match="max_paths"): + bfs_paths(g, col(ID) == 0, col(ID) == 4, max_paths=1) diff --git a/tests/test_bfs_scaling.py b/tests/test_bfs_scaling.py new file mode 100644 index 0000000..5927938 --- /dev/null +++ b/tests/test_bfs_scaling.py @@ -0,0 +1,98 @@ +"""The BFS family must keep its frontier inside Daft. + +`bfs`, `all_shortest_paths`, and `bfs_paths` used to pull the current frontier's +whole adjacency into the driver on every hop, which made them fail on ordinary +large connected graphs. The search now runs as Daft joins, so what crosses into +the driver scales with the answer rather than with the graph. These tests pin that +property by counting the rows that actually cross. +""" + +from __future__ import annotations + +import daft +import pytest +from daft import DataFrame, col + +from daft_graph import DirectedGraph, all_shortest_paths, bfs, bfs_paths +from daft_graph.schema import DST, ID, SRC + + +class _PullCounter: + """Count rows crossing into the driver via `DataFrame.to_pydict`.""" + + def __init__(self) -> None: + self.rows = 0 + + def __enter__(self) -> _PullCounter: + self._orig = DataFrame.to_pydict + counter = self + + def spy(df: DataFrame) -> dict: + result = counter._orig(df) + counter.rows += max((len(v) for v in result.values()), default=0) + return result + + DataFrame.to_pydict = spy # type: ignore[method-assign] + return self + + def __exit__(self, *exc: object) -> None: + DataFrame.to_pydict = self._orig # type: ignore[method-assign] + + +def _wide_graph(width: int) -> DirectedGraph: + """A hub whose frontier is `width` wide after one hop, then one more layer.""" + src: list[int] = [] + dst: list[int] = [] + for i in range(1, width + 1): + src.append(0) + dst.append(i) + for i in range(1, width + 1): + src.append(i) + dst.append(1000 + i) + return DirectedGraph(daft.from_pydict({SRC: src, DST: dst})) + + +@pytest.mark.parametrize("width", [50, 200]) +def test_bfs_driver_pull_does_not_grow_with_the_frontier(width: int) -> None: + """The rows pulled must not scale with frontier width.""" + graph = _wide_graph(width) + with _PullCounter() as counter: + path = bfs(graph, 0, 1000 + width // 2) + assert path == [0, width // 2, 1000 + width // 2] + # A path of 3 vertices needs a handful of single row lookups. The pre-fix + # implementation pulled 2 * width rows here, so anything near the frontier + # width means the search went back to collecting adjacency per hop. + assert counter.rows <= 20, f"pulled {counter.rows} rows for a 3 vertex path" + + +def test_all_shortest_paths_driver_pull_is_bounded_by_the_answer() -> None: + graph = _wide_graph(200) + with _PullCounter() as counter: + paths = all_shortest_paths(graph, 0, 1100) + assert paths == [[0, 100, 1100]] + assert counter.rows <= 20, f"pulled {counter.rows} rows for one 3 vertex path" + + +def test_bfs_paths_does_not_collect_the_whole_source_set() -> None: + """A broad from_filter must not be materialized into the driver.""" + width = 200 + graph = _wide_graph(width) + # `col(ID) >= 0` matches every vertex, so a source-set collect would pull + # them all; the search only needs them as a Daft frame. + with _PullCounter() as counter: + result = bfs_paths(graph, col(ID) >= 0, col(ID) == 1100, max_path_length=3) + assert result.count_rows() >= 1 + assert counter.rows <= 60, f"pulled {counter.rows} rows for a broad source filter" + + +def test_rewrite_preserves_bfs_tie_break_determinism() -> None: + """Two equal length paths: the smaller predecessor must win, every time.""" + graph = DirectedGraph(daft.from_pydict({SRC: [0, 0, 5, 2], DST: [5, 2, 9, 9]})) + # 0->2->9 and 0->5->9 are both length 2; predecessor 2 < 5 wins. + assert bfs(graph, 0, 9) == [0, 2, 9] + assert [bfs(graph, 0, 9) for _ in range(3)] == [[0, 2, 9]] * 3 + + +def test_all_shortest_paths_still_returns_every_tie() -> None: + graph = DirectedGraph(daft.from_pydict({SRC: [0, 0, 5, 2], DST: [5, 2, 9, 9]})) + assert all_shortest_paths(graph, 0, 9) == [[0, 2, 9], [0, 5, 9]] diff --git a/tests/test_cc.py b/tests/test_cc.py new file mode 100644 index 0000000..f10699f --- /dev/null +++ b/tests/test_cc.py @@ -0,0 +1,46 @@ +"""Tests for the connected_components driver.""" + +from __future__ import annotations + +import daft + +from daft_graph.algorithms.connected_components import connected_components +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import COMPONENT, DST, ID, SRC + + +def _comp_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return dict(zip(d[ID], d[COMPONENT])) + + +def test_two_components() -> None: + edges = daft.from_pydict({SRC: [1, 2, 4], DST: [2, 3, 5]}) + g = UndirectedGraph(edges) + assert _comp_map(connected_components(g)) == {1: 1, 2: 1, 3: 1, 4: 4, 5: 4} + + +def test_single_edge() -> None: + edges = daft.from_pydict({SRC: [10], DST: [20]}) + g = UndirectedGraph(edges) + assert _comp_map(connected_components(g)) == {10: 10, 20: 10} + + +def test_star_graph() -> None: + edges = daft.from_pydict({SRC: [1, 1, 1, 1], DST: [2, 3, 4, 5]}) + g = UndirectedGraph(edges) + assert _comp_map(connected_components(g)) == {1: 1, 2: 1, 3: 1, 4: 1, 5: 1} + + +def test_isolated_vertex_is_its_own_component() -> None: + vertices = daft.from_pydict({ID: [1, 2, 3]}) + edges = daft.from_pydict({SRC: [1], DST: [2]}) + g = UndirectedGraph(vertices=vertices, edges=edges) + assert _comp_map(connected_components(g)) == {1: 1, 2: 1, 3: 3} + + +def test_chain_collapses_to_global_min() -> None: + # 5-4-3-2-1 chain should all land in component 1 + edges = daft.from_pydict({SRC: [5, 4, 3, 2], DST: [4, 3, 2, 1]}) + g = UndirectedGraph(edges) + assert _comp_map(connected_components(g)) == {1: 1, 2: 1, 3: 1, 4: 1, 5: 1} diff --git a/tests/test_cc_labels.py b/tests/test_cc_labels.py new file mode 100644 index 0000000..97a3029 --- /dev/null +++ b/tests/test_cc_labels.py @@ -0,0 +1,48 @@ +"""Tests for global minimum label propagation in connected_components.""" + +from __future__ import annotations + +import daft + +from daft_graph.algorithms.connected_components import connected_components +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import COMPONENT, DST, ID, SRC + + +def _comp_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return dict(zip(d[ID], d[COMPONENT])) + + +def test_cycle_is_one_component() -> None: + edges = daft.from_pydict({SRC: [1, 2, 3, 4], DST: [2, 3, 4, 1]}) + g = UndirectedGraph(edges) + assert _comp_map(connected_components(g)) == {1: 1, 2: 1, 3: 1, 4: 1} + + +def test_joined_stars_collapse_to_global_min() -> None: + # star at 1 (1-2, 1-3), star at 4 (4-5, 4-6), bridged by 3-6 + edges = daft.from_pydict({SRC: [1, 1, 4, 4, 3], DST: [2, 3, 5, 6, 6]}) + g = UndirectedGraph(edges) + assert _comp_map(connected_components(g)) == { + 1: 1, + 2: 1, + 3: 1, + 4: 1, + 5: 1, + 6: 1, + } + + +def test_complete_graph_min_label() -> None: + # K4 over {2,3,4,5}: every node takes the smallest id, 2 + src = [2, 2, 2, 3, 3, 4] + dst = [3, 4, 5, 4, 5, 5] + g = UndirectedGraph(daft.from_pydict({SRC: src, DST: dst})) + assert _comp_map(connected_components(g)) == {2: 2, 3: 2, 4: 2, 5: 2} + + +def test_distinct_components_keep_distinct_mins() -> None: + edges = daft.from_pydict({SRC: [2, 5], DST: [3, 6]}) + g = UndirectedGraph(edges) + assert _comp_map(connected_components(g)) == {2: 2, 3: 2, 5: 5, 6: 5} diff --git a/tests/test_cc_local.py b/tests/test_cc_local.py new file mode 100644 index 0000000..290e059 --- /dev/null +++ b/tests/test_cc_local.py @@ -0,0 +1,121 @@ +"""Tests for the local solve and strategy routing in connected_components.""" + +from __future__ import annotations + +import random +from collections import defaultdict + +import daft +import igraph as ig +import pytest + +from daft_graph.algorithms.connected_components import ( + _resolve_strategy, + connected_components, +) +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import COMPONENT, DST, ID, SRC + + +def _random_edges(n_nodes: int, n_edges: int, seed: int) -> list[tuple[int, int]]: + rng = random.Random(seed) + edges = [] + for _ in range(n_edges): + u = rng.randint(0, n_nodes - 1) + v = rng.randint(0, n_nodes - 1) + if u != v: + edges.append((u, v)) + if not edges: + edges.append((0, 1)) + return edges + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> UndirectedGraph: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return UndirectedGraph(vertices=vertices, edges=edges_df) + + +def _partition(df: daft.DataFrame) -> set: + d = df.collect().to_pydict() + groups: dict[int, set] = defaultdict(set) + for node, comp in zip(d[ID], d[COMPONENT]): + groups[comp].add(node) + return {frozenset(members) for members in groups.values()} + + +def _igraph_partition(node_ids: list[int], edges: list[tuple[int, int]]) -> set: + idx = {n: i for i, n in enumerate(node_ids)} + ig_edges = [(idx[u], idx[v]) for u, v in edges] + graph = ig.Graph(n=len(node_ids), edges=ig_edges, directed=False) + comps = graph.connected_components(mode="weak") + return {frozenset(node_ids[i] for i in comp) for comp in comps} + + +@pytest.mark.parametrize( + ("n_nodes", "n_edges", "seed"), + [(8, 12, 1), (20, 25, 2), (40, 55, 3), (30, 8, 4)], +) +def test_local_matches_igraph(n_nodes: int, n_edges: int, seed: int) -> None: + node_ids = list(range(n_nodes)) + edges = _random_edges(n_nodes, n_edges, seed) + g = _graph(node_ids, edges) + assert _partition(connected_components(g, strategy="local")) == _igraph_partition(node_ids, edges) + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4]) +def test_local_matches_distributed(seed: int) -> None: + node_ids = list(range(25)) + edges = _random_edges(25, 30, seed) + g = _graph(node_ids, edges) + local = _partition(connected_components(g, strategy="local")) + distributed = _partition(connected_components(g, strategy="distributed")) + assert local == distributed + + +def test_resolve_strategy_routing() -> None: + assert _resolve_strategy("local", 10**9, 5) == "local" + assert _resolve_strategy("distributed", 0, 5) == "distributed" + assert _resolve_strategy("auto", 3, 5) == "local" + assert _resolve_strategy("auto", 10, 5) == "distributed" + with pytest.raises(ValueError): + _resolve_strategy("bogus", 0, 5) + + +def test_auto_routes_both_ways_to_same_result() -> None: + node_ids = list(range(15)) + edges = _random_edges(15, 20, 7) + g = _graph(node_ids, edges) + via_distributed = _partition(connected_components(g, strategy="auto", local_threshold=0)) + via_local = _partition(connected_components(g, strategy="auto", local_threshold=10**9)) + assert via_distributed == via_local == _igraph_partition(node_ids, edges) + + +def test_auto_falls_back_to_distributed_without_the_local_extra( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A core only install must still answer the default strategy=auto call.""" + import daft_graph.algorithms.connected_components as cc + + monkeypatch.setattr(cc, "has_local_extra", lambda: False) + # small graph, so the edge count alone would have selected the local solve + assert cc._resolve_strategy("auto", num_edges=1, local_threshold=1_000) == "distributed" + + +def test_auto_uses_local_when_the_extra_is_present( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import daft_graph.algorithms.connected_components as cc + + monkeypatch.setattr(cc, "has_local_extra", lambda: True) + assert cc._resolve_strategy("auto", num_edges=1, local_threshold=1_000) == "local" + + +def test_explicit_local_is_still_honored_so_it_can_raise( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Asking for local by name must not be silently downgraded.""" + import daft_graph.algorithms.connected_components as cc + + monkeypatch.setattr(cc, "has_local_extra", lambda: False) + assert cc._resolve_strategy("local", num_edges=1, local_threshold=1_000) == "local" diff --git a/tests/test_cc_ops.py b/tests/test_cc_ops.py new file mode 100644 index 0000000..e08c40a --- /dev/null +++ b/tests/test_cc_ops.py @@ -0,0 +1,23 @@ +"""Tests for the large star and small star operations.""" + +from __future__ import annotations + +import daft + +from daft_graph.algorithms.connected_components import large_star, small_star +from daft_graph.schema import DST, SRC + + +def _edge_set(df: daft.DataFrame) -> set: + d = df.select(SRC, DST).collect().to_pydict() + return set(zip(d[SRC], d[DST])) + + +def test_large_star_points_to_min() -> None: + edges = daft.from_pydict({SRC: [1, 2], DST: [2, 3]}) + assert _edge_set(large_star(edges)) == {(2, 1), (3, 1)} + + +def test_small_star_merges_local_minima() -> None: + edges = daft.from_pydict({SRC: [1, 1, 2], DST: [2, 3, 3]}) + assert _edge_set(small_star(edges)) == {(2, 1), (3, 1), (3, 2)} diff --git a/tests/test_cc_vs_igraph.py b/tests/test_cc_vs_igraph.py new file mode 100644 index 0000000..f5ebe78 --- /dev/null +++ b/tests/test_cc_vs_igraph.py @@ -0,0 +1,56 @@ +"""Validate connected_components against igraph on random graphs.""" + +from __future__ import annotations + +import random +from collections import defaultdict + +import daft +import igraph as ig +import pytest + +from daft_graph.algorithms.connected_components import connected_components +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import COMPONENT, DST, ID, SRC + + +def _random_edges(n_nodes: int, n_edges: int, seed: int) -> list[tuple[int, int]]: + rng = random.Random(seed) + edges = [] + for _ in range(n_edges): + u = rng.randint(0, n_nodes - 1) + v = rng.randint(0, n_nodes - 1) + if u != v: + edges.append((u, v)) + if not edges: + edges.append((0, 1)) + return edges + + +def _our_partition(node_ids: list[int], edges: list[tuple[int, int]]) -> set: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + g = UndirectedGraph(vertices=vertices, edges=edges_df) + d = connected_components(g).collect().to_pydict() + groups: dict[int, set] = defaultdict(set) + for node, comp in zip(d[ID], d[COMPONENT]): + groups[comp].add(node) + return {frozenset(members) for members in groups.values()} + + +def _igraph_partition(node_ids: list[int], edges: list[tuple[int, int]]) -> set: + idx = {n: i for i, n in enumerate(node_ids)} + ig_edges = [(idx[u], idx[v]) for u, v in edges] + graph = ig.Graph(n=len(node_ids), edges=ig_edges, directed=False) + comps = graph.connected_components(mode="weak") + return {frozenset(node_ids[i] for i in comp) for comp in comps} + + +@pytest.mark.parametrize( + ("n_nodes", "n_edges", "seed"), + [(5, 8, 1), (10, 15, 2), (20, 25, 3), (50, 70, 4), (30, 10, 5), (15, 40, 6)], +) +def test_matches_igraph(n_nodes: int, n_edges: int, seed: int) -> None: + node_ids = list(range(n_nodes)) + edges = _random_edges(n_nodes, n_edges, seed) + assert _our_partition(node_ids, edges) == _igraph_partition(node_ids, edges) diff --git a/tests/test_cycles.py b/tests/test_cycles.py new file mode 100644 index 0000000..b873829 --- /dev/null +++ b/tests/test_cycles.py @@ -0,0 +1,71 @@ +"""Tests for cycle detection against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.cycles import has_cycle, vertices_on_cycles +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + return DirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}), + ) + + +def _ids(df: daft.DataFrame) -> set: + return set(df.collect().to_pydict()[ID]) + + +def _nx_cyclic(graph: nx.DiGraph) -> set: + cyclic: set = set() + for component in nx.strongly_connected_components(graph): + if len(component) > 1: + cyclic |= component + for node in graph: + if graph.has_edge(node, node): + cyclic.add(node) + return cyclic + + +def test_dag_has_no_cycle() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2)]) + assert has_cycle(g) is False + assert _ids(vertices_on_cycles(g)) == set() + + +def test_directed_cycle() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 0), (2, 3)]) + assert has_cycle(g) is True + assert _ids(vertices_on_cycles(g)) == {0, 1, 2} + + +def test_self_loop_is_a_cycle() -> None: + g = _graph([0, 1, 2], [(0, 0), (1, 2)]) + assert has_cycle(g) is True + assert _ids(vertices_on_cycles(g)) == {0} + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4, 5]) +def test_matches_networkx(seed: int) -> None: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < 22: + u, v = rng.randint(0, 11), rng.randint(0, 11) + if u != v: + edges.add((u, v)) + edge_list = sorted(edges) + nodes = list(range(12)) + g = _graph(nodes, edge_list) + graph = nx.DiGraph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edge_list) + assert _ids(vertices_on_cycles(g)) == _nx_cyclic(graph) + assert has_cycle(g) == (not nx.is_directed_acyclic_graph(graph)) diff --git a/tests/test_datasets.py b/tests/test_datasets.py new file mode 100644 index 0000000..d73a0ef --- /dev/null +++ b/tests/test_datasets.py @@ -0,0 +1,171 @@ +"""End to end regression tests on real graph datasets. + +Validates every algorithm against networkx as ground truth on the canonical +Zachary Karate Club and Les Miserables graphs (both shipped by networkx, so no +download is needed and the test is fully reproducible). +""" + +from __future__ import annotations + +from collections import defaultdict + +import daft +import networkx as nx +import pytest +from daft import lit + +from daft_graph import ( + DirectedGraph, + aggregate_messages, + bfs, + connected_components, + k_core, + label_propagation, + pagerank, + shortest_paths, + strongly_connected_components, + triangle_count, +) +from daft_graph.algorithms.k_core import CORE +from daft_graph.algorithms.shortest_paths import DISTANCE, LANDMARK +from daft_graph.algorithms.triangle_count import TRIANGLE_COUNT +from daft_graph.message_passing import MSG +from daft_graph.schema import COMPONENT, DST, ID, RANK, SRC + +DATASETS = [ + ("karate", nx.karate_club_graph), + ("lesmis", nx.les_miserables_graph), +] + + +def _undirected(factory) -> tuple[list[int], list[tuple[int, int]]]: + g = nx.convert_node_labels_to_integers(factory()) + nodes = sorted(g.nodes()) + edges = sorted({(min(u, v), max(u, v)) for u, v in g.edges() if u != v}) + return nodes, edges + + +def _daft_graph(nodes: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + vertices = daft.from_pydict({ID: nodes}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return DirectedGraph(vertices=vertices, edges=edges_df) + + +def _bidir(edges: list[tuple[int, int]]) -> list[tuple[int, int]]: + return edges + [(v, u) for u, v in edges] + + +def _nx_undirected(nodes: list[int], edges: list[tuple[int, int]]) -> nx.Graph: + g = nx.Graph() + g.add_nodes_from(nodes) + g.add_edges_from(edges) + return g + + +def _nx_digraph(nodes: list[int], edges: list[tuple[int, int]]) -> nx.DiGraph: + g = nx.DiGraph() + g.add_nodes_from(nodes) + g.add_edges_from(edges) + return g + + +def _partition(df: daft.DataFrame) -> set: + d = df.collect().to_pydict() + groups: dict[int, set] = defaultdict(set) + for node, comp in zip(d[ID], d[COMPONENT]): + groups[comp].add(node) + return {frozenset(members) for members in groups.values()} + + +@pytest.mark.parametrize("name,factory", DATASETS) +@pytest.mark.parametrize("strategy", ["local", "distributed"]) +def test_connected_components(name: str, factory, strategy: str) -> None: + nodes, edges = _undirected(factory) + ours = _partition(connected_components(_daft_graph(nodes, edges), strategy=strategy)) + theirs = {frozenset(c) for c in nx.connected_components(_nx_undirected(nodes, edges))} + assert ours == theirs + + +@pytest.mark.parametrize("name,factory", DATASETS) +def test_triangle_count(name: str, factory) -> None: + nodes, edges = _undirected(factory) + d = triangle_count(_daft_graph(nodes, edges)).collect().to_pydict() + ours = dict(zip(d[ID], d[TRIANGLE_COUNT])) + assert ours == nx.triangles(_nx_undirected(nodes, edges)) + + +@pytest.mark.parametrize("name,factory", DATASETS) +def test_k_core(name: str, factory) -> None: + nodes, edges = _undirected(factory) + d = k_core(_daft_graph(nodes, edges)).collect().to_pydict() + ours = dict(zip(d[ID], d[CORE])) + assert ours == nx.core_number(_nx_undirected(nodes, edges)) + + +@pytest.mark.parametrize("name,factory", DATASETS) +def test_pagerank(name: str, factory) -> None: + nodes, edges = _undirected(factory) + bidir = _bidir(edges) + d = pagerank(_daft_graph(nodes, bidir), tol=1e-12, max_iters=500).collect().to_pydict() + ours = dict(zip(d[ID], d[RANK])) + theirs = nx.pagerank(_nx_digraph(nodes, bidir), alpha=0.85, tol=1e-12, max_iter=1000) + for node in nodes: + assert abs(ours[node] - theirs[node]) < 1e-4 + + +@pytest.mark.parametrize("name,factory", DATASETS) +@pytest.mark.parametrize("strategy", ["local", "distributed"]) +def test_scc_symmetric_is_one_component(name: str, factory, strategy: str) -> None: + nodes, edges = _undirected(factory) + bidir = _bidir(edges) + ours = _partition(strongly_connected_components(_daft_graph(nodes, bidir), strategy=strategy)) + theirs = {frozenset(c) for c in nx.strongly_connected_components(_nx_digraph(nodes, bidir))} + assert ours == theirs + + +@pytest.mark.parametrize("strategy", ["local", "distributed"]) +def test_scc_dag_orientation_all_singletons(strategy: str) -> None: + # Orienting each edge low -> high yields a DAG: every vertex its own SCC. + nodes, edges = _undirected(nx.karate_club_graph) + ours = _partition(strongly_connected_components(_daft_graph(nodes, edges), strategy=strategy)) + theirs = {frozenset(c) for c in nx.strongly_connected_components(_nx_digraph(nodes, edges))} + assert ours == theirs + assert len(ours) == len(nodes) + + +@pytest.mark.parametrize("name,factory", DATASETS) +def test_bfs_and_shortest_paths(name: str, factory) -> None: + nodes, edges = _undirected(factory) + bidir = _bidir(edges) + g = _daft_graph(nodes, bidir) + graph = _nx_digraph(nodes, bidir) + target = nodes[-1] + + path = bfs(g, nodes[0], target, max_path_length=len(nodes)) + assert path is not None + assert len(path) - 1 == nx.shortest_path_length(graph, nodes[0], target) + + d = shortest_paths(g, [nodes[0]], max_iters=len(nodes)).collect().to_pydict() + ours = {i: dist for i, lm, dist in zip(d[ID], d[LANDMARK], d[DISTANCE])} + theirs = dict(nx.single_target_shortest_path_length(graph, nodes[0])) + assert ours == theirs + + +def test_label_propagation_is_valid_and_deterministic() -> None: + nodes, edges = _undirected(nx.karate_club_graph) + g = _daft_graph(nodes, edges) + first = label_propagation(g).collect().to_pydict() + second = label_propagation(g).collect().to_pydict() + labels = dict(zip(first[ID], first["label"])) + assert set(labels) == set(nodes) # every vertex labelled + assert dict(zip(second[ID], second["label"])) == labels # deterministic + + +def test_aggregate_messages_recovers_degree() -> None: + nodes, edges = _undirected(nx.karate_club_graph) + bidir = _bidir(edges) + edges_df = daft.from_pydict({SRC: [u for u, _ in bidir], DST: [v for _, v in bidir]}) + state = daft.from_pydict({ID: nodes}) + d = aggregate_messages(edges_df, state, to_dst=lit(1), agg=lambda m: m.sum()).collect().to_pydict() + ours = dict(zip(d[ID], d[MSG])) + assert ours == dict(_nx_undirected(nodes, edges).degree()) diff --git a/tests/test_directed_graph.py b/tests/test_directed_graph.py new file mode 100644 index 0000000..16a0e16 --- /dev/null +++ b/tests/test_directed_graph.py @@ -0,0 +1,139 @@ +"""Tests for daft_graph.graph.DirectedGraph.""" + +from __future__ import annotations + +import daft +import pytest + +from daft_graph.graph import DirectedGraph, UndirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _edges() -> daft.DataFrame: + # 1->2, 1->3, 2->3, 4->1 + return daft.from_pydict({SRC: [1, 1, 2, 4], DST: [2, 3, 3, 1]}) + + +def _deg_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return dict(zip(d[ID], d["degree"])) + + +def test_derives_vertices_when_none_given() -> None: + g = DirectedGraph(_edges()) + assert g.num_vertices() == 4 + assert g.num_edges() == 4 + assert set(g.vertices.collect().to_pydict()[ID]) == {1, 2, 3, 4} + + +def test_accepts_explicit_vertices() -> None: + vertices = daft.from_pydict({ID: [1, 2, 3, 4, 99]}) + g = DirectedGraph(_edges(), vertices) + assert g.num_vertices() == 5 + + +def test_custom_columns_normalize_to_canonical() -> None: + df = daft.from_pydict({"a": [1, 2], "b": [3, 4]}) + g = DirectedGraph(df, src_col="a", dst_col="b") + assert set(g.edges.column_names) == {SRC, DST} + assert g.num_edges() == 2 + + +def test_custom_id_column_normalizes() -> None: + edges = daft.from_pydict({SRC: [1], DST: [2]}) + vertices = daft.from_pydict({"node": [1, 2]}) + g = DirectedGraph(edges, vertices, id_col="node") + assert ID in g.vertices.column_names + assert g.num_vertices() == 2 + + +def test_edge_attributes_are_preserved_through_rename() -> None: + df = daft.from_pydict({"a": [1, 2], "b": [3, 4], "weight": [0.5, 1.5]}) + g = DirectedGraph(df, src_col="a", dst_col="b") + assert set(g.edges.column_names) == {SRC, DST, "weight"} + + +def test_out_degrees() -> None: + assert _deg_map(DirectedGraph(_edges()).out_degrees()) == {1: 2, 2: 1, 4: 1} + + +def test_in_degrees() -> None: + assert _deg_map(DirectedGraph(_edges()).in_degrees()) == {2: 1, 3: 2, 1: 1} + + +def test_degrees_is_in_plus_out() -> None: + assert _deg_map(DirectedGraph(_edges()).degrees()) == {1: 3, 2: 2, 3: 2, 4: 1} + + +def test_traversal_edges_are_unchanged() -> None: + g = DirectedGraph(_edges()) + assert g.traversal_edges().count_rows() == g.num_edges() + + +def test_reverse_flips_every_edge() -> None: + g = DirectedGraph(_edges()) + r = g.reverse() + assert isinstance(r, DirectedGraph) + original = set(zip(*_edges().collect().to_pydict().values())) + flipped = r.edges.collect().to_pydict() + assert set(zip(flipped[DST], flipped[SRC])) == original + + +def test_reverse_is_an_involution() -> None: + g = DirectedGraph(_edges()) + back = g.reverse().reverse().edges.collect().to_pydict() + start = g.edges.collect().to_pydict() + assert sorted(zip(back[SRC], back[DST])) == sorted(zip(start[SRC], start[DST])) + + +def test_reverse_preserves_edge_attributes() -> None: + edges = daft.from_pydict({SRC: [1], DST: [2], "weight": [7.0]}) + r = DirectedGraph(edges).reverse() + assert set(r.edges.column_names) == {SRC, DST, "weight"} + assert r.edges.collect().to_pydict()["weight"] == [7.0] + + +def test_as_undirected_returns_undirected_graph() -> None: + g = DirectedGraph(_edges()) + u = g.as_undirected() + assert isinstance(u, UndirectedGraph) + assert u.num_vertices() == g.num_vertices() + + +def test_requires_id_column_on_vertices() -> None: + vertices = daft.from_pydict({"node": [1, 2]}) + edges = daft.from_pydict({SRC: [1], DST: [2]}) + with pytest.raises(ValueError): + DirectedGraph(edges, vertices) + + +def test_requires_edge_columns() -> None: + bad_edges = daft.from_pydict({"a": [1], "b": [2]}) + with pytest.raises(ValueError): + DirectedGraph(bad_edges) + + +def test_validate_rejects_duplicate_vertex_ids() -> None: + vertices = daft.from_pydict({ID: [1, 1, 2]}) + edges = daft.from_pydict({SRC: [1], DST: [2]}) + with pytest.raises(ValueError, match="duplicate"): + DirectedGraph(edges, vertices, validate=True) + + +def test_validate_rejects_dangling_endpoints() -> None: + vertices = daft.from_pydict({ID: [1]}) + edges = daft.from_pydict({SRC: [1], DST: [2]}) + with pytest.raises(ValueError, match="not in the vertex set"): + DirectedGraph(edges, vertices, validate=True) + + +def test_validate_accepts_a_consistent_graph() -> None: + vertices = daft.from_pydict({ID: [1, 2]}) + edges = daft.from_pydict({SRC: [1], DST: [2]}) + assert DirectedGraph(edges, vertices, validate=True).num_edges() == 1 + + +def test_validation_is_off_by_default() -> None: + vertices = daft.from_pydict({ID: [1]}) + edges = daft.from_pydict({SRC: [1], DST: [2]}) + assert DirectedGraph(edges, vertices).num_edges() == 1 diff --git a/tests/test_edges.py b/tests/test_edges.py new file mode 100644 index 0000000..13bbae0 --- /dev/null +++ b/tests/test_edges.py @@ -0,0 +1,59 @@ +"""Tests for daft_graph.edges.""" + +from __future__ import annotations + +import daft +import pytest + +from daft_graph.edges import ( + canonicalize, + dedupe_edges, + drop_self_loops, + symmetrize, + to_edges, + validate_edges, +) +from daft_graph.schema import DST, SRC + + +def _edge_set(df: daft.DataFrame) -> set: + d = df.select(SRC, DST).collect().to_pydict() + return set(zip(d[SRC], d[DST])) + + +def test_to_edges_renames_columns() -> None: + df = daft.from_pydict({"a": [1, 2], "b": [3, 4]}) + edges = to_edges(df, src="a", dst="b") + assert set(edges.column_names) == {SRC, DST} + assert _edge_set(edges) == {(1, 3), (2, 4)} + + +def test_validate_edges_passes() -> None: + edges = daft.from_pydict({SRC: [1], DST: [2]}) + assert validate_edges(edges) is edges + + +def test_validate_edges_raises_on_missing_columns() -> None: + bad = daft.from_pydict({"a": [1], "b": [2]}) + with pytest.raises(ValueError): + validate_edges(bad) + + +def test_drop_self_loops() -> None: + edges = daft.from_pydict({SRC: [1, 1], DST: [1, 2]}) + assert _edge_set(drop_self_loops(edges)) == {(1, 2)} + + +def test_dedupe_edges() -> None: + edges = daft.from_pydict({SRC: [1, 1, 2], DST: [2, 2, 3]}) + assert _edge_set(dedupe_edges(edges)) == {(1, 2), (2, 3)} + + +def test_canonicalize_collapses_reverse_and_self_loops() -> None: + edges = daft.from_pydict({SRC: [2, 1, 3, 5], DST: [1, 2, 3, 4]}) + assert _edge_set(canonicalize(edges)) == {(1, 2), (4, 5)} + + +def test_symmetrize_adds_reverse_edges() -> None: + edges = daft.from_pydict({SRC: [1, 3], DST: [2, 4]}) + assert _edge_set(symmetrize(edges)) == {(1, 2), (2, 1), (3, 4), (4, 3)} diff --git a/tests/test_empty_graphs.py b/tests/test_empty_graphs.py new file mode 100644 index 0000000..f7c7e53 --- /dev/null +++ b/tests/test_empty_graphs.py @@ -0,0 +1,56 @@ +"""Tests for empty and edgeless graphs across all algorithms.""" + +from __future__ import annotations + +import daft + +from daft_graph import DirectedGraph, connected_components, label_propagation, pagerank +from daft_graph.schema import COMPONENT, DST, ID, LABEL, RANK, SRC + + +def _edgeless_graph(ids: list[int]) -> DirectedGraph: + vertices = daft.from_pydict({ID: ids}) + edges = daft.from_pydict({SRC: [], DST: []}) + return DirectedGraph(vertices=vertices, edges=edges) + + +def _comp_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return dict(zip(d[ID], d[COMPONENT])) + + +def test_cc_distributed_no_edges() -> None: + g = _edgeless_graph([1, 2, 3]) + assert _comp_map(connected_components(g, strategy="distributed")) == { + 1: 1, + 2: 2, + 3: 3, + } + + +def test_cc_local_no_edges() -> None: + g = _edgeless_graph([1, 2, 3]) + assert _comp_map(connected_components(g, strategy="local")) == {1: 1, 2: 2, 3: 3} + + +def test_cc_auto_no_edges() -> None: + g = _edgeless_graph([1, 2, 3]) + assert _comp_map(connected_components(g)) == {1: 1, 2: 2, 3: 3} + + +def test_label_propagation_no_edges() -> None: + g = _edgeless_graph([1, 2, 3]) + d = label_propagation(g).collect().to_pydict() + assert dict(zip(d[ID], d[LABEL])) == {1: 1, 2: 2, 3: 3} + + +def test_pagerank_no_edges_is_uniform() -> None: + # With no edges every node is dangling; the stationary distribution is the + # uniform personalization vector 1/n. + ids = [0, 1, 2, 3] + g = _edgeless_graph(ids) + d = pagerank(g).collect().to_pydict() + ranks = dict(zip(d[ID], d[RANK])) + for value in ranks.values(): + assert abs(value - 0.25) < 1e-9 + assert abs(sum(ranks.values()) - 1.0) < 1e-9 diff --git a/tests/test_graph_methods.py b/tests/test_graph_methods.py new file mode 100644 index 0000000..c525025 --- /dev/null +++ b/tests/test_graph_methods.py @@ -0,0 +1,51 @@ +"""Tests for DirectedGraph triplets and subgraph filters.""" + +from __future__ import annotations + +import daft +from daft import col + +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _ids(df: daft.DataFrame, column: str = ID) -> set: + return set(df.collect().to_pydict()[column]) + + +def _edge_set(df: daft.DataFrame) -> set: + d = df.select(SRC, DST).collect().to_pydict() + return set(zip(d[SRC], d[DST])) + + +def test_triplets_carry_endpoint_attributes() -> None: + vertices = daft.from_pydict({ID: [1, 2, 3], "name": ["a", "b", "c"]}) + edges = daft.from_pydict({SRC: [1, 2], DST: [2, 3]}) + tr = DirectedGraph(vertices=vertices, edges=edges).triplets().collect().to_pydict() + assert "src_name" in tr and "dst_name" in tr + rows = set(zip(tr[SRC], tr[DST], tr["src_name"], tr["dst_name"])) + assert rows == {(1, 2, "a", "b"), (2, 3, "b", "c")} + + +def test_filter_vertices_drops_incident_edges() -> None: + vertices = daft.from_pydict({ID: [1, 2, 3, 4]}) + edges = daft.from_pydict({SRC: [1, 3], DST: [2, 4]}) + g = DirectedGraph(vertices=vertices, edges=edges).filter_vertices(col(ID) != 2) + assert _ids(g.vertices) == {1, 3, 4} + assert _edge_set(g.edges) == {(3, 4)} + + +def test_filter_edges_keeps_all_vertices() -> None: + vertices = daft.from_pydict({ID: [1, 2, 3, 4]}) + edges = daft.from_pydict({SRC: [1, 2, 3], DST: [2, 3, 4], "weight": [5, 1, 5]}) + g = DirectedGraph(vertices=vertices, edges=edges).filter_edges(col("weight") >= 5) + assert _ids(g.vertices) == {1, 2, 3, 4} + assert _edge_set(g.edges) == {(1, 2), (3, 4)} + + +def test_drop_isolated_vertices() -> None: + vertices = daft.from_pydict({ID: [1, 2, 3, 4]}) + edges = daft.from_pydict({SRC: [1], DST: [2]}) + g = DirectedGraph(vertices=vertices, edges=edges).drop_isolated_vertices() + assert _ids(g.vertices) == {1, 2} + assert _edge_set(g.edges) == {(1, 2)} diff --git a/tests/test_graph_subclass_preservation.py b/tests/test_graph_subclass_preservation.py new file mode 100644 index 0000000..907141b --- /dev/null +++ b/tests/test_graph_subclass_preservation.py @@ -0,0 +1,97 @@ +"""The graph transforms must return the caller's concrete class. + +``filter_vertices``, ``filter_edges``, and ``drop_isolated_vertices`` rebuild a +graph internally. Before the class split they hardcoded the single ``Graph``, so +after the split a naive port would silently hand back the wrong flavor and an +undirected graph would start traversing directionally. These tests pin that down. +""" + +from __future__ import annotations + +import daft +import pytest +from daft import col + +from daft_graph.graph import DirectedGraph, Graph, UndirectedGraph +from daft_graph.schema import DST, ID, SRC + +_FLAVORS = [DirectedGraph, UndirectedGraph] + + +def _build(flavor: type[Graph]) -> Graph: + edges = daft.from_pydict({SRC: [1, 1, 2], DST: [2, 3, 3], "weight": [1.0, 5.0, 9.0]}) + vertices = daft.from_pydict({ID: [1, 2, 3, 99], "score": [10, 20, 30, 40]}) + return flavor(edges, vertices) + + +@pytest.mark.parametrize("flavor", _FLAVORS) +def test_filter_vertices_preserves_flavor(flavor: type[Graph]) -> None: + g = _build(flavor) + assert type(g.filter_vertices(col(ID) != 3)) is flavor + + +@pytest.mark.parametrize("flavor", _FLAVORS) +def test_filter_edges_preserves_flavor(flavor: type[Graph]) -> None: + g = _build(flavor) + assert type(g.filter_edges(col("weight") >= 5.0)) is flavor + + +@pytest.mark.parametrize("flavor", _FLAVORS) +def test_drop_isolated_vertices_preserves_flavor(flavor: type[Graph]) -> None: + g = _build(flavor) + assert type(g.drop_isolated_vertices()) is flavor + + +@pytest.mark.parametrize("flavor", _FLAVORS) +def test_chained_transforms_preserve_flavor(flavor: type[Graph]) -> None: + g = _build(flavor) + chained = g.filter_edges(col("weight") >= 5.0).drop_isolated_vertices() + assert type(chained) is flavor + + +@pytest.mark.parametrize("flavor", _FLAVORS) +def test_transforms_keep_traversal_semantics(flavor: type[Graph]) -> None: + """A rebuilt graph must walk edges the same way the original did.""" + g = _build(flavor) + rebuilt = g.filter_edges(col("weight") >= 1.0) + assert rebuilt.traversal_edges().count_rows() == g.traversal_edges().count_rows() + + +@pytest.mark.parametrize("flavor", _FLAVORS) +def test_transforms_preserve_attribute_columns(flavor: type[Graph]) -> None: + g = _build(flavor) + kept = g.filter_edges(col("weight") >= 5.0) + assert "weight" in kept.edges.column_names + assert "score" in kept.vertices.column_names + + +def test_directed_transform_result_still_has_directed_methods() -> None: + g = _build(DirectedGraph) + assert isinstance(g, DirectedGraph) + kept = g.filter_edges(col("weight") >= 5.0) + # would raise AttributeError if the rebuild downcast to the base class + assert kept.out_degrees().count_rows() >= 1 + assert isinstance(kept.reverse(), DirectedGraph) + + +def test_undirected_transform_result_still_has_undirected_methods() -> None: + g = _build(UndirectedGraph) + assert isinstance(g, UndirectedGraph) + kept = g.filter_edges(col("weight") >= 5.0) + assert isinstance(kept.as_directed(), DirectedGraph) + + +@pytest.mark.parametrize("flavor", _FLAVORS) +def test_filter_vertices_drops_dangling_edges(flavor: type[Graph]) -> None: + """Behavior check alongside the type check, so the port did not break semantics.""" + g = _build(flavor) + kept = g.filter_vertices(col(ID) != 3) + pairs = kept.edges.collect().to_pydict() + assert set(zip(pairs[SRC], pairs[DST])) == {(1, 2)} + + +@pytest.mark.parametrize("flavor", _FLAVORS) +def test_drop_isolated_vertices_removes_unreferenced(flavor: type[Graph]) -> None: + g = _build(flavor) + kept = g.drop_isolated_vertices() + assert set(kept.vertices.collect().to_pydict()[ID]) == {1, 2, 3} diff --git a/tests/test_greet.py b/tests/test_greet.py deleted file mode 100644 index 8b5cdc3..0000000 --- a/tests/test_greet.py +++ /dev/null @@ -1,11 +0,0 @@ -from __future__ import annotations - -import daft - -from daft_ext_template import greet - - -def test_greet() -> None: - df = daft.from_pydict({"name": ["John", "Paul"]}) - result = df.select(greet(df["name"]).alias("greet")).collect().to_pydict() - assert result["greet"] == ["Hello, John!", "Hello, Paul!"] diff --git a/tests/test_hyper_anf.py b/tests/test_hyper_anf.py new file mode 100644 index 0000000..25f42ec --- /dev/null +++ b/tests/test_hyper_anf.py @@ -0,0 +1,79 @@ +"""Tests for hyper_anf (approximate neighborhood function).""" + +from __future__ import annotations + +import itertools +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.hyper_anf import APPROX_COUNT, HOP, _hash64, hyper_anf +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + return DirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}), + ) + + +def _counts(g: DirectedGraph, max_hops: int) -> dict: + d = hyper_anf(g, max_hops=max_hops).collect().to_pydict() + return {(i, h): c for i, h, c in zip(d[ID], d[HOP], d[APPROX_COUNT])} + + +def test_hop0_is_one() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + counts = _counts(g, 0) + for node in [0, 1, 2, 3]: + assert abs(counts[(node, 0)] - 1.0) < 0.5 + + +def test_monotonic_over_hops() -> None: + g = _graph([0, 1, 2, 3, 4], [(0, 1), (1, 2), (2, 3), (3, 4)]) + counts = _counts(g, 4) + for node in range(5): + series = [counts[(node, h)] for h in range(5)] + assert all(b >= a - 1e-9 for a, b in itertools.pairwise(series)) + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_approximates_reachable(seed: int) -> None: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < 18: + u, v = rng.randint(0, 9), rng.randint(0, 9) + if u != v: + edges.add((u, v)) + edge_list = sorted(edges) + nodes = list(range(10)) + g = _graph(nodes, edge_list) + graph = nx.DiGraph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edge_list) + + max_hops = 6 + counts = _counts(g, max_hops) + for node in nodes: + for hop in range(max_hops + 1): + exact = len(nx.single_source_shortest_path_length(graph, node, cutoff=hop)) + est = counts[(node, hop)] + assert abs(est - exact) <= 0.3 * exact + 1.5 + + +@pytest.mark.parametrize("precision", [0, 3, 19, 64]) +def test_precision_out_of_range_raises(precision: int) -> None: + g = _graph([0, 1], [(0, 1)]) + with pytest.raises(ValueError, match="precision"): + hyper_anf(g, precision=precision) + + +def test_hash64_handles_full_int64_and_uint64_range() -> None: + # must not raise for negatives or values past the signed-64 boundary + for value in [-(2**63), -1, 0, 2**63 - 1, 2**63, 2**64 - 1]: + digest = _hash64(value) + assert 0 <= digest < 2**64 diff --git a/tests/test_indexing.py b/tests/test_indexing.py new file mode 100644 index 0000000..296314c --- /dev/null +++ b/tests/test_indexing.py @@ -0,0 +1,127 @@ +"""Tests for vertex id indexing (reindex / restore_ids).""" + +from __future__ import annotations + +import daft +import pytest + +from daft_graph.algorithms.connected_components import connected_components +from daft_graph.graph import DirectedGraph, Graph, UndirectedGraph +from daft_graph.indexing import ORIGINAL, IndexedGraph, reindex, restore_ids +from daft_graph.schema import COMPONENT, DST, ID, SRC + + +def _string_graph() -> DirectedGraph: + # two components: {alice, bob, carol} and {dave, eve} + vertices = daft.from_pydict({ID: ["alice", "bob", "carol", "dave", "eve"]}) + edges = daft.from_pydict({SRC: ["alice", "bob", "dave"], DST: ["bob", "carol", "eve"]}) + return DirectedGraph(vertices=vertices, edges=edges) + + +def test_reindex_makes_contiguous_int_ids() -> None: + indexed = reindex(_string_graph()) + assert isinstance(indexed, IndexedGraph) + new_ids = sorted(indexed.graph.vertices.select(ID).collect().to_pydict()[ID]) + assert new_ids == [0, 1, 2, 3, 4] + # mapping is sorted original order -> contiguous ids + rows = indexed.mapping.sort(ID).collect().to_pydict() + assert rows[ORIGINAL] == ["alice", "bob", "carol", "dave", "eve"] + assert rows[ID] == [0, 1, 2, 3, 4] + + +def test_reindex_remaps_edges() -> None: + indexed = reindex(_string_graph()) + edges = indexed.graph.edges.sort(SRC).collect().to_pydict() + # alice=0 bob=1 carol=2 dave=3 eve=4 + assert list(zip(edges[SRC], edges[DST])) == [(0, 1), (1, 2), (3, 4)] + + +def test_reindex_preserves_attributes() -> None: + vertices = daft.from_pydict({ID: ["x", "y"], "color": ["red", "blue"]}) + edges = daft.from_pydict({SRC: ["x"], DST: ["y"], "weight": [2.5]}) + indexed = reindex(DirectedGraph(vertices=vertices, edges=edges)) + assert "color" in indexed.graph.vertices.column_names + assert "weight" in indexed.graph.edges.column_names + erow = indexed.graph.edges.collect().to_pydict() + assert erow["weight"] == [2.5] + + +def test_connected_components_on_string_ids_round_trip() -> None: + indexed = reindex(_string_graph()) + components = connected_components(indexed.graph) + restored = restore_ids(components, indexed.mapping, [ID, COMPONENT]) + rows = restored.collect().to_pydict() + by_id = dict(zip(rows[ID], rows[COMPONENT])) + # ids are back to strings, and the two components are intact + assert set(by_id) == {"alice", "bob", "carol", "dave", "eve"} + assert by_id["alice"] == by_id["bob"] == by_id["carol"] + assert by_id["dave"] == by_id["eve"] + assert by_id["alice"] != by_id["dave"] + + +def test_restore_ids_preserves_column_order() -> None: + indexed = reindex(_string_graph()) + df = daft.from_pydict({ID: [0, 1], "score": [9.0, 8.0]}) + restored = restore_ids(df, indexed.mapping, [ID]) + assert restored.column_names == [ID, "score"] + rows = restored.sort("score", desc=True).collect().to_pydict() + assert rows[ID] == ["alice", "bob"] + + +def test_restore_ids_unknown_column_raises() -> None: + indexed = reindex(_string_graph()) + df = daft.from_pydict({ID: [0]}) + with pytest.raises(ValueError, match="missing"): + restore_ids(df, indexed.mapping, ["missing"]) + + +def test_reindex_internal_name_collision_raises() -> None: + vertices = daft.from_pydict({ID: ["a", "b"], "__new_id": [1, 2]}) + edges = daft.from_pydict({SRC: ["a"], DST: ["b"]}) + with pytest.raises(ValueError, match="internal names"): + reindex(DirectedGraph(vertices=vertices, edges=edges)) + + +def test_reindex_edge_internal_name_collision_raises() -> None: + vertices = daft.from_pydict({ID: ["a", "b"]}) + edges = daft.from_pydict({SRC: ["a"], DST: ["b"], "__new_src": [99]}) + with pytest.raises(ValueError, match="internal names"): + reindex(DirectedGraph(vertices=vertices, edges=edges)) + + +def test_original_constant_is_exported() -> None: + import daft_graph + + assert daft_graph.ORIGINAL == ORIGINAL + + +def test_reindex_is_deterministic() -> None: + a = reindex(_string_graph()).mapping.sort(ID).collect().to_pydict() + b = reindex(_string_graph()).mapping.sort(ID).collect().to_pydict() + assert a == b + + +@pytest.mark.parametrize("flavor", [DirectedGraph, UndirectedGraph]) +def test_reindex_preserves_the_graph_flavor(flavor: type[Graph]) -> None: + """An algorithm typed to DirectedGraph must still accept a reindexed one.""" + edges = daft.from_pydict({SRC: ["a", "b"], DST: ["b", "c"]}) + indexed = reindex(flavor(edges)) + assert type(indexed.graph) is flavor + + +def test_reindexed_directed_graph_keeps_directed_methods() -> None: + edges = daft.from_pydict({SRC: ["a", "b"], DST: ["b", "c"]}) + indexed = reindex(DirectedGraph(edges)) + assert isinstance(indexed.graph, DirectedGraph) + # would raise AttributeError if reindex downcast to the base class + assert indexed.graph.out_degrees().count_rows() == 2 + assert isinstance(indexed.graph.reverse(), DirectedGraph) + + +def test_reindexed_undirected_graph_keeps_undirected_traversal() -> None: + edges = daft.from_pydict({SRC: ["a"], DST: ["b"]}) + indexed = reindex(UndirectedGraph(edges)) + assert isinstance(indexed.graph, UndirectedGraph) + # symmetrized at traversal, so one stored edge walks both ways + assert indexed.graph.edges.count_rows() == 1 + assert indexed.graph.traversal_edges().count_rows() == 2 diff --git a/tests/test_iterate.py b/tests/test_iterate.py new file mode 100644 index 0000000..15b0d6d --- /dev/null +++ b/tests/test_iterate.py @@ -0,0 +1,89 @@ +"""Tests for daft_graph.iterate.""" + +from __future__ import annotations + +import daft +import pytest +from daft import col +from daft.functions import when + +from daft_graph.iterate import iterate_to_fixed_point + + +def _decrement_to_zero(df: daft.DataFrame) -> daft.DataFrame: + return df.select(when(col("n") > 0, col("n") - 1).otherwise(0).alias("n")) + + +def _increment(df: daft.DataFrame) -> daft.DataFrame: + return df.select((col("n") + 1).alias("n")) + + +def _value(df: daft.DataFrame) -> int: + return int(df.collect().to_pydict()["n"][0]) + + +def _converged(prev: daft.DataFrame, nxt: daft.DataFrame) -> bool: + return _value(prev) == _value(nxt) + + +def _never_converged(prev: daft.DataFrame, nxt: daft.DataFrame) -> bool: + return False + + +def test_converges_and_counts_rounds() -> None: + state = daft.from_pydict({"n": [5]}) + final, rounds = iterate_to_fixed_point(state, _decrement_to_zero, _converged, max_iters=30) + assert _value(final) == 0 + assert rounds == 6 + assert rounds < 30 + + +def test_respects_max_iters_cap() -> None: + state = daft.from_pydict({"n": [0]}) + with pytest.warns(UserWarning): + final, rounds = iterate_to_fixed_point(state, _increment, _never_converged, max_iters=3) + assert rounds == 3 + assert _value(final) == 3 + + +def test_checkpoint_dir_round_trip(tmp_path) -> None: + state = daft.from_pydict({"n": [3]}) + final, _rounds = iterate_to_fixed_point( + state, + _decrement_to_zero, + _converged, + max_iters=30, + checkpoint_dir=str(tmp_path), + ) + assert _value(final) == 0 + assert any(tmp_path.iterdir()) + + +def test_invalid_args() -> None: + state = daft.from_pydict({"n": [1]}) + with pytest.raises(ValueError): + iterate_to_fixed_point(state, _decrement_to_zero, _converged, max_iters=0) + with pytest.raises(ValueError): + iterate_to_fixed_point(state, _decrement_to_zero, _converged, materialize_every=0) + + +def test_checkpoint_dir_reuse_is_not_corrupted(tmp_path) -> None: + ckpt = str(tmp_path / "ck") + first, _ = iterate_to_fixed_point( + daft.from_pydict({"n": [3]}), + _decrement_to_zero, + _converged, + max_iters=30, + checkpoint_dir=ckpt, + ) + second, _ = iterate_to_fixed_point( + daft.from_pydict({"n": [4]}), + _decrement_to_zero, + _converged, + max_iters=30, + checkpoint_dir=ckpt, + ) + assert _value(first) == 0 + assert _value(second) == 0 + # The second run must not inherit checkpoint files from the first. + assert second.count_rows() == 1 diff --git a/tests/test_k_core.py b/tests/test_k_core.py new file mode 100644 index 0000000..971dcdd --- /dev/null +++ b/tests/test_k_core.py @@ -0,0 +1,61 @@ +"""Tests for k_core against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.k_core import CORE, k_core +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> UndirectedGraph: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return UndirectedGraph(vertices=vertices, edges=edges_df) + + +def _core_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return dict(zip(d[ID], d[CORE])) + + +def _undirected_edges(n_nodes: int, n_edges: int, seed: int) -> list[tuple[int, int]]: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < n_edges: + u = rng.randint(0, n_nodes - 1) + v = rng.randint(0, n_nodes - 1) + if u != v: + edges.add((min(u, v), max(u, v))) + return sorted(edges) + + +def test_triangle_core_is_two() -> None: + g = _graph([1, 2, 3], [(1, 2), (2, 3), (1, 3)]) + assert _core_map(k_core(g)) == {1: 2, 2: 2, 3: 2} + + +def test_path_core_is_one() -> None: + g = _graph([1, 2, 3, 4], [(1, 2), (2, 3), (3, 4)]) + assert _core_map(k_core(g)) == {1: 1, 2: 1, 3: 1, 4: 1} + + +def test_isolated_vertex_core_zero() -> None: + g = _graph([1, 2, 3], [(1, 2)]) + assert _core_map(k_core(g)) == {1: 1, 2: 1, 3: 0} + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4]) +def test_matches_networkx(seed: int) -> None: + nodes = list(range(12)) + edges = _undirected_edges(12, 26, seed) + ours = _core_map(k_core(_graph(nodes, edges))) + graph = nx.Graph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edges) + assert ours == nx.core_number(graph) diff --git a/tests/test_label_propagation.py b/tests/test_label_propagation.py new file mode 100644 index 0000000..d0754e9 --- /dev/null +++ b/tests/test_label_propagation.py @@ -0,0 +1,53 @@ +"""Tests for daft_graph.algorithms.label_propagation.""" + +from __future__ import annotations + +from collections import defaultdict + +import daft + +from daft_graph.algorithms.label_propagation import label_propagation +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import DST, ID, LABEL, SRC + + +def _label_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return dict(zip(d[ID], d[LABEL])) + + +def _partition(df: daft.DataFrame) -> set: + d = df.collect().to_pydict() + groups: dict[int, set] = defaultdict(set) + for node, lab in zip(d[ID], d[LABEL]): + groups[lab].add(node) + return {frozenset(members) for members in groups.values()} + + +def test_two_triangles_recovers_communities() -> None: + # triangle {1,2,3} and triangle {4,5,6}, disconnected + edges = daft.from_pydict({SRC: [1, 2, 1, 4, 5, 4], DST: [2, 3, 3, 5, 6, 6]}) + g = UndirectedGraph(edges) + assert _partition(label_propagation(g)) == { + frozenset({1, 2, 3}), + frozenset({4, 5, 6}), + } + + +def test_clique_is_single_community() -> None: + edges = daft.from_pydict({SRC: [1, 1, 1, 2, 2, 3], DST: [2, 3, 4, 3, 4, 4]}) + g = UndirectedGraph(edges) + assert _partition(label_propagation(g)) == {frozenset({1, 2, 3, 4})} + + +def test_deterministic_across_runs() -> None: + edges = daft.from_pydict({SRC: [1, 2, 1, 4, 5, 4], DST: [2, 3, 3, 5, 6, 6]}) + g = UndirectedGraph(edges) + assert _label_map(label_propagation(g)) == _label_map(label_propagation(g)) + + +def test_isolated_vertex_is_its_own_community() -> None: + vertices = daft.from_pydict({ID: [1, 2, 3]}) + edges = daft.from_pydict({SRC: [1], DST: [2]}) + g = UndirectedGraph(vertices=vertices, edges=edges) + assert frozenset({3}) in _partition(label_propagation(g)) diff --git a/tests/test_maximal_independent_set.py b/tests/test_maximal_independent_set.py new file mode 100644 index 0000000..f6c1a0e --- /dev/null +++ b/tests/test_maximal_independent_set.py @@ -0,0 +1,77 @@ +"""Tests for maximal_independent_set: validate the result is a valid MIS.""" + +from __future__ import annotations + +import random +from collections import defaultdict + +import daft +import pytest + +from daft_graph.algorithms.maximal_independent_set import ( + SELECTED, + maximal_independent_set, +) +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> UndirectedGraph: + return UndirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}), + ) + + +def _selected(g: UndirectedGraph) -> set: + d = maximal_independent_set(g).collect().to_pydict() + return {i for i, s in zip(d[ID], d[SELECTED]) if s} + + +def _assert_valid_mis(nodes: list[int], edges: list[tuple[int, int]], selected: set) -> None: + adj: dict[int, set] = defaultdict(set) + for u, v in edges: + adj[u].add(v) + adj[v].add(u) + # independent: no edge between two selected vertices + for u, v in edges: + assert not (u in selected and v in selected) + # maximal: every unselected vertex has a selected neighbor + for node in nodes: + if node not in selected: + assert any(neighbor in selected for neighbor in adj[node]) + + +def test_path_selects_min_greedy() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2)]) + assert _selected(g) == {0, 2} + + +def test_triangle_selects_one() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2), (2, 0)]) + assert _selected(g) == {0} + + +def test_empty_edges_selects_all() -> None: + g = _graph([0, 1, 2], []) + assert _selected(g) == {0, 1, 2} + + +def test_deterministic() -> None: + g = _graph([0, 1, 2, 3, 4], [(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]) + assert _selected(g) == _selected(g) + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4, 5]) +def test_valid_maximal_independent_set(seed: int) -> None: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < 22: + u = rng.randint(0, 11) + v = rng.randint(0, 11) + if u != v: + edges.add((min(u, v), max(u, v))) + edge_list = sorted(edges) + nodes = list(range(12)) + selected = _selected(_graph(nodes, edge_list)) + _assert_valid_mis(nodes, edge_list, selected) diff --git a/tests/test_message_passing.py b/tests/test_message_passing.py new file mode 100644 index 0000000..a32f90a --- /dev/null +++ b/tests/test_message_passing.py @@ -0,0 +1,90 @@ +"""Tests for daft_graph.message_passing.""" + +from __future__ import annotations + +import daft +import pytest +from daft import col +from daft.functions import when + +from daft_graph.message_passing import MSG, VALUE, aggregate_messages, pregel +from daft_graph.schema import DST, ID, SRC + + +def _min_update() -> object: + return when(col(MSG).is_null(), col(VALUE)).otherwise(when(col(VALUE) <= col(MSG), col(VALUE)).otherwise(col(MSG))) + + +def test_aggregate_messages_to_dst() -> None: + edges = daft.from_pydict({SRC: [0, 1], DST: [1, 2]}) + state = daft.from_pydict({ID: [0, 1, 2], VALUE: [10, 20, 30]}) + d = aggregate_messages(edges, state, to_dst=col("src_value")).collect().to_pydict() + assert dict(zip(d[ID], d[MSG])) == {1: 10, 2: 20} + + +def test_aggregate_messages_to_src() -> None: + edges = daft.from_pydict({SRC: [0, 1], DST: [1, 2]}) + state = daft.from_pydict({ID: [0, 1, 2], VALUE: [10, 20, 30]}) + d = aggregate_messages(edges, state, to_src=col("dst_value")).collect().to_pydict() + assert dict(zip(d[ID], d[MSG])) == {0: 20, 1: 30} + + +def test_aggregate_messages_both_directions() -> None: + edges = daft.from_pydict({SRC: [0, 1], DST: [1, 2]}) + state = daft.from_pydict({ID: [0, 1, 2], VALUE: [10, 20, 30]}) + d = aggregate_messages(edges, state, to_src=col("dst_value"), to_dst=col("src_value")).collect().to_pydict() + # 0 <- dst_value(0,1)=20; 1 <- src_value(0,1)=10 + dst_value(1,2)=30; 2 <- src_value(1,2)=20 + assert dict(zip(d[ID], d[MSG])) == {0: 20, 1: 40, 2: 20} + + +def test_aggregate_messages_custom_agg_min() -> None: + edges = daft.from_pydict({SRC: [0, 1], DST: [2, 2]}) + state = daft.from_pydict({ID: [0, 1, 2], VALUE: [5, 3, 99]}) + d = aggregate_messages(edges, state, to_dst=col("src_value"), agg=lambda m: m.min()).collect().to_pydict() + assert dict(zip(d[ID], d[MSG])) == {2: 3} + + +def test_aggregate_messages_requires_direction() -> None: + edges = daft.from_pydict({SRC: [0], DST: [1]}) + state = daft.from_pydict({ID: [0, 1], VALUE: [1, 2]}) + with pytest.raises(ValueError): + aggregate_messages(edges, state) + + +def test_reserved_state_column_raises() -> None: + edges = daft.from_pydict({SRC: [0], DST: [1]}) + state = daft.from_pydict({ID: [0, 1], VALUE: [1, 2], "src_bad": [9, 9]}) + with pytest.raises(ValueError): + aggregate_messages(edges, state, to_dst=col("src_value")) + + +def test_pregel_min_label_propagation() -> None: + edges = daft.from_pydict({SRC: [0, 1], DST: [1, 2]}) + init = daft.from_pydict({ID: [0, 1, 2], VALUE: [0, 1, 2]}) + result = pregel( + edges, + init, + to_src=col("dst_value"), + to_dst=col("src_value"), + agg=lambda m: m.min(), + update=_min_update(), + max_iters=10, + ) + d = result.collect().to_pydict() + assert dict(zip(d[ID], d[VALUE])) == {0: 0, 1: 0, 2: 0} + + +def test_pregel_carries_extra_columns() -> None: + edges = daft.from_pydict({SRC: [0], DST: [1]}) + init = daft.from_pydict({ID: [0, 1], VALUE: [0, 1], "tag": [100, 200]}) + result = pregel( + edges, + init, + to_dst=col("src_value"), + agg=lambda m: m.min(), + update=_min_update(), + max_iters=5, + ) + d = result.collect().to_pydict() + assert "tag" in d + assert dict(zip(d[ID], d["tag"])) == {0: 100, 1: 200} diff --git a/tests/test_motif_dataset.py b/tests/test_motif_dataset.py new file mode 100644 index 0000000..728d747 --- /dev/null +++ b/tests/test_motif_dataset.py @@ -0,0 +1,98 @@ +"""Validate motif find() against brute force ground truth on real datasets.""" + +from __future__ import annotations + +from collections import defaultdict + +import daft +import networkx as nx +from daft import col + +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _bidir_karate() -> tuple[list[int], list[tuple[int, int]]]: + g = nx.convert_node_labels_to_integers(nx.karate_club_graph()) + bidir: set[tuple[int, int]] = set() + for u, v in g.edges(): + bidir.add((u, v)) + bidir.add((v, u)) + return sorted(g.nodes()), sorted(bidir) + + +def _daft_graph(nodes: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + return DirectedGraph( + vertices=daft.from_pydict({ID: nodes}), + edges=daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}), + ) + + +def _adjacency(edges: list[tuple[int, int]]) -> dict[int, set]: + adj: dict[int, set] = defaultdict(set) + for u, v in edges: + adj[u].add(v) + return adj + + +def test_single_edge_equals_edge_set() -> None: + nodes, edges = _bidir_karate() + result = _daft_graph(nodes, edges).find("(a)-[e]->(b)") + d = result.select(col("a")["id"].alias("a"), col("b")["id"].alias("b")).collect().to_pydict() + assert set(zip(d["a"], d["b"])) == set(edges) + + +def test_two_paths_match_bruteforce() -> None: + nodes, edges = _bidir_karate() + adj = _adjacency(edges) + result = _daft_graph(nodes, edges).find("(a)-[]->(b); (b)-[]->(c)") + d = ( + result.select( + col("a")["id"].alias("a"), + col("b")["id"].alias("b"), + col("c")["id"].alias("c"), + ) + .collect() + .to_pydict() + ) + ours = set(zip(d["a"], d["b"], d["c"])) + truth = {(a, b, c) for (a, b) in edges for c in adj[b]} + assert ours == truth + + +def test_directed_triangles_match_bruteforce() -> None: + nodes, edges = _bidir_karate() + adj = _adjacency(edges) + edge_set = set(edges) + result = _daft_graph(nodes, edges).find("(a)-[]->(b); (b)-[]->(c); (c)-[]->(a)") + d = ( + result.select( + col("a")["id"].alias("a"), + col("b")["id"].alias("b"), + col("c")["id"].alias("c"), + ) + .collect() + .to_pydict() + ) + ours = set(zip(d["a"], d["b"], d["c"])) + truth = {(a, b, c) for (a, b) in edges for c in adj[b] if (c, a) in edge_set} + assert ours == truth + + +def test_single_edge_scale_wiki_vote() -> None: + import gzip + from pathlib import Path + + data = Path(__file__).parent / "data" / "wiki-Vote.txt.gz" + g = nx.DiGraph() + with gzip.open(data, "rt") as handle: + for line in handle: + if not line.startswith("#"): + a, b = line.split() + g.add_edge(int(a), int(b)) + g = nx.convert_node_labels_to_integers(g) + nodes = sorted(g.nodes()) + edges = [(u, v) for u, v in g.edges()] + result = _daft_graph(nodes, edges).find("(a)-[e]->(b)") + d = result.select(col("a")["id"].alias("a"), col("b")["id"].alias("b")).collect().to_pydict() + assert set(zip(d["a"], d["b"])) == set(edges) diff --git a/tests/test_motif_find.py b/tests/test_motif_find.py new file mode 100644 index 0000000..7c3e2cb --- /dev/null +++ b/tests/test_motif_find.py @@ -0,0 +1,83 @@ +"""Tests for motif find().""" + +from __future__ import annotations + +import daft +import pytest +from daft import col + +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph() -> DirectedGraph: + vertices = daft.from_pydict({ID: [1, 2, 3], "name": ["x", "y", "z"]}) + # edges: 1->2, 2->3, 3->1, 2->1 + edges = daft.from_pydict({SRC: [1, 2, 3, 2], DST: [2, 3, 1, 1]}) + return DirectedGraph(vertices=vertices, edges=edges) + + +def _pairs(df: daft.DataFrame, a: str, b: str) -> set: + d = df.select(col(a)["id"].alias("a"), col(b)["id"].alias("b")).collect().to_pydict() + return set(zip(d["a"], d["b"])) + + +def test_single_edge_matches_all_edges() -> None: + g = _graph() + result = g.find("(a)-[e]->(b)") + assert _pairs(result, "a", "b") == {(1, 2), (2, 3), (3, 1), (2, 1)} + # the edge struct exposes src and dst + de = result.select(col("e")["src"].alias("s"), col("e")["dst"].alias("t")).collect().to_pydict() + assert set(zip(de["s"], de["t"])) == {(1, 2), (2, 3), (3, 1), (2, 1)} + + +def test_directed_triangle() -> None: + g = _graph() + result = g.find("(a)-[]->(b); (b)-[]->(c); (c)-[]->(a)") + d = ( + result.select( + col("a")["id"].alias("a"), + col("b")["id"].alias("b"), + col("c")["id"].alias("c"), + ) + .collect() + .to_pydict() + ) + assert set(zip(d["a"], d["b"], d["c"])) == {(1, 2, 3), (2, 3, 1), (3, 1, 2)} + + +def test_negation_excludes_reciprocal() -> None: + g = _graph() + result = g.find("(a)-[]->(b); !(b)-[]->(a)") + assert _pairs(result, "a", "b") == {(2, 3), (3, 1)} + + +def test_repeated_name_identity_mutual_edges() -> None: + g = _graph() + result = g.find("(a)-[]->(b); (b)-[]->(a)") + assert _pairs(result, "a", "b") == {(1, 2), (2, 1)} + + +def test_vertex_struct_carries_attributes() -> None: + g = _graph() + result = g.find("(a)-[e]->(b)") + d = result.select(col("a")["id"].alias("aid"), col("a")["name"].alias("an")).collect().to_pydict() + assert dict(zip(d["aid"], d["an"])) == {1: "x", 2: "y", 3: "z"} + + +def test_no_named_elements_raises() -> None: + g = _graph() + with pytest.raises(ValueError): + g.find("()-[]->()") + + +def test_self_loop_pattern_raises() -> None: + g = _graph() + with pytest.raises(ValueError): + g.find("(a)-[e]->(a)") + + +def test_reused_edge_name_raises() -> None: + g = _graph() + with pytest.raises(ValueError): + g.find("(a)-[e]->(b); (b)-[e]->(c)") diff --git a/tests/test_motif_parser.py b/tests/test_motif_parser.py new file mode 100644 index 0000000..2c53094 --- /dev/null +++ b/tests/test_motif_parser.py @@ -0,0 +1,57 @@ +"""Tests for the motif pattern parser.""" + +from __future__ import annotations + +import pytest + +from daft_graph.motif import EdgePattern, VertexPattern, is_named, parse_motif + + +def test_single_edge() -> None: + assert parse_motif("(a)-[e]->(b)") == [EdgePattern(src="a", dst="b", edge="e", negated=False)] + + +def test_chain() -> None: + assert parse_motif("(a)-[e]->(b); (b)-[e2]->(c)") == [ + EdgePattern(src="a", dst="b", edge="e"), + EdgePattern(src="b", dst="c", edge="e2"), + ] + + +def test_anonymous_edge_and_vertex() -> None: + clauses = parse_motif("(a)-[]->()") + assert len(clauses) == 1 + clause = clauses[0] + assert isinstance(clause, EdgePattern) + assert clause.src == "a" + assert clause.edge is None + assert not is_named(clause.dst) + + +def test_negation() -> None: + clauses = parse_motif("(a)-[e]->(b); !(b)-[]->(a)") + assert clauses[1] == EdgePattern(src="b", dst="a", edge=None, negated=True) + + +def test_negated_named_edge_raises() -> None: + with pytest.raises(ValueError): + parse_motif("!(a)-[e]->(b)") + + +def test_lone_vertex() -> None: + assert parse_motif("(a)") == [VertexPattern(name="a")] + + +def test_unparseable_raises() -> None: + with pytest.raises(ValueError): + parse_motif("not a motif") + + +def test_empty_raises() -> None: + with pytest.raises(ValueError): + parse_motif(" ") + + +def test_negated_vertex_raises() -> None: + with pytest.raises(ValueError): + parse_motif("!(a)") diff --git a/tests/test_optional.py b/tests/test_optional.py new file mode 100644 index 0000000..f9a23b6 --- /dev/null +++ b/tests/test_optional.py @@ -0,0 +1,78 @@ +"""Tests for the optional `local` extra guards in daft_graph._optional.""" + +from __future__ import annotations + +import builtins + +import pytest + +from daft_graph._optional import has_local_extra, require_numpy, require_scipy + + +def _block(monkeypatch: pytest.MonkeyPatch, blocked: str) -> None: + """Make importing `blocked` (and its submodules) raise ModuleNotFoundError.""" + real_import = builtins.__import__ + + def fake_import(name: str, *args: object, **kwargs: object) -> object: + if name == blocked or name.startswith(blocked + "."): + raise ModuleNotFoundError(f"No module named {name!r}") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + +def test_require_numpy_passes_when_present() -> None: + # numpy is in the dev group, so this must not raise + require_numpy("a feature") + + +def test_require_scipy_passes_when_present() -> None: + require_scipy("a feature") + + +def test_require_numpy_raises_guided_error(monkeypatch: pytest.MonkeyPatch) -> None: + _block(monkeypatch, "numpy") + with pytest.raises(ImportError, match=r"daft-graph\[local\]") as exc: + require_numpy("the local connected components solve") + assert "numpy" in str(exc.value) + assert "the local connected components solve" in str(exc.value) + + +def test_require_scipy_raises_guided_error(monkeypatch: pytest.MonkeyPatch) -> None: + _block(monkeypatch, "scipy") + with pytest.raises(ImportError, match=r"daft-graph\[local\]") as exc: + require_scipy("svd_plus_plus") + assert "scipy" in str(exc.value) + + +def test_require_numpy_catches_plain_import_error(monkeypatch: pytest.MonkeyPatch) -> None: + """A broken (not merely absent) install raises ImportError, not just ModuleNotFoundError.""" + real_import = builtins.__import__ + + def fake_import(name: str, *args: object, **kwargs: object) -> object: + if name == "numpy": + raise ImportError("DLL load failed") # simulate a broken build + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", fake_import) + with pytest.raises(ImportError, match=r"daft-graph\[local\]"): + require_numpy("x") + + +def test_has_local_extra_true_when_present() -> None: + assert has_local_extra() is True + + +def test_has_local_extra_false_and_never_raises(monkeypatch: pytest.MonkeyPatch) -> None: + # a find_spec that blows up must be treated as "not available", not propagated + import daft_graph._optional as opt + + def boom(_name: str) -> object: + raise RuntimeError("broken import machinery") + + monkeypatch.setattr(opt, "find_spec", boom, raising=False) + # find_spec is imported inside the function, so patch importlib.util too + import importlib.util + + monkeypatch.setattr(importlib.util, "find_spec", boom) + assert has_local_extra() is False diff --git a/tests/test_pagerank.py b/tests/test_pagerank.py new file mode 100644 index 0000000..7bd5949 --- /dev/null +++ b/tests/test_pagerank.py @@ -0,0 +1,76 @@ +"""Tests for daft_graph.algorithms.pagerank against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.pagerank import pagerank +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, RANK, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return DirectedGraph(vertices=vertices, edges=edges_df) + + +def _our_ranks(g: DirectedGraph) -> dict: + d = pagerank(g, damping=0.85, tol=1e-10, max_iters=300).collect().to_pydict() + return dict(zip(d[ID], d[RANK])) + + +def _nx_ranks(node_ids: list[int], edges: list[tuple[int, int]]) -> dict: + graph = nx.DiGraph() + graph.add_nodes_from(node_ids) + graph.add_edges_from(edges) + return nx.pagerank(graph, alpha=0.85, tol=1e-12, max_iter=1000) + + +def _random_directed_edges(n_nodes: int, n_edges: int, seed: int) -> list[tuple[int, int]]: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < n_edges: + u = rng.randint(0, n_nodes - 1) + v = rng.randint(0, n_nodes - 1) + if u != v: + edges.add((u, v)) + return sorted(edges) + + +def test_directed_cycle_is_uniform() -> None: + edges = [(0, 1), (1, 2), (2, 0)] + ranks = _our_ranks(_graph([0, 1, 2], edges)) + for value in ranks.values(): + assert abs(value - 1.0 / 3.0) < 1e-6 + assert abs(sum(ranks.values()) - 1.0) < 1e-9 + + +def test_dangling_node_matches_networkx() -> None: + # node 2 has no out edge (dangling) + edges = [(0, 1), (1, 2), (0, 2)] + node_ids = [0, 1, 2] + ours = _our_ranks(_graph(node_ids, edges)) + theirs = _nx_ranks(node_ids, edges) + for nid in node_ids: + assert abs(ours[nid] - theirs[nid]) < 1e-4 + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_random_graph_matches_networkx(seed: int) -> None: + node_ids = list(range(8)) + edges = _random_directed_edges(8, 14, seed) + ours = _our_ranks(_graph(node_ids, edges)) + theirs = _nx_ranks(node_ids, edges) + for nid in node_ids: + assert abs(ours[nid] - theirs[nid]) < 1e-4 + + +def test_ranks_sum_to_one() -> None: + edges = _random_directed_edges(10, 20, 99) + ranks = _our_ranks(_graph(list(range(10)), edges)) + assert abs(sum(ranks.values()) - 1.0) < 1e-6 diff --git a/tests/test_pagerank_personalized.py b/tests/test_pagerank_personalized.py new file mode 100644 index 0000000..90ed4dd --- /dev/null +++ b/tests/test_pagerank_personalized.py @@ -0,0 +1,75 @@ +"""Tests for personalized PageRank against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.pagerank import pagerank +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, RANK, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return DirectedGraph(vertices=vertices, edges=edges_df) + + +def _our(g: DirectedGraph, sources: list[int]) -> dict: + d = pagerank(g, damping=0.85, tol=1e-10, max_iters=300, source_ids=sources).collect().to_pydict() + return dict(zip(d[ID], d[RANK])) + + +def _nx(node_ids: list[int], edges: list[tuple[int, int]], sources: list[int]) -> dict: + graph = nx.DiGraph() + graph.add_nodes_from(node_ids) + graph.add_edges_from(edges) + personalization = {s: 1.0 for s in sources} + return nx.pagerank(graph, alpha=0.85, personalization=personalization, tol=1e-12, max_iter=1000) + + +def _random_directed_edges(n_nodes: int, n_edges: int, seed: int) -> list[tuple[int, int]]: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < n_edges: + u = rng.randint(0, n_nodes - 1) + v = rng.randint(0, n_nodes - 1) + if u != v: + edges.add((u, v)) + return sorted(edges) + + +def test_single_source_matches_networkx() -> None: + node_ids = list(range(6)) + edges = _random_directed_edges(6, 10, 1) + ours = _our(_graph(node_ids, edges), [0]) + theirs = _nx(node_ids, edges, [0]) + for nid in node_ids: + assert abs(ours[nid] - theirs[nid]) < 1e-4 + + +@pytest.mark.parametrize(("seed", "sources"), [(2, [0, 3]), (3, [1]), (4, [2, 4, 5])]) +def test_personalized_matches_networkx(seed: int, sources: list[int]) -> None: + node_ids = list(range(8)) + edges = _random_directed_edges(8, 16, seed) + ours = _our(_graph(node_ids, edges), sources) + theirs = _nx(node_ids, edges, sources) + for nid in node_ids: + assert abs(ours[nid] - theirs[nid]) < 1e-4 + + +def test_personalized_ranks_sum_to_one() -> None: + node_ids = list(range(10)) + edges = _random_directed_edges(10, 18, 5) + ranks = _our(_graph(node_ids, edges), [0, 1]) + assert abs(sum(ranks.values()) - 1.0) < 1e-6 + + +def test_empty_sources_raises() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2)]) + with pytest.raises(ValueError): + pagerank(g, source_ids=[]).collect() diff --git a/tests/test_parallel_personalized_pagerank.py b/tests/test_parallel_personalized_pagerank.py new file mode 100644 index 0000000..c1b3f99 --- /dev/null +++ b/tests/test_parallel_personalized_pagerank.py @@ -0,0 +1,66 @@ +"""Tests for parallel_personalized_pagerank against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.pagerank import ( + SOURCE, + parallel_personalized_pagerank, +) +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, RANK, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + return DirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}), + ) + + +def _random_digraph(n_nodes: int, n_edges: int, seed: int) -> list[tuple[int, int]]: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < n_edges: + u, v = rng.randint(0, n_nodes - 1), rng.randint(0, n_nodes - 1) + if u != v: + edges.add((u, v)) + return sorted(edges) + + +def test_output_columns() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2)]) + out = parallel_personalized_pagerank(g, [0, 2]) + assert set(out.column_names) == {ID, SOURCE, RANK} + + +def test_empty_sources_raises() -> None: + g = _graph([0, 1], [(0, 1)]) + with pytest.raises(ValueError): + parallel_personalized_pagerank(g, []) + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_each_vector_matches_networkx(seed: int) -> None: + nodes = list(range(8)) + edges = _random_digraph(8, 14, seed) + sources = [0, 4, 7] + g = _graph(nodes, edges) + graph = nx.DiGraph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edges) + + d = parallel_personalized_pagerank(g, sources, tol=1e-12, max_iters=300).collect().to_pydict() + ours: dict[int, dict[int, float]] = {s: {} for s in sources} + for node, source, rank in zip(d[ID], d[SOURCE], d[RANK]): + ours[source][node] = rank + + for source in sources: + theirs = nx.pagerank(graph, alpha=0.85, personalization={source: 1.0}, tol=1e-12, max_iter=1000) + for node in nodes: + assert abs(ours[source][node] - theirs[node]) < 1e-4 diff --git a/tests/test_power_iteration_clustering.py b/tests/test_power_iteration_clustering.py new file mode 100644 index 0000000..42f5753 --- /dev/null +++ b/tests/test_power_iteration_clustering.py @@ -0,0 +1,66 @@ +"""Tests for power_iteration_clustering.""" + +from __future__ import annotations + +import daft +import pytest + +from daft_graph.algorithms.power_iteration_clustering import ( + CLUSTER, + power_iteration_clustering, +) +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> UndirectedGraph: + return UndirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}), + ) + + +def _clusters(g: UndirectedGraph, k: int) -> dict: + d = power_iteration_clustering(g, k).collect().to_pydict() + return dict(zip(d[ID], d[CLUSTER])) + + +def _barbell() -> UndirectedGraph: + # asymmetric barbell: a triangle {0,1,2} and a 5-clique {3,4,5,6,7}, bridged 2-3 + triangle = [(0, 1), (0, 2), (1, 2)] + clique = [ + (3, 4), + (3, 5), + (3, 6), + (3, 7), + (4, 5), + (4, 6), + (4, 7), + (5, 6), + (5, 7), + (6, 7), + ] + bridge = [(2, 3)] + return _graph(list(range(8)), triangle + clique + bridge) + + +def test_separates_two_communities() -> None: + cl = _clusters(_barbell(), 2) + # each community core lands in a single cluster, and the two are different + assert cl[0] == cl[1] == cl[2] + assert cl[4] == cl[5] == cl[6] == cl[7] + assert cl[0] != cl[4] + + +def test_at_most_k_clusters() -> None: + assert len(set(_clusters(_barbell(), 2).values())) <= 2 + + +def test_deterministic() -> None: + g = _barbell() + assert _clusters(g, 2) == _clusters(g, 2) + + +def test_invalid_k_raises() -> None: + with pytest.raises(ValueError, match="k must be"): + power_iteration_clustering(_barbell(), 0) diff --git a/tests/test_property_graph.py b/tests/test_property_graph.py new file mode 100644 index 0000000..4807374 --- /dev/null +++ b/tests/test_property_graph.py @@ -0,0 +1,60 @@ +"""Tests for labeled property graph support (typed degree).""" + +from __future__ import annotations + +import random +from collections import defaultdict + +import daft + +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _typed_graph(node_ids: list[int], edges: list[tuple[int, int, str]]) -> DirectedGraph: + return DirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict( + { + SRC: [u for u, _, _ in edges], + DST: [v for _, v, _ in edges], + "type": [t for _, _, t in edges], + } + ), + ) + + +def _degree_map(g: DirectedGraph) -> dict: + d = g.degree_by_type("type").collect().to_pydict() + return {(i, t): deg for i, t, deg in zip(d[ID], d["type"], d["degree"])} + + +def test_degree_by_type_counts() -> None: + g = _typed_graph([0, 1, 2, 3], [(0, 1, "a"), (0, 2, "a"), (0, 3, "b"), (1, 2, "b")]) + assert _degree_map(g) == { + (0, "a"): 2, + (1, "a"): 1, + (2, "a"): 1, + (0, "b"): 1, + (3, "b"): 1, + (1, "b"): 1, + (2, "b"): 1, + } + + +def test_degree_by_type_matches_manual_count() -> None: + rng = random.Random(7) + types = ["x", "y", "z"] + edges = [] + seen: set[tuple[int, int]] = set() + while len(edges) < 30: + u, v = rng.randint(0, 9), rng.randint(0, 9) + if u != v and (u, v) not in seen: + seen.add((u, v)) + edges.append((u, v, rng.choice(types))) + g = _typed_graph(list(range(10)), edges) + expected: dict[tuple[int, str], int] = defaultdict(int) + for u, v, t in edges: + expected[(u, t)] += 1 + expected[(v, t)] += 1 + assert _degree_map(g) == dict(expected) diff --git a/tests/test_random_walks.py b/tests/test_random_walks.py new file mode 100644 index 0000000..79a59f6 --- /dev/null +++ b/tests/test_random_walks.py @@ -0,0 +1,58 @@ +"""Tests for random_walks.""" + +from __future__ import annotations + +import itertools + +import daft + +from daft_graph.algorithms.random_walks import random_walks +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + return DirectedGraph( + vertices=daft.from_pydict({ID: node_ids}), + edges=daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}), + ) + + +def test_walks_traverse_real_edges() -> None: + edges = [(0, 1), (1, 2), (2, 0), (2, 3)] + g = _graph([0, 1, 2, 3], edges) + edge_set = set(edges) + walks = random_walks(g, walk_length=5, num_walks=2, seed=1) + for walk in walks: + assert len(walk) <= 6 + for a, b in itertools.pairwise(walk): + assert (a, b) in edge_set + + +def test_walk_count_and_start() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 0), (2, 3)]) + walks = random_walks(g, walk_length=4, num_walks=3, seed=1) + assert len(walks) == 4 * 3 # num vertices * num_walks + starts = sorted(walk[0] for walk in walks) + assert starts == [0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3] + + +def test_deterministic_with_seed() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 0), (2, 3)]) + assert random_walks(g, walk_length=6, num_walks=2, seed=7) == random_walks(g, walk_length=6, num_walks=2, seed=7) + + +def test_dead_end_stops_walk() -> None: + g = _graph([0, 1], [(0, 1)]) # 1 is a sink + walks = random_walks(g, walk_length=5, num_walks=1, seed=1) + by_start = {walk[0]: walk for walk in walks} + assert by_start[1] == [1] # no out edge, walk is just the start + assert by_start[0] == [0, 1] # 0 -> 1 then stuck + + +def test_undirected_can_walk_back() -> None: + g = _graph([0, 1], [(0, 1)]) + walks = random_walks(g.as_undirected(), walk_length=3, num_walks=1, seed=1) + by_start = {walk[0]: walk for walk in walks} + # undirected: 1 can step back to 0 + assert len(by_start[1]) > 1 diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 0000000..7bf264d --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,19 @@ +"""Tests for daft_graph.schema.""" + +from __future__ import annotations + +from daft_graph import schema + + +def test_column_constants() -> None: + assert schema.ID == "id" + assert schema.SRC == "src" + assert schema.DST == "dst" + assert schema.COMPONENT == "component" + assert schema.LABEL == "label" + assert schema.RANK == "rank" + + +def test_internal_columns_are_distinct() -> None: + cols = {schema.U, schema.V, schema.REP} + assert len(cols) == 3 diff --git a/tests/test_shortest_paths.py b/tests/test_shortest_paths.py new file mode 100644 index 0000000..9520fa1 --- /dev/null +++ b/tests/test_shortest_paths.py @@ -0,0 +1,67 @@ +"""Tests for shortest_paths against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.shortest_paths import DISTANCE, LANDMARK, shortest_paths +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return DirectedGraph(vertices=vertices, edges=edges_df) + + +def _dist_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return {(i, lm): dist for i, lm, dist in zip(d[ID], d[LANDMARK], d[DISTANCE])} + + +def test_path_distances_to_landmark() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 3)]) + got = _dist_map(shortest_paths(g, [3])) + assert got == {(0, 3): 3, (1, 3): 2, (2, 3): 1, (3, 3): 0} + + +def test_empty_landmarks_raises() -> None: + g = _graph([0, 1], [(0, 1)]) + with pytest.raises(ValueError): + shortest_paths(g, []) + + +def test_empty_edges_only_valid_landmark_self() -> None: + g = _graph([0, 1, 2], []) + got = _dist_map(shortest_paths(g, [1, 99])) + assert got == {(1, 1): 0} + + +@pytest.mark.parametrize("seed", [1, 2, 3]) +def test_matches_networkx(seed: int) -> None: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < 22: + u, v = rng.randint(0, 9), rng.randint(0, 9) + if u != v: + edges.add((u, v)) + edge_list = sorted(edges) + nodes = list(range(10)) + g = _graph(nodes, edge_list) + graph = nx.DiGraph() + graph.add_nodes_from(nodes) + graph.add_edges_from(edge_list) + landmarks = [0, 5, 9] + got = _dist_map(shortest_paths(g, landmarks, max_iters=20)) + for landmark in landmarks: + nx_dist = dict(nx.single_target_shortest_path_length(graph, landmark)) + for v in nodes: + if (v, landmark) in got: + assert got[(v, landmark)] == nx_dist[v] + else: + assert v not in nx_dist diff --git a/tests/test_strongly_connected_components.py b/tests/test_strongly_connected_components.py new file mode 100644 index 0000000..19f7f48 --- /dev/null +++ b/tests/test_strongly_connected_components.py @@ -0,0 +1,96 @@ +"""Tests for strongly_connected_components against networkx.""" + +from __future__ import annotations + +import random +from collections import defaultdict + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.strongly_connected_components import ( + strongly_connected_components, +) +from daft_graph.graph import DirectedGraph +from daft_graph.schema import COMPONENT, DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> DirectedGraph: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return DirectedGraph(vertices=vertices, edges=edges_df) + + +def _partition(df: daft.DataFrame) -> set: + d = df.collect().to_pydict() + groups: dict[int, set] = defaultdict(set) + for node, comp in zip(d[ID], d[COMPONENT]): + groups[comp].add(node) + return {frozenset(members) for members in groups.values()} + + +def _nx_partition(node_ids: list[int], edges: list[tuple[int, int]]) -> set: + graph = nx.DiGraph() + graph.add_nodes_from(node_ids) + graph.add_edges_from(edges) + return {frozenset(c) for c in nx.strongly_connected_components(graph)} + + +def _random_digraph(n_nodes: int, n_edges: int, seed: int) -> list[tuple[int, int]]: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < n_edges: + u = rng.randint(0, n_nodes - 1) + v = rng.randint(0, n_nodes - 1) + if u != v: + edges.add((u, v)) + return sorted(edges) + + +def test_cycle_is_one_scc() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 0), (0, 3)]) + expected = {frozenset({0, 1, 2}), frozenset({3})} + assert _partition(strongly_connected_components(g, strategy="local")) == expected + assert _partition(strongly_connected_components(g, strategy="distributed")) == expected + + +def test_dag_all_singletons() -> None: + g = _graph([0, 1, 2], [(0, 1), (1, 2)]) + expected = {frozenset({0}), frozenset({1}), frozenset({2})} + assert _partition(strongly_connected_components(g, strategy="distributed")) == expected + + +def test_distributed_labels_by_min_id() -> None: + g = _graph([0, 1, 2, 3], [(0, 1), (1, 2), (2, 0), (0, 3)]) + d = strongly_connected_components(g, strategy="distributed").collect().to_pydict() + labels = dict(zip(d[ID], d[COMPONENT])) + assert labels[0] == labels[1] == labels[2] == 0 + assert labels[3] == 3 + + +def test_local_and_distributed_labels_agree() -> None: + nodes = list(range(10)) + edges = _random_digraph(10, 18, 7) + g = _graph(nodes, edges) + local = strongly_connected_components(g, strategy="local").collect().to_pydict() + dist = strongly_connected_components(g, strategy="distributed").collect().to_pydict() + assert dict(zip(local[ID], local[COMPONENT])) == dict(zip(dist[ID], dist[COMPONENT])) + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4, 5]) +def test_local_matches_networkx(seed: int) -> None: + nodes = list(range(12)) + edges = _random_digraph(12, 22, seed) + assert _partition(strongly_connected_components(_graph(nodes, edges), strategy="local")) == _nx_partition( + nodes, edges + ) + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4, 5]) +def test_distributed_matches_networkx(seed: int) -> None: + nodes = list(range(12)) + edges = _random_digraph(12, 22, seed) + assert _partition(strongly_connected_components(_graph(nodes, edges), strategy="distributed")) == _nx_partition( + nodes, edges + ) diff --git a/tests/test_svd_plus_plus.py b/tests/test_svd_plus_plus.py new file mode 100644 index 0000000..bbe83f9 --- /dev/null +++ b/tests/test_svd_plus_plus.py @@ -0,0 +1,68 @@ +"""Tests for svd_plus_plus on a synthetic low-rank rating graph.""" + +from __future__ import annotations + +import daft +import numpy as np + +from daft_graph.algorithms.svd_plus_plus import BIAS, FACTOR, KIND, svd_plus_plus +from daft_graph.graph import DirectedGraph +from daft_graph.schema import DST, ID, SRC + +_ITEM_OFFSET = 1000 + + +def _synthetic_rating_graph() -> DirectedGraph: + rng = np.random.default_rng(0) + n_users, n_items, dim = 15, 12, 3 + p = rng.normal(0.0, 1.0, (n_users, dim)) + q = rng.normal(0.0, 1.0, (n_items, dim)) + mu = 3.0 + users, items, ratings = [], [], [] + for u in range(n_users): + for it in range(n_items): + users.append(u) + items.append(_ITEM_OFFSET + it) + ratings.append(float(mu + p[u].dot(q[it]))) + vertices = daft.from_pydict({ID: list(range(n_users)) + [_ITEM_OFFSET + i for i in range(n_items)]}) + edges = daft.from_pydict({SRC: users, DST: items, "rating": ratings}) + return DirectedGraph(vertices=vertices, edges=edges) + + +def test_recovers_low_rank_ratings() -> None: + g = _synthetic_rating_graph() + result = svd_plus_plus(g, rank=5, epochs=60, learning_rate=0.02, regularization=0.02, seed=0) + # the ratings are exactly low rank, so the fit should be tight + assert result.rmse < 0.5 + assert abs(result.global_mean - 3.0) < 1.0 + + +def test_more_epochs_reduce_error() -> None: + g = _synthetic_rating_graph() + few = svd_plus_plus(g, rank=5, epochs=2, learning_rate=0.02, seed=0) + many = svd_plus_plus(g, rank=5, epochs=60, learning_rate=0.02, seed=0) + assert many.rmse < few.rmse + + +def test_factor_schema() -> None: + g = _synthetic_rating_graph() + result = svd_plus_plus(g, rank=4, epochs=5, seed=0) + assert set(result.factors.column_names) == {ID, KIND, BIAS, FACTOR} + kinds = set(result.factors.select(KIND).distinct().collect().to_pydict()[KIND]) + assert kinds == {"user", "item"} + + +def test_deterministic() -> None: + g = _synthetic_rating_graph() + a = svd_plus_plus(g, rank=4, epochs=10, seed=7) + b = svd_plus_plus(g, rank=4, epochs=10, seed=7) + assert a.rmse == b.rmse + + +def test_empty_rating_graph() -> None: + vertices = daft.from_pydict({ID: [0, 1]}) + edges = daft.from_pydict({SRC: [], DST: [], "rating": []}) + result = svd_plus_plus(DirectedGraph(vertices=vertices, edges=edges)) + assert result.rmse == 0.0 + assert result.global_mean == 0.0 + assert result.factors.count_rows() == 0 diff --git a/tests/test_traversal_undirected.py b/tests/test_traversal_undirected.py new file mode 100644 index 0000000..13c8da1 --- /dev/null +++ b/tests/test_traversal_undirected.py @@ -0,0 +1,75 @@ +"""Undirected traversal and filter-before-orient coverage. + +The refactor routes direction through the graph flavor, and `prepare_edges` +applies `edge_filter` before symmetrizing so an undirected graph only walks the +edges that survived the filter. These tests pin that behavior, which the +per-algorithm suites did not exercise for the undirected + filtered combination. +""" + +from __future__ import annotations + +import daft +from daft import col + +from daft_graph import ( + UndirectedGraph, + all_paths, + all_shortest_paths, + bfs, + hyper_anf, + shortest_paths, +) +from daft_graph.schema import DST, ID, SRC + + +def _typed_graph() -> UndirectedGraph: + # 0-1 (a), 1-2 (b), 0-2 (a). Stored directed as given; walked undirected. + edges = daft.from_pydict({SRC: [0, 1, 0], DST: [1, 2, 2], "type": ["a", "b", "a"]}) + return UndirectedGraph(edges) + + +def test_bfs_undirected_walks_both_ways() -> None: + g = _typed_graph() + # 2 -> 0 is only reachable undirected (stored edges point 0->2, 0->1, 1->2) + assert bfs(g, 2, 0) == [2, 0] + + +def test_bfs_undirected_edge_filter_removes_edge_both_directions() -> None: + g = _typed_graph() + # keep only type "a" edges (0-1 and 0-2). 1->2 (type b) is gone both ways, + # so 1 to 2 must detour through 0. + path = bfs(g, 1, 2, edge_filter=col("type") == "a") + assert path == [1, 0, 2] + + +def test_all_shortest_paths_undirected_with_filter() -> None: + g = _typed_graph() + paths = all_shortest_paths(g, 1, 2, edge_filter=col("type") == "a") + assert paths == [[1, 0, 2]] + + +def test_all_shortest_paths_undirected_no_filter_is_direct() -> None: + g = _typed_graph() + # without the filter the 1-2 edge exists, so the shortest path is direct + assert all_shortest_paths(g, 1, 2) == [[1, 2]] + + +def test_all_paths_undirected() -> None: + g = _typed_graph() + paths = all_paths(g, 1, 2, max_path_length=5) + # both the direct 1-2 and the detour 1-0-2 are simple undirected paths + assert sorted(paths) == [[1, 0, 2], [1, 2]] + + +def test_shortest_paths_undirected_reaches_all() -> None: + g = _typed_graph() + d = shortest_paths(g, landmarks=[0]).collect().to_pydict() + dist = dict(zip(d[ID], d["distance"])) + # undirected: every vertex is within one hop of 0 + assert dist == {0: 0, 1: 1, 2: 1} + + +def test_hyper_anf_undirected_runs() -> None: + g = _typed_graph() + out = hyper_anf(g, max_hops=2).collect().to_pydict() + assert set(out["id"]) == {0, 1, 2} diff --git a/tests/test_triangle_count.py b/tests/test_triangle_count.py new file mode 100644 index 0000000..ef22a74 --- /dev/null +++ b/tests/test_triangle_count.py @@ -0,0 +1,62 @@ +"""Tests for triangle_count against networkx.""" + +from __future__ import annotations + +import random + +import daft +import networkx as nx +import pytest + +from daft_graph.algorithms.triangle_count import TRIANGLE_COUNT, triangle_count +from daft_graph.graph import UndirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _graph(node_ids: list[int], edges: list[tuple[int, int]]) -> UndirectedGraph: + vertices = daft.from_pydict({ID: node_ids}) + edges_df = daft.from_pydict({SRC: [u for u, _ in edges], DST: [v for _, v in edges]}) + return UndirectedGraph(vertices=vertices, edges=edges_df) + + +def _count_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return dict(zip(d[ID], d[TRIANGLE_COUNT])) + + +def _undirected_edges(n_nodes: int, n_edges: int, seed: int) -> list[tuple[int, int]]: + rng = random.Random(seed) + edges: set[tuple[int, int]] = set() + while len(edges) < n_edges: + u = rng.randint(0, n_nodes - 1) + v = rng.randint(0, n_nodes - 1) + if u != v: + edges.add((min(u, v), max(u, v))) + return sorted(edges) + + +def test_single_triangle() -> None: + g = _graph([1, 2, 3], [(1, 2), (2, 3), (1, 3)]) + assert _count_map(triangle_count(g)) == {1: 1, 2: 1, 3: 1} + + +def test_path_has_no_triangles() -> None: + g = _graph([1, 2, 3, 4], [(1, 2), (2, 3), (3, 4)]) + assert _count_map(triangle_count(g)) == {1: 0, 2: 0, 3: 0, 4: 0} + + +def test_no_edges() -> None: + g = _graph([1, 2, 3], []) + assert _count_map(triangle_count(g)) == {1: 0, 2: 0, 3: 0} + + +@pytest.mark.parametrize("seed", [1, 2, 3, 4]) +def test_matches_networkx(seed: int) -> None: + node_ids = list(range(12)) + edges = _undirected_edges(12, 24, seed) + ours = _count_map(triangle_count(_graph(node_ids, edges))) + graph = nx.Graph() + graph.add_nodes_from(node_ids) + graph.add_edges_from(edges) + theirs = nx.triangles(graph) + assert ours == theirs diff --git a/tests/test_undirected_graph.py b/tests/test_undirected_graph.py new file mode 100644 index 0000000..c739692 --- /dev/null +++ b/tests/test_undirected_graph.py @@ -0,0 +1,89 @@ +"""Tests for daft_graph.graph.UndirectedGraph.""" + +from __future__ import annotations + +import daft + +from daft_graph.graph import DirectedGraph, UndirectedGraph +from daft_graph.schema import DST, ID, SRC + + +def _edges() -> daft.DataFrame: + # undirected: 1-2, 1-3, 2-3, 4-1 + return daft.from_pydict({SRC: [1, 1, 2, 4], DST: [2, 3, 3, 1]}) + + +def _deg_map(df: daft.DataFrame) -> dict: + d = df.collect().to_pydict() + return dict(zip(d[ID], d["degree"])) + + +def test_derives_vertices_when_none_given() -> None: + g = UndirectedGraph(_edges()) + assert g.num_vertices() == 4 + assert set(g.vertices.collect().to_pydict()[ID]) == {1, 2, 3, 4} + + +def test_edges_are_stored_one_row_per_edge() -> None: + assert UndirectedGraph(_edges()).num_edges() == 4 + + +def test_single_edge_gives_each_endpoint_degree_one() -> None: + g = UndirectedGraph(daft.from_pydict({SRC: [1], DST: [2]})) + assert _deg_map(g.degrees()) == {1: 1, 2: 1} + + +def test_degrees_count_each_edge_once() -> None: + assert _deg_map(UndirectedGraph(_edges()).degrees()) == {1: 3, 2: 2, 3: 2, 4: 1} + + +def test_self_loop_contributes_two() -> None: + g = UndirectedGraph(daft.from_pydict({SRC: [1], DST: [1]})) + assert _deg_map(g.degrees()) == {1: 2} + + +def test_traversal_edges_are_symmetrized() -> None: + g = UndirectedGraph(daft.from_pydict({SRC: [1], DST: [2]})) + t = g.traversal_edges().collect().to_pydict() + assert sorted(zip(t[SRC], t[DST])) == [(1, 2), (2, 1)] + + +def test_traversal_reaches_both_endpoints() -> None: + g = UndirectedGraph(daft.from_pydict({SRC: [1, 2], DST: [2, 3]})) + t = g.traversal_edges().collect().to_pydict() + pairs = set(zip(t[SRC], t[DST])) + # every stored edge is walkable in both directions + assert {(1, 2), (2, 1), (2, 3), (3, 2)} == pairs + + +def test_stored_edges_are_not_duplicated_by_traversal() -> None: + g = UndirectedGraph(_edges()) + assert g.edges.count_rows() == 4 + assert g.traversal_edges().count_rows() == 8 + + +def test_as_directed_returns_directed_graph() -> None: + g = UndirectedGraph(_edges()) + d = g.as_directed() + assert isinstance(d, DirectedGraph) + assert d.num_edges() == g.num_edges() + + +def test_as_directed_can_swap_orientation() -> None: + g = UndirectedGraph(daft.from_pydict({SRC: [1], DST: [2]})) + d = g.as_directed(src_col=DST, dst_col=SRC) + e = d.edges.collect().to_pydict() + assert list(zip(e[SRC], e[DST])) == [(2, 1)] + + +def test_round_trip_through_directed_preserves_edges() -> None: + g = UndirectedGraph(_edges()) + back = g.as_directed().as_undirected() + assert isinstance(back, UndirectedGraph) + assert back.num_edges() == g.num_edges() + + +def test_custom_columns_normalize_to_canonical() -> None: + df = daft.from_pydict({"a": [1, 2], "b": [3, 4]}) + g = UndirectedGraph(df, src_col="a", dst_col="b") + assert set(g.edges.column_names) == {SRC, DST} diff --git a/tests/test_wiki_vote.py b/tests/test_wiki_vote.py new file mode 100644 index 0000000..6a98eb8 --- /dev/null +++ b/tests/test_wiki_vote.py @@ -0,0 +1,123 @@ +"""Regression tests on the SNAP Wikipedia vote network (a larger directed graph). + +Wiki-Vote (https://snap.stanford.edu/data/wiki-Vote.html): 7115 nodes, 103689 +directed edges, ~5800 strongly connected components. The dataset is vendored +under tests/data/ so the gate runs offline and is fully reproducible. Every +algorithm is validated against networkx as ground truth. + +triangle_count and distributed strongly_connected_components are intentionally +exercised at smaller scale (test_datasets.py) rather than here: at 100k edges the +triangle self join and the many component coloring loop are the heavy paths, and +``auto`` correctly selects the local solver for SCC at this size. +""" + +from __future__ import annotations + +import gzip +from collections import defaultdict +from pathlib import Path + +import daft +import networkx as nx + +from daft_graph import ( + DirectedGraph, + bfs, + connected_components, + k_core, + pagerank, + shortest_paths, + strongly_connected_components, +) +from daft_graph.algorithms.k_core import CORE +from daft_graph.algorithms.shortest_paths import DISTANCE, LANDMARK +from daft_graph.schema import COMPONENT, DST, ID, RANK, SRC + +_DATA = Path(__file__).parent / "data" / "wiki-Vote.txt.gz" + + +def _load() -> nx.DiGraph: + g: nx.DiGraph = nx.DiGraph() + with gzip.open(_DATA, "rt") as handle: + for line in handle: + if not line.startswith("#"): + a, b = line.split() + g.add_edge(int(a), int(b)) + return nx.convert_node_labels_to_integers(g) + + +NXG = _load() +NODES = sorted(NXG.nodes()) +EDGES = [(u, v) for u, v in NXG.edges()] + + +def _daft_graph() -> DirectedGraph: + return DirectedGraph( + vertices=daft.from_pydict({ID: NODES}), + edges=daft.from_pydict({SRC: [u for u, _ in EDGES], DST: [v for _, v in EDGES]}), + ) + + +def _partition(df: daft.DataFrame) -> set: + d = df.collect().to_pydict() + groups: dict[int, set] = defaultdict(set) + for node, comp in zip(d[ID], d[COMPONENT]): + groups[comp].add(node) + return {frozenset(members) for members in groups.values()} + + +def test_dataset_shape() -> None: + assert len(NODES) == 7115 + assert len(EDGES) == 103689 + + +def test_weakly_connected_components_local() -> None: + ours = _partition(connected_components(_daft_graph(), strategy="local")) + theirs = {frozenset(c) for c in nx.weakly_connected_components(NXG)} + assert ours == theirs + + +def test_weakly_connected_components_distributed() -> None: + ours = _partition(connected_components(_daft_graph(), strategy="distributed")) + theirs = {frozenset(c) for c in nx.weakly_connected_components(NXG)} + assert ours == theirs + + +def test_strongly_connected_components() -> None: + ours = _partition(strongly_connected_components(_daft_graph())) + theirs = {frozenset(c) for c in nx.strongly_connected_components(NXG)} + assert ours == theirs + + +def test_pagerank_matches_networkx() -> None: + d = pagerank(_daft_graph(), tol=1e-10, max_iters=200).collect().to_pydict() + ours = dict(zip(d[ID], d[RANK])) + theirs = nx.pagerank(NXG, alpha=0.85, tol=1e-12, max_iter=1000) + for node in NODES: + assert abs(ours[node] - theirs[node]) < 1e-6 + + +def test_k_core_matches_networkx() -> None: + d = k_core(_daft_graph(), max_iters=200).collect().to_pydict() + ours = dict(zip(d[ID], d[CORE])) + undirected = nx.Graph() + undirected.add_nodes_from(NODES) + undirected.add_edges_from((u, v) for u, v in EDGES if u != v) + assert ours == nx.core_number(undirected) + + +def test_shortest_paths_matches_networkx() -> None: + landmark = next(n for n in NODES if NXG.in_degree(n) > 0) + d = shortest_paths(_daft_graph(), [landmark], max_iters=50).collect().to_pydict() + ours = {i: dist for i, lm, dist in zip(d[ID], d[LANDMARK], d[DISTANCE])} + theirs = dict(nx.single_target_shortest_path_length(NXG, landmark)) + assert ours == theirs + + +def test_bfs_matches_networkx() -> None: + source = next(n for n in NODES if NXG.out_degree(n) > 0) + lengths = dict(nx.single_source_shortest_path_length(NXG, source)) + target = max(lengths, key=lengths.get) + path = bfs(_daft_graph(), source, target, max_path_length=50) + assert path is not None + assert len(path) - 1 == lengths[target] diff --git a/uv.lock b/uv.lock index 74c4fb5..aef3eeb 100644 --- a/uv.lock +++ b/uv.lock @@ -1,29 +1,56 @@ version = 1 revision = 3 requires-python = ">=3.10, <3.14" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", + "python_full_version < '3.11'", +] [[package]] name = "ast-serialize" -version = "0.5.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/6a/a68c7f823e67714393060a0f232b0d75e0b2d2f1a7aef0633f7007411804/ast_serialize-0.7.0.tar.gz", hash = "sha256:934c0920454381b0beb46a5dc0af114d48699a44537b97282c2346a23990713d", size = 845507, upload-time = "2026-08-06T14:07:38.216Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, - { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, - { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, - { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, - { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, - { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, - { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, - { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, - { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, - { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, - { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, + { url = "https://files.pythonhosted.org/packages/27/d6/4a95e85a3c52f10dba58e15f9c93ef691cf11a076cedf79817afade8d1fb/ast_serialize-0.7.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:c8c5af5650f2527e758f1fbaaad3deb37c91f1a2d01c7430c63100f51fbb2807", size = 1177734, upload-time = "2026-08-06T14:06:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9a/c2dca32e435b9c1006f030a65bf487f3bd2e74d38c0e29e1c4deb17ff4cc/ast_serialize-0.7.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:d8ba35d33b1fbd962a7afd835928b1741e90654c81344749b691b92e3aba98e5", size = 1169359, upload-time = "2026-08-06T14:06:39.206Z" }, + { url = "https://files.pythonhosted.org/packages/55/1b/30c73b248905b3de20d1ad11263a5eb5c7d8eec195e5b16e1b30f299225f/ast_serialize-0.7.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f858a3f274f20ae65f1dccc9408de10c46a27ebf76eac5708793815f2f36182a", size = 1225641, upload-time = "2026-08-06T14:06:40.915Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ed/9f8d0c17598769fd07a3f57eda6a9f67eff729044067bff0e24ec32643f1/ast_serialize-0.7.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:db05954e883cc91310493cde193c1c1acbd00ee8ea583f1eff9be9740af50ceb", size = 1227063, upload-time = "2026-08-06T14:06:42.371Z" }, + { url = "https://files.pythonhosted.org/packages/73/28/42309bc14ca149f57320f6808c175689c656f1779ddaa64313dbf079fa38/ast_serialize-0.7.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:507f5633ffec9d7cedfd8b90b986c329ceafb9deeaf4cd2bba9782affd83111e", size = 1425301, upload-time = "2026-08-06T14:06:43.891Z" }, + { url = "https://files.pythonhosted.org/packages/4f/75/493961f5deb0b02e688a83b8b9761aed76c8d655519f0cae8388d3ce4082/ast_serialize-0.7.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b1356c62fd91e73786e5f9b9de71f094c8d850d194f652de8c45ee12690534ee", size = 1245906, upload-time = "2026-08-06T14:06:45.412Z" }, + { url = "https://files.pythonhosted.org/packages/56/9b/98350c0b7530218cf0db629ebc39c23f3c8b45f82d29be415cc5a455bab1/ast_serialize-0.7.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eadaedebf5e3bf0d6dfa309c53283737c668a9e04ea39648e3607ee74cfef904", size = 1250047, upload-time = "2026-08-06T14:06:47.027Z" }, + { url = "https://files.pythonhosted.org/packages/d8/3f/c8f2864f3d088bf0e41af2bb52462348a1d6c24c4fd0b4988b17c6594d78/ast_serialize-0.7.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:1af0a4509871fd55f15335a5e8f643114c5c5cb485b90fcfa0d8b33861cdcc63", size = 1243423, upload-time = "2026-08-06T14:06:48.571Z" }, + { url = "https://files.pythonhosted.org/packages/71/02/414c98fed866c0b584ef2d294a65561d5eaf5c58e8f23d099ea00f42e529/ast_serialize-0.7.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8530f722344889785da4006f663735e470bfb22fd30eea20e89840ba3cb42fa1", size = 1294528, upload-time = "2026-08-06T14:06:50.157Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4f/b6b207f6fcf03b75c01605a16966e0133c3eb40e07c8e3dc66354ee7d550/ast_serialize-0.7.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:80a1f859f8f5707848fd92c36daf01d522dbc3f2c066a2d9287e3bec0d79b4ac", size = 1401849, upload-time = "2026-08-06T14:06:52.016Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/676e2b65a919f39b6a04dae6ae863334561e0fbf6d30a403475f94203f35/ast_serialize-0.7.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:8d5ceed027a42507a65f65746c9b1ada27dd898487306b2ae56b67af54fe5f28", size = 1502708, upload-time = "2026-08-06T14:06:53.707Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/d80bef4b92cdff36a58fc21eea99738af7087f8edc26f87c8e9102446d7e/ast_serialize-0.7.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:ee63de1439b46de948996d81170d7c0b467d8c7068be44968c176539d05f7c36", size = 1496545, upload-time = "2026-08-06T14:06:55.141Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1e/1c7854a500b67d709f9185b3a5c14af6093e8c7b20d1b2d5c8a1edc1ea1a/ast_serialize-0.7.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:90ecb3b1cebca24299eb84069ad5c0fb0e85d3f062167525dcc89a894641d619", size = 1558827, upload-time = "2026-08-06T14:06:56.884Z" }, + { url = "https://files.pythonhosted.org/packages/c8/34/e806b3768ec4249e36b000021cb31b441438e818141b654bfc1a7efc39e7/ast_serialize-0.7.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:47cb2d836aa8905f3b14d47cb298f630968544829b93c7eb8a08337f9860e049", size = 1417164, upload-time = "2026-08-06T14:06:58.272Z" }, + { url = "https://files.pythonhosted.org/packages/d4/84/9dc9d0fd28324ee89212ec074973bb4db5b2ff826afb0a045f2aee813a36/ast_serialize-0.7.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:7c54e159fbb62af577b4d20ac2ffb1f56ecfbe8c864a5222a4853adfdb245e25", size = 1446211, upload-time = "2026-08-06T14:06:59.695Z" }, + { url = "https://files.pythonhosted.org/packages/83/1a/02fcdac28c67ae7186a906b8b72ff8c94b7108b4d903a1e0a98260c3a295/ast_serialize-0.7.0-cp315-abi3.abi3t-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1dc8030604a7b1abe0ba3f31572e61312d5bfbafa0ab8cc255f8c285002a9d88", size = 862416, upload-time = "2026-08-06T14:07:01.247Z" }, + { url = "https://files.pythonhosted.org/packages/ea/72/840b2c14b693f40a69ba43c650a88e0dadea875faa1430c8b97dc0613d1a/ast_serialize-0.7.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:cef4d7fa14f6f259acf0c6cc4e9cd7a4ad773bdd13ba28c7d4127cb78e48d2c3", size = 1063825, upload-time = "2026-08-06T14:07:02.694Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cd/6de6248744d30875b875dd8e43a76427b0c2bf42b735ee26149002b9ee04/ast_serialize-0.7.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:537f7a41a7bc1108e60cf8d6f5575beafe5b8a17d1a6ef0e758b4d4e4ad90d18", size = 1105533, upload-time = "2026-08-06T14:07:04.068Z" }, + { url = "https://files.pythonhosted.org/packages/00/30/d17d123c6d6558fad5b7aa026d7df45a639370156cec03d8cf33be9401e7/ast_serialize-0.7.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:913ddfdbbfaa6294dc841579211baa00d789a60d9ae1ad5e04a1e98de11926e4", size = 1076322, upload-time = "2026-08-06T14:07:05.605Z" }, + { url = "https://files.pythonhosted.org/packages/73/73/329d080bb3f1ef96d852fa017617827d6edb38cad31abd0c5f5b7cb5df16/ast_serialize-0.7.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:832e23968712b5b5e052e2095cfe050d32030f2af2d58c6f2c733e8148c82c47", size = 1184035, upload-time = "2026-08-06T14:07:07.273Z" }, + { url = "https://files.pythonhosted.org/packages/9c/48/2bb83025fa197d38f3380357b70adc9f14bc12d87df062dfcbcfeb9e76af/ast_serialize-0.7.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7196e1351389df3d1f8e2acffab03db167a14b5bd1f108d8530171b0866f6f56", size = 1177582, upload-time = "2026-08-06T14:07:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7e/f9b13d64699eddc59bae3d027971e7b913750231249319898a4552228190/ast_serialize-0.7.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a2343625ba33710f7e36a5be36e866ac7dbdd4369f11b5dfc6cd97d4aa85ecf", size = 1234638, upload-time = "2026-08-06T14:07:10.867Z" }, + { url = "https://files.pythonhosted.org/packages/58/e9/7fdbf053f3e35cb2a48d62f57c6a166e475ac9e7421c5004e0b602a7492b/ast_serialize-0.7.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4251412007121afee236b654b80aca3a8072a073abe2d984c582fb507bf1aa4c", size = 1235796, upload-time = "2026-08-06T14:07:12.548Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/a6b29764a60036fe35dec206b08c4c69a184aeb76d5b0ba00d5ab5acbf6e/ast_serialize-0.7.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76ca687ac87e97f8621d0be6f470aaa6307e4027ff1c449d36d1fb4f9fee1192", size = 1433051, upload-time = "2026-08-06T14:07:14.13Z" }, + { url = "https://files.pythonhosted.org/packages/73/c9/0eee1122c539407c2a7fbdd2461f33d3863fa20d38614f69f0cd7a6eca28/ast_serialize-0.7.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:03e4fb60db9185c730db3521d73c6824dfd88d26ab2b2600a9ff1a466a79f0a6", size = 1255585, upload-time = "2026-08-06T14:07:15.682Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d2/33bcfd2507f247b15efd827818ff462b376dd3c637e1576e6f0211a6c0d3/ast_serialize-0.7.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f058bd0b1e44276375730cfb11cd6beba00698dee2bb3d20b831ae319ca3d2b", size = 1258578, upload-time = "2026-08-06T14:07:17.135Z" }, + { url = "https://files.pythonhosted.org/packages/65/1f/61bfa3e75b50f5dd49cac0b8beb1e24d795d0648821bb531e97dff5c1a01/ast_serialize-0.7.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:54c8f5d009b2829bd98d7ebe0c29fcfc8f47f38e85d583d460837c54191b486c", size = 1253582, upload-time = "2026-08-06T14:07:18.753Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b9/2db828b7e1830411703e72626b692db540c26923c475f319d637657bb993/ast_serialize-0.7.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7f57b0a8ed664e163294e57b41c52c38527e5a0c73c01ead51f7073e1e652dd2", size = 1301023, upload-time = "2026-08-06T14:07:20.357Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/f93e40143f59ee37aebe9e5f8667f3c878232322febc03bb3b977afbf3d3/ast_serialize-0.7.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a0f4ce65748e2ef28046324254e9b1ad088fae56a93d69d07fbddf97d875b85", size = 1410178, upload-time = "2026-08-06T14:07:21.932Z" }, + { url = "https://files.pythonhosted.org/packages/1a/46/a316dddccecedafea3002a9ed1cd6666ab5e64c3094951625b5a1ef95a1c/ast_serialize-0.7.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:95219c374d0ac76639b26b66cca26e8cb090bf8c515d6bc376af484514d10151", size = 1509449, upload-time = "2026-08-06T14:07:23.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/34/c58f49cc9da933af6ee0f802eb780c48648ae9d0ee9188cb802c10dce29f/ast_serialize-0.7.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1e71d8879b8c3480eb461128bbf0d5ff87f5ce368fcdb10c850a95010aa5ece4", size = 1505368, upload-time = "2026-08-06T14:07:25.101Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ce/4c5a9ff4e2851ec38ecb8aeef8cb7e0a02308616279c04429342e12985f4/ast_serialize-0.7.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:85fa81ed590407c6ef6f85cac8e451051bf2623ecd875c4e124af8b8251de3ac", size = 1563486, upload-time = "2026-08-06T14:07:26.937Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/f239d17f902a2ce39da8e6dc034f48f45da93dfda658fa88189a02763510/ast_serialize-0.7.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:fd018e40c9462235a1d2d101b420cff6c577e5a51b2954ffc05b907a0807defc", size = 1427995, upload-time = "2026-08-06T14:07:28.773Z" }, + { url = "https://files.pythonhosted.org/packages/62/6c/024f58d8b52ce29717c54a578f0bbd844439fcb8eb442596e756fd6eaffe/ast_serialize-0.7.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90a614b8c844620473b7445d31a2a6bf9500fdc4a8d1e2b6a70d40f38beea0e5", size = 1454215, upload-time = "2026-08-06T14:07:30.526Z" }, + { url = "https://files.pythonhosted.org/packages/12/48/f046778df64acef37a36a7338a9bdaa795f46b19d35a4d28bddcd8dfaec7/ast_serialize-0.7.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e6cfb423d4a14d774b3b82f6509adbc5da065246b6ec679221ad133820e0f7f4", size = 868142, upload-time = "2026-08-06T14:07:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/aa/fc/c862cc8d4d8d749f360035baa245fb3098b8b934c57122c0222ac5078762/ast_serialize-0.7.0-cp39-abi3-win32.whl", hash = "sha256:e818854e8521846f5a5271b1488c490231b8b6e02d0158ec42e57bc06dd764f0", size = 1068874, upload-time = "2026-08-06T14:07:33.895Z" }, + { url = "https://files.pythonhosted.org/packages/92/33/e846301850c18fa31598e342bb80b32f380c1bce285df571f5c3711960ff/ast_serialize-0.7.0-cp39-abi3-win_amd64.whl", hash = "sha256:942921b81440d3ea57f90370d5d29bf28dc81c0778e26b736013e83c7b258023", size = 1111845, upload-time = "2026-08-06T14:07:35.308Z" }, + { url = "https://files.pythonhosted.org/packages/6d/8c/4ee99be6306e72fac6051461794e5cd7895bbbdafe5b921a9a8c4b6fde1c/ast_serialize-0.7.0-cp39-abi3-win_arm64.whl", hash = "sha256:43b73cf3924aa49f241be6e5f6a19943c8e2e07a2786fc719cacaa54ac6fd2d5", size = 1083656, upload-time = "2026-08-06T14:07:36.828Z" }, ] [[package]] @@ -46,7 +73,7 @@ wheels = [ [[package]] name = "daft" -version = "0.7.16" +version = "0.7.23" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fsspec" }, @@ -55,39 +82,68 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/5e/074454010c539d34d20a15d42a4500c309967ea0a1ad8045acfb6c9741ba/daft-0.7.16.tar.gz", hash = "sha256:74b907db43efd13d278fd8f46d39a2a00deed601165b4d2ca68735a5cf8aedc4", size = 3326253, upload-time = "2026-06-26T04:50:42.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/d1/b2717423fb1ff50f1582cca8f96a81377517884a8ed5b077834f2ea0890b/daft-0.7.16-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:7666f9849045fb631dd1cddf2fbcc75f6e758206364ea2f5ea837b0998a653e0", size = 52833545, upload-time = "2026-06-26T04:50:05.708Z" }, - { url = "https://files.pythonhosted.org/packages/35/c3/7e90480e14ab3e27f7758886d4ebe2a3049fa8e4234cbdc26865685759f4/daft-0.7.16-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:b023f77da48db378f0bafc2b85203299c5020888dad06dcf3365fb234280721f", size = 48860370, upload-time = "2026-06-26T04:50:10.512Z" }, - { url = "https://files.pythonhosted.org/packages/eb/37/5bcb987648671f25e3107941e52230f5204df8fa8633eee8f1a42b52b63f/daft-0.7.16-cp310-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:548d58d2627ae4238f4387f2b252992d21cbced3844c3e28c239e9f9c40f1113", size = 51133361, upload-time = "2026-06-26T04:50:14.958Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f2/7e659d5cdb24e93c26874ac83a9fca256cc76669ff3e831678295ef4077a/daft-0.7.16-cp310-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:aafbc5a1d621c53cfc2d72186a84ee88a452ca7a99127ac56af3a6c6453758c5", size = 53294616, upload-time = "2026-06-26T04:50:19.869Z" }, - { url = "https://files.pythonhosted.org/packages/20/19/c0cd00f02720799a4cc4386ba2293ab3701345ef6db10ac406d153255e92/daft-0.7.16-cp310-abi3-win_amd64.whl", hash = "sha256:ed9feb33d4c674299ad63b6cbb7a08f21474646ae971204bbd41d00d4191d7cc", size = 52312328, upload-time = "2026-06-26T04:50:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/ea/3d/19621ac6b75b0a05860f1300bbf51f0afab77d6fa89eee33d863522ce996/daft-0.7.23-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2e0401ab2b6aba0c44356e80898522d81570c5e923a721a720c4d16f559c1098", size = 55747933, upload-time = "2026-08-05T21:58:19.94Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a7/68d72113e10b32068675b22e75d2d76ce947fa8317a3e13af5ff3161db3b/daft-0.7.23-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2884b0e15f31107edf745e4cd58fef3fc083face532f52120f97dfd3669cc552", size = 51602439, upload-time = "2026-08-05T21:58:23.112Z" }, + { url = "https://files.pythonhosted.org/packages/5e/82/e004e73e781fa2e7da6362531bbbd305f0d04d10ee61a1f2cb6c5a6b5b35/daft-0.7.23-cp310-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:bb73eaa39c317284e1e8cbfb85dc71bbb9e6de400455e865e1bff92dd721e949", size = 53911416, upload-time = "2026-08-05T21:58:26.246Z" }, + { url = "https://files.pythonhosted.org/packages/98/7a/3b52af66b5cc1be79cf15403b9db2677f14e76fa637844bfa6d85ce5d78e/daft-0.7.23-cp310-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:4fd93b1d39308f7e06dd56cd92abbcf0bc0d13cedc51f189bf9a7ca1074945be", size = 56126245, upload-time = "2026-08-05T21:58:29.964Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/9fdbe7132e63d69a6906b04524b927b96581e0436d8b74e91d3f176e0710/daft-0.7.23-cp310-abi3-win_amd64.whl", hash = "sha256:d90e2c4832ec2df622109a43884055187ad1fd8de3cdd77fe7abcd47f4fbc36b", size = 55232502, upload-time = "2026-08-05T21:58:32.997Z" }, ] [[package]] -name = "daft-ext-template" +name = "daft-graph" source = { editable = "." } dependencies = [ { name = "daft" }, ] +[package.optional-dependencies] +local = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] + [package.dev-dependencies] dev = [ + { name = "igraph" }, { name = "mypy" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pre-commit" }, { name = "pytest" }, { name = "ruff" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] [package.metadata] -requires-dist = [{ name = "daft", specifier = ">=0.7.5" }] +requires-dist = [ + { name = "daft", specifier = ">=0.7.5" }, + { name = "numpy", marker = "extra == 'local'", specifier = ">=1.24" }, + { name = "scipy", marker = "extra == 'local'", specifier = ">=1.11" }, +] +provides-extras = ["local"] [package.metadata.requires-dev] dev = [ + { name = "igraph", specifier = ">=0.11" }, { name = "mypy", specifier = "==2.1.0" }, + { name = "networkx", specifier = ">=3.0" }, + { name = "numpy", specifier = ">=1.24" }, { name = "pre-commit", specifier = "==4.6.0" }, { name = "pytest", specifier = "==9.1.1" }, { name = "ruff", specifier = "==0.15.20" }, + { name = "scipy", specifier = ">=1.11" }, + { name = "typing-extensions", specifier = ">=4.0" }, ] [[package]] @@ -113,11 +169,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.29.4" +version = "3.32.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/57/3ba6e6cb097f85b855b00163d169f35365f44277df044dcf96d55b8f62a3/filelock-3.32.2.tar.gz", hash = "sha256:c33351e1f49cae33414acbc6d56784e6ecee82514ec90795da1161fc4836b5b8", size = 217172, upload-time = "2026-07-29T22:46:04.895Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/72f8cef9fdfeffe06213fe8508039396ee48daa0e3259457ed766173bfd6/filelock-3.32.2-py3-none-any.whl", hash = "sha256:87dd94cf281e586d135fa51132b8e3d9a598b316e90377a288663c9321036c82", size = 98830, upload-time = "2026-07-29T22:46:03.52Z" }, ] [[package]] @@ -138,6 +194,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] +[[package]] +name = "igraph" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "texttable" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/be/56bef1919005b4caf1f71522b300d359f7faeb7ae93a3b0baa9b4f146a87/igraph-1.0.0.tar.gz", hash = "sha256:2414d0be2e4d77ee5357807d100974b40f6082bb1bb71988ec46cfb6728651ee", size = 5077105, upload-time = "2025-10-23T12:22:50.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/03/3278ad0ceb3ea0e84d8ae3a85bdded4d0e57853aeb802a200feb43847b93/igraph-1.0.0-cp39-abi3-macosx_10_15_x86_64.whl", hash = "sha256:c2cbc415e02523e5a241eecee82319080bf928a70b1ba299f3b3e25bf029b6d4", size = 2257415, upload-time = "2025-10-23T12:22:27.246Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bc/6281ec7f9baaf71ee57c3b1748da2d3148d15d253e1a03006f204aa68ca5/igraph-1.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a27753cd80680a8f676c2d5a467aaa4a95e510b30748398ec4e4aeb982130e8", size = 2048555, upload-time = "2025-10-23T12:22:29.49Z" }, + { url = "https://files.pythonhosted.org/packages/2a/38/3cd6428a4ed4c09a56df05998438e7774fd1d799ee4fb8fc481674f5f7fc/igraph-1.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a55dc3a2a4e3fc3eba42479910c1511bfc3ecb33cdf5f0406891fd85f14b5aee", size = 5314141, upload-time = "2025-10-23T12:22:31.023Z" }, + { url = "https://files.pythonhosted.org/packages/7d/da/dd2867c25adbb41563720f14b5fc895c98bf88be682a3faff4f7b3118d2a/igraph-1.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2d04c2c76f686fb1f554ee35dfd3085f5e73b7965ba6b4cf06d53e66b1955522", size = 5683134, upload-time = "2025-10-23T12:22:32.423Z" }, + { url = "https://files.pythonhosted.org/packages/e5/40/243c118d34ab80382d7009c4dcb99b887384c3d2ce84d29eeac19e2a007a/igraph-1.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2b52dc1757fff0fed29a9f7a276d971a11db4211569ed78b9eab36288dfcc9d", size = 6211583, upload-time = "2025-10-23T12:22:34.238Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b7/88f433819c54b496cb0315fce28e658970cb20ff5dbd52a5a605ce2888de/igraph-1.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:05c79a2a8fca695b2f217a6fa7f2549f896f757d4db41be32a055400cb19cc30", size = 6594509, upload-time = "2025-10-23T12:22:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5d/8f7f6f619d374e959aa3664ebc4b24c10abc90c2e8efbed97f2623fadaf5/igraph-1.0.0-cp39-abi3-win32.whl", hash = "sha256:c2bce3cd472fec3dd9c4d8a3ea5b6b9be65fb30edf760beb4850760dd4f2d479", size = 2725406, upload-time = "2025-10-23T12:22:37.588Z" }, + { url = "https://files.pythonhosted.org/packages/af/77/a85b3745cf40a0572bae2de8cd9c2a2a8af78e5cf3e880fc0a249114e609/igraph-1.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:faeff8ede0cf15eb4ded44b0fcea6e1886740146e60504c24ad2da14e0939563", size = 3221663, upload-time = "2025-10-23T12:22:39.404Z" }, + { url = "https://files.pythonhosted.org/packages/ef/7e/5df541c37bdf6493035e89c22bd53f30d99b291bcda6c78e9a8afeecec2b/igraph-1.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:b607cafc24b10a615e713ee96e58208ef27e0764af80140c7cc45d4724a3f2df", size = 2785701, upload-time = "2025-10-23T12:22:41.03Z" }, + { url = "https://files.pythonhosted.org/packages/b9/73/bf1d4dbbc9123435b3ca14bb608b243a50a4f158ecea564bf196715248d9/igraph-1.0.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3189c1a8e8a8f58009f3f729040eb3701254d074ed37245691d529869ec940c5", size = 2246636, upload-time = "2025-10-23T12:22:42.314Z" }, + { url = "https://files.pythonhosted.org/packages/59/ac/28482f2af45cc0a0ca88a69d17a6ea694f58bdbd22cc876e7273a0379282/igraph-1.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ebe9502689b946301584b3cfacdbc70c58c4d664d804e39b6daa31be5c20bf46", size = 2036101, upload-time = "2025-10-23T12:22:43.957Z" }, + { url = "https://files.pythonhosted.org/packages/56/80/806a093df1d1ddc3b30d0418b1ee56388ae7018f8ae288677ee2b3a1abaf/igraph-1.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:f117683108c54330d6dc67a708e3724c13c9989885122a29781296872989a222", size = 3053403, upload-time = "2025-10-23T12:22:45.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/bf/cf7aeff230a4368c0b8bc6b02f3ea27db41db33714b51e1e8a7c1458f31b/igraph-1.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:077dbff0edb8b4ce0f9fefdf325200346d9d5db02de31872b41743de08e67a16", size = 3262472, upload-time = "2025-10-23T12:22:47.248Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ca/dbc06072d5eea402a6dc81f387afb1b7e0c415f1d8a75232943fc4d1bfdb/igraph-1.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fe7c693b2a84a4e03ca31e65aa05a2ecd8728137fa9909ccbf6453b4200b856d", size = 3218861, upload-time = "2025-10-23T12:22:48.46Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -149,61 +230,62 @@ wheels = [ [[package]] name = "librt" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, - { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, - { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, - { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, - { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, - { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, - { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, - { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, - { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, - { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, - { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, - { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, - { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, - { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, - { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, - { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, - { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, - { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, - { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, - { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, - { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, - { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, - { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/ee/999b97f4b8eb28c4416f9f8a6708077daa1639145cde1c25ac76517df966/librt-0.14.0.tar.gz", hash = "sha256:474eedc5e910d88c1a12193a238f69b2522561d6f10bda9fbe9e70961d2e64e3", size = 214292, upload-time = "2026-08-06T14:52:21.049Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/fe/c84ae5dec0b6fd9a6f3ea5c9aca7a9a96f1e2b7ed9a713eee41b692e2ec7/librt-0.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dd8270544defd2a25e57f4bf2ad94ff62a74addf7e82a479eea4ce9c5687516", size = 148643, upload-time = "2026-08-06T14:49:19.233Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6d/5248bfe9260b699495f414f7bdf7e52fc7dc7c9a6124bea563182f9d3a32/librt-0.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36044fb53cc1aa274a69406b67c7c8d90511fdf01389a6cf6d04f7b9bc57f067", size = 153536, upload-time = "2026-08-06T14:49:20.804Z" }, + { url = "https://files.pythonhosted.org/packages/16/b5/be43f0e5cb1ac9601ed7c8416b20822d28784653115369233edc1f268f7e/librt-0.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a2571b37e8eaa23c2aa886698ab5bff51f3d8770a9e83dad44c948ee102aa4f", size = 494314, upload-time = "2026-08-06T14:49:22.259Z" }, + { url = "https://files.pythonhosted.org/packages/d8/9d/69384d4b45273e84089b7fd1c5d96bb66d5ecb0ac41e0070b04e22034821/librt-0.14.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:3b63b5469755d61c634e00223b95496e3098efcf8c7cb68c8cefd4e5294cb289", size = 485394, upload-time = "2026-08-06T14:49:23.855Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b1/2dfc0cdff4e07c473e69997e8db397851470f7a7ab32f30beff4d25d0238/librt-0.14.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1b29b29f92f8ea8b5e8aa427a553786a368726c1f266a5f3116a0cfdb300acd", size = 515383, upload-time = "2026-08-06T14:49:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/9c/9c/cac6921dc1f5ec90e8eeb77a9b49b661d61e0a1c155b96847341fdae88ab/librt-0.14.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2767207d65ca0cf795d60fa67d3fd027ad6612a65c7c9a8286d9cd5a9bba0d41", size = 509450, upload-time = "2026-08-06T14:49:26.772Z" }, + { url = "https://files.pythonhosted.org/packages/e4/9b/063e66ea495b894107a7c254ba878e8217ec3312745a795a561d9c9ae693/librt-0.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5ccbd6c9b6a25e5fc2c741691bc77070635ef337b437b8d41bbf1ba917c848a2", size = 532490, upload-time = "2026-08-06T14:49:28.167Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/327b48fd29598f6bc978b37051a013079d1bf222392975a73be542d62d5b/librt-0.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:42fc770d647dbc26f3424715fed2a298e5adcbdce2390f463f0494c0114c6fca", size = 537012, upload-time = "2026-08-06T14:49:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/65/f3/6da0649987c00de52e0389d84bfbf2f87fad367f4ff9ea77b47915fb12e3/librt-0.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7c994b599682ec83e711aaadc0e00b12adc38eaf2d7fee814ef81572332d6676", size = 517105, upload-time = "2026-08-06T14:49:31.447Z" }, + { url = "https://files.pythonhosted.org/packages/89/77/27fa7c9752d0fac645226c2a6c9453f0f755ab5bb4127c6f6277b148c3ea/librt-0.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:89179ae255fd9404ac2a423aa58458498518240dfe96dd5e779b4876a83c52bf", size = 558629, upload-time = "2026-08-06T14:49:32.912Z" }, + { url = "https://files.pythonhosted.org/packages/fd/64/9a22a05f8e5292c73d049e923005db8242c29d8c419af34118769775435a/librt-0.14.0-cp310-cp310-win32.whl", hash = "sha256:100cbca2c49533bf5b2cd449d6d499404ae74ecdcb14b337e5547a1404beb9a7", size = 104396, upload-time = "2026-08-06T14:49:34.275Z" }, + { url = "https://files.pythonhosted.org/packages/21/77/a2536afe1c13f16e272d556921ad073d7dfaf3f1a565e00df95bf634bb60/librt-0.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:e9d9c86af6ae6647abd2f7161e7b2d26d20d0bedf9208b48dddd5f67f13cfbc9", size = 125007, upload-time = "2026-08-06T14:49:35.626Z" }, + { url = "https://files.pythonhosted.org/packages/51/9f/69010fbc4bbf0a19860398340ef46bd62a2c2ac74105a3f409e96500bd30/librt-0.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0b77992d446fcf9fcefee57103786f45bb88009b45156863f4b47cd21c6e10c9", size = 148045, upload-time = "2026-08-06T14:49:36.878Z" }, + { url = "https://files.pythonhosted.org/packages/c8/35/dec33c00efadb83cb666d6bcf8561694cefc6f471f0542c22c0840f4cc5b/librt-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ccf121eaf0b28f83221c8c754006293ea4a5e4afff0dc0ebdc90d48520c0972", size = 153028, upload-time = "2026-08-06T14:49:38.297Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/a91d4a6802fb31068738ef37833ed4a15f007a70e8b77f43a637312caa6e/librt-0.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4db93f3c82a0fca78360a52da98ee1709bfe369b5bbf935dd064f57753c7085", size = 493046, upload-time = "2026-08-06T14:49:39.778Z" }, + { url = "https://files.pythonhosted.org/packages/c3/21/b3c71e5bfc4089a71a76230e6e163a770d9df65c1221c66d274b9b14f111/librt-0.14.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:04f1ece0f80171c4cfef795fa7bf4a75ffe79ec69cc715027d690c8c9b6166fc", size = 485498, upload-time = "2026-08-06T14:49:41.368Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b8/7003a2c56d34d0ee104b292361d442db1f0fcd4679c53fc2a6112a532097/librt-0.14.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:61aca1a405f6d97ec93f676146265677fba3f7c19f779f16d496162853b7816e", size = 515912, upload-time = "2026-08-06T14:49:42.834Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e0/8f0f4bebe4affe3c073bd7ca52abfba3af44565a435f3ef01406c4624495/librt-0.14.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69b592abaa76483401de68c6f52e0ca37687807cbc9928aa126bfd8c11e61b68", size = 508569, upload-time = "2026-08-06T14:49:44.369Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/fd28634391dc1899e0714d67ba935d135025902233c1a6854dc761fd9f15/librt-0.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:595bb8f3c5193cdc7235ad2ab7651b2e823de27a1c4cb83029a57da1e11ce326", size = 530361, upload-time = "2026-08-06T14:49:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/7a/33/b8aecde080731781016180fed0b830cbb40ff9a60a30fa28983c6585315b/librt-0.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ffdb67c2a6925dc4f191771ce9b279c496953639730a343ade0631b222551b8f", size = 534232, upload-time = "2026-08-06T14:49:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/c5/7c/3bd4aa098a4dbbe8ef6b3c851a91d281e19b1785593de3ff5b06571e31a4/librt-0.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5c320c201223605656bda3f07c0536e7629f91a79d84167ee1b71257f0b86921", size = 514269, upload-time = "2026-08-06T14:49:49.013Z" }, + { url = "https://files.pythonhosted.org/packages/61/e2/596e551af59cbc6795dab12653e814b7c069f67ab0134214f285aa945cd2/librt-0.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0660dce4717a345319824ae99bc9036ad887ba5a189422a8c6431bf63d2947c4", size = 557582, upload-time = "2026-08-06T14:49:50.387Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ad/abc3f8fe20618babe70a9864ccaa17b76fa5b7570fc06b35a17315328718/librt-0.14.0-cp311-cp311-win32.whl", hash = "sha256:bd79b5c8a3f09abdc77dee7fea06427bb4cb5fe8e7151029ff9799c75886137c", size = 104900, upload-time = "2026-08-06T14:49:52.002Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/15bf402734bc8237ce8489cd3ab059810d6c11f19ad04f266bbe5c363e52/librt-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:5878e3f8a09e5dfe862a58f4665c3e56912ab74dec6b3cb74d336e8136ab2141", size = 125843, upload-time = "2026-08-06T14:49:53.47Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/5c8d94429511443140aa02188a746544e0f47983a7fc62e2767a7d6c7b18/librt-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:0d907a964a0ad582f87726cf4a71bf60f710bdfb2550adbe52070bb88bf1dc4f", size = 111831, upload-time = "2026-08-06T14:49:54.732Z" }, + { url = "https://files.pythonhosted.org/packages/78/7d/ca9feff7e486d74ae3efb5bc66ab95d15fe29090bd902c24be660deed7a3/librt-0.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:171dd324dd6b269503e3777b9cc66c1c6f7bbcbf9b9403b96c5901377bde8f4e", size = 150997, upload-time = "2026-08-06T14:49:56.049Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/9452106e9b2b0f5c771c5656af763927c351b60363e6496fff2775280507/librt-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c648d09e7842f42cb8bcae41b6a39d6536448612ee4c516ce58b284300f5a066", size = 155241, upload-time = "2026-08-06T14:49:57.344Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5b/eeb378de1b84761d555e100e5c69facfa6cb6266ef4a11baab55d64ac6b7/librt-0.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9bb36c48a582caf7d604bc3f3554b550431d1aa6b131a618e33e2816261620a", size = 503094, upload-time = "2026-08-06T14:49:58.775Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/e5c372c7c543cbe721b25ac6837190f27d627838a9208b7069e167c06f9c/librt-0.14.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:93c52e7d5f9ac110db854a4c49cc5fb0362e77047e7ebf71f1a6f35829395d47", size = 496536, upload-time = "2026-08-06T14:50:00.43Z" }, + { url = "https://files.pythonhosted.org/packages/e0/81/9d64cc59740a37a68e4bcad1fb1734a354c6e2899cc66871feff4c1a9032/librt-0.14.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31e9931b28896420c0870332eefc8e966121adf8013850d14a22d694c17c8cdf", size = 531811, upload-time = "2026-08-06T14:50:02.018Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/c643336b08dfd38e27a77ad3811ec7f18c2aa83677332f21300430ad5fcb/librt-0.14.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:52e3630f4d00aa1fd1c224e2ae7ea2a31058493247f765393b7aac2bd0f3bf01", size = 524425, upload-time = "2026-08-06T14:50:03.528Z" }, + { url = "https://files.pythonhosted.org/packages/3f/19/aa78ba06cf8546a0f3d5ef6985b721d4bad60f1d938e147b6576f826dae7/librt-0.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c66f6183f4dde136d5567fbd192c86236c8a89caa73f2720f2ed94c176194041", size = 543060, upload-time = "2026-08-06T14:50:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/f5/18/a21935834a687940ad83ecfc14aa9118493f0da53b6e9861082f8cae2381/librt-0.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62d566b4b4f6510d471fb6fa1b6bf8d183a1039d2c058d85f7481ee24ee3d27d", size = 546840, upload-time = "2026-08-06T14:50:06.571Z" }, + { url = "https://files.pythonhosted.org/packages/e6/d1/98915036feb2315cabd700f25d9915969f81a8391d2cd9c0cc93df6f7116/librt-0.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:069a5790f1ba9b6abd882033a486b5a5b68754854b7ad015ee5ad8da1d23e11f", size = 535732, upload-time = "2026-08-06T14:50:08.478Z" }, + { url = "https://files.pythonhosted.org/packages/40/e4/280b07ef374464ca550523ed82049bffe8e06f73d11ee947543389b3cd23/librt-0.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ec4541a9e522871fa2f2983478d7ee9cd994af1e4718b7e291904c15430b062", size = 573579, upload-time = "2026-08-06T14:50:09.949Z" }, + { url = "https://files.pythonhosted.org/packages/9b/75/773489183b257cab2f341087d9327155f7763c1fab07984a8d748554bdfa/librt-0.14.0-cp312-cp312-win32.whl", hash = "sha256:1697ebe1612604233cd69383c4369c91a40f7d8ad1bd890e1647acec35700f32", size = 106097, upload-time = "2026-08-06T14:50:11.414Z" }, + { url = "https://files.pythonhosted.org/packages/ed/7c/f60a3379723295761403ae6301be16afa98b389dee8c6aa1b5de35794d76/librt-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:3e30f225dc2598638a03bd0fd56d479e09263fd769298af57711c2c921498e59", size = 126933, upload-time = "2026-08-06T14:50:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d5/08147d10a6e5ca676dc0e140b10758a83de73fc863712f167751f2aa8bca/librt-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:d6c98dd32e0f8d1a701bcfd470de0d09972d725c8dbad3cf9791b5262caae1a4", size = 112237, upload-time = "2026-08-06T14:50:14.12Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1e/eb17e048ef320dc3c780f883c25e8bbcb32d9b99baee4df47743dd19fd83/librt-0.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e483dcacff2ebef0e390002475fafe026f1171632e88e1a8bc69bbc4fe31d92", size = 151028, upload-time = "2026-08-06T14:50:15.505Z" }, + { url = "https://files.pythonhosted.org/packages/48/0d/a08a4e73990d401031d17f2ac25de61e32efbfc2f195903a5f41782ea2ec/librt-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7a469d3a638cb1310f177ad6263478661c31929628e04ad8c64eae43117aa935", size = 155140, upload-time = "2026-08-06T14:50:16.866Z" }, + { url = "https://files.pythonhosted.org/packages/73/97/5fbe0c8f05a549678180c56f0792aaa85462c4184645a0dadb720e1e7edc/librt-0.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53a97882a260cc133838c4eb5083c1d39c6dd9c5bb0ef88f052f7ee078cf132a", size = 502531, upload-time = "2026-08-06T14:50:18.292Z" }, + { url = "https://files.pythonhosted.org/packages/0c/19/b1124bbc3b53884726b36feb5893f17c64638560f363e474af1ca808a961/librt-0.14.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b8bdb74dc214de53fd337adc08d01decf478c94b98eb71de8175476499b5f094", size = 496114, upload-time = "2026-08-06T14:50:19.722Z" }, + { url = "https://files.pythonhosted.org/packages/04/1e/ce212234460b1420d223c6d531579e6192d2529624cd4037fbb46da0041d/librt-0.14.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b75736fc71bf792451f3c09f6924e3f1a456d60bd9e522205229f6a4501b1dac", size = 531575, upload-time = "2026-08-06T14:50:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9e/13a82455687f65d52f886b4189d1f1546d55c76441984b490f4fea0beb44/librt-0.14.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc4f4191d97ca3e5d1c9dea91b9c363e727ca01a4ecd8cb75c150d262e0be6e6", size = 524444, upload-time = "2026-08-06T14:50:23.043Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/30b4b024010410bbd2145e8f1d09568465bf0e9a8ae74b689bb605cb1567/librt-0.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:079407feefd3746de68a73918e9ef43d0c5b791a3b198a9488568fe42baf57f8", size = 543090, upload-time = "2026-08-06T14:50:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/ad/08/befe0e170b1063ac49d4819c2d4854698656a9dd404c4c9d62e00b426bf2/librt-0.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:67d756804347354df8bb9bf1e9f8b7565b4d0528cf14406d543937bca04d8545", size = 546405, upload-time = "2026-08-06T14:50:26.192Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/e55773f575c8ea1d33b29c2b6883f05ee109a112c764f7127a09e0c288c9/librt-0.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f91abb5c73148dc49188ae47341902432893ebca5cf12c60f03b25615ac906a2", size = 535995, upload-time = "2026-08-06T14:50:27.829Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c2/83730da76d7e273163da50184fe04cee3678fe65f7c8002fe33811c5a621/librt-0.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7574c4ee6d9df5a3de33d54b0e93c4a069a4671e9a757c7ca8fd93a8635d16b", size = 573590, upload-time = "2026-08-06T14:50:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bf/22e882115f94276a7d922c63af87ffbd0cdf5e3f545d3dd75dd8e7a9614d/librt-0.14.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:184b24430f480b4e4f910cd5dbcd22eb1ac2ef91aa98822a774acc032ed17ec3", size = 82189, upload-time = "2026-08-06T14:50:30.571Z" }, + { url = "https://files.pythonhosted.org/packages/62/70/0389b1e1a9ee1ed73751cb18decf3a33bdd19781c9fadb46415271720081/librt-0.14.0-cp313-cp313-win32.whl", hash = "sha256:48112eff5a4fecd8919260363f35009354909a48bc49c100ca2a93b6b344ea46", size = 106198, upload-time = "2026-08-06T14:50:31.794Z" }, + { url = "https://files.pythonhosted.org/packages/23/19/c38ecd86f649a1014bc0bfcd9c6ce620491f67a42975929dbeb87409e9a5/librt-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:6ed36b53455622ea42b20fa776f6ab7fd15225b417cda80d5def848f66a692c7", size = 126963, upload-time = "2026-08-06T14:50:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2c/9b485d945e64e94cdc703aab7a2b2e88dba27c8b1bb1667ffb301476812f/librt-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:9b24351679fce00a3609505d6898a098f554ea00dcbd2babe89fd8710bb27809", size = 112127, upload-time = "2026-08-06T14:50:34.331Z" }, ] [[package]] @@ -260,6 +342,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -269,13 +376,172 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, +] + [[package]] name = "packaging" -version = "26.2" +version = "26.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] [[package]] @@ -289,11 +555,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.10.0" +version = "4.11.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, ] [[package]] @@ -393,15 +659,14 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.4.2" +version = "1.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, - { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/b7/1581a8103855c43567776aa34135e5ec3c597346c23bfd10c7eb5e0b10a4/python_discovery-1.5.1.tar.gz", hash = "sha256:e2ea8b884cd1701f386eda8cf327b87743f1dc21b7f784470799537d95635384", size = 77200, upload-time = "2026-07-31T22:06:02.48Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/6a/07/a89b539750a159d5101c4eb9fc84e2961f65cefbd5e0b7440b284471c0b0/python_discovery-1.5.1-py3-none-any.whl", hash = "sha256:ac07f44cade589d954e9d6a1e1468539fdddd2cf676beb51da73e0f156b7c932", size = 35752, upload-time = "2026-07-31T22:06:01.116Z" }, ] [[package]] @@ -475,6 +740,162 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, +] + +[[package]] +name = "texttable" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/dc/0aff23d6036a4d3bf4f1d8c8204c5c79c4437e25e0ae94ffe4bbb55ee3c2/texttable-1.7.0.tar.gz", hash = "sha256:2d2068fb55115807d3ac77a4ca68fa48803e84ebb0ee2340f858107a36522638", size = 12831, upload-time = "2023-10-03T09:48:12.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/99/4772b8e00a136f3e01236de33b0efda31ee7077203ba5967fcc76da94d65/texttable-1.7.0-py2.py3-none-any.whl", hash = "sha256:72227d592c82b3d7f672731ae73e4d1f88cd8e2ef5b075a7a7f01a23a3743917", size = 10768, upload-time = "2023-10-03T09:48:10.434Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -525,16 +946,16 @@ wheels = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] name = "virtualenv" -version = "21.5.1" +version = "21.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -543,7 +964,7 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ea/fa/18004e5cb15541ad2a68ff219c755233b012b12d4ec8663d06a258082bec/virtualenv-21.7.1.tar.gz", hash = "sha256:d0dbfaa5483487baea28d7210ef8d24c9d1bd0f10f449eeb215568825a9b334e", size = 5525237, upload-time = "2026-07-30T15:40:36.36Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a7/ded126c19495158a05c7202b3389139839d4cf78d622d453867778e0f7a8/virtualenv-21.7.1-py3-none-any.whl", hash = "sha256:6394973f990536e34c05157179146c020284c42fe01da1dfeb0ba16c345280d9", size = 5504576, upload-time = "2026-07-30T15:40:34.512Z" }, ]