From 8cbcd8f6b5af7983e00bd9a597c6513758a9ab3e Mon Sep 17 00:00:00 2001 From: Nishaanth Reddy Date: Thu, 6 Aug 2026 16:16:33 -0700 Subject: [PATCH 1/2] feat: implement the graph API and core algorithms (#2) Implements the Discussion #1 two class API and the core algorithm set from issue #2, ported from the validated internal prototype and shaped to this repo's conventions (Apache-2.0, hatchling, ruff/pydocstyle, Python 3.10+). API: - abstract Graph plus DirectedGraph and UndirectedGraph; the type carries direction, so algorithms declare the flavor they need - construction from edges with optional vertices, configurable columns, opt in validation; transforms preserve the concrete flavor Algorithms: - connected components (regular + strong), pagerank (+ personalized) and parallel_personalized_pagerank, bfs/bfs_paths/shortest_paths/ all_shortest_paths/all_paths, label_propagation, power_iteration_clustering, find (motif DSL), aggregate_messages/pregel, triangle_count, k_core, cycle detection, maximal_independent_set, random_walks, svd_plus_plus, hyper_anf, plus reindex/restore_ids and edge utilities Packaging: - core depends on daft only; numpy and scipy behind the optional local extra - Self imported under TYPE_CHECKING for the 3.10 target - igraph and networkx are test only oracles Verified: 331 tests pass, ruff and mypy clean. --- .pre-commit-config.yaml | 2 +- .ruff.toml | 2 +- README.md | 97 +++ daft_graph/__init__.py | 88 ++- daft_graph/_compare.py | 16 + daft_graph/_optional.py | 72 ++ daft_graph/algorithms/__init__.py | 1 + daft_graph/algorithms/_traversal.py | 47 ++ daft_graph/algorithms/all_paths.py | 71 ++ daft_graph/algorithms/bfs.py | 318 +++++++++ daft_graph/algorithms/connected_components.py | 305 +++++++++ daft_graph/algorithms/cycles.py | 40 ++ daft_graph/algorithms/hyper_anf.py | 133 ++++ daft_graph/algorithms/k_core.py | 62 ++ daft_graph/algorithms/label_propagation.py | 70 ++ .../algorithms/maximal_independent_set.py | 74 +++ daft_graph/algorithms/pagerank.py | 183 +++++ .../algorithms/power_iteration_clustering.py | 111 ++++ daft_graph/algorithms/random_walks.py | 63 ++ daft_graph/algorithms/shortest_paths.py | 75 +++ .../strongly_connected_components.py | 179 +++++ daft_graph/algorithms/svd_plus_plus.py | 127 ++++ daft_graph/algorithms/triangle_count.py | 55 ++ daft_graph/edges.py | 82 +++ daft_graph/graph.py | 369 +++++++++++ daft_graph/indexing.py | 128 ++++ daft_graph/iterate.py | 93 +++ daft_graph/message_passing.py | 135 ++++ daft_graph/motif.py | 221 +++++++ daft_graph/schema.py | 36 + docs/usage.md | 442 +++++++++++++ pyproject.toml | 35 +- tests/data/wiki-Vote.txt.gz | Bin 0 -> 290339 bytes tests/test_all_paths.py | 80 +++ tests/test_all_shortest_paths.py | 88 +++ tests/test_bfs.py | 75 +++ tests/test_bfs_paths.py | 163 +++++ tests/test_cc.py | 46 ++ tests/test_cc_labels.py | 48 ++ tests/test_cc_local.py | 121 ++++ tests/test_cc_ops.py | 23 + tests/test_cc_vs_igraph.py | 56 ++ tests/test_cycles.py | 71 ++ tests/test_datasets.py | 171 +++++ tests/test_directed_graph.py | 139 ++++ tests/test_edges.py | 59 ++ tests/test_empty_graphs.py | 56 ++ tests/test_graph_methods.py | 51 ++ tests/test_graph_subclass_preservation.py | 97 +++ tests/test_greet.py | 11 - tests/test_hyper_anf.py | 79 +++ tests/test_indexing.py | 127 ++++ tests/test_iterate.py | 89 +++ tests/test_k_core.py | 61 ++ tests/test_label_propagation.py | 53 ++ tests/test_maximal_independent_set.py | 77 +++ tests/test_message_passing.py | 90 +++ tests/test_motif_dataset.py | 98 +++ tests/test_motif_find.py | 83 +++ tests/test_motif_parser.py | 57 ++ tests/test_optional.py | 78 +++ tests/test_pagerank.py | 76 +++ tests/test_pagerank_personalized.py | 75 +++ tests/test_parallel_personalized_pagerank.py | 66 ++ tests/test_power_iteration_clustering.py | 66 ++ tests/test_property_graph.py | 60 ++ tests/test_random_walks.py | 58 ++ tests/test_schema.py | 19 + tests/test_shortest_paths.py | 67 ++ tests/test_strongly_connected_components.py | 96 +++ tests/test_svd_plus_plus.py | 68 ++ tests/test_traversal_undirected.py | 75 +++ tests/test_triangle_count.py | 62 ++ tests/test_undirected_graph.py | 89 +++ tests/test_wiki_vote.py | 123 ++++ uv.lock | 623 +++++++++++++++--- 76 files changed, 7483 insertions(+), 119 deletions(-) create mode 100644 daft_graph/_compare.py create mode 100644 daft_graph/_optional.py create mode 100644 daft_graph/algorithms/__init__.py create mode 100644 daft_graph/algorithms/_traversal.py create mode 100644 daft_graph/algorithms/all_paths.py create mode 100644 daft_graph/algorithms/bfs.py create mode 100644 daft_graph/algorithms/connected_components.py create mode 100644 daft_graph/algorithms/cycles.py create mode 100644 daft_graph/algorithms/hyper_anf.py create mode 100644 daft_graph/algorithms/k_core.py create mode 100644 daft_graph/algorithms/label_propagation.py create mode 100644 daft_graph/algorithms/maximal_independent_set.py create mode 100644 daft_graph/algorithms/pagerank.py create mode 100644 daft_graph/algorithms/power_iteration_clustering.py create mode 100644 daft_graph/algorithms/random_walks.py create mode 100644 daft_graph/algorithms/shortest_paths.py create mode 100644 daft_graph/algorithms/strongly_connected_components.py create mode 100644 daft_graph/algorithms/svd_plus_plus.py create mode 100644 daft_graph/algorithms/triangle_count.py create mode 100644 daft_graph/edges.py create mode 100644 daft_graph/graph.py create mode 100644 daft_graph/indexing.py create mode 100644 daft_graph/iterate.py create mode 100644 daft_graph/message_passing.py create mode 100644 daft_graph/motif.py create mode 100644 daft_graph/schema.py create mode 100644 docs/usage.md create mode 100644 tests/data/wiki-Vote.txt.gz create mode 100644 tests/test_all_paths.py create mode 100644 tests/test_all_shortest_paths.py create mode 100644 tests/test_bfs.py create mode 100644 tests/test_bfs_paths.py create mode 100644 tests/test_cc.py create mode 100644 tests/test_cc_labels.py create mode 100644 tests/test_cc_local.py create mode 100644 tests/test_cc_ops.py create mode 100644 tests/test_cc_vs_igraph.py create mode 100644 tests/test_cycles.py create mode 100644 tests/test_datasets.py create mode 100644 tests/test_directed_graph.py create mode 100644 tests/test_edges.py create mode 100644 tests/test_empty_graphs.py create mode 100644 tests/test_graph_methods.py create mode 100644 tests/test_graph_subclass_preservation.py delete mode 100644 tests/test_greet.py create mode 100644 tests/test_hyper_anf.py create mode 100644 tests/test_indexing.py create mode 100644 tests/test_iterate.py create mode 100644 tests/test_k_core.py create mode 100644 tests/test_label_propagation.py create mode 100644 tests/test_maximal_independent_set.py create mode 100644 tests/test_message_passing.py create mode 100644 tests/test_motif_dataset.py create mode 100644 tests/test_motif_find.py create mode 100644 tests/test_motif_parser.py create mode 100644 tests/test_optional.py create mode 100644 tests/test_pagerank.py create mode 100644 tests/test_pagerank_personalized.py create mode 100644 tests/test_parallel_personalized_pagerank.py create mode 100644 tests/test_power_iteration_clustering.py create mode 100644 tests/test_property_graph.py create mode 100644 tests/test_random_walks.py create mode 100644 tests/test_schema.py create mode 100644 tests/test_shortest_paths.py create mode 100644 tests/test_strongly_connected_components.py create mode 100644 tests/test_svd_plus_plus.py create mode 100644 tests/test_traversal_undirected.py create mode 100644 tests/test_triangle_count.py create mode 100644 tests/test_undirected_graph.py create mode 100644 tests/test_wiki_vote.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c35bf98..b79460a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -38,7 +38,7 @@ 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] # 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/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..fea63c9 --- /dev/null +++ b/daft_graph/_compare.py @@ -0,0 +1,16 @@ +"""Internal helper for comparing DataFrame row sets during iteration.""" + +from __future__ import annotations + +from daft import DataFrame, Expression + + +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. + """ + left = a.join(b, on=on, how="anti").count_rows() + right = b.join(a, 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..2a061ad --- /dev/null +++ b/daft_graph/algorithms/_traversal.py @@ -0,0 +1,47 @@ +"""Shared frontier traversal helpers for BFS based algorithms.""" + +from __future__ import annotations + +from collections import defaultdict + +import daft +from daft import DataFrame, Expression + +from daft_graph.graph import Graph +from daft_graph.schema import DST, SRC + + +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 neighbors(edges: DataFrame, sources: list[int]) -> dict[int, list[int]]: + """Out neighbors of each source vertex, fetched with a single Daft join.""" + 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..4d38248 --- /dev/null +++ b/daft_graph/algorithms/all_paths.py @@ -0,0 +1,71 @@ +"""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 + + +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. + + Raises: + ValueError: if the partial path frontier exceeds ``max_paths``. + """ + if source == target: + return [[source]] + edges = 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..638caeb --- /dev/null +++ b/daft_graph/algorithms/bfs.py @@ -0,0 +1,318 @@ +"""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. Both expand the BFS frontier one hop at a time, +using Daft to look up the frontier's out neighbors, and 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 collections import defaultdict +from typing import Any + +import daft +from daft import DataFrame, Expression, col +from daft.functions import to_struct + +from daft_graph.algorithms._traversal import neighbors, prepare_edges +from daft_graph.graph import Graph +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. + """ + if source == target: + return [source] + edges = prepare_edges(graph, edge_filter=edge_filter) + pred: dict[int, int] = {} + visited: set[int] = {source} + frontier: list[int] = [source] + for _ in range(max_path_length): + if not frontier: + break + adjacency = neighbors(edges, frontier) + layer_pred: dict[int, int] = {} + for s in frontier: + for d in adjacency.get(s, []): + if d not in visited and (d not in layer_pred or s < layer_pred[d]): + layer_pred[d] = s + for d, s in layer_pred.items(): + visited.add(d) + pred[d] = s + if target in visited: + path = [target] + while path[-1] != source: + path.append(pred[path[-1]]) + return list(reversed(path)) + frontier = sorted(layer_pred) + return None + + +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. + + Raises: + ValueError: if the working set of partial paths exceeds ``max_paths`` + (a guard against exponential blow up on dense graphs). + """ + if source == target: + return [[source]] + edges = prepare_edges(graph, edge_filter=edge_filter) + frontier: list[list[int]] = [[source]] + 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) + found: list[tuple[int, ...]] = [] + 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: + found.append(tuple(extended)) + else: + nxt.append(extended) + if found: + return [list(path) for path in sorted(set(found))] + frontier = nxt + return [] + + +_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: set[int], targets: set[int], max_path_length: int +) -> tuple[int | None, list[int], dict[int, int], dict[int, list[int]]]: + """Multi source BFS returning the shortest distance to any target. + + Returns ``(depth, reached, dist, preds)``: ``depth`` is the shortest distance + from any source to any target (None if none is reached within + ``max_path_length``), ``reached`` is the sorted targets at that distance, + ``dist`` maps each visited vertex to its distance, and ``preds`` maps each + visited non source vertex to all of its shortest path predecessors. + """ + dist: dict[int, int] = {s: 0 for s in sources} + preds: dict[int, list[int]] = {} + frontier = sorted(sources) + for level in range(1, max_path_length + 1): + if not frontier: + break + adjacency = neighbors(edges, frontier) + newly: dict[int, list[int]] = defaultdict(list) + for u in frontier: + for v in adjacency.get(u, []): + if v not in dist: + newly[v].append(u) + if not newly: + break + for v, ps in newly.items(): + dist[v] = level + preds[v] = sorted(set(ps)) + reached = sorted(set(newly) & targets) + if reached: + return level, reached, dist, preds + frontier = sorted(newly) + return None, [], dist, preds + + +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``. + + Raises: + ValueError: if the number of shortest paths exceeds ``max_paths``. + """ + sources = {int(x) for x in graph.vertices.where(from_filter).select(ID).distinct().collect().to_pydict()[ID]} + targets = {int(x) for x in graph.vertices.where(to_filter).select(ID).distinct().collect().to_pydict()[ID]} + if not sources or not targets: + 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 & targets + if overlap: + result = daft.from_pydict({"__p0": sorted(overlap)}) + 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 = graph._orient(full_edges.select(SRC, DST)) + depth, reached, _dist, preds = _shortest_path_dag(traversal, sources, targets, max_path_length) + if depth is None: + return daft.from_pydict({"from": [], "to": []}) + + paths = _enumerate_paths(reached, sources, 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._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..b46d32a --- /dev/null +++ b/daft_graph/algorithms/connected_components.py @@ -0,0 +1,305 @@ +"""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 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.""" + return small_star(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 = symmetrize(edges).collect() + + def step(labels: DataFrame) -> DataFrame: + neighbor_labels = labels.select(col(ID).alias(DST), col(COMPONENT).alias(_NBR)) + nbr_min = ( + 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..7e9a3c7 --- /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.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 = edges.collect() + + 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)) + current = ( + current.join(msg, on=ID, how="left") + .with_column(_HLL, merge_hll(col(_HLL), col(MSG))) + .select(ID, _HLL) + .collect() + ) + parts.append(snapshot(current, hop)) + + out = parts[0] + for part in parts[1:]: + out = out.union_all(part) + return out diff --git a/daft_graph/algorithms/k_core.py b/daft_graph/algorithms/k_core.py new file mode 100644 index 0000000..97d602a --- /dev/null +++ b/daft_graph/algorithms/k_core.py @@ -0,0 +1,62 @@ +"""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.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) -> DataFrame: + """Compute the core number of each vertex (undirected). + + Accepts either graph flavor; edges are treated as undirected either way. + + 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) + undirected = 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, + ) + 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..9577025 --- /dev/null +++ b/daft_graph/algorithms/label_propagation.py @@ -0,0 +1,70 @@ +"""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.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)) + undirected = 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..472952c --- /dev/null +++ b/daft_graph/algorithms/maximal_independent_set.py @@ -0,0 +1,74 @@ +"""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 + +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.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. + """ + all_v = graph.vertices.select(col(ID)).distinct() + if graph.edges.count_rows() == 0: + return all_v.with_column(SELECTED, lit(True)) + + undirected = symmetrize(canonicalize(graph.edges)).collect() + status = all_v.with_column(_STATUS, lit(_UNDECIDED)).collect() + + for _ in range(max_iters): + undecided = status.where(col(_STATUS) == lit(_UNDECIDED)).select(ID).collect() + if undecided.count_rows() == 0: + 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 = ( + 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) + .collect() + ) + + 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..44ea8fb --- /dev/null +++ b/daft_graph/algorithms/pagerank.py @@ -0,0 +1,183 @@ +"""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 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``. + """ + 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 = graph.edges.select(SRC, DST).distinct().collect() + if edges.count_rows() == 0: + return pvec.select(col(ID), col(_P).alias(RANK)) + + outdeg = (edges.groupby(SRC).agg(col(DST).count().alias(_OD)).select(col(SRC).alias(ID), col(_OD))).collect() + 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..4b43b12 --- /dev/null +++ b/daft_graph/algorithms/power_iteration_clustering.py @@ -0,0 +1,111 @@ +"""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.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. + + 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 = symmetrize(canonicalize(graph.edges)).collect() + 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..aa20a5c --- /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..c59af39 --- /dev/null +++ b/daft_graph/algorithms/shortest_paths.py @@ -0,0 +1,75 @@ +"""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.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, +) -> 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. + + 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)}) + edges = 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, + ) + 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) + return 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..ea5d42e --- /dev/null +++ b/daft_graph/algorithms/strongly_connected_components.py @@ -0,0 +1,179 @@ +"""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.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 = graph.edges.select(SRC, DST).distinct().collect() + 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) + active_v = active_v.join(confirmed.select(ID), on=ID, how="anti").collect() + active_e = ( + 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") + .collect() + ) + 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 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..d686cde --- /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..e6246ad --- /dev/null +++ b/daft_graph/graph.py @@ -0,0 +1,369 @@ +"""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 traverse edges read +:meth:`Graph._traversal_edges`, which is the edge set as given for a directed +graph and the symmetrized edge set for an undirected one. + +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 _directed(self) -> bool: + """Whether edge direction is meaningful for this graph.""" + + def _orient(self, edges: DataFrame) -> DataFrame: + """Orient an edge frame per this graph's direction semantics. + + Takes the frame rather than reading ``self.edges`` so callers can filter + or project first and still get the right orientation applied afterwards. + ``symmetrize`` preserves edge attribute columns, so an undirected + traversal keeps the same schema it was given. + """ + return edges if self._directed else symmetrize(edges) + + def _traversal_edges(self) -> DataFrame: + """The whole edge set, oriented per this graph's direction semantics.""" + 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 _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 _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..f5971b6 --- /dev/null +++ b/daft_graph/iterate.py @@ -0,0 +1,93 @@ +"""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. + +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] + + +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("max_iters must be >= 1") + if materialize_every < 1: + raise ValueError("materialize_every must be >= 1") + + current = state.collect() + 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 _materialize(df: DataFrame, checkpoint_dir: str | None, round_index: int) -> DataFrame: + """Truncate the plan by collecting, optionally via a parquet round trip.""" + if checkpoint_dir is None: + return df.collect() + path = os.path.join(checkpoint_dir, f"round_{round_index}") + df.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..e011f68 --- /dev/null +++ b/daft_graph/message_passing.py @@ -0,0 +1,135 @@ +"""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, 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_.""" + 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]) + return edges.join(src_state, on=SRC, how="inner").join(dst_state, on=DST, how="inner") + + +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) + return 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 = 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..9b91e3b --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,442 @@ +# 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.context.set_runner_ray() +``` + +## Reading from Iceberg + +Install the `iceberg` extra and read an edge table directly: + +```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. Iterative algorithms emit a + warning if they reach `max_iters` without converging. 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/wiki-Vote.txt.gz b/tests/data/wiki-Vote.txt.gz new file mode 100644 index 0000000000000000000000000000000000000000..578cc24b856267c67b071561c3c41beb26e4cd3d GIT binary patch literal 290339 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�+wIa!J+Gq?bxE(e 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 z zJY2`?7J8m`+>R1?WY7oz(D)jSrZVm$Uf&`*qfwJ3w<+xf3YkQ$*Tm{`S+>X3++Duo z7UFi9D4MLL^Ee(&+)M-AF7xe@-lmP$qlr>;pl~mc+Xc$5TY5Cnq=!&b&0(_nx%YX| z2WU9uHE#Por_v*vn{y(Ul$&#)bB$p90Vq5oR@0-E?$y;JjMp#`84ZxI4*my{v{qFx=g+vW6JJ;zjqvUei46%~Vl zn&93Z<5aT*YO2CBNg*#i*9+e5wStOqKhZ*pK z7TX=4LXD*j!-dUAEK6mT1Wh+26TV|>u^%pYf!Y=T9Dj0PoM8ZfK>Wa4274MH%po&b zfQ(Loqfg5H(3mp-TC~7a7>JzK=2K^}omSeeK*9C(_iNLY0)47h0 zr3t{%!-3O*yM#|Z@hOXNy*N}0IQMYwWMTl&b?Np=v1=pUJ4qfJfCWo;N{V+Wa1<-U zpPm5i-P%U~eriL2>rcENS!hG^w9V$ObM+XQehL7HWMT4brCgR?rzF?*_O9*i-Kp~q0{M^+ zo;vRUVD8s7yfg>`u;c>)DuI25!jLKa0aAh~ng4|yLFYls)I>LoTmf-t(?WV6Y&MDR zZj?f#hgDVb5YuAmJMn&H#aPIi>PG1XfMi&5S1Q-U(;1O_m zFTJc*fGxaqObD9FvkmUENqX3txao1S_;g~W+{PfrNb#z z*0{h@6=2hC_w&})5*EAs>B$FojR2N>ILQhO1E<5STmjeWxjJuM0~G@z9|C{c{&Wo9 zGEgnX_gy1rOH(&M!Do|&~3j?@48pD zBLZ^&@}Q9u&h?;C1)|fa%lwWfFZVKqrh9>=0DyLf+s9v_DMaLam&n)lt_H}XY4rqL z>Do|L@iOXM2yf-cZ-sd%ge)H8~gScyEs zwC4d@f2fx7IOl8RvK(CZCR+W~zbgDbIXnkp|g;QWRs`Ub-QCX!JL2Sqw-DG zW1Fwp(zq;uyCnrEbAVPOMkaI_$26Ml2RhQ4iA1Ln*fr?2q0f-#QWi9m0fY<6L`R4w z8}B+4qpqXnMOMfGIEuYhSTaY>IF97Niv-}9pYY~kdGoN28H*$4bea1HP=XX;u4BeR zs<{OAnM#=~?KV=TuV z_iN;ET53)&FsGLlTj?iy4yR?{H1RJr;??LlmD73Lj*<5da&LYPXF__KRKnK3JDWnL zh3hYlaa|+6@yHI}io2G(eC_PPG!%DF$JkmPuSb?=$hsai=KR;C;lBdhTNU8UzS_R6 z;_5Gf*X1iyW_R;EtXy(QFlspkFnV{0722U8y7jCd0D8ErP%Z_g0CLL~V1bv4POHj9 zN4Zh^hbXVnvme=qZR>PET<*8+$`L*L?GA?%D^%pWuF_1EVB#_bk*mj-9Ei?ml>L74e@XJOjN@8QF1<3#I7RT@5Z+cYsZg0}|6cyV#VY@pe zciElC;-S=De>~W-+gq~jeOG~y4~IU$b29c>4g(%}d%IQOdv zp%Q33Y&@pe$Yr5|0Gb&0zkzl)`$>i41AxClAR@B458vKtZSS-y1gob9UI3;6Nrwe| z+XBh0t8xXz8f;CKy?F?m2YO15*1uWy_qp&Zja(3(*bCvS zScAHPGWDAi#jL?LYp}iSZ?EJP3BLpb2S-+fkR~M>`*~tL zXZ*y06ks;bMR{6FVC7dn>WJsL_QZ-5aLTH{^E75wNWKw|#H zoNV3H19*=0obpp(imnO_B}igc;3`%+Wyjo_;CFAO#JvvyZ~DNNEHGg6^19*1&+a82KEVqR5}#so=@Fyd|-W~8V_(C z7;8m=Q0{Ev<1^^4?)jeb5ZF+RuffUNO;eICd$}Q^Mjef!w|lM*zJ)$X}t4LgeC!k`ra4XVj&F zJuady!S(piM&|U9ZlULVXV*;!sz6h5wcQ7DDmpv`a;x)o10fID)p=|Wq`5miZnRDx zo}NBDJ$-n3`k)l(G`c-4JS4OY14<)2btL3)Ty2TMQ^|GvO{&?m%3p_5UOY-ZLk!3d zXl8h0b##TYfPB^;z!m>&`-C1KzIA(_1x%gB00e;tSXydUA9(^>`pJhb!#wRK&ojw* z5=0hY*XXu)0Uo|o?4vN0^q%M0;!Dav8m(4}zIX6qLIIWXPJ2!FSZOAsQ3@c1Qc<=K zGrZf16L;(R$IH9#%jnvVyi6hPwMvy5XEs3ORBsDx${vB;OZUjdNVt;uU(5Wjlv)8m znXm5l0CX61#nmlzBtP_t8_8=?|JB30084?n9u{D2Eb|9Ytn1u4CII{?j)8r08aVKC zf=^+>nR2fW;7`M!#xZzqCi^UhaS6lK2^W>z@ikL?%@kjsN&pOlY2b9y;JJiP?ZY(# z#w6TtTVc3(TpwYeOa19Ox;rl2bX?2}q(7yi<4sKf=S7LzHUAE<4OBrWOaff{IMe66 zc)V92RSXK&VL!v2q2=iIMgVAcI9FF&0;1C>jkZ%6E|5;ufI8plflK4?M6Ml8!V}#; z$mz{+9@C8{$vTj`+>?9BNITH>03F+%%okmWR{MoZdcQ_p7Rt#neUDS_@g%JyppMbq zBN5HD&N-3E#T+j4uUSk7=5{zL90J`&9!ZjaUu&)txi$FQ4^YqX*^^UCSrL-Ygows? zNnG6fYqlS?QixO|KCwgOno|=V=y|?N3kTH7<3;jn5H7c*B+@{x`No4jLZ)zO3P976 zg=p9jXn4-j=pqo=LLiT!+avq;XL=T{Z@@I&y&;~r45@(qguNAx<`({3n~MjcJ{s)0 zEXDBRVWZGMT&r(bpX%Cta3O7a3vI{PqUbhBT8MWGP&}TMU7Jt3_h=b9ChDh)dAdla z3v#T8^+V3!y4Kfeq=nEoWwq++^_%NQZwly8+8jYWg99>IfafevtH<(=tH3s(aMJ9< z@@{H*?*!n?r}C1F%Dbr_(6M6y`%vEf_{wzw$wby`sns)QUPxldXVL`}VdR?o=&rY2 zKpakwPoMubpKPe`A|GDB>(UbHj@wIB!kF5uMs39TO)`tH%;E>QM+>V=0fYePTx>ox zesR@FKHOx5xu(#|r2wG>xdZwEvaGa5;7wTTn#u6x9X4pv~8mo~@3fn0};j-+G%kyg@ z$DlwAXst$n{2Y!nlE~q>T}WS^1?s-{sPyGoqCFeAR6QiLDM(+V1$v!Zj|N_aEu@WV z+R9ELosy>QDF}H$50A1-Q7kQBr4hCUb?s&dkUi#gnX7Lx9SXL|Zi-On)Bd9XwjBkY zfRZ%n&ga=>`ka<_rDa={FzfncO^L_}Xy=x_!7Icgra~GaKXe;K`m1{CS*Zu;szd`9 z2w9_x1w@@W(WZ1mi3d>)a*w#O~((h<4L+G7AYUmfiOc^t0pBPm1ztzpz(v%gY930DqX zUp{(4^wd^T9{}=*-s>KtRQa^`D|FoZ`+Az`NEpcNyq~Aa?lG=)_W9j+Ao8t+(xdZE z_xYVpMTb=H$apglcZ<(&w{t$aooQX@z9+8x`RuC9J8l7Thp)F3xL}F31BfH!iIa%B zJbCECEKLC%gB9St^!OI##6JQykcyCCvhYR7t4) z+`B*Yj8diIfeX>H-*SCM5q1k*T|&`_w2+RAsVBR2QBUhaj<&4 ztZ#bUPPa1+jRJuJ6{0NxjNR!WPcAAx>cEvd*g?l*|4U^ z@Yvpm5?p{~u!n)7V-W`E=&3LsywE{=MpsGE&IphO>gj=cBpfA5`)PO{-S3F+su3=j zpmLAgc@$tihh*z$Z;h~9pEMwJUjtw&o$GW&E3Vu0x=k++&k0iC+UKf#5&q<)l$qcu&T!;u zz{UvA*D!Aop0D9)8lIiOqdb7;PfyV`al;c=>VW{afl`iinAgD7`jl4=0~aHYeNMOy zRGs&21ATg21cG--0Ja0~V{i=|=Ge!LiL-bDhe_4qxl5jF@!a4h&Y=@Cq1EUbt^VqA zQvN}7I>tFhuCs}K$jSiF=sd=9IGn5zd1RRUM3bNBg(9>sCmQXywvk(F%r4*TQcaHt zp0*KnEqmNi`X|&e+_aPf61gKyrb5y`B99~!tZ0H2dK_7(zjpb~XTReyOUWUTrwUL0 zh&)GlLq@1$?EZQ>WltU6q#=s-%db(V*ZraOGMf7fDbEmf`R@ILT!QGnjIL4isEcq} zveU~heR{0T9)BiN(Hxwg4kdXZ`Od$%#-_QZ{7alwnmVPo7iprD<{i@1DgV;$Bu$-? zdV2-5;YgF9q^1DCP^`EH*+2!$t5}aiGAXqJa26s0Y3?gM_tV^0dhVwbzJ*--kF%?! zsxA}d?L_-)IOlFAEqhL-WL<1n*M!dx6LIXMRKI~vI2@jiC3=pL<23Tu_xbB{ zdgh;{`1m9C+*}UY+$k5u9-lUwL#A~A(Dg$maSE_ohwR=IfUFqcDi$Oe08e2c1|zVK z3&SO5$j%Hv`cvBHsia&!3S5&=3oF&;YHzrKp%j1e|2p| z;faosE2D8VY%_*vu0~a$gLzoifyQiAMW%Euob`bo*=A z?|DE|mW+WsJX|&BfE+_RWT5b5ATKtoen^zVGo<5k?HE=oBs!y!^G)}lW+{+I1obX} zmPB`0Hes!q}W zA7~#<%qcQEW)I$J7Nd{`%l(KZ5!1mfVG-Tl*xV4=rpwy4rk zU>m3krB%cEF1bM&)nF`Lki!=aykqKjO!V~lMA$$@0kXlv0)$}+z>pZe0IxX{kz^dR zg5z`>9v#vW+)5MFi3;nA-HzLN%xLubtJ|38)kM=@SC@Xd>3GqPDAmB)1p!T$ z02ZW#`fIlp`81$T<;+H1x8sqly$Vpb-|1A&`-A#x*X?@B4H)AlTJ6_)+%EI=e396u z5LfAG^UOkSjh=j3xq)`09ud*KfF~G%vQy6P%QOj~jt0?b)T8Nj30}7lv<66_+~VTH zf!r>0c&tgz4mA3!`;sSzL>>{d#~&{~5}na#Hgdjr84l?GPt|!ONRq-(bltZJoPhgZ zY!ZE=npM3b3A`0T4D}=EEr6zC!;ZVl_;u23pQ)J?N-Fy$E$3)y?E392{Ok6wyM7JR z6&>Yhsi*W%|A55>kXr1I^sUF|9@AgAT*CDcY7sn5NODTUb+jGtc71I1&>#6%hqs+K zw>4bzAldCYJlD*wc&~DFT&Bl4KTd&1duZK~?9a7-oR+*^l7zJ(NM&%{{@k)*kQd}u za%-w}>E7@c+u^weJ)-}J-{Mpmq}aySJ$I{CVgHXbMa%w{!wZL^8D+Rf!KwjCYm25a zjHf`LWm0%u?G!pUAms?Etsu>RIR6Hz#Bu7Kl;+?-<#rVEb_%x3>S*j7o|(s0`k>L9 z)V#p)TsFH#AFkh%6w2J{=eYiybd{==8ih$+Gx-|5C`1~)*UX zqryE}!}5vBJt#Kfi-ub-8YX114;op1F!c>$dT{v_L_&J?5J1mc0iq!d+9}RBa~K_x z1P$5+1~6S%^DDS*x@Q{mSL}Q2)FxMSv@}bM^EPZk15BE_(mmv@6I6Ue~A7v$vQeTpE zOw1YV$R@>LYy*1*rqHI#y>)SS6ku*R3iZL{4$}Q}+ThS0* z`O7n~q~DmoaCN{auen4tc2-V4s+zFR&xQ__a*srqqY} zQD8@sq?(E7b{lpJf1%zT+x4+~k$)XwQoMVaDioK!+tqLVWH+_(z#{3<(YPDOwQyYf z5NjrFlWYGv=hyXdopT(Sf$Yy^fKzo)m{e9Ced4+nuEvPC*S}tdw>=_lx6|JOV~Ff8 z?M0J96#6z_z#-|^u3Fe0eRfw`wnvri7P~#F{1(}!?Oz@8xc*gTy4{MmTk)P#u}$^g z?OAd!mHl4ot6V83g@4`tRXu7ihvK%oJzs9m8arI3;k1PF7R%qNA8p8MlAQBU8`|Oe z2=$}A)m}FAvy%L)+vz^dZo}lfU5y9ZEpcCsK6ny}#I*dHJ>5=I>pX4RO>O8$N2FQ& zM{?^LJ7H-Ygj&s)+4E1}FV|LD`97HR!bW~{DDFhEavr#nVZtOL9)? zQv&PRxvqRLm}aa>Cm)_k$PPLsr=Ai|Pf4X8OlA=B@s%?*pBX@SGk6I+kC+)Ucc z?{5rSYM}~+lkH5Qf!ZdfuxyG=t+T5ncUQx}yK!uwO#QBAyj8j!en45Kd=X$jdsT)U zo!^VTyw9kpiMJW z4+aB-Exv?9z-1rm*gfFYtxW?)4>rTWt=+q6GU8HOE+H5N?xZF#E*OZoRDjFU;cXHd z^}Eu0BZWvs!rOjV`fQ*pg7pZ{5h-c3I&-)0k4Yzuq>!hogm2qi>5!pD{p(WU(Wpz3 ziUvxmNn7A)QTA0NHwinHg9EsLGE&Ty*y5qrMR7Mc3%L5O%E=A z!g}Vx5*&Jlpfzo^Ekfg0Q2d|Q(~R!5CUv`U`nY_P3Dg-{!#POO(63v$q2@`_Vr-pj zfXmxvpd&Y|^+0mNUL!5)wX5=xKTR&zQ_7E~GD+$9qowrW{ugL4j)AK?NmrrV?Ml$M zct$s=tBffdNh5x$8?>$@XcaudUL)x+^~>cS-P2zDwjW=-=d1?kJ_x+bh;bS*rFo}rG;l9*%}7pz+=jwV8|1vEYFhN zr@EkYGU>`~s!Z2ZS*=j2PCnTCN$UC* zoj++clibkk(rDJ!ueg$#w3$BGaNIVUm4pd4No6`9riRfh`NIz9(e@WE!$sYzhkV0WA(_ zw)Ybbe}abVgUPSbNc;mF=RH7?=z?eHf@kQ0XTl*2eg#$6(3Q{NTV0vZFO*`&z6Ybg z5q<){V$0Co&(M|6&`r+=eF+#cv}F?HM|sEcJc6o^50rPP{wHjK{Ol>9%Ww5FO)7Gn z1&1s(fs_^M^d~ia+mv={ID-5|*k6Rn{*J$OrDNU-y^8>9rlF8={0>rhU3IU)JES(8 zx0=};3g>2{MlgG(Iux6nb{ zh15EzOdg?|S4q8@6tWvSH5`+c-OguHDBGpx4xouH_;wO#6e-Od$b$AHLF({SVh1_A zqclST|L8bJ$E5*1Nszw9nhRvNim^?0iTW-+fO!BwQ-!|2orin+x{*>R|rroC9w%t^I zzv<+PG#=3E0!Xo?Z5fh(T}9m#CV#6M#Ipb(=hxFo=i=dAWd2NQ_mnb#mPsLxVOsX* zv<$~PT;g1sg|tCH_IDND)c8VOktCPKbR5&AJJpk6X@{g7Er+)>I`-xIU9R8dx?JiN zeYsZGw7=!>qR8L6oIQ8K@+xVV9I|U+ssHxS2!hv}N&ZzMh!zu%G&XgQO_cwDRJ!3b zhvwTJ;W%8T;WCZmc%AyVj?Jmxn)9fCo7D8h*2oI$MIfhdJ5|_0gX&kM#p55f-;z#| zLv|bAF6ZMiJ??|YEyCj@?@fa2&%NTf2S9fh`!guN899YH40FVIB7Y&?O%_-P=6auYYS>YCbc5P=Cv91jY0M|kI3;h$I)E|U1Q?# znrnE>rmk|5+Vk)_9TI+$7U=!h?b@GeVR+1jO)rqkc{ICqySbb_w(~eK$+;X(vsZY- zWA+%80`)EGQYX1gJ$h5C8suLc@^rb(PKknn0FX_@R#V=T4EL5@S?73JwkZk^%&5l&TTq@mm$WPdK_ znC0egT|(iy47aQ8R(d|k3+HXSr)@h&LI;T@CO2F?RX`qg{M=@lrI z3P8hDzxrFp;kD@WT9g{IAo=sShUcO{ng=EN3Q2I2T+UPD^PHMP=kym-Eqi(#f_-(+ zb|}Z89EWloigrqKIX165=hCe9Bm2|*_KVj3)VuMdG|1(=mVaG#+YV1--Snt3ms4f& z%?$Oo8E%~(Rj7NMq;X?3Xx`PiHjd;MEcPYsU0kjP0Q=B z^=*H#9q+Y2%>m0YvgI)hZwG_4R#~n4*WoYJPM6nf%j>n}_1dyz^ta8k+wr%j+?3Mh zvhuoaS*!}J1(3_HyZkJ3TYcK!^0(nHTvi^#BzO<$v?n#Y+23>-OqYQrV{1AE(<$(3 zY0a>kdMzHRPx2}V+7}?rWw1z2Qf$kyEzM+1aha4`+aE?MNZXG#1 z+9rqWz3JNOzsq~hwOv*gK(Fo8Z>N4Q(=J*jrOzYH@=kME`o`ML%Rd!z-C+s(s#GND9;bO!}g$_IaZLq_kM77T&9c_iEw3 z8hxA8cEjtE-*l;v$1tg^cn1PxH(+9!yot6@JfOAB9dG#p3lR*jQ1Tt3T12F;(doC^(ZQaCWYO! ziVUyiHOa?hmMKsXHpyxBDtCK-zP;YvZhzZ5aNbHt(u~Oa36H#H-Ai*kb)S+{ z``fj@y-wt9gQVqe>r78I&HDtPWzx5n9d7SF_g;2$*>9(QACtqggl~HXxzF;iYE3$* zex%WHdkweG@vmx=+h>W}d&F&d9d9orxm>PG^7>%&Dvslo)^8)MV_mPMj-@?^htHjd zz`8XMx)Jc+Kt#oNDlt8n`Uz_QT@83}n_vT=-xN~&59#(lIP?rnr36O7e%Al-aHms2 zDWt$3X%41~lr&m44r}H|mpW3|xJ!}}s>97LYsX>pC}EBJETB-x&Koaehs}V5&3ih;ZQ<&r7m#)aYucnE+D13;DgJVE=9^C z59!l|^y%XAzR82epQsaXX-zh+Q~~%bGGGyu)T6@SQt0Bf#e}1uEM4ssL9_`syl5=ye-Vx7%o@DVqLvmfICA|9Tib4Niyk^gvtWINGqA zL+0A(T8qG%eM#4GI7jX3SLzd3?+>J0c9kWq#U?rRl;c=G5TslV6(nuvhu-=j>DSp! zYHjN3k3u0+)_dLkuWTKK^$nA5Hz|Fg#z772q%lnPxAd($qLkIHEU$*%CrY9$;qpxz zlKdStcN(UCbX7t$Jso9j6olFCnKiq<4g zQkS46IY|DF+KW!duFsdQPlm3~QLax#u1`huE>hA|`HiUoh4xr-99kNzs4R6Y`@Fh; z9@YjAYlA;Js`KTPYEN+ALvx=784VMssl{>z-cnfJGA)tts(v^9)Hv?#CO=J*pG+?yKrCKuF4s?|rT)kiNb{ou&YQ3f$joT)KQFD^Y8`bmku(=PI( zq;hbZxH<}00>+M2^U?E6XKML;{7_Spiki#kq%}l`S)fX7(vrUx{x$roia~?o^q@Gk z@C7MV?1a`kKYjx0_z5V`5iNfk{#BJmW!R)MO-j{t954ZT{CrfB^D&)|>2LF5D@p!V z_piflQ|W`@&m@NuY8F&?J${5LY1wTlya>B(g$%{lGo|hdQW@m?(jcd0JLJ9mZTQ#g zUtLGXx#Z+CwU7qsTL^hT3i-Gsp|T3n7q@xN$R!S27v75vC6HJn*`7@9nmm31!LOHaX|2xR3Tsa zo1aHbrx|MANe%_(NR!kXDV0fbxvVg`oY(dj8Y8G)2~w(}nVjG7U>+Jvs6Yu)DB(Ky z*i044B(+l-7fsUONrlHGhqpC;Qr$4gGtAMbqJ_?sqF|C!dez3e$K&p{J3O)ukE}G* znKVqNrZ0Wbl}oCgJsJ+Lrnj;DtNJuIa*w#ipHcx!a%`|rk)&QmrKcp#0Ru;XNntnD zG!>VQ@6J3r8bP#7n^MMkgyZHUwHz(trqo%IRHk!ie3@h0O(QyOWIQ@b3nhg=;xYGm z_jo?d#_o;BrAsrJpp1jJUk~Mehqqskd87yef^WLbxjDtGr(o_*Lz2mv^-gx>^Wb zTCzF1se3F(1Z`29qV8YU5JeLmrda#L>m||EB|1=;6fYM?mJj+(ZqRjjPAL~hmJc3F z1(UjeRlRD#bae*_v`zjt6fe%W{3>|TP|KklF{w9`V&edGz3jTO!Jc%Al=E@z@A}uP zv`qC5N+XYDkV#>;ZMW@jo%-eaT~0F>YFF<{f&7Jff%Zg6{=%t;O!HW32~w&;ZI^eI zK-*-0+vL{de#rYqN%gC`5bqnED{JgYP4RLmc6E>F(RC}iZj(2P6y9|^z-iL4WHjk& zki*>_P>9raDJ=!*i>}TsNyMY=FI0oQAAK>@FT2|st+6Lf`>_C#Qkn*lpU) zUre>);aZ?3dXh#OYLg#Z8#tC!CuwA*rgxIcg__xqw#Gy)X09}!lT??~a!yjqp@o$s zwQTAMKXUm|JNVf0!6Rt+g7k$)<>|4Q+PB9RTaK+JBq`0)ZI^a!LGlL;+DA*ZPc7Ia z&0y4IeH0E4ho?#Yb*n{CxAfR%49Nbje|3(ImNoNejF@i4TF_l-fP(yO_qXaP)bTvF zRgFy|(DK;1JfBm) z@%U^dNn<;889`g*CG2oYXEQlfQ!~z*8hsYGgjbr0Ao*LWEovqvIekk#WrfRXYyPFa zUy|yGCqqeUFUx&v-JukC3CDAzB+a$d8+$aoN}g)5)Z}`!G+)M23Vu6Gh2pjwogqY82RYDPJ`{gpkm&t|JoC~b(F2GdFKr7Y=wl4=KY--)((KR!tb`vN zhC`F-S4M)8&;*m5A}k-1a`;TzMOSng7Z+ejAwr`_vOg?@9^q*9WpgR#yf$>3hRPpN zMfnk$^dmHdq{fs|wcE>Oj)rJNYoxmFV_C zR`UB=fCbZ)$n{!u`UrOelEQ9rMVrB^sHWD8cgl*+)ywEVz`Ff`U9uhi0Ncq2tqF!; z6S{uNQS1kpVt)Xk&0Rd~`@vR#ECW3c@!;|&yc7K3bO8fo@hqxuBn$!b1wr)HLu)Um zEV-qVxTIk>#ixe6v|oDEm2>VmuAf>@>T-j&W>QzkSmh@v2Yl4-5nf+Path*h*UmN_ zR*O^08|z6+gwgyFR-ak5pWLv{djxS)RBTPzV8_i>`$ksJ!+NJ?{MujD;*n47Pg>Ue?! z;0eUF>4-GKH~|b`%lp=-xS@;m8!VwD^&kh;2dS_0fJ^dAHK+4^e*m5u&b1O=_8#P> z>;XMDsOLTCxo|k&$Wi|N<>%vHs+~VTa|FE#O*#m3!UW8NEtj^DgGpnAqyWJv;BVLW zPk(dBc+d^^6I}Xj1CX8nK_F;${)EP_m=cZx4~{-hD=-Ob6BKFccL73h2*HqZ6aX;N z#sYkF8DwkU5Gg+BSAn9)UnpQqaU{F-E*eY;tg3+gYsxH0{`DvY23bioU7f8yfKO1e{Vn_46Oci#SNyES9E;Fe* zM~c0Z@=nMjjG{?H`M_=Qiqa2fKEOAdr5A6aG;x%WAGQaRJ}DQM^b zpQguiKyQ@-cG5kbli{N|r;I6iS{yg06}nx8A?{S8m=Cb2L&O zCZ2{DPTya8dINkWW2Bh={Y6FwbO{o&#p$`(g!KVf+RZDAE8$!e(3; zp%dr=EnCoNrX`wJwCr{nBlKfI%M@X<+p(JxfHfXyd?v+~GHlTDugl*$K&j8@*I%gi`_SM8g$B|z9h2f6 z%fC98X`Kpm{Hxj;_DY~uq;FGFMe-N^7Hg;^f9oCq3sa!*ui13xr+r|#sT|8AhnY5sh^Lp+n;7o*c}2Xr8tiOX%4`~9;I*! z0aCjkS92azFCM|>32BH_mpG3AjU&>c1u7aKr4K3^Ak{vsi-3+vt!`q6^k`{rg#8PU zdJ4`DKpL}QOCsrLESYXi%Pj}8_@t)MV!4$-7N4|K29Q1{9hL5KyT^U#B$rTl9D&dqV3L4h52$4%Tz<}& zqGDp74z23K%d7BuKD13uQdp25f#ilqGn4ja9>(oVm?~dJTpqz#L{eW&ZgBY*RE0k8 zq1tQ@y=%SLyVhP^FQ_MB9)52kW8TF3+YQ8#B;O5IDC|4=3NR9Ix#I30SNHuZ7l;}` zxghO&G!&G3oJSZylcv5+4aY~&Don!WH)+akC^j5DC!ulu2-j)OdtBgo8)K%m^7r?x zU*6xvzlVSEzQLH-Ndh*#=HmlnLeiUX`4iqZ7<|w-;V>e{d=zzxy8v>Tf*1k@m~T%d zT>gaT5grVNd==?c3h_L`pbj&DQ9w`(>I@QM9+KS@4-D!irI;sas(7GUkww=(74lGC zHS8U2%?^@%`pBxtzd4Td!5-TOckx|&$3AckDIvGv4~|?eT_9iU1G!JwbZMWy$NiIx zB-N2mp9*ujokli`_ZM5<9+Wxs;Fh-sjshM)=Gb}nC;`1KpcH)A(pnJxy|(Eks>unZv)%X41EF+wF9de?9(IHO&oSmr-Vt{Ba~p zvRfObX;S*I&;cne5XgYK$)OB~;=H9XXwY&BTvlnUhO|uf=Q4oEv&(!m=oa}4=QqC3 zq*R6T8_s38F2i-{R*wg&NdCgL6>cx#)*0KOxaFjZ7fI>E2Dr;aHA!(`E1cwV-Y(tk z{;}Qex65?9wQr|oyG>$QmE`tvoR;HuaNJ_gC{i4p`9fzINzUbQPxeTl8B%7eL0#cp zm*kBi#hcF=k^HMm_qtbHmoB#3U1qBvY1t4wv?DE1_}48;^CBd>Ih3aU!OeJ*YJpqu zB=uk18UU#uX_XLOB_#QaWq%&yvG9E4(T7`bkm49>v3)q?p|*=V1t5n!wtw9YMg2|d zl+b28$+`46gclh|`gJ)+d9DZAMgGF^F4w|3_NR7*dj?6N-UIW@q-DAymj<@#NiN-3 z4lk6_aQZ@{%5USXP`1XBzTFPCW+oU%Ciz>3;{FB|K2Wn;vzun7ew_N_+H!xx9R*NR z>aW{|=U#5)lib=p{lW|}X`7UmtC@3r>d7!5O!BW9JGp&NQktP+2y%E$rGa}8Amtp& z5uj-|wV_|L*SGE|!?QSr09{6VLES5(;a~l&djKxOgH(PL2XvXUP4c{otFB2~<$Smf zTiEyYg?-=e??hJDKQ8MQp6GaRu=smG7p(`E=8B5af7s@(if!$|+k08YZ>D#$X4rP7%V%gtvY|H*Q zjW2ipB}rUTd?uwHvbLn7@b)Kv`xZ@;;^2~Z(NZ{Q>I+E8`jVsz6ej`KXPmAL(-L` zTF}mX(Kd0?QQ42v?A8_Uad^i$ziJz>8w_$ebJ$z777W_H+K1uaulU>cgaVu)0!(r?){$)p9;yL6f97rp6)I$OIj`DfQ4WCD~0Q(sD~&ODzKT z^+}31)O(gk*tN?|^^0edla9(B)=HAD@*5DHCG{&(TZ^tTfQ>@XFsTQ`xsSalDjE4yiKz;L3%I(!uNd59q1$Pf7nfcPnw z@#85(oh7=>B)7bS^a#egeqCD82Hu8z071i$uyaVzFeD%t-b5$m!g7R8JV)>~p(Jdw zhBs8h+o<8q)5r}_H|Yz=hDUB<6yD7x-0)IaMg+286ajm3;{4Ka@9=q#B}wDc=y zv~g%L^~?s**=%fO^Qrf30EJ4kNmJkvB-4)|6nTVoMpD-7Y-(?_@nxhWtg9Y%#mbFa z()>g@UnT7&I*g5ZeYA?MzJOpOsq0&u7A7gpxOW7KVbZtIfqIlSrbr6!I7g@1t?OGf zrKGKRkIJQArw=oAlH)}W2Qf$7S|p7rQu*PgQW8eqM|rO&Dc?Itx=J&w{wL)viKKkD zA<6lG4e)FsILJtvb_=<6x1_Gpz}<~AKQ*S0j~6_GG4Myh%KB)A6_gXh{Alp>SDMrt zo0GkLQriCjYD<&S{)A1>fv9QTB;n9A27(8;Y!6dCD`Up{Z$T(uCMqjg5Ed1>dtKHsJ4R?9RVIpkJP5 zC2ab|Z1y0V*8_c?%JvDFiZy8>G6`o3OEzheaAvS%lZLcR`W3_bl64x;P$-bPgN|tG z+qLX3ob9Xd!l8t6K^gVRb1}sRd2iBG9BIduq>%IRgGc>ScwF`^*%cn}qCppXKBP*mtw=By~%3*JXL|)5M_!Vx2 zA)!{%SfZ(K+2SnaULo<;vh;dcdYu%Xshj-8b|@!ASD`@UwJgz2np?E=EtYyo&hPcN z*J;5C&9a<0X?&tHO-vuIlP5udwJfhq8q=ikaCWgQuU#SK|7CgY(t~9nl_}-5%ktW# zr^b>X^G`Y=EFzPp!n-Qx>BdK)QPN`iC>C0JCJLndP6$>WjgZ??2~kk9^n48H@QdYd zaU~bjnLa#~kTg`6crYNzbsoE$hQ2ro8P{Nwu5t-u{*U%hox{d<63eP{y6sTLU!7VL zt8->wog393VCnh*-K=YF?&`YjkRG{uuH!{X8UBM#M% z<`tpIci#NV#5-h4$sch5=-R_QVt4M6J-_@;<%-7wk2n~2Nhw$Nu-x5{x|WxZAk%w= z^eH@8m_&JDSuiLVSc7|pHMoFUgL-8@z<%HX1_tS*Ea{}Ip@JX0S+|DPLIXNF4;rL= zNx>hvy37Qid@h^7Rn60y&jZ!db?scKF)Z0;Nn(S&UYy9D^CMU&Ng4`;OZoV?q>PWz zKgbe^k4wrQU_bPOn}frH9CaRK8Aqe+C7Qx%Y_31R{`x^C>Ol+v#%>{ag&9E4SkgRDq}ZT6m|eiHRL6OPSw)EvBk9=f@>A~L zl9s-0DrWYI@ZH5&k~_ts-6N2aOW9}m1TaZuO68f*4dx`=k_g}L%j#{eXf+9rcUk8u zH;#PaTYa%K28Pf0vQ1q1TWV5V=Afa-EtD#?^QC1@(o$m!rz%wQ>JLjA^N+AY4Be{+ zsTQ~_NjeOL_X-~kH40dfLA&kz6|XOXHp>OC&^_X!H+=pPKL1Ek`SHvl@{Hh!j%c}| zcC{Bu_d^$*pV9OjyYuyh4&az0+h)h_4Un3to8%88Ev)V{TuKd}mG!`12sHM`&i=qI zS?60<9JlmqA8Z0ryfxY;FdXO+)>`X|ahkrWCAz336{JtfU1S_&l`GNji@p?Hr|50$5+tnm$FAt&wAHvk6O2U(E` zmwp@lVDkO}u5c&Fec4TE;g)^NW*kWQ;H;Jf@|l{x#g;wk zh}b-?gvm+D8*hYx6beVkOCz?RP?~WDo1{9S9?BzZyI;j&Lz>jXq-y>2E%i&b-Uk64 z#)j9`L1Eeo5Beor*4{v?i>}<*U9WEBZ&`Z-ExRp$u@nck=UbNDK+6=}q}0c!Uqj7z zgyV%CN>V8G;r!zC|Itys!R{MK<;)vbN!R|=h6WagNzTVrso{lckXj;7qmtB9s5g?N zTvBMTWit*Gc2nQt-7EChAyR3~QN3C1ly|hYEX9FB-%k5mYD0K08Kg3$9?ByqStqIX zr%N0!7$ZrxyeO{3Kt+ck4v=ts+`UG{l1@e174nBuZwLQQV$EY^Bq0|4hH%law#IKH9j2a=8_hU8)Cp zgXqXs)JbznS$ydUDcro}c7xyZ0i6f9)0$R+8ogZlWsN+7E)r;wpp|h$!ulJu2APDu zd-WcD#tFH~)w|}8xMqKZUUKzbZxZV224DEd1b4+2X`2<97*n+Q>;5j?@Dkypw zIO&ix+yi9#XfyfjG(oQSC?DM>EzpGG+&$cE+>fG;2idY7VAYkB-6MT{_?7a42RMkj zAKv4*X>zCmQlONuF1_pyU@Q}wCJFMBYTOBjesYNq7=+88a`}+3=yH(}9xx{;J-Gr+ zxI;icI0pcYCQ>Z0D+KCxTg{|zQyfB4X*9=?i&s$ZqN$L3_}9xa!9E1!FVc=JNac&e z2$JGWt3yet=LA}cZ908Z#nbY2QPAXhJ&RXPVz_y1B=QEwl?oasq_6X@w~qm zW6johv~ig=0ada!YHaHIrEm7z?Hq3&0Y6fFd>!q=6BS`j>I47rqU0oA(z#J-bSQ zzZkC?ZIM36tE>r~F8(N&>km>`n{er}^;HUD6OQ2Or?(b*fCl~`dv*f-yWdQuORTD`1tyTV@no6Cg4TKYKR|15)bmU@{Hc981|)5{VLp6>8RZe?hjyIwLnB~GsfnAETE&@+ zyaIF^dr|-V7v=hH^XCZogA`&tz&a`k9rzI@y+F+i&7*Ze$QWQ1xYN;!{vEW-V7uiQVK_s z_Q#MR>-vooYc!a)`l9j^_jTAGDA80L#`v>$VT_kmmuE z=}60@U-O=G5}I&QA13)rovNf1*(BwomY`tBFzb{w^#zpflJe-{5iZMQ(wovADUi5N z<2sA}K`zjr3V9AhPlNo>1*7o~F#SAOa#?cOTW|@d2?n6$H4)HgqUB#hZWu2?TeSS^ z>ZTA~{fY&4(o}3Ii~=c{s;f-{Y)N@_0pwI+*_w2iX? z09=0D=oth0Q*LhbjPYX98hL`EOe2Q?{)7(ygZt4XFPQ=kK?%c9IUCJ7gM!}K8lYs~ zL^%n7m?~KM04XZiYal6PEOMu8n}fDPIV!fUyl`8V+h7%P)h zZk!N6{`LCTtF$at9dyn>Lm}r86e#TPd?w{%xzv_J4u=;mHEg`5o$KdH$6ac3>@AkECJfhpq&}g6)E1vG|7M^=;{l$$l=y9c%B3rqEJG(9GWuym(&yr z?&c@Oq8qMiK7v5`5oJJAi%%)(-{~##%(n$exGZsJusnkU3+z(8zp3kyu;sG}#?-eT zAWh0-DClwlg^mPtxZq|XK`xzf<>^jB$R(I8Y?>1o@;P)#xog4{9DW7a&ZVq zW?G_oMM?uiji9M-^AHPkOt+c*ty9odY`C`oQo(853chFxQjx)?3@G%)xQchGARB8x zJX$3wHjXZ#8F0$l3h0;|GTWpqoFt{f2uS74S#ipG256fOzvZkrWhn!s>cdq!&}^dn zne^@Ow?pZJJ&PoWAdGaKZv3U%ev@ZR@Vh3r7Rui4;IOz{YB$MW1$b+qV)6n80 z7%fcdbBmN>9DXIS)oh7slD7O|z5VD0^RJRPDv1Nz-4EDp^g=ucReU6XpNIB;LCbE3 z!E$pkbAN2=3{TzW(P8{*=7%=|lR|N*7!OsrL5c%szs$uDDOK}qe!JM2-#h90HO^p~ zgA2mkc4o+z6UtRF-G7`AuLrJ6of;69Ht!5S=#SFJ^>O}Qd3MoAX|Vkf9+gUh#AbcG zVotgw2#D9SpPt!T5=t%D)w_Aw;sYR!d0Bx!r4`1j3qx1egA#HPv1q>zZ~@$tF5O_e zOE=g+p$Amm#L}`MF9ciw)(@%XPrxo@X|<3V=lnd61p)Mf)xrwZLvXxVS}iP^ z#k_eDgaNXyg4^2%TX5-z3qDYYMl)T}lE3CJn*B929hn(R!k+w<0v5!+I0Bk-!!))$ zQTdX{^(B$(OC2cQ&5XfXZ5AgsOL?6j7n*BR1i7@mU)#B*YR>~KnAfGiYh4x6RfE1R zn(KBfMV}ArgI2c)aRPR1z>>Sa84<9^0_j(X=xpK+SgU=j1po}qTFeEP1l0v^? zaN7&ZKr!D+T7|K0qcG32)=X1L0HXw<5%HCPYQ1Nb}$sf7fO2zJ+iBhq9 z>5GfxHOV29a!`z>)l$-7D4Z#ikln@`Vvi^h4DXm?DS5qsH5gs9=u-y|0=E}2_`2RN zh5B()O)CjwQ{;=$Df8e}g9lja58fw8fZ$2-Jh=Rc$F@n3bPXNW4m}Q#1nKP<+BrW$ z8yPb34$2BlT1yoAf_d58L55Z`KfuWHAd?-NqO|aG<44dUc!an5lBNP(I3dhXa{x}7 z{1q2v9vz2rKE--)z!8$*#o=Aiq%;%q2)E)M;a1#@s}C+OlHUDmpE(3<^{}U9n_)@I z_5nWIAK#EmN|hDR;)m?#P?lw=-2YQ)^-5{=K{sSVrL=mbv z5|y;N>55{DuKitqt1{yb(ps{h_)K=&3J(&|)SOlEs$p1q1sz)b3aJz{V&%4+Hyqwr z`xUh8rV{6JE;V95(RR*pEWa&dN;=914t}6Rs}x|plKQe;{_Zk`(tH&!UL||9rsBmT zk{~pjK^gZ>flYeD{qXk}f{!gp%AQ=N?5TD*^*L{W=hqMn?CH*;>)ja?JJqCKDrjs` zZn%<;e#K^YPi-}mLSH}=;?Yqmxixx}x|T_pw?O=Y^VTDbOIufhGq0agHQ6X9A9w>D zriI(3M^~jXaZa$G)=#G!tfb$)RAzhz>;d;Tk04Y}y7WeI*rS${$6h+c-?}N1lqMM; z4J=!CFs}2d?;Z|P2g}oaG*}(3NVc|f4=`#SuAR0vct60V;6YP(lMs`Ba8~#O4owHv zkP{R;2bojp!+zlbO6Q%T=0-EA7lrAHMiYho9p<4m<&W#6?cHfo*zGn`Hz_UMDS&`8 zNol99)Ak|aBWN={nvQplZqm2d_k#R|!&^>0)^SNk;l)&%BTUXYHbDDm;kA8axqW2$ z$h88a1fc$jmK%z4)TdgLTH)bU=xi)lAs)?xN7@xfDijZ{VFR}OV2(SQ$d1(=j;EHS zn4&AUvGv7Nusj|(9+FSzZ2Z(>b1*-&3-$nW?+^0UT|)NX)0Lb~0qj$q&FS>eX30$* z+HA8)V~OSvEfMq4)hdr{nt)a}m0O68>3*hW(zm^TCc91fn@-DCHdxn>Trxb6eH2P5 zMN~;wWr|TKX>OIXN(0y9FEo+)1+?jtAht?UY`8+6Lm1}GjvpVIDehVf#^)N zsHqk;p_iO~v1m9`YuJNJpYJa}Tl#FN=9in=@@G6hI6OBv!GZMzhtWd|^neLqH_(~D z(dPaEA`QH8_W;A_53c-R+B&o)pAd`-^YPJ9z|k)MSlE9o;y)JbC#@!m5FOL?uUFq< zw+&Jpn4gjyGTYoEhupV+MV}2FQiAMuMbODR;g%b`bqzY*B)6qsyFuv%yQwrNLO$VMDNB1v z#~~lJ#^LfC4v%}fM_2X11>}+GUQ&ia`+Eua?Wl3n>|O!_kTm!QY}|*E@+0M(r`?Yf z93F3-qgJjp!h_97vHv()ES#g`t)wZpd<6%zwkSe$mPu|njyhHcN>ZNH1`ls*k?iI; z#$|qC!U&`stJNMIg+E$OGY()L#ity({M1$t?O7#l=Q1r@fyNX~(=vrZf$S{lDBkT< z?M+#zQAEx(r6}y z%u(sMA08U5A%)3bTz}!z54BO;`X)`&G9A${IgX(^=h0nKvzyChI_Fb8p-;64o_!=a zrK=Sw&C8)I)ga!011*0M_P3RSXdrSaf)!og{zJx~bfh`f&M$OAQKfNv@+< z_NVrY8-yf>9G}VYhJSVW#c>?YM>x&yTUyl|9l<4a`*TXU<~h>qPI6js%y|w?+k=LF z^%%%CbdpQ$YJ}_8Wp!OHkZLDwf3fY)`Mn;&uhPQ(GH9FptJ=`;7(2dhx86+C6sG#Z z;SGPQaSiq?LCfDRe{uZlv71xx5o9#S+tj;xs+Y9v7XCsdg!`UJUHyaSfJrK6o(O{M zPi2oAq9p&S-h)^7Kx%8l^T+VGF+4_ShCVdRPwJv=H^(tGG2`3O#aoWU(0S`a%>(;U|15g^=q6W#j)+ra~Z6rlcs5#>`y&v z9E~)vK1{Mdm+sMwIH0nc z{-VfHj?v?!EQt|KiH`~)4+-TlG3Et&1 z-+;?+K7HJpU=s#^g6`UbrWig(0d2MihhJgmdTB!|;qoV}@*hMK*o7fucYFC1ctT@1 zM(T^Qf^wQx!xEGpw4Dbk9`wu07r+5_k!O`5_81SgKHu_l<)Xsm;p_E@U6OK5 z(d5-b;iM%ByQ#o<#+Rh(p*SdM*)68YzeboGUL4U?O8ZyTOp3QV-tK%ryZBm{6BH(u z2Byg*=dEx5di?EKCWqoOfEIF+yEVbT|n;X8GS#xVy#vYLa@o6YQM8-nROL%@B>(DEVcJca-Q-`VQh<{wkYii+ z=Qgz5hSqkx$7wm`<}cLmC`<(@RnWS=*6sxIKvDbIbB)EA%*FRJmY+7FWsXVb>Bhywl)8%lyY-_ z^@KY}t)1$9Ny`-Wr$M=UDU_j6irez5=haF2c4=;f zU1X4IkXzg&rw?1#tLM-`r%3UJ#x-s~ubwnd^3>Vg)~MK+Cr65P2wFil9S{* z!kc3xriI&%tH-{Rj>=`YopNEEq|)Hh_t8+BoKyAj8wf>B;c+2*^;q?zm|E4~J%FUE zF@i$MOY#4u0f(!*rw`ClCN(YY)Y@h86zWbbVkUL-A2fywotUe`@2lg4JGF?>xx!1t zr|+p{1`5&Om(6D()kH+yz~qmOsMX?K`DD{Op%3{6(C)aB+ibc~>VW zS0^Y}Cn#y?gkRwb#?=*!%Qt*8U<`Hfk24e>^G%pjKtEi)>I%k{cIB0J|l`kKb|Db4kjjHHl1b`_t}B%NiFTUTjli5FhZg_mwg zTcm=d3_R)j+v||8ejRI=?6#LlIbtgaso*4Ce|z=o{M0h4jZBra7I7i%@g$ekoDRjQ znl9bB9p2WjxD=8!-6CBYOLb1keA1OaHz;blCOJH}GA(q1c9ubE7kFeI*6q9XYVx3%qB*r&*GXsqLPC(tq}rKj1g+0Cu}42R-& zaNX9fOXxbyo^Zz3x9N&BDvzdFV{i%Yi&R(7&yo~gQ;)}k93a)q=qi`r-d=Hdpa0S(e@MfFsWLY@wJs{GbcUH(Yl zF16?78Fqz6pXD*)=S|^lhqtw;T<%fpINqxs8XoVXt2AOLE#X>?sc}4}(h^g%hBhxj ztJa{)e8kq=2upg8_U9Gpr;>yvy`-i-vxjD!?Q!yW!F;@OgAHv`esQSye-AC$U0F@J zM_Z%Ai%1WyVFS4TIe3#IVbKp4w8s+CBc-={r1X}wx=8WBgmn^**B`;?Thf-lsp#8M ziX@GCG-hDF=`wte=6YUI?VZ$`s13nM}cW{510!4#2Cgc zjL#uN&g;OOrh&N|MZ=m5462I>jRh7sO9C;-fCWs@x*{en7XzXpx5?V1J%pqlynvj`JQ)H_`#XA&l}nD=;SpdhWpHOhJ>F=xpJj2-v@`(Bbx8uDumHWxZ0? zU2r(oAznu@D#xR;IV$PHRSp?|(4VC|fY|=vD0CXC#o@$JgRu0-Zu zhFHnjm4-YCAy#s>2ZUCA6k-LU`zvnF0dF!D)W#f5M7dZhnwiqfy98g z3y(*??j{Fm0LMSqF%ArX(*sMdQ4X71V6H=f+XD$SyD(l>M~A>2xZEb8MFO1eh1fFy zRXWWBXg3Y7HF#Q|RKKc1cv7C!G}h~6mdG}tGb*4)Or10#&h^RfZ+qH^^V>p5_K_A< z*bf`0*+CoNKb%hN+Q7XSlHJfD+wcsTI1AS> z9(~`q&H~ymm$ZpTsl$~};5glQ2^h#mwHWzWT08 z>=7IA#n}^Sia1r>e`ZY?BkHNsmff=jItV$4FjYL>q>t=^heFZCga z;~HmqsBq_T8kVz#GjQTM#_NbR)?b=7B}RD&h3kR%)-K^vs~8x2R>{f(j;Io6ceq{> zUp~AFoK1n@VMtU&cRB2dx%8PhWvTbhHFNLkG@Z{kCUKbk(Z#^j``>TX#)0|>+hQWd zg5JGwOT@zM;n>EB>97yAvPWoJo!VHSDRNRqg(<}#VCt>_H*-HVjr@p>q)!bBokyoh z$GtDH5eYBK%Zcvl#ufFBbbG21{X>T)V1=;jvt6H^@_Sa{9}mvktZJEmGE~L4s->>4Tq~Qa^2Qx`VzrVr%I~NUs)4U+ zziU-&tNiW?q1x}NTtv0LsP+)m1EQM7UXFNINpkOym$Q(+A1*mI4hp>{N0YX`EAC89D%b5kG9Ylw%O&xb7X0x2y{oc4Q=pV4>> z4rI%CNLg>tDwlBkLx51aSlQjN~PVE_D=p*RB z0NMQ#(Q6;F4JY=`xa>-SO=9MM*Vq&oE^86bb-4_sVeo|D zVQT&X*~lMmCw*+YYCk-5TOYU`@#@YH4&_kzZ0`>b`seV@b9h}lyaydav*4&5W7^2V zBr%`{n4n;$gPB?=48Gx%#BIQsS~863wlJa21!7vKH(rUMa3p_C4Et3dnVwt5^n{YQ z9GE)M^n5Y&XcADZX?l3g9G_*pMzyACLEo6PsRxYKT~u42;Zh(_&FzU?*szvXMDMKt zcB18JHD3m>YggGUi_H?3{OU|+jA8at>xgeRS%KLdmMmeyCqOdQYc)s;q8Dh4t=e4 zOk(IOw~0-CJK~;&iWT=4n8#)h8fs zct^mW@qjQfTn=2Sj%|T%J|^G{_2%O16l>%g1I@17xMywlVH~IM8n0bvQ#okwm^d6* zRJfer6DtioE5uU<3SoS%fbqF?LBkr@U7I>99BE}wiNy1tz}@YEllIs(=x!>g0Ed&V zUE)JZDSrrs58_sAOXJDg@wsWN``NGh;^qT@z;M!IyPbJ3f4CI!t{Q^M0&yscLqWsv zR&yKx_&^8030KC^uIy2hKlh(^wvUE2Kkr-eO#lK<(cilaXxOvFI(VbOpEP98ZzM1v zR4CpaP^fC-czz-Zw~%!Jvoyz)CZ`sBkGN1qK&e))dIH;i3=C)etUp_ENd z-w(HoL1TL2R&vxj9`-17j@S53;WfG>*ZfpaD_p26?kH4p@DM!FnP1l_i`ErbRC1zk z`3+9)54S5D&ruS$68YQUBZMwF*^jt~pB<)1k~G0TY}hU*qQA{r_a$B<)N$H|9W4<}B0xb~}at%vPXa&fdkqhX+ctDkS?BRTlf}>pG zaTIsi!*vLgV&#Xi3#xUbR>D?a0u-{9=~s!HWB{&5lU2n)ICGl`^Ox+E>c zW*rISBNZ%-;f40pH8LVw(x`eGG&2F@D)=RZlT9KEMmJu7JlCA#Z(eltHHSyQIi2z; z3DBIp5dh>0a?Z+wE`I=3Zj*O&5+xU$%MpYYRMUDJGqgj}Ll#LKc4z$g_SlUAs2SQn z&C))YrG4PHA%NT$%u+7MCmz~0_EzY?C;`c59x$r_9WezFiO0yDKVE%6!nZSX{t)y~ zUwPZH1Yc>v9c8{g$}%6ICBo1;i9_A9;Es}5*5Z0(e~rU#A{xp1yscTK#kD>cAMgr! z!jpLHm%GwLRLeuwcp_TYL#_@V@}M%YhjBTCU1^}4A#piFJnR8FHVzU%h1)B~zwu4U z(b^7JuH}~}01vsKd$?RPFgQb84q<=xySF@yZV&2&t82fw>gZQHUINtFW6lk*fqPt9gzADo$LEA@i7p*oB+1_%rf4R8%fpEuovxIga=UWl{`C=-$d9_+rpfuxP_{JYuO*q;D<2+& zCGKUc0_6UDts2yp(bKl>>&jyiB7nr?TN=8W<@+*1lnK&9R*j~ ziMu}AQCJ@SL~&+e1y3$hcVK8y0O9R+tXK<8~$8deowRPkvD zS-jXroPW@<#j4MilM_{05#^!kEh;%YiB6mjVOO}CmzIU;iQ9fp)#m9=`l&>oYKW)G zjG~If;exvE?vh$0R+ny{$AdO&Kv=ENLqc~2NNNE*Mz=Ew(Gopu^lGVHv%vD&Kha6| z+_-o#i+G%L?#tJ*+#}KTrrm1dE?4&LAh^SjE0 zXSikQ`NNtda5qTkaJMTK*M5N2)^P=h=qT3b3h*5H-cxm5tmb~KjauTdEBAK~!(}Fw zw?r4os`h|eHo$5G>;7@Q3L!qXUn%P)x?jX_L;MXe!x;9vT)tB8dAMC9u}4j*9(Qp4 z23W)Po;B4jcj;ekE8<<{AoazE6j>#D>V=i7L>Pzv;gR0Jx_?NktN7#?O)7Qo?UaGC z4QVAs*J|StrlRmFHo7jFJi^K%x>l*-Q?c-=Sac~xBRwKC8c5bzu>*DL5JE*kPA95N?>|fzAI-6QuBcS<=beuaO=N)Ij4|4Vcq(%rC!*F$IL9{!E7pIa(yqY7tX6kemFkOpC*M}kr9 zK?|7ZG#^TI73#!3(WNsekAJu-;+RhFY5wfG5#VWEU^NYC)dCgR%JTs3Fp3MQ~O1@UYoD*>7*gWmj&$LplsUP{~;%A*SzcMQIe| zYh+k&KIC#e@yM^of?3_SbX6Z}l77em;A#9eVN_~))=^up zsI6DjwkKA#zHnIK6-`(Y`;eM|5#B51b6)U}?@|#v@oX-R9%PGqkmB+OsppaK#+viL z&#)jM(LaO@NvZb(I&#ZRF=!2wLdm@^Xq)_PTCM{fQ7-R6ui!E>d6tlrl?)1Jb@N-8 zi+F?`Qc@1Cj~2Bt_LRcId)^7dNWSWXHj&Rdq5UQzwv`nu;J%7taF|Y^5@8Cq^!I*9~21YWaDbkhi`G9*U1y7fg=6j6cg3T$`#t=bHV6%wOdE3oYr z^;`7V^~4{nzU^KZ6mSE3p(~3Qxwd58dvR9ZmTPazwetb8N#Y69whVh)hP~g8T%l;F zS=vGFrF$fgmfWtxk5k&3Jv66L7s+->ofDl9ZQ34=%Jx|0x_hBNo0SHh zya3C?9);oJEDt=COq>pk(pJ0@XAeAUOPqzvpK&je56M;LuX7=ls;t1mv8M4VFo_>f z#iP(oARO+fy3m?pm&^@K!+te?EH7CfQc*V>&r%e>jf7pB+t2JSN!Zc>M#$9z>kqj@ zc{uwpI%26lJzQ=(vc&M(Xzy;g<0!Vxg~zd~>5r%ZaC|}Py*0(Q`8@JWb02pa>pE>; ztI7B4*usuw4BPi%e)Jol^7r#O@=vmm^LhK4h<0-7cvdg7{iy#TDJY*#TeJKf%hF@T z+%v4tcYM%wJPe%WyjLqdwgjQquR!R2ett@*Z*<`xUs}5Ipc~!iI!@p6Irkq2xKnps z&aFVN`_s9Vyjz8{9n^L+IX^fMuJ}SF4LOtYmA2p_qseP!*K5VrAMx1mZXFiht-DH({4IrXkI)uh4AezQtaw{_ z;N~Q;6%K#KW4v2ht`A3rz>~LIs;!x|?!Z>1fk$15+aYkQx}~`abYPr&A5uJ&*c=#_ zk+&36AGWU>xBN&%mN)7k027u1CMPIuOsGJP2y^WP6K5j4|zhC zSRou&mZTmJ@3LPm>2ImFCibsz8%Mdi)U_YK+d_@RwT#zMbQkKNl40VqhiWBw?3L&W z&;d=N8@JAC9#SbPJ4AQis5UvPHs@%caVWhym3g?!^}5t`PLk-Bs+s5EcGCC6?01j4 zY(5M`8vc^lD~QoPFSv0W~*`>RRK{Y8LM2Y_+r&(%jFPm zh&`&6M5Q6BQ1@5pQKMIflzMYwB~r_tL#z74YA~k~d8-jr$I)59)dOsaIu^LcJEVJj z0B}#um8a&)<1N%ZR(!aprp@DvB=^{wz&*6VO7wV!H_3=Ds4?7N#_%wx(}a6$kKpPh zF``G9F)N$XjX-A%R}Y2}J&TO(X-1k6R}WzkJ!H;sztHCHVNHFr>H?3iCrSjD=+?Jt zSXtdUUfoGw!wqIlPhV>~4Qo0LYk3&N`&vYo?{5)$jP7KsCW#-8LWg_YrPulXxH??w zcL00gEOdrWFAu$3E0MSIcCXSafnTCM1TRG-Is~0?B)aWLIQX!}NnP7Wyiur>#c*$t z)<J)}(W;qtT?HOj?uH;pL2;Vu%}g&H;c$M$?I!Cc~>&)jHZpZe^S z-|I9~#YWBD64yPvUaiEfG+cLo-ERRpL$&;iyDEf=qE>)$clmX{z=Li=uQb0_=q@5n znXA{|gyq*m#r~o^c*xw}Ex#U$Vd9qPF>2Q|F2PHp*Pgq30KpSblK2khw@lE%=(i`k> z{I+MDE*BM#xD`o+L?ICpl2*3ofZ17qTc1&wm8i~g-^mKeo1L;oa?6g#LoJ2)3U}ctbRK?O$m%i2kj8-&{#ikQ|E6T1@$bML_6h$-6uI&VD01iChLbZoctz`VqRn;=8 zbVgaB^BLqo4#)wPQTa=_@-TP^Z_NWH2=2;>qqjCAc|fJ=VysxT1e z6Tb~9EnGe7Y{=6cZqfqaqhKjd3dJ8m`z@*I+q2|P-BC9LlXlS&on=zIP0e`fWQ;hC zQLeZAUd2xFF0d8$ukeqnL%=N!;4Z(%L<26?U2>6T-iI=@wRd_SbUyYMpO|B|*sYh@)Rxyvs#NB4k*JkV+?F<(~-AOyad) zjVygid;#poxSBD_H6xf4O#I2uaTE}C0Ud&d?Y^o+R~4gA zKohy3=&OoxY2%v+DSaZ2=7BCEJmw69kQjxDKHkHn9xm7OkN!>-($Sr!M4Tfhu5Q07 znWM`-iLjWM2&IrjHDSvw9`dI0L&~e$GZnZ9Gem3YM>VspQ7bJ9HWJ6B?$e?~wdk$m zcVZZ?{bJ>nIGSA{PzGXFd^np!z%47HKbzMc*5?pZf?M~V6615Y!4|!x#S9$fclE+Q zuFhsedDup*&WTPLWXr(y2OSt^ufA)q7JO!|9S7RTn(VPpmqop^C)q3{i^;xZDN@vw1y)U3aF-8B4vo9c|f6~<6PJVIXGmZp|MD&G^;Sk@ohM%N{tPXn&4^zw#V;&GV`jnp=#*T(1COtWVM9K$>qo35Ddj&JEa9P{@>$;dT~E6T^Yw_7?D#>vhV8i>q;eo*0Fd$jN>@yc$eAg`rGue0ev zY7R6V< zh>lpZ)6m&0(DgRDTQzl_fZc(ON@R`Hp6VV}U=)%(oNj$IumYUrI$b2Q0-Ke{xhgOZ zAfp=X51qH^rZH-a8q;l^hr+-Lcdc@b^6R1aHwxNAc!nD>T4A|X-eQ((rL2awA36Pr16k&FVw5c|7A~G@x7c z4;@&qTy&Q&HTi(9d0u5C{_#+;RdTuqu~`%LtToiEMbxahdyc9L9sneca;>C~Ss_#f zjH-aK%0tz~*cGk^kXb7Q$@hW1(4WmJ)aeO@?@bXM?sR9Evx;w4@y#d?r$VUYtV+(R zf?Cx|mgmj6JoEB~DbdMURZum6{IEjsS`@G93d=*4SuKcuXb(~8)oMG@5!VnjYhs;I z1r?QssDg^^*tQ1{-ZLXQ^Sg>j#d3w=YK#)u{?Rw|G%eWxy+m2HIHd(!;3yBCooMw2 z=-HGnx)RH8rR;iHMzqj|XcZ43XP!|k(N-Q=Ry zsWb%raMucCSI60ZJRHxlw2<@g)YARby5!VW?$p#wYdS>Kg%3T;zJ4n+_igw9Y%ELW zmPI0qhJr!*mEzH{EGE(2#z!%yN&a|z`=~2CZJc~~HuTtZ5?jrZ40^~uUy?()!@o{_ zOKJ32mK-ix#{oH%qclsdxTM7~mY(SY&T_qq(4v;5+f~5uXRQL`Y4PwyOJFNk7jM({ zXvz2ua93cb{2rB)PK$>xZ~~V@7|yxnq|3)f!*@A>y>K|U?u7T~q*88nh$#9Z_J_1&@t}t>FQW)37U%$E~ZKs`+Y%ynFzx zaBr2F#tXMOZiv0Rpt@H5tAsU6O7}!ZJl#R5 zJr8uJ(Lr!MRRKJO?m{#>3#>$jYqM*2xuk-gSf$>pfA{OAVIjps=Z%`+iRw68rw#ib z8_Sk3c>C00^L|khCxtD2hkQstUubOG9@{o=Yd_MMwLMa8os8wH7y4D>&i4oA-1k~w|ZfNxC)Q4a-j)f*T7Qe(LjHu zfhF%WuEGjoI_Wbiim+dvtqos=B$n$atnytI*b1j?oeK9l^Ah^+I!4+x2<;kq>g+nP zJM+8Jz+>#hDqjskb)ucP9PzC>zLgxRbR@cw^CD8V z(RE>QZXTt7M(3@Middt`mT@cKwd*Pm8aI5QvyENC*j>WdUBcL1L&(EQL-k5t^91ht z%%#qIFc=3a$H$GU>&#~B9t_Z(?1y%}ZVwj5ZkfCSj$!oRFm%YaOH#UZUj?I)Tc4$V zyX1Sjq;tEDU3W?6c8S?`$<}tsz_u*HB(_i*EHc_9x!EPI*(HM6dgCB*yK#A!ylXGI zpBWfMylVLMw&{3ndAuq-9xv}7;u94uAKX|Z9SY}D69g`0-&+hQ?CC>hJikDqu3^52 z(Mc4?|A&ReFHsdhE(YVd6Z-3W1aLv=PHZuD3Fm&WVOEp#8#( z81N{s8ak%!?Ip5{Cr+0it^xqBJ+KESIytnY+wh8pK9i2v!>|X5OY^;q&q!&~tD572 z;$sns%{9BJ1o#kl8xo>`2=E&j>9qAD4@%~!+N-nAd?W)cF(1ijcBSAepTjr` zL5fIoSYdY7LDU>p81ictEnl<1%<+)ND~T8;f%X8~VGng_EKXVNJT~tAkn`VM74E96 z=|ziQGx|ds*XEY(`QMgUy%Fy(bRjj-^7RBPF7ovR2!E4>^e6DziA2iiCTqtLnFwF=-d zt_oq7-&28AB70?CZNGVN20DsSndfy4V1?kS(rP-fDodTt&sqSx{mv?C7^VeQQByxR z@$|w~SRvfL)`=AxFP#AYxVlJI6)Ly%iSY+3id|8hQCNN}WveP6s`XX(q?+A!CAq5^aeWIc zzss55@dqn}U1``AShYUR(7;OBslcj3o+|27nLpJ$PYob7%4uf<+=Ufb#dfL(P^JQO zhOVcG-@MogXW=TW&-N}X*UIs&CU+~xw_4wI)A)@`tomPhiI$8bz)RtRj8vH%=nYY>om+N$ypK4s_yCTBrQ~6d2ny(HFZc32#i9f zVO7Md+T^N;YZmD>i|BS4us*9a)UvGC%pmar&?$>bWK?BEHKUm2T16ezs-s$Uc)dkK z9Kb4)@EWGqEYfQh={0rq?TM<_z~5_#@3n&LHLgpw?BV(Tpp$c2xmH6wwOl@4Ch1NC z&`Ga_C=Gl7D>*zGSJVrN9Nq7@a1?K7=HQyKQpDeLFR3{s?DANWD=-8$^@2EA`sFpfv zwL%*rz>00(KWGoft#8zPJdWG%IQ7|a`yJN{Et(1gR?4pDH>qE(_IGP1^{Yfzi}a`x zclmYWn(fa%&CN&`W>#J4eMp6WJM7Nz;^){>XN_8Z$O6Pj0n6>nw?H8hATh+-{ZRMn^k=V=CdlBhh z0PWh{2&8F|=vGZbGKtfJ?wT&MRr{7TJ?pJmflcpjc9&Gp)5z&5^$Ec`%q3(Yq zy6r?w~)8hink$fqVM zi7A2C=!)GnQAv22=plaB8gW-$>{C4{q`ak^64aYXDIHE}-g-@% zYA-O}PEz_(2?*qTOf?7`1CW%KsXha_B1y3=mu~JNL2g6KrMsM#ygUnfudzPNe|ayf^~Ii-#ux6^R_hGW|fCHH)w-A(#- z`-|KKf||-NSN9~9&~dpOm&;lH)n#y;-yE?)VK*4(RCSyJin2y1R$sbU|F+RT8kc1WW^M>X7kErqU zBO38|gk}7XDqtSrJ=#S_v2j9r$YbB|qveS_(s(!=sB~`&Hh12WM%WLAE$#e>XIXw|=@Cz%!e(BgOx;9TvH@1iZ4^%9yv8Wp_RH-OQ1MFU z{*bcfhZGMdu5xu6q(&Fh64FHHOwzN56imHWpC~1GNL`A23(ASqCiRC>3Qyc})p9=k zxJ#BTEa)Y29Tv;wtf#0SshmssG@p$M{jPhcu`1_f(4}6G0z!xrWzglJO4QG?wve5* zA27_|A?$lo3X-(2p_WJ8zaD+5h88-GeKZvuC7X{NGLK^)H6?^6I**2aRU-`@y*^?O z3^!aa36q9tniLAvdLB)s>Nw-7miVW759#$iy7G6OH|qL5+Dg4DGHm4nk9J*Sl(6+yn%Eo#k5dz;lipXko>}N9xL^l5k-WwYEZ0`9 zZa+FWOq>7i=K(Q@Hja$6Jls&J!F50xQGse$Fe zJz6J$7SPvx;w4nJxGEA3EYy?O|0Et6KG{=D9o?^|k7=YLlyyv-Tg?3b&dk1)o627S)fV zx>HoeMm5ivjbvbn2bca{3~#46S9 zL=XJErlel;P_Lz4-<5`4MZM1-tPrXZ>?-wB?cuaP=xmY!dmQMZrW6(EaBmGZw`%iNy-_JJv6k^Q`ShB6`t^LQBVFJ&dR*u84ZvQXb+7S1 z7m1E^ffd3kzo%S1p6Q?$XjkgHB)S)lRuQ*R=+YTAb?}78{vO zL%a@`yl|*l0WOoHvt{#6!{N3?74ZB*&_EwS)PZJ&6arc)kmBi{~r(&zU zs?PO+6<9UpQ|;k+nics*QrIP9%DSk*SHqUFZ%e0~8O38{#T?$*O^$Z}AYf(G5n%DjrU+5mz-MKH&rO za#8viiQYA@8rN5i>#JlSvT^ZnRU%g}*IusmS$Hgwg^ENs7^!9?R*tuawpHQ<*{w)) zXIQ&ZR?E5{I<`|ae>{t#UH8IOA_Ez=Nc7aq zhln1==cGsN=PhM^=!%u52+$!^1CJ6ZN@*m~tDxWZ!hU-zbhXIbMPdb3OCK4*Nc7Gp zYH*Bd>oNqI=ms9c%`QywacQ+X?qm(*E+&d{wil-&A<<-vol z%&#W8A4fG$K6?c8sKK|6fVW)hv*wvW_x2uk$0pU6NIAa+_|rRV*?o<$-PgoFVt<$V z-fj}?p97t>89C zb%7IDv7KwI;?rokZR#B8a95S2&VIM8o+r9h$j(cY4Th)}2qJATe1(5p9T<(f0?T!j z->RXgYKba`U7zhLb%~d@Z%ZXsdUqwVI*@KaMHzHRTrSB|X*ktrbq2A01M1Lg8@q%I&*DQbT*~~YHtC)E zu5EN#afn^pyj|O|Z8htM)%rZ2ZO>=>&-i;vec}AiRi}HWPH2wp((dxRFI5t@pR{;R ze}3g<*Y`U`_dnDfj2~*_{E%DOA8NC<-y0Xq!nJb3^#ppY5OC|Lz7p_I!_2L7$m_d% z4<)L+di6e0=M43tLLwIex4OZtZg5Mmf2$GSlK)=4L!UUD35yADwWV7!rt1d*Ue^zERuN*kH-7@Zd%M|k+78L$ayYy?tu^VnxH;<$A z`{2glhAqqAIx)CmGwKg{#r~E&`j)AdyJTPP+OFkY9-Hp^>r+Nu?k*3tA4x>_y+@jM z_mo#ZSr23ut_H`er<#6!2E)+u$9u#o#-2vm zQPSxo%5xI2uL5>^P*F5ZWWXrDp$KT%2UsDDI}1aoA+1UOo_LJA{8oA=n|qUl1^6f2 zYY(eA;&q(fRXL8&xSJ#1-GOa?<{_GKs0`v~u<6aM0lmv!u&Pe`L2Qf3z_L z914dPqJXRLC>gFeo5KUHa%COr@)Plv->cNn;!@&t8eK2FDx+5obji39RqVQYL>!K7 zO^3Tyc{pETxmL=;g+~z<&;`!55wJenRXV#$XSedOdZEMJ*X3|3y{Do$;pCk1tEo}9 zfQU|xXUYDzNzv9zVs{bex%uKx_l_`>_O|5SyeE#X$404-Ab7kVDHvXU&c@rUgk^*6MbIb3F{uPFC+Vu>5#`C@Y9T$#w zXN@-%A0T-@K)+}o?_VXMX+1(Vo9-?m2>m+zEq8d$JG@78Ituy2_Bbi*7Q^H(Lg_pX z8F#22UFYIDyvibMUL@UNx+E#2uaPbF5xF1H5Ftch-4T12%H2|VP=DRC86Iqb>>*GP#~ z)Z5LU7n^{qTr1q|j?{LQIxaR5%e4}DD#tJi16kP^87Yowh{&WLjLu&V`HVx<9cN}@wpHG(f!EN__*T>+~~r#b^K zkq}+f%dJnco`+Gfg*&9=H$Z&L2Zu&lHatF)U=YmBrZ>EpBn*Za6RP3*2sYLq78)DGwYj~bdlPw8uW=KU3p zJsgkgw0D(wJ#|nAF>$!&qh_+G^$1n=5~uS{tW`! z8?{C`c@hI{)P_o8TWLIud&HenlRScu%-r;h3ee>ssZuLZQnOuWpi4@dsKiQ{=2hECMoE9I&>`$f2rTeAgjpdhXWoOo&5;7#O1jqI&7$jU*1Z?X!_dUrEc9(nS|JblsBIzz&kwsx z@>GAJV&bo4+~~VEZLNFD)k?I}EO}{`HZ)5c+WN=NzO=Jh?qiEq6&{s;?H86T)&qxv z@oIfl%2utlSFN8{tw&dNfK}_!Rg2Ii^Wi}5nwGA~1DEksB60Q#WCvKad`o*B3d~J% z)p}|9Oepavn=a~Q0ntNECl6gZUTLgqTdNiaOL``WQGQj0tJ>kx)ys#TuKn0nqMF(A zUK*$jjo~um!`z9pIfSzn+tSK&>GDP5aUE0p>3aGh2}SfH?wud`3kuY`=bP>mkKWwu zA96#Rhz8SZmg&v=)m{f7i738a(@?K7k;GGekHhWPpFybvxL2VQIqp|D3mpQdtX_vE ziNk4NH||cT#kZ^n?#obtycrfb-Tf7;ofU%=}mWl zfv$O)bp~F=riFXAN#(>*uBQUKt_!~8-uuvm(LHtaWeU?a9Zn}dC@1xo!Ui`D1pT&DMjKcS8OB`od(T# zqa*|mVSFNSI%QhPjOKFerlB=@K5-i)j-F%u@kbftc#2JEv=6z!8^hz-s3V_;9E}pE zJ-~bfkTpLlQNJm2iyc2xtIKGIBNtxu1S ziLA3(ZDA>$i+1q3U;6F&Mz7qsGV?pTNo?KA;#E9wYgf)Yc!)NPST7_V#Uu%1Jw)5< z^;uy53YU>NT|N&P)hWZBGN^Nz#IM8MGjz5+;?K92<@siLQz*P!UtEPEk#*uwERsl% zsBRt>I0JX#F|r!MCvZRq#x-tYFC1T?W8)y480B|Y6t`TjlSAS3umLXMb|QxhSTp>? zDq^?FsE%a&T0j>hucH7@FRXA+g>cKmbxCUE3ojQE`&ZZsok(>O8|wnD@=$dgRmV|v z5v^P+;&5Zq#vyS!4N-NmtB!Zo8+!)A?$_B&kNG#-@5T}YyPkX9taCU1!L8?GVE&LeHXI5s?)gpPikoa>Im zmneYl7oJYSC$ht)91_o;!NOw%FGeJCEiksmPcKA99c$wKUAt1n2v6H0g?|SLeHA5Vr(8NW(lHZiJTJA9%gAp zB|1`d(^C1-20p1Njk8Rg;hLWirq=IfJ0$5y2!Wr^tvjYKDh zGsvp}>YUE)ksfFd+;C360}XV|>4<9P?Y%$faH9g-E+`u6N<6MM>C-%2@oS2d;;qAIoseH!g zr|3^8?b`}RlS|^YUv|pG8-h0%Lw2Jm6-!vB_zJJ_C{(Ry%Hl*XYY)AQIRLxE z<-7>H4)#k4J1i|IGm<<49R&w)*i>-1r;>i0b%`5c24NP;15Fzf!+6TWRcz8iidyib zJ%erJCKi?pKI8s_Dl=Z$2A1E-rgpArOQ8Io;n;XbDa{Hjr|h}~^s0Dmc+1#8;&cew z;NZ@`aE4u_^ZVLevxiW1pK2x7xSPhiU1vq__BN%H@AkH^WR~ytw(xL--RGaeJd)TY z$&GuBA5pZPq(F|{I1>Me;@?N{nfxm%b^Cze(NS91kJ9!)-^M5`58M|3%e9s3_zI_S zm0uTzq;ZMZGXcxPHL`yu_EzZ6xKi1*WCM=EW9(nyDl8A9T*oeS8n}4@It>)g0xPgx zes}pj<@Z!+ zkhEw=)v=Zo&sqfw;c6ATz061h7FWbvU%sL7hZ`G-;1mcgJQgyWJ?seV0u0`XCy&Fg2a@l;&7T)qAMig%2v0rt@TRrer zx4Yi*BsvG~jMs`+iM#OBXVR`7z*scC^tN&cf0m`@uu|C`N z*>*{)E8Xf!*Ru0OH!63f%SX3_PVaK#kVHMPe}%KqvGH~sus)kk!>a05Z@qf`2Y4!q zQ^~pYS=9v>7>RE5?(d0?IC|k2h2^*EA}YPyfB~IjN-u%sq3U8c7wWF2v8&nds!a_v z*9vn$XOla$95fsoE%OtXu?CpoMxZlQ*vRp?vpg*ID(YU@{GmOB6RCwu;#5g$iIV8r z)G{Ba&qj4US|d*6mh+8tA6nn>@%pnQ!>)Pi{``3lb)gv2)C*GnkTRQRDTepwPuD}} ziEJbfxx7wf6x~|Gr}2HCB#ii{R6I|ufgZBEp1Re0nwPn!lp{~6j3hGL;6!ow>e2%) zhmK5FB!#?j>GPxOPqk1vbt;(1A_*_}Po3U9mtW*AOOoRRXc#?_c(-89!`bJi7yL$twMRA1sUK{ z4b7Ng4Ed$?lf>ro<+I9( z+odCI@5HI3uj@kPgT#s5DGS)l$p*RD4yW7Ul-|XeR<<~^+FsoWJ<}V=#PXm9H+_@# zA&q?i9RhdbXI9(uOK`w53Kf_%g%h_za1)r7N$IT~>8(CvLmZrz)?v?35KCftw5XGm zG{f{cJf5egm8D};_Eu%jZ@d9RAxMy#=oGjUExmShfP$B$AWrd(4&xhPN zCb9&VEN>?UhnQC$VOO=TCHtzsVw1%B`l8{Fn8Ig736WksrkHf}h1P+$RHbeyMcukl zck7m1+GBF;vaXOOoZ2z&VMGtTI%&N=U%kyq@r-aw@$#19<*ifITZg8J=zF&`D1mmR z`$JBgz;bmI(oauxE_J~A@HD&PO%dQ#a`;XN zuq!r6>mDA5%V!B5(llScT?O8?9lA32_K2h5_3`WNRrI|IZb+#23}0c|zg85pS*%Cb z{ZdrjRHC&&zje%zSjpkOIB^=! zA6yDoGw?%d`9BP2NUOUGo`@s$DON8BPm3i(rt_CFdlx-x~F#L;KdWZI)!w5REi@Rl`@d(&%+mp~S5 z$zBu3G)@Pm@nKifQ^6lB~It!7ED zVT^0sDmty}t_{cnR~WCN&^^mr_w{cbAYU7h1-krY{o-z4oJn+rYd4d)9dQ*&uj<#$ zn#4*O&oAz-_HIM3K$nj0+gzJ}&9?cue5D@^yv9+ku1(o|df1(G-IT~S-wKcYa_M+W z()*Cxm1~2n{aWC8eSmdSQrrOOv#9%R=k)}-d_&m8K!dA+723q+0FijJcO&?@~ zc!18E)J)lp)4$y+jLB#hH9{U?|DQDUD{46@bNUFK9HeiL!^89dvYX?;l##UjtMh>~ z075~%zUGnEFO(L{g^wumO2nm2VBf|y3(Ga4(5{ek0b8L zLlCf%gTqJQRT;FZ5N;QVQRvU+Di5bTxWZ{UJaM^jDIo%GClay>;O&Lw_pU+*7F7XJ zNe?%~R;{U3bE3gP;HLgr+%1o@TcdwhPXxqRyS;aJoL}#z;w7COZf3O!u;WkEv>sG>L3ekRt zI~gYw;w*G>a0(7|C!^3Tae6S|JVdl#-0UYh3XGqL%iWYJion?mXQ6WedyT;OgB9*} z8mg^xWlpTXJOt6qBe7hoprT5r8aSnZ#0tR!DK8TNs~qA~2$jgVe_Z|9c8BB%DbY2w zJ*@Oy4RL$8qJ2qXg;2TJRle2rDDWdzY*n$>Hl#PVAyyQiY4I<8rdt03Yo zED!D%wEPBi#d7gP{NtfQ7!`uY>hY~|^{_JV9u07p-&0YX@=&>`5s`}HKraWlY9l(4 ztP?M#~o+i7r$~Fo3PFDt1*O@wx%ATs<0%6@?XXEf4rKBhX!hhCP7i587{y zJ7ZVP*MvQ`y9gKF#8Eg49l~k#*(}#e@2wE3OONV>zgj7D6;f^tJSwQ!JXX)T?E0t+f} zJ%{HAc#}3$sF5hDgGDvM zcL9YhTDmWfyt=lUM&e=j(aHa4!}R^3c`!4!W=C5R`Hg0FoZcxslM6^)TYnv?+BKRD z`6ogfYorMSEWF$5|_%p*HWks=9`LX-N$BW@*| zUi3)9Q*{o9mT;(CkLpV9zCHkk@c=?4`uFIbd z7YMbQ5+UFPwqxvHVYv=RJY5Ugj3!n&K)nL^C${6{%v-LN3n)PX#~<8<4i_?e;HnVf z50;1RL_&lGtoFc7F3=%FRaUrPXlL83909Bfh$=H~eVdgdfU^~rYc(zy;REi%<7(l; z|KYB7w%zU8t_BXJcHk)2^#>iru6pCN+^kaptTH>*cDRBEI)qbc;P{$Y^+t(UV&x4- z`M`?pR#9_Po><-VRztki9&Y@>`mEZ+tu}exCV3PHv|n23BkD6A2mxEUI+46`2%HX~ zh84cfk=WhrX?ZVkcqHuA(RsxKc#YGQ)oU2mj=5Pi9q97a#x-%3s|RT9K$}(5fgTBZ zwWxkPB5S*q*d4-klhght(G5o17ht)%JB=!8+Rh;=Iiq{j7&WG9iJ!Rbx5`)R`evQ@ zL_7$VxIMg5WDImO;@eM&H)@u2NzUPN_$~hw8!gT~j2aR7FzLfn(=bhS|8_i@bNeSz zZMQkZhbrCY6SjpPDz4sncJqic>!Yw6>b!T)Ko2=nBwo96sOwz{Qi-r>n8>~TL$2cb z)cMb^GI%UG`uRgRKl@dn-R>W9oHe}mpjfp?hEvZ9A2JRSl}CjC)5wPtQbCx zeyF}Wd{5!wq9kE-eLf_(PU{$$>B|7c7#7eEsXL4`5YbGXM7iUmtzW4K3Pcr@0SVx; z--?nq7Kr6~mazh3k(J#EAVwG1oiIWQ+_`}0%6r%g^jRJvyM z=-JA(muuHS@WVeIB;T2%{0`5)v-C3Pls)iVtp3d zc$GfwjuKnpa2jyH4_uCzyQ9QifgQ)jv258qIIuFrleqQi9xAXqHf~*jyRbgHUt#&Z zDrf2#0h@~iir>I73d_TA8kQ?a17l)TxRvAeC*sO{xUw~#0-XyOvLg1v`m7Qel``mc z16O^vD&kdstFpMHOx%C4{2nKAe}B*+P<{aPXWOk?!+T;SXIFB#%~>|m1w5m$Jlu-7 zx(FwThujANT^FZX6gNMKqj1(|Rg$Ob_*8n&Di6o$y;UT)I%M^2ZHW@&4@RNG<(6pK zvUy^2QTOWIwBZVLy=lLa=r-A_zx7dmrz=*gs2rc%owUMT&0}AeL~8ZFZj7l*cDcHw zRFBxex<|$?>C+=N%I+~zuQENN&@D+J-)USnyu(CMKDP(S1Z?ByW} zuaYjA(&{y8_8K*NjhfwZSzs9FF)pW}M}=_eGncK$(q3a}mkr9qS!v)3aP=BXyVrV& zyIfB%+=Y%}{)wW}u&N{}Cjyr1T7?clqh_~6X!(gL;vZLs5LH>>rl1As>NRTi8a2D_ z$N)#V&T?JlS`|RGhvoOG25#if!gB4-5I5q$^00d0A6G}rJuh*E@Ys~Aiqywy) zzs|8Xh>31ozoGIg45P!9a469or#Fut9#(q|tKD0M=!DZ^ui)N$jTqG(7Pc?QcggziH zTD5-8G_r+ca+S!z^NTcjv5Y)Rg~)0T=N?{phaP=>q-Gs{R5X!Wx$u(`2puIGek=1KpMC%yr9|?u@S~SN zM=`&taA}z*kxPW|gOd+w6zf+*ZZxyz06#C5sDXc4#pj`hm02xy`p|bR2V1g(^b)^+9`sL@06M15_o}oL= z%Hc!aAO4b{h=Twdmxv!}mHU3i1*fsLi(q@Tv%T8cQsrFI*G}s4r+uJwvmYtF zn|k|6N8j?)ZEY9x_8Mn?8yvTNtJ zt)#hjEm3y0kFD3BfZJ)%62*Grz$n+sF;BABu7%64CCaWP%GQkn;w*Hy%XQ@K-nM4u z+O;m(wHVnJcdT9OlI`QE#L9eB=6U1OjXP6}4sKAP;r$ z@UXd;>EI#J+0+rkvi>=Hr0_R5;j`nOeJsuKvHY{5H0b$syoP!sa0vFp^>b!w75 zHOU@NF+cR~>*%eWL~Sz9yO5^O*0k&95;{>5BVHT;%)Mev&!^zkHKV#_R1ZmuH`58# z8o+k_fL9@CaRcKw&M8$<)65N49^p$Bqm17Ww{ceqH7AEl+St~TdpPDXIu~O)4bmh_ z?4NOLV}(GKu!n&oopAg8k!CZucE`6vvsUDITN`ur26YW=rhYzVv_mC=b54mBB>TZeK;qs8|;c*mF zPfO(Lug)BINlx~%WNJU9HMF;fR3O}b@#D89(K;Y43!zf?(8b9_>Lu>Ule8Zosatl~ zAF+cz!YKIY=u38mq(fp8dDI;Ps`AkmagY57qwb^77r3{d6y2m>DdK)Km4f4J;60p2 z*Qviw0S{{*ZT+f6PU0xnsgESdNo>v7w{exL)2Ri~wt0HsEx*@M^l1-V`fNW340O3` z6ni)->7zbVMNc2#C0=`&m7!S~;`NhlyZFFuzZ^jlyFXi1W~<6<{rT*Cg-%&jyl#vn z&L}*_%3HV%Nr;?H_H(%GiHV~;RBXGV*p*(2P7?ogg=!@f7bo8GP*r#;4adz}tDwZH z<5LlH&;!e5>B5D+6?3*Zn|(kHq8jUUynPd5M1h6}r)Cd!KmS>$yGyM&UKK za&=wkVhqsXwo&1Z@;l3QmETn!;uu{+l0hW8t@mE(?Y&$*JoWDO(5vg|Y7Nk_b@vNC zkIBK&eyc<3YR&fJ*}&nzxNu7h7hiQ=O<_QP#tS!zyRgRPSp)NOL*z@Dz$*3SHmSul zusrPgtfE*|zR~}nJ#c56SlNs!$r|lxvzzGTP^Ubx61gidF6e=OTph)(lHBJHR$$kk zRp*rsOJXYzRk5eC$rJU&KU3srg%v{8{8<&nc5-e-%&RJi{SP{XyJ{M)a=6ubufvso zP@+c-$vL(kUI%)_O40?3#9503f*8*9Gml16}j0yM|U(?5depQu@Rw4_^BGYQ>o!{SVr2)%>dJ zUbX&Rqw+SrHIPI+u>zZ3fzbXuaFyRm@2vFBsLxLMtu!pRogX^f9!qGS2)N7jl&hB=>v&XNHHFepKd{>5 zsn|~C7*@4_l{f8hAKr4Uyxr;-HE;eFD^BnASi%RHfzEt<&T)KBL)0=fYQY)Z?JT<6 z`fp75jH9q3o-Pt;0Y6-BeX-ohq84Ql9x@|5Fh%W9V!P&l_{Y^Ls~U>qokUO-5a@_& zG?E+-=zaUGG+g)NxNZ--mL2*);_1T0iXJ&akQclIWJYhg<5N?*4nao$ckp@rTZ4jRbsh z6}aj%&t!Xfa_5_@K)32$+v*)2&vsNUYSiHSt%=+Hc-IPK*H(Gg8hqFOSkm5y)y{UU ztM_T;I?8p`XKwaCbS`Qn_+fo^%7YvDzST5pk+W-Ey>EqZ-MCIotfv+^r)J1go7W%q z!efl`8|CV5dRoQSyi7VJP1Cha;3%AhnXX&s+ZDn6fuwTa6qqtx}3zHVYAXIFA4 z<~*gydi+o>&}oni5_pZP8QmVFvifkW3&I-aP; z(yFmE+n73j92|HQsVlWvJK^}X6(Gx?SrHuQ&v*!z7=_2Nsdk#xX42qtxpHPrbaBW? z(rHx>%|hM8PzdV!z+=2d%|6Xi$%o<0%RUa!Q5>gNlCaZc9}$SPh{x>1kg5pWzc3;3Z0kcZTI5M`v@#$6%+9rC=j?3N^IM3Uu1E z*r}75#IRqAanl+wvs>pBr_K}3^uhKi@$i{dCsVf1fvmJ?`$iA7WJob?6LML2O5A!% zuzH?s@hC8f4j)cuQ{uHm=R!x=4>g07$m~49RBuL?j$~A4{;deD!)&*BoU-zE%9h$G zi&`hmM|B}@3Z61@b4qXGq2`HYwX>7*%D;)ukk&rZ2sqoxWfh`e$mzhL-x*Fi>bt|5Oh|W=-6~X3LJ$BSC;s0sYxe> zJy0l-*c~wihKWZ>mkG7Rc8t9|I5wHrx}|lKCdOGL{eQ@gl7^}2`n1`%TqVSQ$i3sG zJ^Tc;uWPfecWGd|iw_t*qMk<5CULNPbmX?XzEH(44S+x5VD|_+n`<+y*OrT}Z=pY$ zVNx7aqItCKZ!67Q@V>mMpqKYoKxdw_?L(^5B*rXs2x`5{7o%<&c1;}RVU9wF!29sG z?!za>Hdg&m$>x?l*jpFo@A7W$*4_EU3YXS+5?weN7!vPS=%iQmN(<-Kjrzpt5N5?T z-HtTOCtl;Kg5oA2ar{9?v0T+{Rhe<{zh%uT@sx+-A#j&(Ljhe- z)%P@o0e4}AQ2l}v8?aof)Nf_;R#9_~ORVlhSq(4>9WG}+V7XSs-s%_kR1{SX*L9%{ z`K`MiiGN1CT^@FMa1^q;^N<=wz-x4EYImR5KjSFZ>2Nhom5qkPU4iX#J>}O!n{_j9Er(skSV>yaZ#WyP4>3$)tp+pbGd?zSz zwL&*fZNG1s(*-(ZQRNUGQ>D^#%fRg|Q?|G4*CtjJHD^gT=a%i*TSj1S8G%i#TwD)V z+EU+T5x^~zsJHB(-m-Z5u(`+TW>uowx-?a88U4IV&vGT=<LC$pNnj4HiA-a+=oGjV z7O(vRb6$AJF%B3CmzPRs%jcd+T!tk!|IT$!BG%_X7M=|U&NAf(412(|Fkv8V8rK%$ znhbE4>z##mtu#<`5*Q9a^>byo8(5!3MT|`Z@f!DMJod1i&Ax}DfR*YAlV#WF4ABf( zB9z5uSs4Q!e})kkIPLd*N-uRXX7eN5vp08@L^hpXs3&_KVZ-_8&(A!R0wP@xp>3Ur z>#Gm>pv~;lp-!H?B(b?pUMPITbv)6oSg`}gEDYl<57(bzISt%K$Tfk?*VJkmSZRR3 z33z&8c{mOMLQ>#z6nIksc>kb3!@x}(g^m~tTwr$^u#8NE;4=}*+`#S}W8DVaih`#c ziQVPE)kflQE+}(PR0Enay`LBr!f_g??g?}Y;H5a=(q~lnOvD~#cFVwK-~U4nw$J?A zBtVVE8V$be}_e36@p)U^0>A1xnZhJwB4I5yP{cXElg+hZLz@cz> zO$5jyJ~H?Lbf`_^^wCu{V3gmhym8}8bQHa!=<^tNeRdp}I@hVr<)^WMY?gi+8(1mB z0Rz#o4Oba=u)t}*b30=7z9Ts#@H)2D{2A`25go#+qE=5m+MoiX0z0G79oI)tdq zM?Zz-Vbo`CR30&p;;AcPipr z#doXY+^Ttvutz6dz$m{SO*{l2ZJmL;{8nw=YJIm_-*uVM(g?9!<0}lKqu?eP=%VK3 zoW$l)Qfn{ZHM%?X8jX65MqV`@ZITna>y5W9iTg*>rm%|+HWZZuyo?SDr8^Nx; z9aZf8r+#y5p1Pi8hV^C=IscDVuER^XF?B zz-8C#68eohE*ZQ_2y~7qIb+c+<7$OUIz?5$>CbR!EpfV_Xx$Xp90Cq_5^+l`5z2|c z(+bPg#rNx*_HZgT2!wzY*sb*5?i^Pm-~)q+*t7$eJ{!}C|W7sd&CBRXx^BBwTc9Z+whtawC&El<4x#)fRGu)N~`ZJC8K!lL$2EA|+d7bX}8~1oqW*Z+t z7vqtt%!U*b*|cV_vbSNvL{&qtI1|Ho{26x{vsdMGH^XULf{9%3&RzxHtCVfD?l&q> z&-9TKHYy3T%4L@8D%_RJUHP$*A|T7#R)G>JP?$FQGiUARW_7``i#u2QKVr|bngc5X zj&qd=C9QW6qfiE>=GuQ9C1pq}JSg*LA_q!yGi}_^?={9(*a|zQ_y?HTlOT7nJ*`I+ zazz^7>_RSM6ORH?hki(LQrNB~5S5VVgmC6gQO#miek_Z7)@m?V#c|mmPnVI3PD@B(4^UsZylj#uQBCRS z4b6-TSI#GF>nPmG+52H{O2cnxRk&RIB&zxzHhaK}UcXh8Rv8fK492&&O+vL*EqO!T0GN0l!N^f!&1xMPpuSq-K?%t;1`&P$H?K~J@{h3 zRLuJAPoYco01fE}_+U#RE}gJe+){{p#5LukqhGnYd^8mt^qrFY1+J51^c0ySM!7~5 zDs(lf?fW7?jIqG-8|C-f6*t_8*I3D6H{73F==K#4qEkjmEN~V&HYgAg{Ta^+68}^~ zMa+s!><)nol*HwfMP-x6s=#ucPT8((!rV?GC577-*MZBq;PCwr*H(dUFOukO8%YOl zg+S{xKqrUKHUpR2+;FMWt_v^~x2UaaArpKLFZhgGs zkht8cB|QP6e4D1GrX9?qCgREU^Ty`dJWDm|=O%5@btpV;-@0;^g!RlLf6=}k#-Xo0 zHI)qHp=PO8vsClh;x5TZ;;zrG1LLN0Hy`mOUM0*^NXbhQhd)~`vfspFznWM|ijvr! zGM>sM&iX70w?EsJhFzsY;S12=YP@VdVFEho_fHi&3ceqfI9Z zh-`28K$9R%)_MeJ&{8@BzBsmbz7Y2kORfZLU*z9Yu6!lUINesV$Qj z$|f)L&)wZ%hr7by!yS*fzk9?^G2AJJ&i2!+rTiV1&-fEE&+#8M`BSGDk}^Hxm+BO@ zj1P=iXb-(Sbf-vr%QULFwsG5aHv83bY}68Kc%_>-9a~fsxRD9Gz0e`-@fl~iE~kOp z?rr=2z$ynGaqUqns9{k>;wlfzDLWPJslaaK_^xt|Lg!7VpTKGut}DqYAGWU>UA|Ix zNjwgBY`e}scO&<#Z3RwL-wO+(xGhut}r)>q=V#%&Mb;^XV(z%5ta z?n`XVuH6ySuvMZa+Qe&gU1$@W*j1>swv;$$^*HqApIhH<%{}qBppI6D)&kqp zT6agUna!iO5+1=w>-1&(nV0dg^vP@YpIs^J!d-iNV1fU~)tMy8vf@y5{C4B?X#X3l zK)Ox-%0b#O0;g|;5Rh1X(TC;>|F{Mdz)7dht{bnSz`CXI3Ws9D66Si{lGqgS`Qt%3 zXJ0I|hmr;hsc%@^cfUfcSsw4J#}5{O|NIML}f}fGo&VZxISZ32-t&39QH62+j8Du zqMCRW8>}xMvU3iZVLd!;8x_Urc3p}MQeNUwBH1~IJLhoc^z@nNGGmV%?vb88ujkBb z>)Jb{*m})lp%d6x?;SHmV>sLcxqKhw5yx0wrSk}8l8TzT!-t6M~%N? z+xO96DC*Kj){TjK8Lu!HCAG$h%{b+kqxx|(J^v{L=)SHV{YacjBRAZx-t;KE`fNE3 zFfzQy4f1$iINIyENBg+fdv90Ia1=T@urf*^X z!EiS&aVhEBvAI3)kV*~@jWxvcDuqp`p@h1+tG7)Ohg!*SIp7}MLM4a0{e{dt#o9PRzx;Ck^#9NI#|Sp0*A>xKl$u=E(b%BgTqBfrv^WMh_VW0u6Fz`CN< zm=v;POFRmJV^T=nUMRnuI>R$#cuca9Od|SB=rBU!FZ$k=r?@ z6dxXaLbl2e&-Vuv7$?-Q3>+RB!s9`BJP3~m;XxqW$HRR*Xwz1xy5MRuQQ>l7S=fzC zPH(Cq8m}f^W~dL9&aaUu+@%ctx;vwwSxj_nNJZ!59Wr1DK4tm{({oaQwgagOPnsae zh>te?UB!V3JvyiC;8UhalkuX*gzat;yHZ`Zq5`LJ36~IkE-c!IMf<{Iyux6PDf<^H z1PBU=!yd*KE=Amr1G|bCT{Uby6gK~Oh;b!s{t>nwD%59?a`Os^&)D=CI5nQi^cF_G0_zpE}c+qa>xojKbqLo5V=-QJi@Mq$g^RE`fRAWxs91P zJ!+{talDL1))>t{VI*L%m_PRg) zA-6FT)njR!{}9`l4?V)r^#0+{s6o-_p?mjO8s%uq^6;uQasOE1uxom9 z^;_XAp+@C-RYSDL2%Ba-tgCV!QsfFN5qvw*jO7u0{l}F9wC){AgdF2LG^Q`ZC`5bE zl@l{>1wfnJu<3}cX>#MasYlqZ`w>UptqF7g<1n>x7f#YP=~v`u(}3(pw~H?0U~k@B z3qP8Sms98V)VVz~J`4t=nQ?n&+%DIx-G)MccI_I>r6$SkI~_X|n?ir)BH?tnJsoao9bH%|V#^uIjqGm{*D~v6Jq=(H@I|bM+gQUa}!YdCH zZZ_X&0m|>x5X4J09$qC9?}9v}1<=E#dcz|a50Bdeo>)k{N*OmtV_U%5Rt#+32dZT1uHG25?NLWiAR*h%qqZzLdtIZ>Wn?y9R5_7wc~s`i zk7}ZQR6Bx?Aao{Hr4(+v2ERgcNvuo96Q^CPxfE`FR#{H0Z8f0$hO^g(^LK>RmkX!S zpViTXLL~>M6cV?~t!AA?5F7OL2BXteMUaVuzIQ-(%e`IcBOS|n~26y7&WY{M7~ zcXkyuT6)%Q%>(P*o;3lbeR{;Yry1ZQrj*8iNt6B}7#x-%iM8M;+zcFYe`1XfiF3%q zR8((_BCa-tN^@hA%$wsCLd`pce_ZbsI=!_>E!=;QQ9y($4CYNOqVeE=;$BUtqWi7< z=?eSgMAmZSbzOzX{Qk$tc)#?cqhA9Jd8_O<^`wdHR+|?2iSvfg9!gaRoWjwB=oFj! zw!+no`i%MB+z|x&v#Z$Hb0xbM$^*3IiES+5mWMHzc}SdzP7WR}ukvgR48wmSdxFHE z#1P^(g2a$tJZ|=I_n_a)k*0G-Ok{r=|HVCs8$Ea0!^1X&L%4;#qlTs%9`DY5q_CDt zTDp(*9T)F!lB;dP)f$(|KM9D30hSA^b%(ng@irb~DCzIcIt;x1`;RM{MQ_+2c~sV+k81Jq2nEwME_NOa zPYQm)aWQd*Fz6K)poPnr%f-UbXC-+R`m<8G6mGk2MbVXPs8I^L5?T9(%a^+W<+oHL zK!sbX5unnG5j*h?<03+`NbHIY4Ls55Jubd7H7s-#bxa3n4^xS(y}-hw^p=JKXuqN4 zTqp86w$eouP6bw4`a;)yxcEv9TIezhH3Ig?uE$FfF;yh?&v=EZH>leaF>@u(CUk7W zAq;ycoIHW6$jFj@^B*yW@3k*cbY<>mmsk2^I#4suy*b{LF)!o z#JgRiUAz32jZmRlpZd0*@hf!7_Eyr_%{yONPrPb!xZA4mDvD4daf@={okqDfr56TA ziS-J2p_<%qb>nbgBFBNw8+Z!UN1-SyR8bGe+7Ii3okCU1I9=gh^_Cz0aoxsaRNiju z!f6l7pIy1SUyQn+sBqYWKMRdI6p!EF^=Vj{pCX>$@i?dE$rWniVR~m*#o~;3p(4h) zqQt2p!Lj?oWe@i2eleGZATEM`2)$k6_?l3o+;3!CM*79kzQl=9W73RZ<(;Fb`RTy) z9P-NBZjp38DfwHDOUzsnF4sSHkEOAHNz7b-zLjvfJoqmQjCzKmaLEJbfo0K9?@1)i zuFvMQE85S}g0e496iW-rzC0x?3wBGZ$i&r+3Zd)>6R~tjyq{6wa&}l+*LU49kyxhE zg^hkS>2DS3R*`NO$hIhawKREmQ!M~0Zd@2YKbra#qR`-Nv5(58>Jego)ENpo-_Y%@ ziKy10dt3|m5(b0k>L77!;k?47z%aNC-P%*wnG-gFp^H<2$EeS+4j8&lwF;^!y$~CQ zZc8oPa;+Uupd#K*&USi_OXoPpwNqI*x=~SJb{V>pv~bz)R>Y-3O++Iax-hh`HKW44 zF7@kdmSs<2r~<;-#L_*{HB_66g@>*bQpVMiVCcrn#Ho^mrA*?o>r!kK$H&$5o;VfT zUPd*!?$W`AR(66h0en|!1+)u>}Wk#c!WcKn=+&(c%pZ!lom$7v4lAdm>S9xC*4h7jijAU2m8^H+p4?*dTdZTJ415zY{UtV~ zrs@0EuKc2_`j)=Jr4WYwPNjvtHgh@k8P-m-OIS`PhdSa4 z+`_5kpz;bGf(t5~&APL#@F)amm}aNZNn-0ERA3we5)YF@MSG%hj0%YylTWGb*++UO*?3hoMG0%FHIQObrXCLa43f!u=WTK}`;JpoJ<)*k~kfM_ksS ziAOd6TUidHa_mkFdk&yt3un`vn+wP3h2!+XamoO-P&vlMH9&c2Dqq|JSUBaOD>>7p zj#KN2Qw?U$X;*dLIn{pWa>PqzHkV_=c%69VS~uzzs(hy#IBwJh%JsNNjvC@TY7g@` zy+;jZUJXq1I*O}tXNLXO4TleLp?0B?v%X{36GBz@@~FI;61iHptNI(RVJlqrt1`oz zzlG{=FtaJttit7bp+Xprc&M`0&@8f`lLsUrAE1>?%}j$AjbfG+&%=+4bg9|Altq?P-0rCGnZ>u`Et#< zto&XU50*~ktToup-XRq3n}WPR|&A5bB{`e*6li^4yySFX@(tl{=F zT&=@(IrvmV;#C^BV4p|JR9-XjC?J|0juG={`FUiU@eo_1c@O&S!uW&o%f+J%S(ooj zPefj(wKUVJnTOb!o;uT?=ny*E#I(AlFof0YrcUH19_12`$~@$b_td%fHLo+IhgTlL zD)yn~!=O?CCan*!6bzg2Gh(vgT!`)qZy}Bb%?1iaocY&{?;5W#s07|CitdY;k4gn4{I9j*NvsAlxg;q zc#WY%Vvi%yX@HVAaSr1#sz|!C2@|QQs}&0GG%96W)a&kr#PJ!`AEbT??WiQKKRApI zVJjCym5tZ#3Z0yxKGfVzD25bnqmqL$J#pHvlQWN9k6mv!I=1U@!ydvByP%fq4SNw& z7f%#+dvHr#uEM_q&;EmoZ8_=KYM8p20_e}2-sMDYH}I{V+PIPr?M)Rb^V><^uI}xk z_F#k~?7(fbU)KfoUcn>mg~w62X>7OO?aadjC(+qF?$XEAa=v9$%1-+;rvGtuV0{?v zp({DZee-CL|Diu~b;Fpi(1E!T91mB=!^&|V;#?Q9_M18q0kmHi6jdo<6>i8fFwqU> zc+frJ*gQsI%dl`6<@dT!uM73MMNuCd)&~cVJOb_4nZIr@o{=cD7uth!5#Jw_hiHFL zfpO&)+N&&7dSlzwLwvZ$#{T2i;d-zM*HE}shlil>03Ghq;mQgRj^TO>^#Gj91lm<~ zL9?(K7SO^v^x$Q?z+tpsr3^160u@_RE8&`_a2xHnf5s~u{!BA^Z}X4DWmnC|-IH+l z%!{`c3y(uMcD)WkZ3lPJ7i#jM1#053>+ok18no{avQ{lr>2xQ(uRpHa=)lwi@Nz|= zYpA=1`tfJZo2UH#M^5eGz+8o%{k!LRE>sGqaT~8u#W!3!)KrH|3KLyAW7>nnW$nrY zZwf1P`yFn-Lo%ecZNl3&A*;ng#Wpo{(9obz;Z8Ti>583B4lEoCr_rA|4aL@fub&DN+{mI33Y}bC>hn5?cg%R3b zKHM&ctqDb|!I1Svp+9p&-0qOuO>R$na5-%EHwz=$j|=VZ7dj0tzU|R-yCnDiXJ})PD5g(a=B4uRg#AN%SoW~(LE_Btv?x_h@N|En+AohZB&yL4PhZ-G z4qhfy=qS8+#hWNVeKx;8s1T;6FKuK8Uoi#xGffhBa-eYA?=iZDyb9I+_fsOd=9grR z-vp@)t!;(V_Gb>)!#2;|CQ6*q4*Emq%>(K1qHTChHnya}aU|CL*87U$EThwKw5+Cm zc;PTkqdtSZQlb57mL6VT46iSS#}aMxhqw7d2IPs3BHSUvH6I${r@OT50D`a96n49+ z-hOS%t|}ee7m?@=Io%6!X;tB|-)mG|OiQoRdZTG6b6Rgi!}LOX(AYOEWzO!{EKl-u z_QT<%4~=ruLL~M0g;%H*;IvRCYle_Te4$*I#z`8YC2F{$OZ5*C1))8+_ zDARh4Y58HgGsxT_Z0-Tl~xz`zx6g~zUr%_{EgMe%kGZFiA9HAZPOJ-nIz zLrY@YGMBd4A36%_6tr#rP+jCSRrm2SRU70#^il)Y1pgKhJj1s>Dc#p6h4<0}d5PRZ z`@94E@wVJVsL$3XUB>!kxAzdE%F?@UiCFkN#620ymrbls!qxI+6N&c|20!DBpNO5$ zPZN6LWZpT0KE(W&xc{KifY~ciiR65exEL5`gXOC&9%4{VROxWUOjM!Hv6t}J!)1ne zoaiAGy{ox~7B{edgq8M>Xo9eK;NwSFpe~)``4MK^N0(oO!VCS1Eucp=em@HS%AK1; zrHdOVg-iu^LJFsG3sDox_6tip7RzUU*7CNm&;Bf*{Yeaa2t~nt;ibd-g$e-!RHDOO zP7XWMl19By5z{UrQBgp-p12&saR|pDI2YTw*iHkDES9w7iA~``)tq>gV{U2t{86E5 zh`Za%_7+RJ-9*>Wbsb-~hj4P(ZI-PtmQ<{T$|emmmaQT6fezZx9X|scG-P*EsMdzxQ zewVQ3nS9fC$uBmO>d9XD#m>prb%=#a9?(3tt~gv<_YW?=^a~6k+C;wpTf&^*|1ER~ zP}e<#0xj_xm1DdpP`K>66~cC4^?G|EE>7CIKrbm?x?}|7@!RDOLP$M~^fnj@tXnXO0e2G=P#JYfgJaCzRy8S*Zg~^$`xqF>u ze90-1^jvLw9`AtO#yOG@JOxB=v$mKijMf{bC9_o8nFYpzvDXbCF zFB%mNxeg_SyY7ClsBkC>%%k-*kr2A+!XRQzRXB|b7Yg3Qu81)q7Aj(F> z)mwW&MGU=rA#+S!dZH5v4Nsy8O$?ZPlBkKoof8)U;K<($E!C73zd253?D*Je27(n-jP~LPskU#1qjs=VMS69OK)x}wlYQnfnOSr-%3?_p1P1l0> zwIF^iQ6$b`yuzc{U~zOUlRtzckZ2DaODug~pS(|;3Sl{AE~&eyVYVjo6@dmQd@V8@BPEe7OtzUE@3EK+%A*2SPm366PIFx&~+_>CaToAPy2AH0=Q3G zs1R`G_2E*d;C5-^)@R#2j-%1?V3Vj2uKEyVwZu!m-0HbrB0TI$FCKSFoT`@L22LIQ z!>!&xRnL2b zaLEIPx_aq@v2iA@7daYnE4iFJ^AU{@!jd-M$k>dSlTrB0r60y9oS$&Z?^2Fg{rM8b zVz{{OdA)}ws!RO{I^IXsi++TAJ!0|>k4I;T=!W?vX@Foa{HO-Cm{k2dYV?QMCrSPk z2Wxjq36!s6d~(GRs(Lfy4>Aj!h7zm87H;9R-=%Q7GRGO|A+$t^N3o%~C(bTZ(Q?Wv zJVpfu-BC>GjuM??$j6DUJlK9FUb}`6w~7=h1WbB`*JuyJuCp2KVL5~~js9%=vu(fI zAvp8UJ{78TpmHj-t4jwan1zZMQ*7c;xG;4n^k>e+anjHEjE?ONqy2^w30+sB8`pJZ zT}N@%n`oO@Xb*1o;c^H!`*3Z>wyWDixTs-#P)JuA_!VxUDu5Psi7E$9-4BO)DRm$Z zm-@KIw<*huMDb<=lA<3|KyyUoRi%5>%On&bv-WHCJJ)lxDy(r=+YfU zm+{qBAB?oW$rp}eA}13s{b_;V+jH1h1 z{5=7-U#Ax1<^(HzsatRk8F22smn>-OEcL8CC(3Sy6~&8Z7d9>|y2l+tke+Uomycy`VqG3I%u)qkOJb>p}2E_YRf=G~_O%Icb}Jr4+Q#<_vY7#UmU zzjkObqihuKDj}0^=@^~xLfw+5wbNlYmh~8TTR#|j5jgKI&N!;O&TV(g>X$$coBA|I z5GrE~yx_Fq5LXK|3x(o!e?`=^0Xb>bPP9zbF{sEdcqK{dk=|XI3(y!x->j1~0)pu?>j> zXU18tY*rUbWW?rC;%QelF~l-<4=vmGp!3nvX8hG;DNbF(5Av((i}l%yntp%$P$lN? z=`??stHtXl--_N!ob91qe%_==C8NQq0HSeA??cXaD-z6`T*68)vLlPu#9iXyaIg2-voJ3r5pgBJaK# zcJLg5f3+v`66{|rG;~c}r3WW1r;bDhz>9jskh^0(Dt>ltpLe_MW?%ewr5@@NTR#=L zOx{0DyUJ;Km2^&Go>p)o_9OITy>rxyv zoc^w&1RpoDvL@IMDU9_YMN?n9{CF6vQU)F!q{WB>7zoUH!sZ(W;CpHe)lZt$1sRPKq74)H#6#PNjRbwul;PqWyL4L8~Wi>m4)4!<1gFuN+-%b^Un8@5^Rsl z0N~>q5{3f)xxwJRb30sfO@GTC*|ho^-1HohIQ0!xBpK_w6xKtda2m{IY5ii9<%9neLfTPx`uKHt^Ov_?Uc`b0`ZY^@oL)?m)_ zM&_%0W2Rfq@SS-$37)sA23O+eQd*RA>-_ez3RW_NnF&fsM#-}Rjrrnc^o^%AU3cm3bxrWz||!zk>})EKm(QW>qzJ^^ z^=(-fvNol27?`Sey;6R5`DdwzpQoo?&Yx8iF?RKy)Tk5|K4rb_dr@1ybgBt~^73;k zR(o5&1b%(#Rzn9OYfG_Sz*JE`e9$uBRl3|cH4%LO8l*j5-u54GOp&_43dut^vZUHX z^+m-jJ9Hk31!edV{q4}JHg1|>*Hh8bY^QQR;l5)uF0X@7NP)8mhH08Z+iAO)!NcmEy9 zIE~Qno+l*SsyT*q6@*UG-&H&j7i7`FN*C$Knw`(g`m~t|oJP1b2f2C&1|*~&-b*8K*?ZW_HkwJ9xB|AImuE<+^{JddpWA9|Na)D9K0jXcyr7T!3b`EITGd z^9(22->#XM;{{Hc;rj+jV|6Rm_IY(gBV&`a81sba{<8VHg@YEsCzJex+o>l~WzKF3 zmHIK=x$PSSifQG`9m9z4289|^k8E(}e#OeJrq&VVb}~O1yb%%Rcvht6;8&=4q4m-u zFCK!-54(QtM%VDzMxdep7Mj{~!Bp>g^rV&vdWw2jlF1#=mDZoBwYGq9B&FY{%aqE2 z(O*l)u>#uq5$e}Ro?!vWf9(Eu=eSVt*cfDaHe8sfTlt- z>-rjrt?eY*d#v$QR48w;q@-gKRuczpyg7<^VzSNz5t0*J6wizJ(u!Mh}?WTo|MI7JYmj{@pa-uPQOTS&Hf zw2Ry;*ww0guv#L6xYC+VDdBU+9(2ySGHp`g!~4IUAM}Y7V`>EClf~_c&F3_f;qj2tcfRoU5nR2Sveuhcj=6J}#Ym2> z&PVh4*%?KKTY=MmY!Prbx{3PbdT*@>FFvEtoV#zqO1mjFQvOTfpme!smjo)x@=Sx2 zvEN71ygx$f3plfWAvdp}x(T`4?j7E|v${P-*LLT-RKbNqyQ-YtJ;%4c=8m{>iZJJr zn-}s5Xmq;uH*o0O8)bpF6R=0QB8>K3D{lU*W|i$%m5{IKv=0(7@ku*FSdUpBQujDB zZAR}>uxlM##U!v(gPAID-rQM>!3oCzM27B#s3^IKOyV`v6E8zD?edT7H%R`X<|F-> z$KN#y8lPf`AVo&HDdUI1uN4<>j)WSEa;9Q*0)sB80AF!5mf4N_2fV*S@tLgYtAM~s zkom}g6y-fJ=oxp9Mb|B%7gy`A9l>W>*ZkoJg;$ev``Iw^F2d;TXXE|D;<0i)|8?C` zdqOR_Krd%B8UySC;Pi70Re|fWnzON8mkePoBDE{)pu|9yTynjv@Fn<-@9h4d z9UUlyuXVhVx_Cou3DfUs!m>Z>5D6+E^HhXwv*62d9}r8*PBu+{dW=eKJK3Bb>8)h> zCTB6oAVjuwFU^WSo^JOlrf|FtxJCFYl5WzRQ>;bRmg&f6bkxUk*)GG1>44uJbYLxR zo4B7iLLFWsKM;`r*ROrMz3X-0=hHl+REjPm4X>WQytoH8hX=BqdhD(M=xkb5NT9>_ zx;2EQn`6W81)4gfB?EK$N{E*^4z@7jVu~c}H_K|@WO`+y(lqSTVZ7#IN}iIsU-u`k zVL#ibjZ;dL0w4Y z$-3y#;c9*teVd~xj{7H-&-*x`sP9PPt-i$_a^p|^qc7U$DcUkYIC)?4hJNS$-|FT# zPp$BB8pojf%nQuA&B!kLsM3hO04}o+fM@Rx3m^cTV{(=Gc$#UX@6|PR$-YLw&GE~Y z_Tu`x<59@ddvle`q%SG{W_Gt3n2d)s^Tt5`8h76GsBVX<>Ft;G5517 zYbR&qni%T=vlA{Opkl*sUw7}*?P6~<^z;DG^P9$%sSus%WnrJ8SitT8x;j>P_Hui; zE>n{?>}grx?AtkP>FHWsUiIVP;+faORJZ&ebswK-D1%Sw7|Z5DNPlmDZoQwWD95Nm z!wTrL_Yi@fg^anvKHow9v$5c*6J4Sl^^=Qt?!sx^{%uOW;4d+1ODv+>ZLQF(bgeZ`+F&S z16`z%6p?uw*nZoec|90~MD6yCKtnt$pNIDb*_10~bi4?@>#vvUH&jEJenAfQk~i3b zY?Nb0fA*L9;G@_SP}2}!eyg>+#A*@ma7L;4)gP}AeSULB*IGScq?bGo)M z-Ti$*XB)br*@1gkm&e>jA8vG(3)}bOll|Vq3^ggVhksz z1x{ZF#crF7eB19~O$+t^cy@iDvBY8~pBrfV+^VwI$L?Vx`2{`j1?f0DBXEoIB{knn zVFe=pLCen0B>uD`AE0cOJR9I=`z+{G{1_jP4?P7Ql%L++|LgrcSuOw!^eZ0U>b3n# z%p{xEvO!QY&3VT|y%nYO50w*D|GZ@%G15wT+=|#~JzlzMpFJ1D_RSqr=l?)W{~aF; zC8dQ1|D`suflXuv)S+(a#(0;&0wc81G#_>O>>mWsKTz6I(l^h|Apy{-lq{uMc~#mY zI@N8z6s>oA;JB7=eI##Mcx7O(k{c)bo{84)RX1I5_o8pBUCMLJ`iIb27PMvxWy`p< zyzudExKvYDkzY7`e6`b`_J(AE*lW#r|w%gp41cTyJ{cvFodFvnL?m%=Q#>zAwhPpKFRsXFC zG&omccVNI*%WfUt2rUOL-%EyIU1rN0SjB)rF)vtFfI)Gsm(&P)i>qH8#5VPi1O5e~ zWIG;jL;_`Eqq}Nez!E0!!j+Y*M7N9~tF%9F&$YWpl?E2J1{HHN#;^2rF|+R^=l|$P z`pY_Y`_bdBwR!FIgfq>P&)u&Z4?Ye_HE`FM|23_bfQFIQthh=w87LwXLDXd4aNVZRzB2&U~+v$X8A!fR*UgGPTmy1T)_0K6$i>H32dh4UqSJXx#m{;3H&<$!jKS z09R&NGiwL$Q84x2KO$rBZWy#B*8uKxBw@?G_4|L8C^xQOq?s)?lSe|nkeHrgA-R3! ze{q!;@5#+YC3wWUF?d%-J;;pmHY??%(d=0&F?ptxPBcJLmvM|w88gHD;NZ6rdjN+X z%T^jTghRPWY*NgYtgU{8SSY5BSbCfZGn1q*pID9lZ88|WGH7H(dVH7*7 zFOMblGDZuL^N*2FZ#4?aO2^n5&$|UVqUeWaM2Xh2i5~cG9W*ablfU!1&1q1|+ZFuh#RCKO zo5S&?6cmxDIlJ}nt*cX-QpVTt`}@CB4$wC8)FJ>{eZ7?aU(|F+@GmN3cEM9lDPL{H zdhC1>$e+}Tl5eK-5cG>!<#irQ%>A22!Pb>}TeLCd$Oyt9x%{+WE|E;5s4rY}5}4&= zGt8D<3w^6RNwc0|5@dKoU4a6E-{dp@ud0_`9HouIUW+8RveB6%lv)1Zi&f0(d8;XZ z+9hJc&Yr3C9#LC5G2sDp`$43l&o)W|*;pjyNHH}iO@wgMnyW%;~mRNdyS3Vw*MsZM9G;SKvfB zPC0I}?7vxe<@mHkJ$0+ot9NXJgDDpN>R}cquh+cvh`uDH5b41<+y(q&37uB7cQY`K z^b;0L?_RKL1q(!WHR4B$W>;YItU0wWGr#D?5K3l{Fav~A!H1ny^^UBat_fX-Unw4> zhc0-@7jQOYf!6!*D*i@9FsvCNXiFBTq+nBaXX7Q{>^x-`w7;`akb|%JIpDc4ZMXCy z$v!X_HyIwN$bh!%`d6jY$A4K=LfLF4e5%+<@00na8uN;EdV^=_wOKR&%1Cdq1D+?+ z2)&H6`{?GxM{`>)JEn>%vl+Vga8YJtqE4YdTJp7NbMu(hnwsOd*R|rl15ODe*r{b zDISJMTetnm>*E+;hJBq5Bh&t`#cKF+ z)4OC!!PVpoAFq(p(aCRq-9Ix-8hto%c9_)N2-2{isV8U^3G+d(&Gtxk_pefR?C$_i z|1qtU$GrT7o0DZ`reLC-y^8761P>7mp9#I4DCFtfCeB~SJ+MXTE5r(^By{XKj|ZAh z93z`o0NGBTjaf9Q#zG%qMZz^!ivCSpjr7xguAc-0(yeU)K06{+Z)3v?uW8eb!iOC|qPGIXZ=CI0 zT<>|~$}E$5kaBnQ_F=J#LGtv%_!JLFN!ISN01q;3ttRJSPB_4sdBT}NZ(sGIS)Rf! zKSFd(PIQ&~U1vw)pkyZPu|8do%pmlEw3$w$8pxO5_^56~osbwcU`FuyQ2C|-Ow^Xl zJa?}G5n#??+MizOcS&h5yu~t1P5%tgz+I2f=Ngy_xU7djvpq~58-;b)Km9kzQ2nUs z%(C*E>4P853Tf^5Feoyj9*4t!fVbO%j&4DH=lo6(f#IH=qo8=HtrAnUtKt6sdT!1Vo49DtOM`IUt==DTA6SKM7_8+ZlM zu70K}*?*%}Vl}N)cLfx(D-^HbUD4{%gH^m!ep?mh@}cIjYN$1PPdk&`J+-L2b$bqM z*XO3aYsIErn+h@(`lmz;-!sb*vvRq%s|0=xd;c9~LdVa_*Z@r};bD=QFt0w9?m{5M z041Lc7uN^T@HGRV*8lX-Mv?`pc61{_!QlPVpBXdJq5UZMwIq%336i-mWP%ml_#9n- zu`fOM6lby%X7$UDPV!6@>$-53_X_r`TNxW1i~amNn@A5J>ZCQOFs+?R6nn5PR{KI4 zPxZGVIU!521zGUUxGFy{C?fr&y#}MFzeLvV(Sfi@W6Oh@L$0NNqB7H6^6pJ^c5CPj zWu1y^gk)Y2(EkH~v?=V$j6q}6-o)|!NQ8C}15>RZ`6hwOSK8g5`dnF(8sqB>R98>% zy=eoc%!2{|=C6a=91kIpUJR5E#z3J_nxvTik;7xRS*LBmPuPU=6Jsxu=U7j7KvTM8 zH?8>dVV_WaF21(oWC>*cc?Nei3PZoT|zgMCDW?fvr_9tJ9NkWa0f#Qgjuo zqJOGn;^=sBq~Y@#_9b+fyehA!I!$H}%Y(KIU{;+c`n%GwrxZ+6ZdQp7I%G_G6MK`%9y)|9e4^n5Iz@A2L4T*ZbuW;0=KUlYF9C<#n|4 z0ZVgpXpH%E@#r#lGK*5lQ2r1Jac zp!BY91kd-7sZE^V9%BJOKQI<%>hKPDAMO^oX5uG*$1mi(B+fBa7n zH`q>Y{ueUK50idn5V-sG_D*w8FZuVm{PIaSvr5eHF-CQ-zkGOYW<{g6en9$a<+Ll? z-L&pEI&T1FeUY?T4oWzX9&^~!zVujRtK1k$6{A^%YqY)ubWmPWU$0&%#NTjkywAzz zvG^UMY`{YE?R@sFUKh6d+wY{?D(M{5(2?iTV?At$>(1t9O%YSN2HV}%M2x%5_`s*u z4&Yi|wKVVa7AQeWyI@#c1h)a&8k!HLKkFu9Ijt$BM+G>SHfWHZMhun!?=1Y%<=+;# zR9V9nvWY_Vr}8LWyba(W+OPSgoW`m_q>@g0_l>aXQt)LjsIHDF36iXWFxtY|9vJqf z^82)GGK!f7IHPI}n@2D_3L|E5-r4EJJGb&tSVl*f|24GrbsNz!!SOMsR$^=r0v55UrFMu@^?->~^qq ztQb)!z}Hj$xSFPb4ewzm0{8+faL>RQ&;DxcQs%5OJs>4dWa&0lZep8{*D&>4dk&F^ zdx}-IWd=XcqO@A0$!hZA+CXC`V=RF?KZ9}Y7;2bq7Be{c8OT@}Z_Ho1dp5_wc2hU@ zli*+bwe&B_kS3(WjgXl_K{>Vjbqu`=$_8j_nI{d#fJ;mnk4acCm)HF4Zy>-tyT!L2 zMFXM$jgE<*caWrr>sHO_)Pdx{9f<6E0`8z`XP7@CqVAHV`|F88L&k0L^|Q`p#il)< zaaQH1kd?09%#$zDF)5Yh_KXhMR4F{(VJQh*Wc%?f!Oy7d6uB-_{kAm{XZ88-k?U*e z+WOV~nLD$(Kh%l?(7X#&k{{?g>FE!f@@ z`3xyAbHZ@nES@57>dEr$JkEpSSU|^80`9)FNM9BX!q~bp`)og`^fX3c@4WWJ)TJ z@cjOEltlVM$kY|+Ao*#GJ2!yF!cet#R|U9szOI>5!IuTYmvnp4?GvO2ywkBw41!u# zOZ91*Sbl_m@qz+y=-q)AxPuR7h7{Lj_WVskUb|9@*t!faksi9t(N7$6##aYZn>dm6 zCS++`3ioHcmnV_jjP(FU`pfkDIHSEQ3-CiM@l)tK<)&v&{WA~Vx$H*uVT3gkmOHU~ zo}+#?(hDo?Z96pl#rwI>L$BxZe+|>lpDO$F+SW z)P%j~am^~{o+&D*I|`?9jhP~C7B!7GkMX`0HoMl!gdx;vB z#QP)8pN4ngn-%y`dMqu8s6TWod=$Ysdg2wSqBd)ok@p_hKU!gHCR|X)j35R={}jBg zz`;*aE{-?}EnKe&DZQ(J0Ac<(GpuK#wy8Z#0=7WNLlwg8t<}EC<10WZLq7M2eC|V- zm>Fv<8&?ZQ@be&{C9l~|BDSEI(fVqTm_04hjzJPX)J0$Fn0>F>51L~*@NX#F?CY;# zd}8+nueD4Wz?Jzq?3auy?T0idZd{+i{Y>j@TShyhXOdDTBBKJ5)z$=P@M%j84cD@k zvuj^mCdnRLHHJ_8SSog4_&C;kt!dZ19UbHQPvB1!8q_SLzef>CyWWrEib!qKn6X9a zHN-l+{Ey!VpLl6vKj;kjbY7E&&F4`si(FprjQ6V3#8+{EfA+FSF|D-vhYkfWNR=5Z z`QM&Fh#%R$mVc4Ay=Lk_AqWa|C11x`aGO|$GADY4|BH(UMLX3b@@`vGi~%$b zW)y1O_XNKU@*j7ETCOxE>)PVkFfVfs$p8P!&Lpzv9}J*DRhyIfAjtNr;}_vo4lZ|! z3Kw|YAM4GE)4KPYyGgj!=PK4(VDoFK55_VwFX*S@;sMpdNk7Sdu4X42uEn@~UZ-kLJ-3L$lc%-9SGTe8lj8&JHrD z5Ws|vk~EGc%$z{!DPH_dKKN{aRSj6nTCF39A^VB69Cg){t6uRbdV=;-b~Fg-l))lc zE4p@~Lb(vjR_=+#ll^2hB>jqgjjCfMSQcQSNO(~h!`H5uD5A?VSGbDUCZ9iP!(Z@p zTAW`R$7-;psk)_Kz6B3pQgIg(uc0|ye;_75yO6cWD=7-Ba!@wuVXYg?Ztb@;g~~2D zpTr4GYAKyCv7U9R^7iDxi$<%3ijYscPwsK=;zI^`m5*O4Z{@aU+zK%3e{t!18K9qqdCB zMS4eiFJlQrlz??){XrR<80fta|CIrz2OH|Q5c}aA+xiy>rp(#i_-Qbrp~Dz>1bc^h z3|R6Gjk7qTuG_(xIh*PknH}TS7O|ame?SL5!R`6BZuzg^kN1NE#~L$5oLmRjLToNT z18BDHc!9SJ)ZORXAhCszcE_hjM*-mh#ry`fd2iYV9YrWOH7SNSCg&-^e(-JgJ#77I zM;ygS5A^XP5zSa2WiuD^OxR#t#$-wOUo5I&*ZFrOqg=16m?|1UKQag3DHqyfkp*tvK@cO}(Mw~P% z^gr?R{!DL}zcxM6-SckE)_8n6k$kfFqUQm^9wuEcs7%2W zh~Pc&F!S>fezRsC(lLG_Ay)cRRe$a6?qyb^^ioly&}>U5#JM%aue*>qZ&?*9_7!kk z;=pC0*wpf0+J#GTEM{5iJw0Hgkmhx>7!6rv#RMp{ z%NvO(a5uF!(}}ZQ`OtCDa|2-y-da_JkX`W3NL)}eCGlpt-ueUt&%sO0%e&sEILrpJ z&b*=Nm3phubdLFxYq5Yek$n<2t}M9OpJ6zcA%8KNN~F)L8>%jl#|&x(-Iq zmy?aR=guoOJdus&_H*ibB5pL5)OtaJdw8uN{<0F>Xrb3lh3kZj3|0FGEt!z<$0of0 z(Sum5AFLM=D3BI6Q&5+)skC+UrS!yf;0?ccUp5F}mpgbDfX<^iZzE4peZ}3I*F6C& zR}l%dG73S-AU?9~LvKEQOagVEhwihEZVP~cts{XoFeR-uO{P_gz;#E5%2V&@t*2XN zk2!8c5uR&sV`LtG>l#@$SH0*da^3WS`qz8UhX)|sz1F#9EVe+$KsnS#Q^9!;FuGELqzZCBa)8BF=2!v*@xr)QVI zVe3yXncPxYG~460Fh8&Fnmn+2z1rbn?OBij1=aeMo{a8lsMI&P8joVvcLb}uKgtpa zr0$EnrEk!I03+OoU8rLZ&k$U+3%GUpLw2g|IBuh~)9mL?)qyrs z)mn>0+sdjp{)ekOIlEaz+|@QqwXOVMgo(K;f}RC2i1B5X~0zfVhO`t zn?8wpCAH2TO_y!gX50~xb~f{By2#iK?-Y&!^|(-)c`h2OcaB)rt2w>YLO=qHP;D$o z!dET6KMie95+++G!u972l$V1Ny&qMBa+C=;yeGsd&}4%WMrgfTqzxYVDq2w(;gYVBCRrY4Ezyu0sxt+JQ@f&SBBYbIso zrz)OXD;F8gX~s)#ikL%-oUbn@v8#pRR-yjOXQ@_7A^02V1bvs-ya85c{)|+UD~l1; zNAIID315A&b{DN7xTq&~u=x~gmEWG2yq2|;u|4+?7id@UpBb0N1N|n|5q!DNxJZ-8 z4A~;h`S_54{0IJIK7ZNH^G%%MHY(>5l%@%iC z&9~d}n~qEHk1y8D{*^kO`qYqM)LS5;?mr&wYgnn;s+ATD0VfKG?TYNJc0RneY>W97 z@~PEt5(>VH6N-6V)mqNpBOQ9%?g`6HwN+!zAKGx2-oNgfTWon8a7OL^J&SE8yMcVE zUi4(KMf{!Bp+3{h^GW2C;(Lp$lf$lg#> zWwF?+>US3r_ILk%XX1s#9fUJ8!v_EjC#|5TpN74YP|B(QuKij(*RK+$MF+7sBnkXE zNbr_Hv~$zmds2$hxO{Edr|Dh}(HpZ^w63hB%FD7NarVEl!#Btl6$>lThlk^k%Yw_; zu>tx=hzJDM$gRNX%K-Ksx)rB^@2^ud;}=6r^ns zx~F}Qw(Eng*Vq2eg<_u(2J2f+%z@gZtR}~>hJXfb^uCY6;EgG15GSZ&!m70bRe+Z~0c-3mO4b95_8JkF>u@ z8^pM3|7Dh6XbdVRo_tWL_a<&c1MEY8BLs{w%{R zql3+#9K{f{mZRL_#7)4b~PCDN`VL%`IfRJO?u}E~BZ6_#h>7Q|usSiiEcy%=3$G z3gkL=<9mfZz5t8@^HhceYN>&p(yL|W9EFTe0GaFwsD9X5*ZX7!iKR*6)uW(FU8v&y zi_)uGCgG*uwgw!noo9LQa!;}KM{CO4ME=DWSckJIPAKy`)`JxitMyUt(x~7&I(*wG z=@|lVE)9wEyt%r@p&jNT7{r{0h_c?AX3Zh=h`#laX@qkeyB7IQ5FvJ|ereEYH1#p= zT$_K<6p8&~{-t`mYB7qgA^woxED+}$Yw<5}_?Q*c^O41Tr^eeMj5NOt%&-&r*=6Si zuB&r*-(*y}<)gB!!1(SXxZVCmPf@8wgADT@y=ci#k@BBBa%LUt1eeGNJh0t2J5po? zliEC||+w0Ws- z5iy(Xvh*c-Hl^Ln{@3AQ*bq^&_r2f4eGQEt0vIrNiG?L2SuH!GrKjc(Z*()K)muHu zIWa%>aaYxnDz$9Y2P;&#za%gJbG4EFYe9Ey%zmdg6&ZGWHH~@w3#$?*K|cSkj;`je zt!=H%=7p-562p^E*00)Jrt50{)jJd!Vhh?;#u^?5TE)6MgNK?HJb+cA zfJ3O6XN^j;u(!2Qt?&cj+GGckonq{LE}ZN1{vm?}>50o@cqrq|>D%L%T&=k$SUp2O z7X?xMl8Jt$?cw@@AZD@YWNmSL_%S%*dJS6WBXsHl!SLc$T^6+uYoezvR-*;4ukoCa zL^eSeH`}LX^hD+V0Sbe6M#t8aP7s!HeSc-LS;V2;swuhKt8T|fW8$Jbo*u4>i{WiG z3!Rm8-}5;NpM}edVJKHNa!KLe0KOb{T<6C4cFKhO-?Zo_N{2(9XDwJGK!w<9cbjCt z<+C1>Ps?8(ALw!WT?vhGCuSY;5NgiWkoeJ}fA{FKaR7FQ<1jmSmdJWq$so0uc3)?| zcX50LbLEh)FN%7+=TN+z_HD;tEd$vh4dxCWdz>Hr1ip0UlWn-&McS@?TKiq`ZS^-F z9bOuaE$H&VQEK7OkYqixb~71%F=DMJe_CkQLkPm*ljdB)jvfY|ka`OqlSl4RL2aKo zJEx~SbWgNxCc3HL3^Sx-rd!^jsNPARw{T3i$oQ#DIIwBjiHFn55v?m;hnTd#qpx>~ z!uH2i(FjxEQ*4L`$`+4=D&*XJPb+Pe)nT%Ld%gveh*xPXx)26gINnPcDfvLo>wnF{+6^R2k9Z9 zuv@9q?nAL(6CMQH$p&2sDR#st&3S6h0=nyejIw@RQ$?V8|}%~kqc%)Yh}!( zIWyAA<~geDfyh!q5QIW8)gg!;EO1}sA|l_aR1@|!7G?T1vhx&VM8WikFU-G;pf!GS zjnn$xp|f5*z|6%IIDOB-U&l4`I7O7t2lkIBSTxlA7RhdYU{^-B{xlsFk zCxMpQA%SWsPOkwAp_BKlI;-hE=`rSpFdKu>BC~$=CJAQc!S~=`0S+4&i_1L?R{(Q* z=fRSYvLC!PyoNVeV3`AAoPnuq+?A$KK#kdmij()lOV(oSoN$nKUiOl^|6wLVOiLj+ zSoFiDDjj)KKB{TuPM_IA$p3@FSl@4!M(hHF$WAA~U9p%i^mg)z@i6(uPE;{OHs<3~ z#B4o!D=FEovUgbL!_n&jH?wwl4Lt-WMiP5f>%(gPIcI^^j%ws~)GmJAV3yoReMWLj zm*8yP`+o;su^)Ic+)haQeDwrsX~fv`O=}@>_PoU}#3lgZCU9njzGm~khwxT9;F=2% z{NWQ5eP5lofz5#W0^%7iicAjcGbL~qfF2PkSW&Ej;K9M+(WE!LKWT(0 z+y4^IB~ESkUKfpn@o|mo=sVB(L%3gkY5RFGpPaH;;+D*h4&tA*ifgTV)niJXopkn} z1n>vlH?|DmLRZ3tXkwi-P9y6Hj4x2ilNK=wO8j0CbhrTMtr1=B6 zHc_(0Jj8anCzC-#C!mqvIOFyp`U~9t^uKp34;Stx&VC!m#$*p=a3tlsMKMuCa`($G zX@;o*fe0HDKIPv)1!oP_5yq_1L75q4_Ng@5{ zc@j>jpmU9f=F>1S1R&Ts`jk9hCP66oHPsGyYXa=GGtib}+OsQIl%t=lzkApbTx9`u z-a|?d={>hjR@p{exmg0Xgkvrz_GRzS!Vf}}h5qCD z?~krM-7^*WmsPbTy0sX)1oLi1ZI$}(=}I5Q2A9Hg`)<*XB9o5N%X zyuM8;=A?O&exYL_X9eV;ZTJg&cG)O4W~7{7FC7)xB;7-c$=tXaw!y=FE+IRpn^7bW8lcQ5ip- z9}=fGv1!{Gm_dX$btGMo(psoGMPa5#N>C&95NaNan@UT)4FW%~s#VmUCp`MK(}q*K z=jPc`oU$X$2f4Nq&NYTPWScqnO1k2fie0;@$F>T`;l|@iO@S)%P4uGyIu7Q&1=^=h z-E5_E8l=$BD)wyaEx&#nv87Fnoe9-1HW_f{avIoAUokjEq2(M-?sBbHNKuomjFYoNYn#_FR%mWqmbYr!=$i%S2!8PulY zz-z>ZOsISvR?24X*uF=vV+oI{q0KA{W3QrKwVPnrecO4Yqnp#FHbt4~rEPv-#g!F}^7=2^)_8nyxW{SJ0X9T5b8nEf$7y+`$@|c%EwFY` z*o?kSSmN9EV81nEg^AW}*z(1gz+A@bC!y&0Y|1?=4m`VPHoPBHc_}c(fY15&W3H`q z18Z~jyYECc0;{*?S9V*^y)sreyP;0!R*M0(UP=~Cyx!}+?QdY@Zy51n>6Mg?AikrG z`HY3p6LDUAe(y%IxYUR1q7@&AbP9vX+w#+fmJPPEO8$^IgbruBBd&c^B%tsc_~u?QJsLdN95g z{A4oAZd%S)KNx1oKm~Y&Z{GU8IcctmGA-qG9@FG^sa7&>$=HsokZzVS>uqm=loFGi zf}b=Mws`?AZlX^@r&lXug_=o{{3LLq$`pIqxEJhTS+N2|A+ztts&FpaYiY8o#)Fo;?-9`tQFdjYl_}Qh^1;rohApb>Jz@m|b<|l48x2ubFXh!v zs9!$gpFvH4zqD3_;OvEH*_8y%rsjZ~t>p0`EtnHW^seXVx0?p;FQrbwXWNF|n#uG? zK60dXHV{YIi?P08JmGe)27(0(;^^7k5@}Ni`$IO^pS@LnkM(U1QN@?n;nm4#p!o5Q zbY;ehFrAe>|ACnD-$bPs043tI(xkmtu2I(Vos;z+|KoVjTAN~&z~|lJhrF<5GE*0~ zW?WCnvh*6!Z`sV8yLDCZ3-u>;_l<4oo{mV>{^YW5oH z-2`jlgSXEMeBft#I9m*NPh~7dOHGGtLoH-eJ^_f6*&+Xyhs=_JtYSmNzy2~hOX5SJR^V<(jcH{%YEn#>85=8oY0UYtn{f;mJvQrF}50HvDxu2|;5WTKM zN5rjAG=kq(Ah{KM0>+V({!)2+^|UE}$I5#j?(kF-x*|5#l@W_U2v1tWtyK8RV7)ts zOqz%py%432A^IcB7@GwzDwLUp*=P&DwXq70nve06Lhu#lmU+C!J^vD4S{)`T^p*N(Su?L;KW@p&IRd3J;?M+y5cfY3)7wp#` z#(K>j6Tx0iO)$EkEJ^Rl<`;dNTgI5Zpe78tnBP%amrh|}Oi!5%6s7ws{$Y1q^7`+yAqo}$ zO)hDmRE~k)%oOEwpcQ63arLLPP)h*DrfFL904)j{HX57P7Z{a~q$v zxg0ign{|vfY@AHTDF)uV`N9qdIuQEdS29zlqss)bsCs41iacNd#JwbrII& zPedj>FjwO1ts37lF@IWyw)m>9{q}|fF<7+RALr5XY)(8~^&)fEJT%7=VGU4ac+^t2 zASh5!*#1W<#Xa^4`xxq;do8E-XzD!MeL?tJ*A|&^r0Ai;=U&ML{#uMj(aFThN#h%g zUSB`jXSVkIpAS4(N5v9q_TaM&)q_^c?A--_6$h90p1)F*Bn+26v!q8yr=n)bPd^7* z#@*#vsZ`2ln!Xat7b=0@u1wDYODtRZR$cyS2rX8PSHwoV-Ue+~r+rfmc-QV~FFyEs_EJOS3Y~+50jozPPquBM3guVS zu%wd2d#-f))28iwM3tkr*o7Bjo{s^0uK<#cNd)PqSSbpzZ=d2Y=AQz(yWT0Kn`(6+ zm<@qOPmCYELDa;Sqop{6TAKb**FYuctfvRj z^FE%<#*F<~;}nLs;ug|3&ty$caKGwPFA3jE-Mg;O*A`gOvUe-JtzYmcmp`fCD0wn6 z^2zc8O^Z%JxMDeevXw7>!S2IC-~#Fpaa>26rgzF-;{Sa3j4t5bb4{xiBP&T7*K6xCl}?=b-!PQBhmb`5hJV%dvcOktzF(aUg6mwy8`y2 zn0c*}r40SCGTXT%J2mNd9@vRr&=3;jl+zKhuJqWsR@t}rtbhluCNw3*-^#z#0wuCQ`a_Eq z>NP;4&LGj5eXOG7@?dnxw~15shN1p_oj}DB>*w43oC+Mi9Bj$?A0z_?!rO++q>=rz zKjyo|?S(<#MsQ9~13Ninx&7a^aP>PUJX@c8-vO519L6H&X1Mt9x+cgh(9<^VCnE>x zAn^y(YV@BTrQ4udS@MhK>(f?3pFW(Yb9W1VFsYJZ#T8H*5o_-|gOj<6bYW;UYVcaL zk(LBF9FNjG>cW(q9O7D-ba~$RSV9BVNn}nCO>;!w7XxWtdgpI1H6E30?LY+m~ zj1~$f{Kz87?LBsg|DEF4<2$cc;MRFNC|%4N*y501$g#dk}DY@18(cVthZ z2E%5mc$GQzOFGTWC3y*{0anYozNxF*7*6M-{x;vP2Ldm@&W?=r3HH*}Ca4twkC958 z-qnrLB+Mh)NE23DRse+Hp6Z8#@5vkMv#&g}vU2S#hQ~fN7Z1m68rxPgry;zst?+ z7pSpj@FF$!?WKkxpMTo0n-@KTV8%DdoZmY^EjH6K)ubFq80PHFY*@ShP$P!K`SwrW zB_Et>)u<42&KalaKBAO+wT^kM9yZX!zt^X7F1kWS!i280p3QJ!B~&w5a(i9wO|Aw18qT=^Tx!RH1 z(?peYB4dx-gPwiR>$)r~{{7Jgu=;=N*J(I5-UsU9rht}RR{smIy7^8NUtvPDDP1GYuO)~%&H(6-~~uEDK;ep{LISl zb!pDkVXLp z(#6h(?$pZV9Ao*MCLbb@C2?jPFW3;wUAsWg2ffoJ^%a0$ ziSaF$vKhJjwOh5p%f)nXuvGl0G)eEXn+uBKQ`~l-I7`?4^$IiJCBSs;dh{x7&*((C zJ@w<3?>oM{ucW@PvrzLoPX~^U>BW}IhVCYVc)419zM6moY1mIQsE{s;O2B1(0=3ec zxPJHQ?hvxUI`t`O+jNJur?Vf^0xzCxb-}GfL*paT-&nKa)=GLMG1Gak9N>4#=L(s} zDI(WR!kqup_O)#m2>v#kGv9i`aA2NQw}*%PpGxYFKUW~LRz{ru#@`Mc&x_&7ZJ{&= zYv;@_8vDG_!BJZ;WrI6%rdoKvVB52Q^Zm7sL)ZxAvr|v1kJ)3L^gZfwD>F70IzKTV z)pHlM#KJz_3k{0JnV~q9(i#8 z9&PAjC|;Tj{O2hI7nux8&{;S9U@Q$xChY0rIl)06$(}L1V0nBwUNJ6Y>_0;$^gRrm z$ExxQ6?;(?h0@+O-Vju^g9$ZKoEcIbKjn#q`Dygt3VnsAo3bw6ri;w}5i+zRK*QY# zTiH;i7Denm2_N{FM4DJ4HEN(dghq4Wk5W5InsBNjrgGE0ftebi} z0E#dR0CYl$w=K}S-KfkQ!ahfNv|(^-cKj<bsBkR8)`qrIXI)Ju4%QwubZl1h(Em zXaKs*;=*x;QQ(#$vA_E#jb!Hl3lvktBmT=8>4mg2cl7DF__EyTDYnDVD0Ju}=n7o$ zd+dE*J?%&{+DsejPr@|+-mNGna;p5RFFD8G1PnADiOzvKmW7SOT2%w{O0^P{H&nBl zS7gQz`)1_5*Fk@U+4MGo7*vy8xlE54RRmWP0Q3?#q*b1q6_ z9;#l?`OpSXK-`G!=z|}$8eT*k?4U*A+QNgw7ze;_CL3kDGwoB=rx-T^a9NmuZO!5! zh{}f3yZyU}6?ez?jBHZ8DU1s-f{f5kZl`(6X#0~#IWha|HJXen;tJ1kV^=zUN?6L^ zK@rpvIhXDSgA_$uWM<>Q46!`uERcj}>~G`^p@-Dz=iry%3Z7k~ag}Xt}pxzE!YZf3TN1%p#FM29b`vRAT5EesTJQZ zvax8@qKbZ0y_HWiLQ{n+eev$I#Dm#xa(K~fzzVCxd!Zp%N2sjPO1O(_P5D>K{ZB3Y z4;ytX_Cw8g5Jn!#G}x`a_-N$CGi%li4Ta_OT8*9Ac;Asf3d?qn%)`iAJ-^K~cQGR90R-u_m_j4klQv;_xp8I2csizre<`e*6X< z>D(*BsjBH4bt{rsc@@(nOP|-3lQoU?8Qn7F`Ds6DY>`BA<*{sE|AA{4*jO{k>| zNuEq)p>_(Bo}Cu!?f#@x;hbt#%Sj#-vMm<+?~n5H3=Y$&zvR$2I8Is0h>aK3#e;uR z9|*mts1`>^<(a(1`g6PP}n=hwmzdcIBsOUD71%kkFdg*cryK>5C4m=SNI({&vX z>h+;ZRLE6AX>UM36h9Vkf>^X2U~iS-@2H0-dhCiuYQC#bw3f_|qV%uLXm{M-iV!`# z7Rw~fNQH#NJ)@RLt3>W@SwH8pxgog$?C#Be^YK*9w#Dzo0np7iKz8-R%zdSGH%dZN z{4Xq1_v{JHU_ra3e2mvu*{1O9Kq{fqC^R3U(#X^K8f-Fk^)dVN;IxV~xq?APD{>;4 zBSZINg{RDt!b^|fwoEd}?)$(~oSJKJk%=JR;outSy2bDinxwDKjhGcNQz}@5VUH@_ z#qOLEZJPAt6+$IubyqFaFb~nHqzWUDM8`&Kd>t$QFZ4{DRbUoVyxxezREoBD-7~oPPN764sINt1t*P21`iW`L?xUMJWNh0i${(_w_1Cs2`tvN8M91_O!_8US zcK=cZ=2H zU})epm?xZDmT*j4$MTiPtO+ybMWT9-Ew z;kN)S!`HJ)+>0d0bIRiIhqy>!VA(|XCU-ty5dHI0^Sk%Fm<6d*rGjVat%!(-TefL4 z_U#AqfA@wEwCm_;en|w6T#meMR=iT$o{0W;)1&z8-8Ln3%>}9063ZNV>de_5pEY8< zQo;o*iQ7Qx&!NIJZ0=2Ua!YlhI?3gZD>?YmRvJ_gb1BP>3a`E`Obh>}wr0H?mXlq& zwN~Y__P2&(*Q|VOoWtHNrQ)caP)q*wgXuTxMa4Re@VMB_5#$bi&~A+$TNxHp`Mtil zt30O-bsQ~1$GEk+Lm=hFE4eSAZ_q#Ga|E$S(H-wL@h^jqq9Clcih214TWHw%GpFqe zUuj1D@#v|~*mKuE$Fh#|;aW`o3M#r0$V;m&Bk)=wxhKI5l@065(aKa!xXJt#OWF(k z79Xwhj}^H63nC}3cJ+jn658_KFz0;fDxk)Sbee&)szYXBl=I1t^OsV7=*o!(x_!uLvI9);KPFX8235;UL8FL*&GH&`AklAsYXK?m(kMh-JMEjp;cbv zihMP=b1@o*3Zl!Um2~)tmVomObv$hItdjF;%*h`Cquw-C@42;$2{@&`Vn9~aP&@DW zy^s^R|L@Knl+by_MNBi~LHLs4a{8TlW-m^;VoueEZYW1c^_TsU%ladGl7l~VkA!lx zHVXe4It(pnFH}-0Ba3KC3H-ELEiA%XH5MJZ27cMBp3C1ILII8R2gG8C%ihIhr_0AG zrzJ*<`7txL6Z1eW3OUn8c|jh4&th+N%-ZA?M53bi8YEi8=?^z_`b}A6W@S}8&{#Uw zZ`ii{)5=Oc1qcNIlR$0{w}Xw^+JjDvXw(!+;)ha{Hows()Zh!X%Ct@#@Muzh57;7& zz?aD`d8%*!STTxpyGp^+HLYS$zsfKnkK>yue58d4YO%^>Er^tx2&Vg<5Zv{DQC zE?9HhbNj(F4o2h{`K`-}-cEd_-J#5CzIwg%sL;-RfwhNaQVWd!6%?`+6rN(=5};0L zKpAb_5d5pG`^(Zlgz|`WxW$to6IKnrEZ^L^2TC}OnYdCqId7)O?vDKj%2M9& zB?YW~GMnxEdsXxkuQv9qw$VFPd0LK?=Bnz%M!$hEbgfD@loHeh4Zb4X>R;DUa&=w=vCgs7wdwbd?7=^}QRqy_ksM_` zoTI{3Ahr-;vHGnTg;cgD!nUb=HB0<*TVGPJ>*+IfvZ-PC9lWylBebmTuczmk*UE2` z&!ZZaIf+dz@@yUSI5SsB!nnNF1gEExv#ADJk_8A?MbL8|(6nM+C?uxAD91;(?n9`i z^7#h)L|mW)elWh(&N@kvSbJ%}DWM(f?@0{%TywlR*i1y=vI#NoB-`=0nC*ti!!HXX zh>6K-JEuQ?HCTO$nT%J?1*Rb_s?)KgT3q%IGye?EG<;DP7TutI#fdy%NRK|~QVz;) zOUpf@r9Sjxs6XU~y1H_hztPoz^ZAR1-X$+Fi60Zpn2?S(xvKht^qV zH3#h%CKW=Fba7{S9B<32mVZQY^DUB!o))D(>->ZGSj6IeE~mXNrmfzS(2}%@qfFG7 zwf^T9#%5XuA456B>K4oEN5+mE5Le`t$ayE=vL^h5mj zPRGma*S&+R&mu0!v{Nw(apr*uMh$Sb-{`FYNLdpU~1^*i+;I}t<3R8 zxgG3m4XlHMBq#THb{>}y?Y1K%d0?OioXqW4w(tY!4arLP#c^gd!L1hC`G*gBR`bs| zU41E9cnOw}Q3pb&s@|i9b|y%9M<#FhC}dl4L^Y1`s8FzpB?uN!OGp91x0;yW-D+s2 zKZAg4Vj(WfEB_T#)P?r3pdOZqtqtScsY>L87n%+#NT0GUZ zC*VSI_HkxRUy|`V|96MR#=zQ9;yS)ltMX15TI zJ674ge%baD&x`?HuP}6pr`PB`#QOg3ID=f7twW7vIr~P!SSXtChSQw`4_6YTU42>c za_F`}08GjKqeHydSMkVFCvx*|5(4_{jn z`dsV2pb+9)X+`cL7kH#!+2DXjawat?U;mhzfT=Gzu zm%rGTlgI2ASXe!Q#~v6c+bzr#qz4b}fo9qgbTkkW;grII2}L(^A&ZSOQGe<)B#_N0 zZ2`G-)uv)m3Hta-^%Q>(AA(HzI0g`f$vf1vDs_MQI}t@5Rd9M3*62Bje3cFXSh-lI zMnaEGH5tS3Aexks8W-X?#0ikthRd{L*EVwtENZ1so{B7^A3*Ur#c7JS73aQj)4$$+ zrq5>SwU+)<_9adlqV~HFnI@BYTQ&fySYGcN{?#ZFGan}eg#YP+0fKH-?3yHkGKV!GS=yMk=Eu-%aQ*@Dz0GKwlmI_>X|Zfe=srGv%vqI!fe!GkQO?J2@J zmSvS@p60cI97hV^@8RcZzans!M7cb{r~nEWoYv8)UTyXdIud>M600VdDD8n~6*R}N z#mtLd+_!VXp*a-nLZf1&Zv%wmDbmY=zg3YDl>wnuqSM4oBaq8awiEHcf<#y}Yj0_g zAn_|&Tpc)M2xZINha@Y*%c%oUjw7A*NsQsJ}|+1dw0Jcp{&SegqHnjv1<3Y-yT8n;n@m@Jo^X zDit`nH}*Desx2+z#1j7 zkY;}GBg)he@*r-IaHg%0xz=dBLC{YDWztDawL%z+nnJ*D#Q4^#%^pS8s|NTop?RRx zYh<`d4SJ}~_bmX!YWnB9*PsA~O}jAIe72F%cXtq3Q|Bk9gGj|R@yVbu0v=HA=gzJD zL*Dif?XjCzpky)kt%rpLQKz6u(o(n+e&-mGe;)0=B53|Ce*F!Z?Uh&WVSn%n_j$+o znqR@EJ-;=>3Yo>{m3t5dLekQR`wlX?YQg z?Kl3ApyL=}90SgfiJqNXU+_|e*S{{C8Z7e_Y3nr;SGLZY^xq?I4g$6RngScKas9*C z9_OA6&PFM=lpSImyGAR>+J+aiCn#Me=$8EW#88;BnC9WdACdAHfqd!n=LO-0to8S; zb2c62yySVEhFH>@%{5mSn(!DcS%3zp{nN(NO}MQ|?m3-+-E;#ym|xgTl1w=#Uw7^lVY-d3 zheHVL&A~TmYsph^jfT%ue935pR~Uu!j^Vekh-bO_UAG-~NO(ug)yETVPzimee7z6z z&;)3qJ<&W55wOeY{VN0@H0Sd)S9$KUCom<3YJLjmNj`hyCd*?PRKkC9)J zG5On{>XfkRQlup~M1SE)u!`@HMtxr`chgq0X~ZkLXPw}iZqn~V3nzo71zsOz<#jE# zH*(*YYlK=qZz9J-8v-^ggCVxo>7p+uMV zzres5EiAx|ajl+AP>9n%%J=t!wb6RVr)+yBRv%ZTVjt@kslWGvaU9Z0|9!r_E5u)? zLJJl6O2csTyS(RbwIl$(9_?7*Zs4`f-I%u3l3i3HT=YW@Sv^amTX;+=VEvZEbl&KFb zr>J@Ec@$NJfaCL*b@iufq!I^uf@X-y5oi05}I)AR{yJ+<0(*VnErP}fWvwrU4g^^!(ttdFY zyi#mmqR`5Jf9~;J5G*r)r$6-zw`HZww|eqqjE6m^VmZ3zCC2@LCq)PWTbRj?8MS4Z zIpdXGU6#2&7U(h$TCocJsOkRK;OIc_=z!C`h5b-=z68h2r2zX1(*5&&I9l)QNQHgL;n&FrMMdaeKOkcwIt!rE(B$;++8TX*C=LPL*! zHW%qQnpM(g?DWnppy+9M3*kjn$Bu`#9e$JEN7=f^t^;;88@>x4^%P1nVz$P-Eoxg~ zWiR$duyw0(L!x!7z1ez4aHW?^^i3W6Q`+V2K@+Ux&V`75>7`L07xz4?&%k;2gyI&B;-8woU$x}O*K348Ug8SB4${mDw=7Yr zohK<$t-{8c!t@WZD&BmhDW_Q?p7lm`pv2z&pNp@6-pw|rsHRrT%^~-}<30I?(;^L?9a>QR)&-h` zQM!;caorq;%raNkU?_NI(|lWeU3tmj!a#G2R#kMTfknyBWyK)XHO-rqB8$*vgQ7`s@5*gTLfeeduDhC!e6j zn1Dw<)l;5q9xfZ!{U1-IUa$e-s1wXR!64NbC)CJCs$y(^?{m)q zYm|kLKpyX&;`EDcvS>>~M5RsshDSjIeR`41{#-$A9esN4=6Hr!FB3ZZQtrTxpG4%k z0_enLWwIhJ!7%}q*arn&@l7_kU5JfBm-^dVTY)L`ePqI#bp76Z?)f@{to!4SCv%s} z^+E3_E5vhanOFw>mu;8#+b3adahGJIU%m#cCE{%!RP}o0NExkYm8@i3SQg_xv8n%C1-v6z&= z0A2$Tp|};EA0hL6^WKG%P*3UVQy@D~8oMczr0VpoWMF=5;!>bfLfvRHZ<{#il>Ts~ zKQDku4;t9qGx5fAj`1)U8b!<3Qmq_d-MstLwc4|8?;u*qB#{|=TFZV7s$|fd?)wJ; zunSd&TinTOQjDSMf8An9oNfsv4u5@3R=JoTy6FGS=#7L>iUR;ZBg=L$`1>c7SF$%Kr!fC zCIvoo)C2;q0aFSywvw|YpgMFnn6ILer-tVx>oNZO?0kq`6teF$|F-{w@o~nDER9z} zjde!rWM=Cm%cXm6+)m8R|oC+x?SOKbutwn^im;4K5yJ4(sbcMQ}{FPg|Ep zTNh(n*HLDLtM_M*pg9x(J}&N)T!3s}n;io5r_0WafWnMcwTytL8Lh&zSO_%|HiV5v z55pc0wDA17JtlrTkm2)2q!C564q9MBnG?*Vg!df!A@P{EgWLTD6<!K8tZt3MwALs6A2GI=5w-VnH-t0do=z{;phB}ywswNcr0cYiEeaxpe6Ay?$^vYs z(>45L^OvewUq^xbvin>unLE##;RnmMw5G=`?KZEx4OyD_Dmfzj z;P&{J5K7{9VS^F|pLxw<-|M!w_TMj76`hp8s1a7zaNPCAyz$DX_N0=?Ig|v&(a_Q) zWdn~caalyk!0e_hoOLdkt90Dsmz!5Aoc3$``Ma$qzb}66nH{a|pIlT~>{!l?`XRJELS*3iE<9 z2~7{Up5WsQb}ka~b)a>k-2R>{%p&OcC?N&P>HcNYWkHU!7HED@(h`M~i6-2%SaDr7 zFOZ(m)FjAYwtT!W=v2)T7%$iw8|?SL2E8T9!_8&WBUXz$Y4D^^$K9{L9zW!qLbyTcQ=gw}nZGNipR4^@e>_C&h_uFkJ3`FBCeXBTt^e6l zZx{I=*1J`wT%)^W0YNS6_4F6)a4m+_!h1V>B*|wb z&CQ3op0;rIr&hP>5>{(0U(5-2{+a3Cf^!Diz)bT2{&nZeqmZ`>{gC;~pPWx-XF{mp z8Zn&HFz3fd8Jw@qq?3c4WXNymS#t{hw3TG~J~KSnq}DanMDF41bP?wqMU4hf2t!Jx z$qOVI07J3;;D3trDcr-tqplP=w`l#YWBj^1K1XpJ5KD~#l}G`=l(PD(GB1Rc{J`rqJ-oEJYmf&#kY?jV*aa*SF`jQCqDDWCy}}KAICI z8Wq4UXcif{{nvf*g?i)wqVsNtW?^9voj#;Z6Rox;sdeBQYS|5Z-x20%Z$KQ>C$taA z?YqU^XW%Kv{WI?bNQA%Y4q5c0WWHYTo~XW!#uK2BA>PaXrJ8m`a`A!Da?-jWq`OG% zTffz3_xbq^SRCH+2r8IOBpG!k>`eAKCey-LZ!Lpg^%%j_(yfiG^b4M^|L@#P59BsG zczGlrPc0iyt#MCkj^`D)q63r@sFb(-Z^!S?L%sC(0EO2ftRNbYmXn+V#I)W z^V`+$Pp(mkZeNo>CIeYSsz;K1l#xN3`5{28=01(S=BnSRx$!}|-XY4#u4nh)B|z#^z|830ndGEO!d! z^`vPCa35GMAyf3FRW_%wZ+$w8}NJB?YwGM`=F zH_I-FNs*gcW2p^mq7P^FEr`^z_>*Sxza{_wS7181;(IE^0IJoeJka5e*hJjdSawY; zr8-steypzYhr=b7rI3bF@PwM!{P|I2-Px%&?u&!l7-;HgxdTSC8+f@T8fsk0YTd?j3MK?d8ID-e zLgr|dcSw`J!Jn6mb`K&{C{F&y*?g26pS0t~s#IcR^~b*$-=MQ6O_KaTaQP8hurCwD zxhU*3+$8!Bz+$DjA^J#xy9h;jivAa#9KJ%mhMH_UJ*yIELqVnq}n0vUn zLuK^;P~;#CITbgGCtD}wH!4_cen!nn9lZ@P6#*GV=XV%jl=~4nrTvtFbujTD3s{4f zlZesRR@c8E>Bek}Ql32j{QlNAx_dtbD^B)7@msU%*N^ThBR@`3E#8% z27opLHIbumoL!_LqHdrndaiGE zhWSy!WhSLxqnC<#iW3&Il89koryOyWYbYzhFqjhFQ0SMDze>wmmUXxzE7|Y=Y1u>} z;~eq#m+vzT)uXCQ5ENkpo+-SoFiZctQ@CW>=Gasqn&vOwz#iYPCfU{_(4`CVZAl-f z>~rSKvfv-WEWQ`aH3_70r~gB%-#%dI>UHs)XURg0tEJ4ok`zX#i$fC*e@jpf%Z#1R zI&wgWSQJlueUu(u`6EKY7bq@~1c$U0OoCMaV5oSKpC|KjRE-_vhedWU0^@LIJ58YmEaM4d!4B>SyqhJfP; z>-2`R#$aS@_GE3pbFb|BNg`sGb}G-#g=y)ZbeTuI zja3P49WG;##yQ303#RDe6Pt1?0HMmvikr~7B&kAo+9xXfBF_jP!s|xa?Gf^434Gt; zlzEWsd3IldvE&E34*O1w4Cbl8oZQg&3K)M9?M zBy}vj%3;(r*p)y!9*FJq1E;=fNttD-cCzhYRHdQTzo9Y0n#y$B&IUu7S&qs`c6iaJ zPRJU~jmkxtl*`@WsmtK?yRIb}%Nua}kSzMy>O=Y12ADrPP zT27d%1pxV8frk6N_T+?8*5~CxSWTTZzr9Y>;og9sggO>?3H=xEU~Y8#9*K6lbAF7l zES_L~x=iFSAL$}yEr?|!kgz0h$XVP4RQ!v-Z=U14nu9wxotYpo{zAw2%odE7)vv@? z?l{SI>q#cSC`j97wIb;2iz00&e?Lu&C}1aogPAKl6~+z5&+3(Rm>ANVD^|KJ+6?>u zyKE$aJ*0VDWF-I%$UeOHc{{)k8Vu?rm9m~LDz+)mrjSzPw2ZlB{h(SE?bd#!z1PQ$ zWYH6!`jEG(;mDXR{`X+AF3saeNk4+ttXRt*B5JMGo4Ab|FF%2DAOIZPUvxNn#C!Gk zc{*K`|5_UMEF0>7QvY@88Cc}~pYq?++(0(9JD04#r>cQis5j4ub+xNdKsjX{PUo2D z43TWoBPdnF25auq;kmi0!VyX=(6q*ymV>5vvluWp5j?1Rdyiv|3_3; zb@Mt2ljU7#tnRqIseGyu|9GXJO&>}HE)`gQ&HML-&i!1wCo+G+poGyl3h7fDy$)ul@BR(RI5^;S3=1b`o=X3*6-&6>-$Q>#2F}s#1V5XXo;I(mR@QER| ze-Bs4ORIcHU6I!8>GmJn%wtNtY}mg@fzOjC)_v@n&yGY1H09v_f?Df#%q3b;s>e3) zijcpQi1(Y~raYhv+^@%sRL{yjT>r()#PtWp`2E-J$s1)T6z!ut)8CKsw0oLlVUS0) zC|q`lFloTXBG6pDeqSMMG%ZLMA}N!B4ki=6_J%mK>3H_E5;C5#}XGLmNWdWf4+FL9t6dQOYcv;ovq$mD@BOR>RPMhKiGTPBeVEDwBoZ3 z%#05Ch3qx+-=wY9jzj@JSo8%Mmm`qb%b?ciF7aIKcOKA^Pap|E=L*SnW2%Pz+MW(u z5mmhqe*TTiUgnf+Bv~~)ni`YB>B6@0&(C@fF&?gn`J30EvX{;|^5G{H|6HvJ6^J)z z!PEYIhYnthzIr19b3iMo3~8c*z=a|2!)P8^jX!GllxUpsr>5z?Aw(8Xoq z^x0UJa{`VE(^*JOF5%`EWaw1J@l%pq4zT@bGF`uPymHNr08zPpe~ej0%yTE5l()rG z58fzDMzm|%hUZvhw7|!>pu|>nTZ&M2*pFxrg-=9XXjb#MFr+yOM*VC@3aCei_JWEs z^?dP{@ZHuR_Uel@lZd{U+Q>CZoTVXsuQ62V+scQv^vc`}T%bTk?47xGwF z)iv|(?#Ba1tK_C`uAfi8Jl^HM1l6Uq9>-|9S&SSe~YM zFFW_mk(hJ{Qt!a7jd#6v67V!ng#rDWuyhA^RRxT=MFmd=gJ5ei*nO)w-%|n28U4yfznN5OK@QZUf!nipmXi#m&o9ng5VfGwKYd7=OmrZJdjM109Z+v zse~XsdMD-6!g#~lZvnwDu!_8_PRczb1`BG?x`r&fr%O*VNX{@0c6>w#ys@%WdRez9 z^k#{9i}M9xge1XWbdS>r_}9Gr#r)X$y!JV(S@)p2jgwqv&iEMT7(ZsM2X$uuBfvv< zBY*g7f&H)5U!B7>4o@2nMjA#38=i7Dd_wVOZCP>z;ivDqJtYo~p$2NuHoK}@6ybd; zz4?DUQiY_9j67`&YO31wEb}pCeTMu<#G7BL+0xW>O)Vh1u}}q5>+#3P%pixGaOr(J zfzD{_a{ChWC&P%MUd81Ey31lvLseiPGjPXM`gAJpCGvPzoiZ|F@|}p+uyD&9i%Op^ zRBKK3?b@kRJyUq#KJDEF>+f9_kLYASO#5Ui)|_0ybSnCSI!tNixBE=>_Hm+!y^E8fM#!z2s@wCh#`VFskrrMi-- zA8=N4!fLzc;?ocH11msoq`CD;G>bgiOxmPPC&)~r*Z4$=rTO0o{(IRKb{=fb=4wqy z&q)1VgFBrFVLfitHsP3WcoP<*o>!6b33dQ!I>Ho4^HyHwB;>{hv&A^rJDN0(S49_pdw9uCY_j7`BH_j?N2Tuxz035R*-(( zfSQLl7p_kcxn~u8dFP9#c3``S>>}dkCQ2K}eNhPdxlr=bj>o>rlrn%49Q00G(Q8E5 zHy>md=vDr5AH&~^`rDlT_v>@_)B6L==Qt;V$OQ~_57JPfC!+@vgOfDJJ$Ynj{)^c{ zE62Ufx`*u1d-ovim#Iqb%NlD3GjLU;YyDs?GytF4ru~xLToS!b|99JWSYffIts`HY zeCDm=FK!8*D+CkB_uklUXrxJQA? z*?RaCg;o!}LgbAHl`unNm>zgPSd!grDzswFiv1%K-&7rHfS0KNAjV0qBku4s<(Zn9 z<+*JIL_bqyfe ziKvc3Sj2FHSzq2F=9y!wSD7R!-qJo+{z@54WCbEOR6w|+oAwo?6$O18Ke1*+w~pbjDUAf(@>-kPD1p-dmA^>xjD4)BlQ zQfc~8Pf9dxwBTGHH19U@UV!?=nyD?`*;+Y*pR$4d#EpccK-VtJUJ*s6xk!XTyU}fd zvG(uK-AD;AXGT}9g=HsNB8sPOK(s-G$|B2&#^&n)O|f7qx+JDVa9x6r#P7*7yHIs5 zyw-=n^NEEt&E+^9ninKtNh|sHpqjqZ$jFxyZ|k1O^uAltn4(-8Z@2-oIiJyp{457w z0w=K9%!NVPOrOsmKj3p*ib(f*S(N(uNOBlL??o%A=5;N;cm%~qIece-22=vcS3E-P ztqIvx(A#M|u1IUIb&#-`-(URqOCs7cRQ(3#-|gRhU2`c$IK$XbU{rxZ_=q84>2Ooq z;vDjt6{5Uz**2Ss%Ln~e=%Vr8O^A|t?WqS%cup0M6IC+D7zQimGsK#vx4iJVy{aXP zzVk2*yzw=fdyY~;SPiyO>fohVcszNOo@nAr7|Fu*%OTZ2>MpL5q*?V{h5l7OFA) zHF@=j&+ctuF;CV*X|bmGpZ8327lLSR=aBbg{M{@vmevOdJ~W!al_djJcl@n+>>sBY zA&Y$omi62WB=xDfVfWbxjwF^9=O1QKRHtTT6us*S@ko1c@%%YINAac-@gNQ1!zCqn zEJ@-=DzjO{r9349ws(@H+LXn5bBB4lC|tv!WqnzHzR=*pT*Kd`J?7Qsm1IX#zN)QFyzCBwWpm}hIfU? zMRUcvF-U|RA0DhY66X;vh6{Vs#In>&tkWEY!Q|kSabg*c7ODcM zKuFxiE5AqO3$xFMx6F$ZF@Y?%zU5Z3+>92Uczb}$XcOzeU7>tZ>dcqhYXHiJ&O=x` z_p_e;?!sD|_$lv1$iw+GcFhBR8oThyL;OVswy?^}H?9>04lO03^XH?|Z3>kJ7(*v6 zWfNx4g-RJtmuHF9>x+rIGu%^$p6C8ky6%qqPf1>l1MB{ERcUlrWBAee>@1DC?xqUn>%wN& z{s|Amg?(yb(!HVGu%&x7RhJ0%M2|x60cDSQI{F+ zCqt^*Gu%0bmLQ2sA(VwqB74tp?-}kr!@Xy?_Y7LXC$b|ATEZ7jrGedRNbj5I(g_8o zPB+}?h89PO{S%HQRGFzO4rzD`6&QQvaIYM+EdeTox?BRN5T-pWWmCO!xK|GMN-d-d zcQ@L@wTDo+?4ZLPbZCWNs0?!m)7c<^4MLZ{(6Z|vg3 zU3|EU58BeBPklxei5rZ@fFW&7;!#{1ciZk3+a19A zwxhd*fm?VK0w1;QL?KZI82DU5Ss<*^o8dPoD)wZ=tPE@$6 zHY?=P@OXa4B}Cqib9_D4=wiCWKOSZXq1~>xcGGXZwOeE3GoBmO&+o_UZ63k~Cb8_S z5+MiQkCq!V4T~3v!8X3YKGP^wUMJQNY2;aj^Dq@g^uWl$Q z+=^|e)HyLHUV8|AhA}L0D2nNnO@D@cutJ4^9os?$1~ZI8rk|@WoZINn)Rd`WEc9otq3xvaZC6(}c8wF&?D1CA^pn|zT_GH& z>^NoDxxj2tsG|1h0GlMBLI_6;k)crGViHMoI}10im=3{3g3abc_m(<-SEzd97Ifh; zhEUCDxI^M9kiu>c-5&ZjI^6!}Ge4* zPVaR2dXj*pKhPdDFu}^L&{eoJdgAu=Ld~AI`LfVaKv(#1YpUZ;d!h!W;oi-i_J_w+ zc%03n-VK|&LJc5P&6$A;cdOLr zc0qYCpW7)r&c$&yuevn1R|*xybxDRZ6zaz~t@Lnx8eW%&EAB=VZsQfIbk?U9g$u}m z!)Om0Y-q-psP4qAf<(2+WdUPZs#xym+$2iWjI*YC%W_A@1tvg+JFh*cx|ijLAIh(L z_gWfDxI6XG;^NwB_BfqTc=g%V$VFqsM77Cvoz3gyTun2-kwQuLWVxdML6t+O{o?V# zLgj*cDuq+1v4jSZ54&0)-Xc#Nb{*<^+v6`TB`MU%wcRiF(ICbRnnZQ~?a>IY#1;;t zgfiS1lIU3kH^LUGpnf6@vw7U5Y432Cwg(HRP|JsNB;lQ_26L`jHLm_B^a|>#y1Cbw z=!nB>thR?xBh2-Dd_DHzzK?lrRdKIYY1iwpE9-`bT%cSj7xHib+LqVMSN8HihWjTn z3H;Y0?dA2c4<#_^y5~erL)X*Lr4>qIoo-5`e0)71hD;^i0R>n9gpC2-NdqIz_a0&? z{lkeF^oJYvo)1^ag>O%mAuQwmi`lDY0gQdbGU>o5ICf#c#4tKg2%$Pv++<2KBnqAgo z4VZSCG8HX zF)O{5!YHw{If*r96^4)zO=?FHYs^Y4l^;+ZSWnaSr1PqU!?+Y!DKrXi6Ndb*&p5kr z8Wl0?e^RfISh|A37Q$s$G?2*ygoU9fYDJz{YvhM&Hj^vd!fp>^3YT5?6<&J?1y&lW!3#u*Qz^q@A+e^Y!l}=$D(g3$D1=Z4fPqAz(g1^-Lgg3&bz&b33_^0@l!vK$ z!x6;9tG15Ah7WNEN1<92L`I-pRV29VKXIvfZjN7*wpiKYoeP|-Nep!ySi|I)z>zhN zJ=w@n;v~u^tah zEIBY_4?^%TVXxatNz` zpOKkgLlNUz!@|iT*(HW2a)Nm#IVznU%JDge;o|ah&@P=EU zU4vg?BQDWlVZ%M~pj%3SD;$dLPK6sy35;OGkgE$7Tj#(j5wK9;P7;~=Uh0A z&RaBLg;&^Xa!AZAk-`w7>n9Gm zViZWswMF5QYi;Qz)|OsjRZrqnU?IO81rpm4_D?vb@CecM6GN_$R1=Rppl2sK1V|=z z=2xz04T;+xu2P2nUZ~Hula9JfT+L{|Tgll@&UU!9`vg?Dm_-vE*l`-%8aQIqab*?B zbxA^!YI^OWaC}Dk#WDEAuIgsbPCRTdxE3pMe#WcVmKqlpu7!(v<2?Kj!lh3+uhz5# zO)RO_=Nl7iTjt?!z-Yj;(fVW_PsGi7SC9lep2h4+s4mVU#zrNdq zlb>xY{2tCr55JME3$HvtM0*F;T4y;x-S}I6c%;=khYzt0`jAS37P^CS={m}v;3yn_ zu>D0Y*t=c3^)Y?mvXDoA9NFc6hz+HOP{6J+?boY@cT~;Dk*zzI15e64C`+1j`tvI^ z6+^}*<#p2oR~m^mF#0@LVqG>;xTdfws*Z3egcJGVM)V7d!&Vm zjlF7UyEG9i^hAGFI{HL(rJ-Fw;1mV}!_x$XgMs1dphT>89-;>h?Oi3-CRAZ(2$ZTG zIxyUUpNOG#Xm=*@Hs*p0+M!*Ehp-)9!>whwwG20ru6DjATlDY0+wMDDR840#%5al6 zQ9dg3sYg&C7gjM(-AS2a z(6MHktwJW{Ya7rAXQQ@j# zc3vP@IEU~E%WMS5yy1=e!q$b;9voY!O;bghIE|_n>W#Afk1IS&sJw9qPgKO%6-yir zVb~SY&_f6h54XGERyJWqn}`YShuDlytY<+IU9s3He~7*7hq#Ji<-3d6oVsqQ>;Lcq zDT7W~2|dIywTC#6R#+3~+>}qIOurMc>?{l-hX-A?ka*BKw}%JoFCTIsuqre0@H2Je zEqzhqxWbyS=XTz=mWPSQ9?mv0yDH+e-Bwx$7OzK3Y@r)TgaTqur zIuGq$3#S6ZN`7k3H*qPKs;WZ+*22RKX(B%~G%du`bX;r4wWgh5pfa?b9QIM`^Afi* z{iI<4?4{Nq6-$$|Q~-mT>;R0!b=#j%HI#$lvis@$hld-GWEI5fsARE8vY919+Y zXGjViLLW=0qNWOA9tq$-%(DZfx5B&z*tE;ybGj&{x-hN769rH<4MiAp+*8+x)E^coY)vu{2mO$`tf_a^6E|jn(DEWQ2!Ikno z!t!HygM;Q|Ti5~gndE<6g1B^FXq&XCeDamw#Kat)^eTW^WWCAO|`(=WHpXSmFR z=Hi8Y8bde~0-JzdCrsQ;fwAib&3F?j(FL!&e8?^9nOA<(1CF2pxA4mEImQq|w>0&< z-a}Z179P2F70IGFtGDC|m;5f(#oB5QRB9z2-V$<` zHV1yhroOE3Usm`(gcR`bG`XTtuTPtH%ZmAxiuobjaz*=Iwl-SUyCR5kLE^muoS1elemn+_+EaFGChE2-FYEo`yB;`5yq#Tusw%pDk1%FK;@_$sFS(arh z2t>bYHpd2(`A_U!A&AKLQML9u>9ubV5kW?ZronKGIaL)Pep?8KyMSiKj>QLX61%e) z1#;tUtVD?3>9u3y6&}YxV4h5MTX&Kkto%Ya`)K z?W4=&Au))q0FW15qPCw@BL}Ry}z=a&p)*3>62GGtr;>}8@$)q%X>y5j#dg80J}WXHp32;KqhO0 zdH|k3gK8+z8AFfrqh5FV>AA}6H;MBjp3Wp^D_IZ}G6w7jOVS~CGl`{v=Lh(GkGC+7N#Y!N4wf^%8C!W1--3#oQBhhJxytk>_@}%s{3D> zF+QI@-q3SfsLl2Rl4%j5* zC$eE%$WPp#kUy)7m5Gz>)<+hJXAoJ6Rskfk9(v1{c$u0jXMWZ9S)jHCH@+=YsByB` zLbwr^xS8Kp+u9$ID7XuLZmd31#p=%}!a*2){zQnzzIx}6QqtGH0Sbc&W7H;^@G!C%Od?o{KDkMBVqMoQoIzv~@NHhW2C)Wk zAtS@0UC0=)ekWq){5Wb|7D4`T)Vj=oT(&=jf4+8!djJ=*6wan@?V`&fsF0D>ee}em zJREihE)O4dgZ^5e z)uUQflgMcWZq9QG{1{m)XA?R6xvzQ1mgg4d(-!CB;hW2ZEKxtxEZm}*gq;KDhbH9D4y*bc zcCI#xPrO3KpiX-(LX3?t1xF+PdrY5N5MZ&%BAf9Bx`9xUOfpZeRJPw%v8PDls|_ zeT^y+6UT1f9*I5KZpv}4>rM}iIm5FGg}3AF>rM}Gf6H|j$#oaW`M_Uc5OJjYu=O3f zYUI0LJ4LS?tkGY!Yxy0`9}j7^VyV{EU@OvDYN_8WmJ-8hP}uRp<(!V)iOv+~YbO za3!jFTFLHwS-g;~G4`LIMc3}I{RjE87RXHkIsNnl8Lw{zFaQ7}(|7vLcb4c1|`+Yc~?t5FX;z z&rAVq3SFq5*(JKNbCn&{q{iFSAZx9vn>J{s6^Y)`fJ|*Ip((5~xs;v7e%2>Wg^Ks; z>($S$BID5O6N$nhWY|50YU8b(K@C*M)V5}HoP`y-ka;lW!ITG69*omI4<(H1rNS*# zSW~>}XTA6vkCPKak#Uad%P)n0!YT=lwQ*x@ z+E|;U8RON|L>1m$k-5UE-CVr{)*Xc^rfSDSVO1rE3t%h#3z;2U1x%bnxP|(eYUikS zH0kzXLgAmUN=@DKXozTnZWUsozG|Q!bplP>tz#_ISM}ffN4gmbI}}z^g(Q;JQYNlX zsCYHKUdR>QP4CdRXkLYDY)h2#;Q@szocF@!pJsDo>D>LZcDC1iGBhmokc zI@H{uQiM8qs8@$tKD1_qTD}!H#+#poTd1rbl})o}#2-`)>;&s$MYXYr)3+e-2=%j} zpRvyRAfs>yx6p-(cm9b%Ni0RiX`5;&lN!pThNAPqLRY*;FC#pch6;@dv%H&kLO)e1>vN&bJn_{6l%uNQ6Gk8_N&?1` zRIw!dQx8bjj6lY#EnAzq7qS#vRlT>uDi2$=zD-G(B2)j}YL>08ywyV6hftBJm+Y&s z_s>@)p>8j6W=UG1Hq&IuVA29uKhIdWhfo33EN1%3wVV1-<9Se`x}2#L#zn0Ui3^!C z=jzmMB?|RbE#&*pSEl9~dXhD$eKlUqOgsv3|DeKh(xrOVq$5dW!bcT#)M2y{TUz~> z7Wbu%7(8YsUQJh5UHN{00x*Wl1#n%B&8tpuRcBWXd%3&qqxnMpObx45dR>YrKU4sf z;%Y!%%JzNlBT;jS7GY|vjkiG(?GGw4^*(7se#im^dtdt@ak>jFochW+YJW>#nVr5# zSg07berEa^XM!JIqU-BXSjq&yDN12kXM6n7HL=dN7OJ+TF4WhhuS=1!&fwFu@Cw=2 z+c5j#rL7Cj@XQoUV=aQ}MFP@etT>7d?g^6#Xn%guOv|jvBk*Oov(O0f-TZ_aG8Niia z?QYv7C~+!)GU=*GEfU*RliJ5_EfQOMQ+UZulwR(K>^s^+_8o&nwVO7BTYJ5IRQSWi zXKOjwS`N1Ka$CE)tzF%(wLjsXuT1T#@RwW9Uw2^Gpm?yQYu?%q{ZL_5XQ8rbv(o;W z3YoArE2bTaeM87nwDq^sFEH>DIYFByUDKp%+9K0Zj}o; zhWhr7)7f&Ns@k~Z|BwlrvSY3QoPU4w9ZMl~p6TqsG>MsW9Gi|2;Lo|x{vfmNN67JJ z(utWAVLV-XsInaj$aW}<^sR_O=J%>nuLiFqQj4HpJpC7WsL8-!}P-%x#PG{DI>>4g<@VA-igxg=C)0uT4 zwLU(S+T>i_PIL+x!{qkihs=XSAJ<8nYc~!j*@a7Ax4v@Lz{8e629Sv25>a^Y`jEZ) z*HM{YQ{jyhv`hKz8Vs&w+@1ZwU8(sJ>WuZUbGbUWYkTI0B8ux6wq5-t{)tTWd(=W& znt!;2ih;suJL@Q9*lP-)dbp~GOQF~A(a(;;sz(Lo$sg*gsxMRzQh~bkp1_05si@<=>5+O*-}+(OnbZEB28f(wJl zx%x;vINE(3i3jIXb_Q$xhYYJdS>MVmbRiSg>15v%OXTQ2k}A%i#B{X#Ix+VQ`^xFxDom?Hwv`3P{k)&`m>Hd)U)%=Us5eg|) z{7^Am1>kJoo`MsVNu5Uhc5*|wg-56u)ZC#i7hIi=Cejm{qy9{p!PWF?Yji&joGTIK zN?~<%vT`*e-jBWHC%+Bh6mB77(1z)1!*r#OYA5Z5`k5;=|MNA7M==})2qi%|>dIc@ zN?~>9&{t($Q|52etN@BvM^Nb2Eb6ZT0m_89nWPbZRqmW@I z$3<(2A39TO?+?eRHhfc+YNr)rM=nhh*{j?8Lm>lf&S$M)d8nR>m#+%f6!K@6(~7B2 z+U0EIFlR#`TJglChiRmFSdAXci8oVZAe!g5KkG#(uxdhMWjz`DE6t333OMA5= zN8vwg@5uMS%KysA)RiOnD}Ph>(A?@C9#UWV7rKYK|NDHQ&yK9S)0LZ_hh8(n9n9_3 zqJ7u@u=B9>wOzPgT7+L28~?D)ULT_Q6P<{`k!T%5n{XyBVGv=jcx9xR$ZYjmb7d8H zW&d|&^mk&v-7AnB{h_?&h^JZdn+1jq<%9!QKPUOn=VqK6QA z9%5e;lc(7sXN?b$+C%iShd848VY}0P2)%wRNV4zcwA%Oo2$Rr{S^$3t0sNs=)jfpG ze4-Iy5RtF6)y$tb_!X7(5cbrCw-JJ0Xs{mBF2-c(M*$rsnH&y_O(hS8Uu7w8g1-pKUYcY9H zhw(}EjOW?7FJ3vsa~s0>Tp<+66=~=VZ8wQuy%NbCOn&Xh0&=( zAo~`wNhV$ePF#by71s1sZC_JVVf;bntX3P~B`RLL3}49X)O41(m0vuHTF5*Y zs}Gli3XcM?<)WY?Ikgk2To><|4K>*4AMR1!xqTm?`~ zo=-TLL>`>VG2U+;K&jQrX)HO=8gn5C5$7{lg^A9rK zQ1#V`XranhmqH5Ju(+F1IJYo}OJSD+FtVlLwA9yeO905QlTC=_Ez#JSu>ISWN5;YLfLo*lp3T-akB?A52N$CBVeR{s1eUhy6bI53v_| z*c+grL$073uJi`UkCZNgcW2&@aBdm$K>9=PRVeyw&+GZwooHBH9zk?YtVt)a)Z~dZ z<0jUG`VazjVojZedlL__a+6rPm%_`hC2tk(DLllZ0Ks@&3D@+JSff0#lud~>R0^GW zs1vJ)=;(#313(;3R4LYCc5t7g@G^!{@FdoHU+A4**v=P@Nwkg^9Wt@(-Vs2Oy<8<=QLRJxe-3+HXD z<5I7u{yw2K9(}EKmh|X-4@yw zGC-Mu72Zw)XAp%fCe9!p!uo*LXG&aXP<{<|C5N7AaOQg?4#H&isHTh;38w#t zb@8X7J`7MQiNxB1XOieMiPkZ|)S>V&dssE*YE6D3WPz#qGSP)s$V~Q3=0)@F~3kb7P4V?J);2Z z660D_;bj0UyALlr7jF9$-YyW1wF*y1hJ*h?7Tz$?C9)LQbOD*#W?%2+@u5wj;;nle zh2DjS=|JIP!be}#u(-;Q7>lI{6EZAbHb@*Co36(4!PI>EARdNYoG5&;Klo;!gqcAh z`#MakA99+CUd{tP$nw*|WFD<$tbkb5=q|dj_K<^5vlMQ|P-fl_p*$-*{j;|TC*IBj zrGVfrG!Zj+Xw8yDpUA9p@d-%`d`+eBbk1-fU3fb?a2HYN{OZg)u6QTjuX6@-nTOmo z@cEKNV;=l4HSeX4Z;KR~5pM^85q#m+*XYz>@twHXLa+ky_uQ>jfhJ%5PV6=7Yc*sc z1|}chPIOGZ+7Kmr;~Le;9`<_lY2&q;Kw^L)6rmxM!!-R3~qL8J) zt%-+SXYZligOSU(DiZY-8p&T)xMx~)%j@r5;Qp#j)mk`Y7}pzrU$@YNE<-!XPp`y(39vfnYwE2o2t*@n0gO)a`AUk}XlUBObajhMwIwUJZFr z7e^kkc}^Qf6gmSy*w4oWQMrfvl+E`&D%#UOYlY6{>l}rpx~W$JJ2f1b`_^Wy-q-6S zTKwLr5-N;7q44+oj{zW3Kf3%D9Sp+tqv{2Zyu;P;_ag}0)(;>*!h-mys?qvUM z0xEPTG7~MYKg2wi==|AISX5Xdv(jR|@v-H?njarRNBppEr58?yEx(Y3CUgZjObxrgss|0+<#C% z+f2B;>L<=m$d1^@D1}SN7^drI?32Aew$X1$16iP@+9^L@g=~3PY62&b9UKn3fLF*Q zwwk4Ue*#UYuu51HX)C1@kG|eNsGo6$SsLiA21wjPc!bQlaX7(4R-r1@)L5>L=qj;u zrNfgdg`6_s7OQXzP4T+I{z*-Pq5NAVYGr7(MIvV-_^e92>Rgx0Y-_U%8JR}+x}2bw zR@l}>7wTtfEUm$3tvrzto5yxx6i2xm+(&j z=HZV3Hn~{Bky7Gj-)RxHkO^;&p{>CSFGq#!lWt$Y>Q1xV$+h?wA$BdVRY?Kyjc=&mSmbGF=R=!SQ0H(+e;Oi z!WtvnuE_2u(I zB9?gBVK}K-YZdGt>cFTDuzwdL!uq~+VlSqqIg77ZB=*9<3tL_<@H+8y46VO1(_l6Y zTlx!6?9EKtO)cW>oKYG#N#iED+ti7zecLA9)k#oAa5E ztzcTy=-4cAGX^*cE8GkVtEa@<*yc-S{WeTu3l-_2Xb>?qKcglV-j7=S^Zv?3&(#)f z`;brEZcuB4j@GZ!uLmWzF6C%NIa*Op8yb5(d)tw5?KoOHj+O@5MkFfUp81bv^P?&J zXbL|(ThG^AKOg`zO?6OKI zweyu&U^Lp-F>L7a^-Ao+K88p{fo>1`i^qW;bxYrWy^+0?cxWB+quvnQeugKp1-07` z?i9KcZzn`0;gx3a_Mply1QR>_p1r<_QN8HJqT_;^-;*$Y1_6!#@n*gZ66AWZhvqIw~#Tk zd~_urB`UyW;d`82NljNnPL?f+&U%1x!^2=N!P^9ds?=Z)g@b?K6dG|T0Gn`qtOvL! zC{%!T3{N)%lQJ`6o-UEslz*0Z9q#wl8lW^9y_A z#I3KJFKKjc5w+C*DwGoJdX66?GlAUGWwwiQ)RnVw#rlA2Q*|CWO=7LKSFo z#evh^LX~220?#TNbvV*Z+$zwfAYjHx*VLohLZXmi)$*pPf;*bq-ck#X0$c^) z(6eAMU26**oEB~cU{%AFX(1O&cq&cYTw7eDkb?Pb-=T@dRj-XZ64?pdxy-n8areXN z3VtvP*(|OJ4?ZX#9xayeD_O{C5N;z2IVr$FWFdPhJUte2_#gG1Gnq;FUQAqEzRohC zBvEykHsDF*M0z$G>!_-a>i6u$oT;keF0oKaZ1#tvsSWND3)v^(7_pFx<<#nmbe&wfA#7)!|h3$J=W#yeP1R{}kp<^lB; z2?uvTp-o3VWIQ6t%rRG z={n)|sTiidKFx8>A8EveuUjaq_A_WYcV^pmKPP{%ih2m^vcyZoHK~U%#z}MrfZ5G+ z{fFEEPMzj_N6`R$=OY4Qv3A9Da z;hprt!^ohvKfH48x>4@BJMMXRoRRgmRM*H8(RFnn+;s`ub@Lm@c(Et+|l3 z(|WqR&3zaEd(pg$y@Z=dz>G4{|DeJiCSl40RQz>sdkOh7SAm?>*Sf;GfX#S46oZl| zuO`4t91II>W1@z?z%MZvR$ZgbAr4=| zc3p{fU5R$xgHD9q|KZEoiBMu4KB8TC85a7iyfe+_##>Q|msQfr@x;x@pd7DD)tfjw z5551%8_!=y))w=5`T2H^ z99~^`SdW@KY}4$AJycG61}r=b0O7olVPW6!F#g~YMwbV#1SF2x@xrlxV)^YaT%B6W z$PXc4Jv=O1o031gOc)O03l&-S!qbl23s0B0jinQ95?xm1!uTM^X)Jxb8 z`_%r221|!4>+pxr23;*|cX743^uz^RjhGk{`Lmwv9-_$g{uL?yfx?^ZFf zz7+oX$^w}Jpl?3J0+e`JimQaN79}19Pz=GY(JR#Ra)DW(u(N~L;DAAN{%o6lERncy@MksH zfrqGgRSL+#Kohc#%apqCGHe~%0GY|nW`NOjp>k$R0rnI!Ui%Y6=xhA>%B+`pG?4XB zk~EM%bJJJb!ao5NuYW>)J^I;Y*-Gk8JnV0n^a>|qINOE#8qE4tDQb_L7%FC{Y$aLVIz%*aT?ui*6$go>AxmAj-Qq+tOWF9nd zaGGAIoRyZYP&qTznVEjZUX7Im$gi%JFRP-$#X56#TAZ2{Dl%8+I%>kBBrc~{tWZD& zU`dZNtdMPt_h1W|Fl=@UZ>I*E-9ol8Y<3Hm z!ulj0{fsLEPR$a_=%$dX2&|wAU0VnLe1i+-&^pDtJBbrB=IEPV ziA!G>ax+?2Qi!G)S`CI~c;0#`RBl>?9-5Sg=Fy?~W3(`qh|4|CgK#d=4BXjvk29PDjM&|Op44?w2cFy++StZ!sT)vD^ zsML)i*JQuq%qYaMv!}xgEQFB%_f1q3_8ivijOKrH}=}&MZ;!uJZ?3pv~57L*POt zu}!F)VHbD#Bu65y5M92`QK$wmWfGSc9y$kl8?O{H2Aqx*vJ}|-T|V_us5-lxAhDh% zLOq!{r?6A|ErA{HZ(i-l;ITMyadfxR@*%dc51svN!}PL{v-Hu=E<0jhinz|rUm_F! zRT%>q#~WU5O$^Qit?NwW^lEUhkjNRv$H!Fzmv64q*ffz-MIU+>GQgf%W7yYQTY3*Y z_q;bMx~CpZS4R`m(QI@y*&IzPM}y&LFdX`^N!*usItfaNv#cN=v`jGnwIDHZ`Tr5D z6caZTT2s=)Y=gzbvl!J_{WHoGvkzzzTU43aVa|3|Ss47kEdRHH%ovE$N)c$2^(=xR^ znLXDP9^z&_;AjKL7*xM7z60`S$C#bc-z46#h?^M9?^fYqGFE+S3l(+^c0N-8J6$C( z>n*&EFg+?{CZSGBY+-%2iBREWfXNOHcS41iVO^=6k6|ynhFxcXnsOe(*U$MDN7$m8 zYW?d0wElG`TrEZ>wnk}btSxDjR*Qxe{pc7V8Wyfjiz9HgXjq8hzN8OYlJ%Fy<&xOH zG%lBvKuco(>Vwn7-T^EP+@*oLv>8~^+brpAmWK0^T84^#AhXk2m(??6Rr%;Tqb6mw z`1FvvjV0a2($-^1+d%(fg)9)q<EX7AjZ3TeORM;MxzDlOY$k*ID}(!ouo}_L0xS!6cyCFH~4p0HwBAv)l+? zZ3HhbIwl?>4muODSYHVY`R*30r1y`yD{ysJ;2}a6WQ%Nukh*Y~!{ODb&}cuS~cvG!*hH>`4;Y z+}uaY{bu1IZiO`kU@vJiqpRByg_n2~;3^NHc-bds3wpXqQD{OJGKrQjFK@6GDy-`( zTfXgI67L^W*sB*ah57{D2T4>Lx0d1Z#%-eM zXRfen08<_;6a&XG)eyIjA%M)JbJl2wErkle!9eZBmG zZnY%3l5l0m6+jK(YE9VZUEOuLy6aNN0EhGMJZhmpou5LjuuEap z+@Yx=j((=X2ghdL54yS^RCt6zt{!Kh#_QtCwmFi zU03R^OY4I|COlm~;|Tce2K6&eVr`yKsIbb8R*}Bfl{kcI`K6ZEibFlym7eWNxu)x2 ziOQJ-qP`^daDRW0kx90s_DvVc3Kc-9Nu=t#WrZ$O66$i>|46rr;ru~`RrhQi*Ol7u zA=g4`0IxKFR~o=eR{&R6-3k@M9zq4!e*!25mDtoh&Ga*_0e$y2Q7vRDo83QO8H0a+ zkO{j6nQH=ZmDo9vwv&QFg;m4){_|BaxMI+FYfb2-Z-YXGRkuH?buCHl2O*K7PhZ$e zj6cZiT#e(adFQGt-|tUY*4gE9&`uQ+RS#G7aMj#bZG6?nSKaec;&0#5g^EFaGL+xY z8XzH^YTzq1@TG&KLdF{!Sz4&txV2C}V?XQ5h=o(Qh*JTW8dcG{t(eHZ-oCI4yFgnj zL;F>_y2F^raj`XnQ`>#%p5p3einiPT`*bO6@4Jqpw^8w&uyc4f@bkAm{wBCH!wq zcv}-*-w`cj5}F3L1p4jVqi>1s+qoodsR7y&FH!wh@;Z-bbPe^=YPFE_@0LbjYo2T; zAcaa;o%Vi0%>>dO?5jLnRg{GEzVupngo;-+c{Pr&MoXyggz7gqz4is!s|&J;s_Ib7 z2N%os>Q&fDES+^wTV1rpkrs*uch}-pic3QZ#VHn?26rn~yoC^gQ;ItjcXud3N{bW< z1a~d&E-&AEZ)VTj$v-zUnVWOZ+55NFO0}|{@VrPQ#7f|a4Wx_bP@ew2Ej(fy2!5U5 zHLxzcnA_R*CSwpj$#$M@Wi$~zh}k?}9gkty)abL3EW`Kj0U)m-vPJG|EW5#^9(^!~pvTuumnUWnmS}nBNl{=haV(>_x z@;>7jc2P*XH;l4vL%0?XCy^$bQG1cwAYNIqhzxLxp;_>0|Kh%kHq=<^T5H2M8zrm23Ff z38Z*;i=DV(mwtT9?UTRzMkmAG!ST-x{P)q6c$YEi^-+rcy=iBD4W8L|oEeZ$Mn>^l z!Od%}=Vs!e9(?yx?e^zplR3io<;h;NwHu2qC@aAPa^k8=?X^1xY+ z1p1Tdks>W~F1h3Dr$x)BFok!@d9Emq+Y0norQ0HY@ZZz{xBQN+$y<~BFvo_L`ln01 z8`$5W*0ws;j3d}Oq7iyBl$Oh=_s;6$-6qTGH2LLAUL}3GA8tp>mPMCc`UcxJ8JAcY z#d)YQZo2L$O`oy-ZhZM7V6%ltDcIQdJAnMP8>rb!vF-oL_`uxm_P5&KfS|n%&%Ks! zVk)2U$2g0PQRPk8@!rVy{6ZKD>@Z5Aux>mU`DOQYZJeqPk3H$p8dwyKfZ6wFy&HE( z*>?iwYvh4K3EJn3K{h*vTI0`Pal(YsaXX9VvZ}H3sq-o7Su0wF2mQf{GbW!sRGoXH zs##AxzjoA(ew$g{v)PH#`z|B!MILH8Nv3c79fj4I4$RkQ1&h~RbUl346^;uIl-@IE z?C)Z%cnmKvIPw=%ZXta-sm0uuNd?-XWv0vao!DAj(8Jg#H@w2)-pSh3J>Fg7Y7@!F&+3vu`e6`A6mw#)=D@26``v8X}I5(+xv#xZI zU`|etWUem!z7W*3c!}oNsacZQY)RtIyXn`;IUbJcW}!$Wj(wGpq-TV|nPO(R%@{Au zGKa;hTgj?42Qy1GFYSjEGMmJ!`_R&0A7*b~_I=8G-U3Y#C;z~(_IG;z#&kC#upxeM zz@hW0_t)Xyd2*<+ai=VQ4gRQQ6^d& z&1QjjPLz?=S5Td4?_ZEGh}<9P1sHz|y0(Q1X)slBmAoAU@xSB~;}8ZM`2J(or3Ns^ zanMYcBF&#UM*#<@{Jrd7zem6QAoLyf!k!pt6t>d42JqLR znj;Hu!a9PsqUJnTN^mNdgE!LvXuJaR#+VgP?^M+VbNU~^h~>Q=fTghNE6 z0`-$i*lG?=do91h#CzXa%^a%3E0$&Dd?P|fU^5@v=c&MO!N< z17zDF!_4Tg%0MHf=D13NtrNOi8IMCN^=5D0WK<3JLgeVyInbCHQ~sB}9g5byk-5dS zHefNX4;*2u+w?y@?k%T+{}z~h=bC?Qz+S1SBAs&0>GYEKY)nj?#4xeaLuCQi5%&k- zq3=tf_n!=$(9HK6D%rVp(VikwAY$z%x=^b+{?e>+SgRh3ADO@|o6Eou#s%JKkF8I>K(w?2 zSUA9&lj7%tgUBtV^4*}}t2BVjz!{MvO3nBLnD37_Tfvc$X7uxYgh(+My}}KCF{QpE zlx;v`;`f>Jd|zX23fw=o4z8YoEp6g-4w3q$aGsFl#p1|(%n?T&2b3=IZBQV8UYhc& z=i;aaZ}J zJ2oVnml6mRxMQmu6~ExUabD|8M-rr65l&Sm+}Gd0(c;2fY$ai_#T>l}m0$S|`@WnB*cEQ42J{v~xH9c$M6TUzb>4GMTnURoj(tt;V+s?0GW);Fm9PhEMGY_k zLPPW3K8}5q1z_K4WC%)!fy_&)=`_MAKQOv9X{k;C0PQA>=$zaDFe;&aH`$dD*cEdn zP1D;X)(z5!yN*t|@JEk+mqI=1_cCGJY~xaifeXolXu(J_hUT=YDiPsnbc!qy zvbEy~exeY1%0R+#(l-`4jV&5_CQ4lVV9Acp<`M0VRpll8Fwr(+`V5}6l<~@flIm=Y$4bNcU;agW$_iqrnDNKVyA_|puR6iIwIe-ngu#DrzS zQN=b#;qBdW|C{{XV6oEw7lG0ss#X=n1+2p|?ZP@Bs3MabzqKWUUs(9B8TNhXNKCtT zrl$7hYNW8*C}S~kOkL1K{oF06bQY3g_j83A5l*aNZ^J2P$~!kf!2W`U>J(^T-sU^N zIMrkBP58t+p@hX@-NZ^Vu6^kS=aIdd&Wba5O6WhFf~PQS!~cax#s0a`f1?VKj}`_8j5!~8u>O&Whb@N$rTt9M6D`bcQz>9+#aXVQ^iO%BcToKFXBB+l7yJh+~LKd zGlA(S{-Qtrq8t9A5&j|^Z}B7EqSF7??JcwQRQY(Yo^4wxafj^*ss5b89W`eCPQfMK zjaIO0ag3$sCz9Gz0$G+E1Bu|7Y!(KKR@wk#nn5)7!5qke@gt<2gtn?$$rpN3eZCyG zQYSke7Rw8g6HhisZU@YtZR9GU}y9<6{o0;e`{FycaWt-iY@~+~ljdTx7nT2kV zJy|TuUZpZlfnnACSKnVG>D zDHnDgyyYyg0XHqRhbFglU8+CUJY-fdZ?GbuQ!b7|Kk%W8C@oAe!D7&}9{;ST2WlI; zsA3biTVSp4;WzQF*u1Rw6%#49B#s|5BIZnsz1-k#QV$BLS)9qW=Z0X3f?rp?I-H~3 z55$=J=NT^$8+CHX>&M6N{a@_00V*6uN`gwC#x*K<53852-tpr7?jtG33l*Z{xZm@| z*P0F`J-WgU6~7ko^-?yuH)<}x)htGy^(6a@K3q%*u2AT&=4dpV^zED8c43RU-sevG z-iV3oE*f97>cQ)1)hZqvZ)D$EOuz5i7YyUDvg)Z3msK-~u85gsV3=HRS%>!hQbP#J z5qmoW_I!D2_nYy=EJJd#VAhIRzFFw}d!-NiYz&VTqlpY_(xq;M`4~Tb?iV?mu5SGi z>^Y=RXKXCrQRfmRj2jQl^PX~-Wu-OLzwAWHl!I@J)-1-nzy~gqKf8VK%MwfZYoDK zR?~n3{xdjpQ!dQq0NIaM|D=*pl81xPrWzeb#)gcqfom3pYr7xT9#g}(9m=0B>#TWm6YyfcL;vx;$><}-(``YWQGsKoomc-77%tmty-xF?h3ONU5%c%)4}hxpoA7-%z!|S1W$7 z0p?Ao2r@0R({R{!psbDIa#?JXQ4FgwIVb3R`%hq#1`F+?QiUAUOC1SoIed9lat z)wbOx1jTs^SfNdRS<08Xuvs7xM$^v-n2!-^V5+*Bi#0P=?btk!$q{Qeri%IKMbgcEHDO`XH&I%f@5AFyIzB`gC#fN;;4KN6nMZGDaAOoB#u)3Z6Or=s z--irs0QSUriJMy(kRtkk6M6gmJ;zy+?5j&Ebf2FL7ECCDQ5|$=&@L|kib*pIWdufpiw{S^4t zVS4V}Q^(H{rtjN4*7D8+t}e%fHF~*M=XZPXv9Lnco3gL|#!nss&<6k6sOmCa=S?L` zJR5%}mnE?Vi5*XoCzk~}s$S>)bxK}=H)AhPh?}5!N=ZNgu3y?h#Bto# z-VP&x&T2A0bFT9e?Vb1R)Z!Ej19)7r=abrNS-|Hd7hJw4`-7l5H*PdmM|8e%@(}3J z&vYw8!$H6FG7@Qj>;+&r^gCULuYOC~u|8sFnYmQ*y&+6oyCjDm+KEf6I#@F%!RGRa zFU6e}jA5t-ek*aO<;Vh@>s;xt*U>Myr_OxD638a!wr!cG=>R8FMWUO14o&%mo8DOK6EG0MYj&|@lr;8KT5`Z`*N zrc9!~Z$!{7Tfam*Rr{l;Sc6!Pu@g#`YKGic{b9eU_D-4ZFFvI(&c$SoU2zQ^I?tpi zuv0)cQR^*RTTszG)>)Lu0%B)yWtZDsWKZynd&7!QQx&R4RfGXo@FQNDX zWwQIdziiQBYRf-bQI~UC^rlhl7PxcEA8}4l(34BFz|}I`)dEAmqJH)>+nF*M{{sdC zg-@A$I(<%ApktgfkW<_Ql7`jEAuD6yMe*##KPo>Zp87r3`n|r<>kWR?zw;4 zOnh!0kFDm@|3)bJ>vA}eMwtf2*DxvuxEKBIuKkz3HqLXRAOjb_4kdiruhS3LzJ8F= z8MyU=&rnn!OIsIrxRDuNXabke&X((q?lp}w{o2&@ym_ta!$@dZ_{3u(9%r}Nx3v-# z*C8PM#qlt&%4})`8o)8}gvh_D@9z*tvgl#2#u{XiK!sPy9~xyKyN~P2Svy1`SQ}kU zx|cq$Fl-J*ns+w1iV#IjoO?@29a@U>dX)9NW#a} zQpc{V*{p9szH!QKh7F`2=~n|1-kM&7epyYrs1Q|dbT-2p^jfNAv3=bOy`gU$Y*f2Y zXJIJs8>_h=b`#*9ZxQ10#LPQQx<@8h>#YYZXASLm-*O|8lZAb~6cC{&Jl-8UJT!Ve z3MV)(NDpO9DtxDXG5$+pLy4VJ8WEsJ)t6pD>bR`)PmpbGHM%6 zbmo5$z#OcQfbU%6x;a%pWNL92OdFW;)2`L2z4Bx5cei6Sn{$6%17y0Qx-H9L6ZKRE z)OzoV2N&@T8`gqSa_Ji#HSAPkLm z3}((Cn*RgTSI)?%NRJod)z>vvlIw%^fRNNpLGYGumSmE+V)d5X8MUIpgEgaOe;<`e zBtXXq=RA?dp{9@oo4hqB&lW5XEib*Vzg$c#KtWt;)!R|~dfSn6oIm2@V?=uCOw8eV z^5(%Kvl`A7H5+geU_bJuhyy@xyub4K%3q4%HI-lY<7CG9ePrwT>@0-KNH>?AKTtFr z>L6PCTBsYkF81&H>vQIFW;kd0NnX}h)}S3P)cC%wX4@}J25iApHC{c+W`SeU@nno* z;Ts&_g%2uWZV3_jCx2b@g2LI|PA*-T>qGt9;Drb8Dn_#mvZqM**1#4754>(!!R$0B z@j)KZ zds$>q+3DkO08|ULTBvdl#VPW?k&sg`ZmZJSmCvFgkR30T!v<4Yq#%Ri@Xf!iW0HHv zr`j!&@~*rL$q06L;l1{zF{oICd)R$j(-@Uus|2@I)`5or+9cQ%;q|0E+fi`pZ%WT8bx{1Hw6SqPhB3wi0d>G`@8F z(DJ=$M5R6pL{f8WHK<%M(@BvZ68eupvP=1cE^EkGe9i(`i4$#6>ZZ~V5>;2-Pqy)a$%s*)_BkMP~_F#f5)viulUSfI~+ZrN!2qZ zeGz@S<}{&4r9==UgYenclZaN>`LRmc7vW%`NytYQT+4z^GIk3Le*@Hee9vK~H#}E8FPTV_!3}v@|i?zj%&m4xF0$yLv{ROZ8wnF+a;*}545>(;>YBR=e%|L5!g+9sW z7_=An&B;rLX-n3c@xP4Lz?g=T)~==T*amFgGk?G9Bl$fUQm0H24*6z8Ph0oty#XeulvSV*Fr+>KH z6lSRY@I+FZuC2I~TBaHSZ5?oW`UApRnP3vU=VzCt!pu7FbeQ}drN`g3{q5>}9unO% z6JrE39-IuQaq5B&R4~3~u28@gvn=UtL-ep!)h%I4&FNTM?AZ2XNA^lGv6jQULxwe+ zo_~P#=o6PzH{*rDym1Y4ANZad;Qp8la&8Q}P2=MA|5Yv2?tMI_v3ebLKl`D6-~Iez z$Cfh!Y(D$Ph+o4>Z{3V5zR-Ys-biP#LV(<^wToLSJ{gZZq!Z9|2vr#TRb#ya8(4kD zT7W1;`(5q6;&ib~QOB5U9szt9ZpLWo^2iP=bjc%;-4z7KcolaE<75(G)O-K_Li;Rk z_lCGWH~8VlW>WLw-l+LW2w!cq;Mq~3fm&G0HPPQ(Mn$(dE%v;Tx1oe&Qjr_8hF)i>!FQhPJji#>> zt*H7nlwZ1U?9YrruOJl)v?d8%d&lM7&?zyu#8$>J z)KxJi%e>cPV*Vf7Lv(MHLK~tqNYd``Q*UonR$`C9G}pqd-=Cb?pS$4}ZXJa+Io-p? zq=)KSqO}UVcMgzPa--53>>2y}fmwvQ4Etp#iQbx)5HpCu=nGm}{n#)$onhr0Ro!K{ z7JeCh$;;850_+uLkA{_iHWb@*e>MdLm#`%V4Z!Q6OCTLI9V;bBH3n)Ii2_Mw7rOa% zk=Xv`u0I}Yb zd6m}Ok&R-U)*FBiPW3r_>g3KCU+sW|b;;9=l1lPC0wj92L>>$bQ@8JzB}{DGXuIPV z>!05O12?H0D*f^>UP^(X0%B6oOtaODE-eE8^*>-6;cr=5%Q#bpPJxR~QEtyybQci8 zt1KFUn}c&S;+=qZ3%Arddx7$K#I3KcWSJ5Q?l>7mmTJk8`IhfOfJLvxd(V?|wa{nE zAw>p*{ICqZAME#}Aqn=7ex3|CNub}I)*xDjju0>NXI!^55uGF|TYhOh!$F4UViZuY zx!x2FI?A3x+KW>~&0$q8A@LL56MR3L@1D|yIhf+NpWACh0bKr3%~8Ib2rp~U#VS2g zeS(J<_&FM)6(s1zni%c;weNV~1M^$BzJaU>S?g*S!%%#rG-Hbr8!2s#mPsd zoBj^YSw_aT#sX}YeiDslqfKUL{LH5wfw=%h|y+!)UsQqQ11> zNqg!73-Ob1wBN}=C&SzYIz>RB|NH>*OaBC{LpTHh9&_?VV zQm&NOs8kL^BC5X8rcCEBAXe78q-^7#s+%ytMp5%Yxr`Sew}0aj#FuSHm$M*?D1|eA z7hnE|=ajGVO)D*4|&51~Y=2(WGVbje0boo-+WS|=-afY81lbOX}@ZQoBKxAsm|E6KV| z+vO8;rg`3@Mp)JlRQ|T#GFFgphvc#hY9yCcS^c0pI&~%nzxCf6+f7SHL)2QX9-#SN zu%0Nw`u#=V7s<}6HX(j0n~)q$=;#-P^sq4!V(m8Q?=zF!*QdJ=Ny|U3jcSv1gEcEY z-_)qf=Fsb4@6g66Pp1>BTQr0aIUev1XU7{Db@Ul2e%5$3V63Wc-7kgKpaK2u zNKJuu(-<{q>?8lYW>i4Gi~CUP)5jILKBQPJGQV#wOr{SlW?S!CA;c%SRhEe`f)SB* z;wozQYmGU$sm+$dn=899T!LtT6*_M2j`#93a7>7XGEzBerq3mB5xJXJ3|yagKws!M*;1sqnSxTzB+_#>$iPwz(9lH3znz@LSzQjU}rdLIh3tJLu6eF0W&` zPj_s$UC~UGd?So6;9j;YvgvhXhqF5`@ANvFlWqYgUp3Zh6@3vJnLA&v#8?CS>XLXs z-hRLRp1t+75YubOOcST3Fd7ExV*c?UCFo?<{jCMVBgoKtfQ73!xTR4IQ+sN_LlahQ zY*r5knGo#WGQ|Hga00BxJx*KS*Y69F)%KONHR!^d1saP*sG{XL4@X=O zW*-fp7TjGs=T=dpcy3UZFm+dt*wqTH){_qVr-lzE*%t85p$D5+lXOL<9Fmd{zm^%+ zXW)2ygiyI=ZX4Yw;FNc*IC!(#(f7M|LxBSdOMuw;NBna-rTz+v{`>0+MB@0AVW7Gv|a`PbNg9`JSFc18qAtb(-UEpn9KJ{7yHFNrjqs zothK#b2f1Hj+C63_7r~hef>$=E>!6}PMRe&JW7yt!HIn26XL)OLm>a*X_>!&TF^to z9F%MfZmueMR4JVN@{;Gq;FyTAp>R~wiz72a#bAIRmHidAGXStn7g1>EEDn;`LT>Wg=&IWGfO`98jqD;py z8HgfmFLA+1`^+i1$JjN2S1!Vaa(s2+l{x`c*hcweu&NIj26lc)`E|{R!Uro90EAXn zXPypjg(!W-Zw3x4I@Ee`HOw`15Z`*K&@q|nJ8QsqUzzthNRd#KnO8mkk4L+sE!X~9 zjo;-0qeGy3N}c#pisEHm(}e1NQBButEnk|G)AtrvlzV4Yzzl>&j7;w>t~*Ou1E?YL z!Zc=>pRIoQ)A8QrC>z-md`6l5mdM*X_dvi3p>={OWlJm74XaHBy{v0N?F`ecE1R&* zSbEp==BVb(WkAN!i^ZdWG!ee)C`w<(%eiK@i0Yk>m#`L!#2qTJR~vBz#fr79waW`L zN8YYv?etVK+y@ro8=h4V*4tU;+=+T_1CcHr-)slZv^?%_AR8uVe1I#x`QDOxV z-H^wfk}sjV@si(pG`8x>?5hSUtgV6f8A;V6&;vye%()3K^+sJYX2}(8^DZ#pJvC!1 z97eXqjnD721>z&}O-I~QBCGrOUgf#6)Q8r8o$0CQHU@hMCq~d`ocwmnM<84TiBCT( zcRvt$D4~2H`G;Q}&kzKy) zKu9RvdZX3{Fj?#&ql?~dHZN@zBWE(TZhmc9@M5%iE$I!!(U%c;El7{qGEf3Q`2`sa z$bHFtEb+LeE4Li@WPrj-rm`4-{|rj1M{^+Ja(!(3k|G{igq@c9o`bIrAR!c^q-j58 zOW&a!ZpIKk#-f}+&Na`s7z;pXWntgHcWfs#A~>yL?@xfwP(}^E8#W>3Uq}tND{(K? zpGO_N)_!i5Pb)+v-$?jz5^;k=u78s6b62#GF*x^ccFZRu5{=S*cMl0hvJK1O8kX|G zWy9Fg#WPy~KT0uTuQ8)$Dpcsbf$fFW?#55C8*xN0+#{Ues6rT{0} zm6%-8@o$njpr0rbjFY2TLiJm){+K(2rhI!)0p5Qz@IDXs85f)Tzw&?Ow`|_naHnQ{ zZpj=)3Qq&{SJ4j#UG6RkvOnU`4sTe^iYJ`ObOXIX8b?YW zZ^uDFb3-8JcC2C_=hS<>FnpWa_~EhWR82(KC7Y7J5B- zBK_B!CQ*ecM2g8A_T%`@ymQUuY)k(2O#~ zBLP#X#OT-<`%Cj;3qa`6%MVK{8EsAJD$YB%P}m`#TW-xqT4>G7&f7 zsv2DUBP;$~51K-iWkj3Jo~_}F@Z=j$jz^)r;wg~O=W7^c2ThLc2XmI3!mCqBKJ&<~ z_=_3re(3$I?ES31!$7*zMF0zJZu3@q`ZnC&G z^R6D|T-mVKZj~qiJo4uG;0Ee(`?ROtsu!rPepMe6P&1W^9@7L{(KL9r?*UtnlGo)( zfJBpXp*<9|j9@Y=Y9bE}jHXNk=5@`nN!EwfCQ;$9RQ#gY@p@kDc4kWjgM-m$Tq+aZ zSs6Eo2kkUh$D3LwZ^MC20eJsDJ>OoR_Ndfup~7T~Z)@#U!z!=tKWQuY7?v`Kv3lyt zvH??oeTF(k6(+kvj8c4Y0hsbmqpH$pq`N%+x_E=AKfr4fWt3_kUBaCw#A2qXrU@FE z5;KM2VNpXiP_;0T%1@6@jhvM+epJ_;WqM+`)#81%h5i+o7gV$>RVF*w4IdaJyt86- zWmt0L)@5MAqVP&*a76KJtCoTW-n@MjnF5)u*qV)GDk4~g9;K8l- zV-F6|@gMLbzVRQ>CZyi+d{&uRwYD+S`Le!pHEgIG3Wm%=Rsb&sF=p>W!Gm?wN_f(X zWMv%c&;P!3a+gj>bWz=cMpFX)Sc+d?Q*-MIi82{`3RsOc&^3_dl%z{%wi-3b33IFJ zJ}~gl@SXg(HsM^5RPuEKUT*xUYCF}#G!YF!GK&xM=?Yc*nU~Z8QPyW3tG9lpgJBBR zcmwn8u^>X7yBRdTW_IE5B?mI$@Y-`Pu6-nU6yV&F<4cC=|Bb-fqmK3=9-X83O(I>y zo*&etUd{N4lR~?y*b8MU^g5fI_kT=RbaugHTKWpPA zASZ&l%s&In%~dvJe^A;|`EIE#@W#8o)&E07=@H|Mm)CcOP2M~8XAK~XwT`s`QymOz zWOu9Nb1{ZU3@)h26}-4sK!%p)Hw0cE^n_AHM%ULl_TN;}4ae3iwepy4H>|-_vltJJ zZ!P}xmfxFgUe(Fnw@G#q?xew>W;pFrf$%URNWSEj;B{&BR=owU@ zZ@YEc9jHN|g$hN-V?E0E55EJWh7C33_()*YpZdn3Z#&;+&a5li{pI`^3 z}#56K$H!y<739dw?Gguw}gLE5(v&s>_%rIzPVZ4Ow0sQ5>DwmxeaRhMpq>BQ; zleIh@l5`rcg5GL&OWt)#?gT_62gj{gU_RDJJCL*daNZmbR8gH=(reSSHw+XIqA_e3 zN?b&3c9R4@6+C)d$vBaO%7wlQu-5STx7I0`YSjACe1h2r>xsiLownL>N9P~D2y6h@w$7Fw3}U`c^{m=W=Xg}^FfElpD-@X7p}Cl zegiXlYj%+lk!GRiKI{IgjJg35CKvXgmQOO^dDW|*D!0J<)||96&xLF^D^G*S0ii`2 zo-Q~U_J$8Zpy#DE5ZB?6qWr8=Q*lhw9CERoJJA60`~uS|ecoyJwkstf=hyuXbL@~M z4;sBWnvbP~o|^8AY>-9;2K2C-&2K$ik@Iiyr&Z^~Z}E9%c5`vwq>m+kXj#Z~o10im z#D;Bc^@I5WucgccCI-GsAHP$X@^}zsZ%0HkUKfoa_UGY+Yv^4U1r~bRg`4jxOu-P` z2Pju3<<{Ps-2LB*SIpHVY*P|y5Trb0iLNx)^2LQ{oFQ)eD@Iha`%Jer;op`rW?cx& zx#KUmHGzJ~nm0j#3WZVWa^5R^B8UljZ@gZo|!vq5JPA-%vo%+6YC6NQ*Or|`~F ze~VPYgjf73<^DIa2dEBe3VT1Q@l`uG({qeEf51k27D$oZZ@vvzTy0S96U3w2B!5(X zH9du>9Nb?Ae;jb!F*uOxu2Hfmp~ZH=LJvicWs&05Dn0sqN6P)RM zlk>d~;7jxcW;E&_j9q+7uk+l!MK9Sc0m24^7%{0Z_lq-tp{T(cy1IEm@Blv5@GNe7 zb?R+ok6-&3XZK}iKl7O z%3fV+s)r1})Y{{G%r*{xFtpX-7bNt>J?V;|fT?P15(DZCD4jfmrfdK0f;x4G9z)#{ zZ755AorwE~HX5n$(p2vtMEVj1)f+OJXYq0_&MuEp!;q6FwJu8S?%W_(ddBvhv0JG* zA5u3Vvx?sZGLq<_LVbc3qE9<_neyAyuhgH{MaYa(FtF5{2^$ySWZ8itFxS@+uWlVU zMnlYA4e;nS50aIP^LbTUR;^DH88h~8ayisQ(P(D4paSarDTQa zTxxGWu0k$uAf(Bcm9=%UufmHIhj!ati<{poH%h3MT^-0RF~8#t)LcEtZBf%nZQtk< z9#XD|FiZ>|UO{<&}a>LM(mJ{0Rtr)!onOGM zm%YxD1M~1Kac=78Y%$Z6!5l-oTW$)tYS!`=_Glziyn+ycS6j`x9RTob4;dlrsAdb? z3C|#lvQ9KF6Xk}Y#qUDOd4_g&ayCvVU3+**R;SdHEA{>YFHj-x%(l?=9!nGW)E@jZ z1?Hx#-VgTR)Ws5__TPO>O>`*27S?lK<|g9g^#uWij(*4leFC6QPR6Bm}BNhN``bZn=#;C#os>Sr|XPqdvn zQs+XzR9PTLLWU+gFLq5+Wd$(HSQ6St_vh=d9_isD#wKbxM7BfF0^w-fTg_-fqvTm= zWG=-Ra9^kBH79(^0nUCQ4Ps1V%$CJKmZWM6a8$+SiP1q*P@b)^AA=TJ{>0H<&6-)# zP@`48mOEUvm1=&j2v&C8-o9#9*(1&ddfsw%Zr!`hX} z*PZNo2|Tu2PAi0c>%w65qUqlrCd&w;SQ5?PgBTl&U;849=y*RVyh-+1jRUO|jJS0V z&VyWCG~_<&U?%xWb$6Egy~w)lrH%0y+@nq0QLrOtWN6BeFz#~lF6)-plr1IaO^Li0 zcQgx#SbXrL3sU1A2zn+uddC{HS~-@coBZ&C_MUO7e+$tHeY0=$QT@Bd^Q+zwR3>d3 zf&nwO9RsR`$)0a-+G%>*2E^L=H>JlDi)o)r`g0D*>cVmk(((HNO3rUof>u~fDU?U! z3b^B_qZ6`*7TrSPG^Tev%TR`yoK~}H&%YFLjgukCLqrXmWARMcy~*pb8+B9Rk2r?D za{tYj>;JWZ?o-ZelmpVn)#Sv6dTFsYW zpuB1!vKlt#&F&Ju7>tpM;ihljKdqp1L9K-Rd;g;q`RQAlFEw(?Mb-e<9Jo2$+*JlO znUz2}YVIk`ww8Tue_}dw(_0%loQDKA>WK|-_edwC5erg?`HEu z&`NbX<|u=x(%<{l&wJ7>zmf(^cisia()eKifnZY49&XiL|5IH%u~C0+9Bpnu*y6~)~1F2B_grv6Z^0UoG)cA4`vK8c53~qSM}^0Pw7S( z%-mU&f3opMYZ&eal%lQ)%KLVR8;tQgDdcNOjZ>5?uIly*hwiF^gyYHDX^4{m7Lt+{Tem7f*D|*t0{bsPku?hY4keC z7(G)ChnV5-bE*Etss(XCEVuqWRrDF3mI(8O56>7-9>NDD3{@nD&>IIx*Ia{X<#-B( zJv>nkZz?uDBkEcq_U56M*1fgMEfl>2)d9+*+`?-%h`FE5WOV1M;Nl$`ka%1pF)tvV zy}Pu?IvDXQ;qjG!F6ez+`MGOKvWXuGTFb)q|y>DYf(nX948VT`7BX2L;iLb*FYqjfTnQ?oCK)Lw=jMImb2 zW1KxY9&DyZ1)hJ4^4I@SS)KW3mqstIUem+5OR!fUn#&{$dwOz<@n^i_*6A9d;W~bu)9lwDR{8aDDZEV>8z;Zy=;1o&R)U7w!xF*7u_1gauwu? z$0L#RS1M5X>wv<%`P194|QsXVtbcvP_nLP}uT9jX)%oqdhE)ZI83?z=c@fk4}4 zEng1DaCxpZn>%Xs;O4TbC&cVmRr>ywXe3ABn5byv?Mgg|UGSYqnESa7_B7JUa?Xo=uuR=YFq8 zU^?LtNByG^%^~E&!8cV2rk`4P)o{)Tr}fhxSXRvf0J50-G!AH`GNe4^n|Y*Sxat0- z^Kq7MbA0K6D+%`cV#P&U@(U|5?%sICw*yAYH7ovh70a*LG)->Xs6tJ#+zP`-hSM8f znJ3buOpVfq&_VA?keW46e&c%S8uZysHPj(^x>7l7#DNoCUcg^#LzNnNB@+bBXsPh)z7Pal7-)a~%Ko2s5&bWKeE@{Q#(f zNR{OBt3jt-V!}yW>}TzvHMki|Q9k>P)qBRKm7Fp!-(Yf(Y@>t92WM$2jWJY8M z%v8Nj;2rOjCJVRK*@lbrFZBaYdT}W;{Rw?hXB?ai+=@naOsBa~8M<4^H07%I#*UNv zZ(aG`WWabFpDG_ctaMtas~ev=_`2Xl$nqJ7qMT5%g6*M;tm=Q^C*QYZEW<6!U^?cI z9}jK-NSX?v8o^uZyJ0cS#E$Ns2+7i?bb4O1slGa|pcnpoJ@YPS3O)ZgCd_$9or|^2uKKFq5oB!?euy#Di=fD{11sR&P>-{-UpPMkvli96u1)f-;pPu3(K4TI*rc?lQ#yh z#Y(^mIqxSc)RyY}E@u!c|FQ`$Jl^x}v;vP~+N&ox6I|5ACKQ5BGqaO4BrXv$z z4ctJ(wN(RP)4VG+9fBQyO)um+6kn@r3a{0sXVsm#n7n1~r6<(ut@nQ>aE<+3q_N-d zBnFS)xiDk<9jty{2MFUV?7W=(BmVDFCJv-bY@~I&^_FE@wnYyk`IUDAZG!rPq&5|U z8PXJn994Zyv-i#jDG|^$joTyUsYVoxsntgSZ)y9J6QLIVeNk)FbLa`;iVOVW`tE?? z73E&BNg|dL->$nZwl~)qe7LvTUGmGR*-bC{+Z#-(xo$9_6gR!Abl85ES5d83{QuU< zu^A{#?}kCw-X)7}-?sLDI6CW~sM9p9!m_NAB3%N~ z-AfCs2%>~^3rKf2eCPdUo|!Z3nPJW!=REV=_kCU0FIJGy(c@f~A;tXxR68+OXS^sV zyjaH@g8SR5a|;m}5!8)9(RD_=jf7|R=Qh5&O*z|55!p_e*!1PhW`v5jXK!%AQa;Qr z%Xf^Z*frP=+6)n$_wu04ZXm>zs^`t?-d{iUW4IJs{S35nkyI^Y_qIB7_0rGX%IFYx zkyllyU>(#O`%azI;<&DdtWWu7^QET zvMj`ci#s{Ztiz2d?nq`=5!7qv&U3yS-IkY`d=Pz{W{$5CeUxl0u-$j8jR>A0Ue+gw z$}W#H+}grp=O;By)q+mtWsCwwS55xY;`|Kh_X&5u#|L7J-^=gx2i6G}WNYOUErSIY z_r(-g)U5cqU0*O`uYQ-Z>=SR|kCww+r))h0rve9B>gPN6Z>7otqCW~Dx!;<909wL- zP5X~Md|5G{e*s3mLs&2Aj({wCJ= zxqEmzNIap)8lf&(w%Oa8kI=&5^!2A1xDMn=nAv(xBiXRu*gKYvP|nwT;cIULn=Wl^ zte`hbY>2Q+Cde~tYA=|Tm1HCc{i0Rh*HGR5~=xLSgGY;v_iS~P z#?J5i2tk(PuCFqE2z+9{T%|qX$q${jqd0hFS29M%)fj9T49i(MPkh7V;LnJZP|P37 zlgvPG_Olz($N4{`%?3@AOBkn`Q@P+lbpqv**T|woa}}e7|7LrF)q59X^ zueaDpGwA+8>e5DLF!($;q6TXUOE~6DSw>LIDW8~u#QG6o@~5Htj$u8&#~oA6sbead z&f$Rkb+7*8I75d!J`LabA7tYwPt1^9`CM>~pDa26Iox@&C5+{nfqC_{ds^Bj z%8UI?HO0Q?X)^D}90Wh@MJYrm0B6RwZ8fQR0c@Z+c>?$!+;|6RbF;=x`UK&GJKXNW zHoOEhsqh#L20NBIU*iyE8*qi4QQc*v3heKF^f`{?juY?lTN@pvmr}>Fe82sjwi%mf zhgU1{gQtg|F(4?EmOJR>7!Snw?200`GM)|p2=T(+Xb|G~LFe|FEVCNg_xG3jMR$d~X@3Ekmt@Eu&H27|b=v(1Xyp)ml^z=P*mHOt3~5Rzo>)e9z@;iLv8&Yc z8+kyGwQSu`uw~UfiT0vVJIVQB4f)wy#pUeWnv=O7P9Tq=2f26+u~t?MzhOT@b6-5W za_zp~kQa|bbUuJ2}WHFWOV0}P5gBG|qg^feezHh>Zj>32aO z9BccJ-_V9Vae1(NNZ>ZTZrVb(s*JowaP;rdWa%xrd4u){eQl5>;;L5^5CBu6wpsix z?k~6PZ!HyT9E#IXPlF7y4C&9#b++M}5Hs;7C{KKMpF3GnnmIZQkbrNs>jj$v+i2z@ zdJ8>UXGULT&hdwVH>IOl9@#83GyADcZ!5FMH9uX`JQ%<2u<}M(*G6%sn`Knq7Ux_M zl;_6rZ`gt^i~81x-#K^Sq2>U<79341_}4uj75K;a6jpy>fLs*tuOaWkmXHr#h^Zj* z0{i`NAKHtI|9Rd^JR)?opc`ZEz+P45X#Q~1h`9C1uryoP>}bsn%)WnL#cOZoGIo%c z%zw8)Wp(2XqRJ|F*ia@41vSeL=XPLPKz&Yna$fYJkF%hL+}v3<9~S9?zrG}$|+)S$IE;S+zl;hL?q^eaO0 zL^J-wm!E0bfsUB|*E`=?g^qBo@gn5O{+#|#jR|x~A9@pleN>hCM%^A$_F#*% zwmGXV#(z|LE#IGZY5C%(TncKjkZYb>(_AusfYwQ1%Zb}2iZ;o(D&d_`zdu9)q3XoA z0$9Cl+fjchj%gU~0HlMF8`oUgsU_u|K9zf>CT7-A&ph|P6sgfTK)hSCCn$TJUD0zQ z@Xb=u+15-|q5wHSYk}Nlq5NgEw2e@bbpkrAmTj#xQ6z3i7}pw zGk7$l=v=5Qu6O)%QIW7nA#vU@QGy&AoB5yeo#l)8(twx`i6ai$`93zmO_|iD&zg{> zYTlIO5(N0!Pk`O(CQC5U$Zgmmu_%|K`ov%6-z*#ZUTl0&9io2zGS2PXXzNn{=6zuF z^JGNmqrdAOR;CaJMrjR}N%$JT0rJOvK?)ka#E%d0z5Z(PRcMY|)6zR^2Cw;H208^4 z*WGU(N)`pam8{7Oz85}JZoR8?Heg70z1E^Bmw69&v@9znOYP+Cdkh;#*S+RZ9sKg& zMT#&%@#5w46xzQ%;~x5$QDo`#?XS)I`z<~iqp$@i+TkCXV8^wfOEfw`;uATyu?w6_% zvYc}q8sEBWi*%!Y)=1m8E80VpJA^6eaA#L)GazO-s1BVOOb`5BRuXL5k74`OsAPBA z%F)&e&t?zBS+>hbz*iqaZghpG-oDGipP)*Z%tJ1!`=QR^mr-e)96p-|e^-s*V?g;& zJlG8-HlxdCuHtDYELU=NqU*gSHstEwW09%d@W8hQv0!7RqztHTTPhy=IlZvWC;*A{(LP>yFx+w?=AbFM}H) zHjX~C#{SfzATYQPs?GfHj@JMtdnKN_AwQ^BV;djFyF2t zt5heyCi(l3B-dL^F5gOzPILR;-9Q$vRk@Ln(XOFuH&HtDXlXJZ|Fbr11}rtg$$#m) z{9XE6)PE_Y1!9%kXu&&`a|r))q1uXp5Uw-{!NQB4&ryu`cwqzwa#bS;6Gz!dRatC+ zHhzsCN@0DD!pAmDOG>!wR(ksnCwDh?>rj8pp^zv43=`VV?mgk6ul;uLi+Uajuk_hC9MX5jQYa z0-*|mr(#N2E4GNcTo_OjOp_f;g_1=OghK&US`_+30AqbFfFk1h`&XrN_F{IL-nNnl zDlZLbZ?7P3oiLX9o?iznlbp@!HyZx9#j306o~aD`5Gm7GJ2q3=KizxsmYsN6xKmFf zpzfpz8{bRlE}bt zYLtYTNfU2{?V+UuE}5Jx7Y+d^kDD)MXhvYiYDJ#)i2a-Gq{h}VkajM+%e>#rHY$P5 zz6^w^o?!$_C727~u-5tV+T_Vk%1H+k%9FWHgJmV&e4Gj(bsr3^Zd_|?71{FVyghagyW;%-!2o=5utG8ARLPpC5rvZY>$B@Q~(M~;zd&D%(s zG$F!!z-2N?!wtgcBlDfOx}yFvt_+04nXFxuk>}-31n4ebMP$>PBx0Mb&;eSsFdqhs zr!W+aMGZOs7{~mzqv|7_yG0ij^AZda#EapWa6$q|z)7EIg7{ofF8vc!?tf~OU(?b- zPu`r$_Dg-v^UiyCm69*@gM3l8A4A%0@BHxCy__eIwmu-WJwfSPyt388Q$BRG2$A$o z`6ZI<=y)LwA;!yz_O?S6;QbqR59%a|wRI%ep(OMDd)W;cTP3S2N=6)3U zdhVW72YJf+>xm^Q;yu2(z0+G53R*}$F>d=1j2@_9RN)i;_3_gqvP7O%Q?*^kf9W1p z8d%NMOymr~dW5M;6**t30(V^o@8oIKPiLm-j~yGkLO*u4c~;N(H zOM|)JC&3UH9c1(kqX%SV72vHzNX~ZcZAo3}6Qu@O2)i|YwW&)@4PuFUO@I=_V62BSfpv*6e&?0FY%ZpaQ~Js;hO{N*;RiR{KOlmmAVjLQvfF*hfu zjdF#VZ`*;X>LV;K4@;bVj-J<=NpsT0G`B}mVE0;?z5%$cb;;&1iw#2cZ}=O}%&^0r za(cW&lgUpW+_A%wQYyzZRLv0h8`I8nIBjb` zuK9(u9mn$P#651}uzL1fW_>@gk1jsfbr&F(bq20sP9H5u^ zSBE5CeTlv-0?{OKAihn^boumP7KXRri<&U?hPaB2aKs$WkuSJ{O-GvM;sP}IvzlbW z$?@6hH_7%j{mq^eyc;{|Sq1llvToRfxYP0tgmtFj1uy0rv8`PL%|LW@uGCY9Onppngn72P$_6YN z@>F{bCzq1Jp-A7WSLjWzcOc74li4z?o$~XAX6LTOOTDZ{Q)z!VaTYO963}SB_;py;DuqoCPdCu zg1uKJQr?RNPexLnun-|87%*z}BuG@#zH{c-;1A{D%M;mywv~lyAUjb*6$I4Qe_`U+<7dg0Ts+jdUvWR8v+r zwLCnlmw!Y3DxM48hhZS_(v`4yo*Dr)ptIeupOnb>T=nR2YPRO%JX@C8KmpRgc*IDl zLdp3bpWn{9_#LgXXruXFv*G+fp{{D4WiltyplAXWWTVO(?pmf=?I096<;mr(g}s69 zj<=;cGjd`Y-U8C7=nKKMJcN+v3&mfP`Vw5Vuc^{PpAo5po$f+$yS^YBPo>eSNrnd@ zzp6zv<41Injm4@Js|kpNGJ+{czOvlJOM3l00q4pkcDX}0l! zoc!MF>Q1B&dA6ZY@XG7@*InA@BwUUwPZfSKq^B`J37pQ=E3m$x2dQg(DQ8)7h@B!5 zEfv(IoIf)9g8jd>C47&3ZxJ`pM6F`q!*klzPc0LCH~an>sqJ8M`_k0X?3XXwSURM? zFzL>VlVXmjYFpCeI{Y{nOQEW3@BaLesI^(+fQ5-WU`w+NV}qTRa|JQdlH7i>O%k({ z_8vhLt{NuASk&d$f2w!YUf>#_z-NARn!`%PF37@NC2peY@!miX#Flxodtlr;0LfDE z`yGaxpN-)p+2D62E2Lq%>FFZE=O@fi==ay|Ik(H&cvYewXLtyA{ATyFBs$Q;yFLG@ zy@;F^e>RB6+Ibtl&nB{v2P4qB^pw%cF4OQS;9d&%nX^>0Dpep;nN>!!n>Eu@u}w}E zx%*5)ikb5nS&RCQM+V)3SJsB(ga8nQdc|BET*cm$1@-ehDKls&B<{x1?UrP2rsc~2 z>Rrg@(*TZ8*Ghw+d!jJ7cZ!Q00uH)0D$Y-iTopt=7t_60>bgz455VV>?PGjjVUou5 zSGbcjH=3pU^7gu6aZ08YcL3qQuu__amhjWy*3$rZkY8kkm zN8fM9<>g`JKGoe%qg34uO}^NIT_m3Pv6!qny~HB5<1dgbRFu5;75{qV33C8|9lrpz zUkS3%_+4~|J>-Lf&o^PU6DNrss7ip76C<=X(NG4AU(nk`#~L4$#A_QS;*dg>COa(~ z!3m^HlqKz(;H!A^oDR{btlvd_mveT+w+ogI#@jGBF+ClO3&?vLKyDwrxXj~ykCLof zf4RzGXvH5gUmqGhb|}y7g$FSvH?*J=XNGPI zn;mn?vm6h3ER$4eOqae2G62jHlY59HrI+1i{kL@nhnGCHq6F}w>OTJ{Qt{p7fC~N z#Z+T`UT|BcNTEdm8G^PnZsnUx55g1~!^<$Nhg%Q*&mU_M9o&*?u4DplvPFsN>EeT9 z=?_S>aWd_%X9YCVC9X!KGv77vlArUQe3+huYP^nTxq4Mo^V> zrkBeKB*llNreM!@cQ+)Ya^z`{x+*v1WH;o1o3ws>W*(dIX*@+B8zzglRcSxCx+ZRg zz{sj;X#WC*Z)9aJPc}%-=24&TPNHo%28BEqdfQQWgsz7J-UmMjnGE+6v*JgB%XP~f zHX%WE?Cn4(t50pGyPxluH$3wSHj|-NJtP3F)1P&AS39j$!R-h{U(Gxzu9m+7mrU8s zX6#xi3-VbVOU(Yx;1lQ9-ZAFCu|c9pEd7@?#np>pc9_lgVeQpruUQswh_~Qwv z!Y+pdwt>m#MvEWC4h|xhNDe&sOe2x9I!~14n~(cn$d2(RiPI;h(K*}m z&*?U70<+q}JxBT?gL(5Fr2$Gci4zQ^?KdmZ%>Lbm!xQ&9#m2)V`|Isj?a*__G;WrZZmnf2 za2YsjU0S&D9RUYi?^nToK&g(T@z40rU5Y#BB9&ih2Ll-{4JZfCCRz187@(w|hia5< zI=oHH?L%wGzSVCdr**#?lz;ah3>8Wc_?$>kW%RYW+L0O|dHeXxmI(F{;sY`Ro`rEA@d@-8({9o&5K( zEtaFxsT?yfPjo7-H0a2AfRDs z6zp#=Ln;|7kzh+mBJ6#NZ_`d940f~Y7}h#|c1*T^PZN`YZzO4!;|t!A8SP~#fG5xD zZe(P=8;i6cg>(!AkN#S{nf>)uh*9`La-f<+ylKVfXLoc0vI^$cI}GQEWvqR%7iOg?WvY9qFa(5Z$TG7 zs%zWQL^ZV#pB8(o2F5@iWKP|DFqSHWnm1>=7pi)`WBj0&1MuN{S@D=d8y~5&XN^X` zqv2KP9W*ucz@N|N7T}iKk@9)`0s__YBr8ujBoL;IFRVeM^GJLQ@%#}^j9g$-TTC^4 zZ@0Wvo#2jNpAVKqj(&P$Ny%Q@8f<0irNm9Iv`m98uQuA*$zJjW-|lrc3vg_}bjnG?=6Ng1jqueyND%=Dpc ztryLEv;Qj1Frvmioj>|RUP@Tc07l>K%1l1`Pqegr{7s|q&bY2GP{#)}EP|`}#v3=4 zLJQmAsBeK;4wV}(^3bSc2C!$G3VOJ2DpiHC2R0|CiU+FAQ>mbMHp<#H8O2*$6p0R; zz?e1{xFngZFN=AL7JE6Ie{t+p!ocMR?CWC#;dr zlH|~tpSg|_X8W)6Fpg;jNC|}ryOieyJsHtXC!ZhA!A|a1x)uYzR|QpeVgF?Ywl>5C!~YZ zKQN`Mp62|3BCm~yXhZznA7A21-l4%7;5LoirVBokTO+(b@Hfle*)nJa)>_H=Wt;OF z?`vcumI}8l+fKmC68Zr-F6lctz9|GK^yJ}j%CCz+wHxrGx9trT>=m77B-h<&a28kv zQ^)XR@@vPc+^#Lb1}kS+D4}Lk6rAHwCNkX8s&tY-14ryE${;_Lgul41^WN$ryrEg_ z%b(7whP{$CP8Uf{IVqC6ja#U3#pS8yRvC9{F$?;5o@lQ^+vBAT7n>o^#{&Hs%$jmZQMSni6 zf`xKFX*h>B*Z1ld%!=2F+9DB3)HaofwZv6Oy9DL2U8fPqRmZ;-eW(mGTyP)T_jCEp z(YBPQG=J_Bmcu5x5K*l*R@7sRI?fRJ*{3s$kmdxY9~QJTEhecsg+pv;6z5I0#x&lI zOj2~u-fAnt$D?hYm@tTS;QW~BAq+ZX>@_ab3 zayoCDz*SU~Z-U!t=5W+-A}QBHI~y0X(nHWWo}|Dg6*r)lJ)a^+!NiL_OVJ-$PS&@E z=;^NcX{5q^t`+r0myo?p>zOK=Q@`K;A{V0wqp!b-z>TWj&tIW_tA|CGb>~`z>Fu4o zZ87l*nH-#NkE+IcnsEoSDG5F*-pgSNf#Ie+`H$?mA9j-fUra|Aup?9Vq6VJ=AET(I zWc^SkqXn%~y@AipO~*$Cw=1jz^Ot?Kos(y{<==&n<@NPYKc*y_wTCu$Mk|t94|Td2 zXO5`-CwosQqdTQqJ}@t8OvATckNPiFjKE)2V0BiM$3NMAZITiSzXAD%vl{GGQ|J9f zT52^|xGMDItrI=j1(*{9Av;wqfA=fRz1lG;&^pt2g7qw=uRI6N`Y$V1vhU^Fg$c$$ z@0xOV^GS=0qKniq!Pt)0<@^H>-aaN$)o_ZwVfllU{V zhtLqy-7bDZc0lz-q2E|<;(sbMWQT&nO6u+slh4NuCTu?XgT&K&Ll^%~hRiSZj zawO}Y6`1&1x)w{{J~4tU8!yndZ|^Rv)b#Pydo~bQzYE`1=uWqAdOZE3 z^ml{VshNaFF!YHN}qd67V1gg^gMU3V&M%QUa9 zbLQsy%`*V&RK66m&m5jS`-$$XU787bcj2@%5HlJ5%`va|_v>6?*X|{P)#*wpokilm zJtwlxm{$MV0-&n43R)IBKN|>n>8q!WtQ@|oTH-F*B#cypY}LvwKuS?ZX~s4 z)kh>RzFH+5Cm%c*CMqy}Y!^dJ@G&zt?CUy^rW5L4gWo}AG5E}fo<2(keJ}4;$9wfn zIr3dxKKSmnM7)E=mNk-sQ&pYq0IYna=$)Jh$;kp+IPv}eObRe62 zP5>sGX%hWh#BG_Cle_7IAeouxC%x#vKg;{QX}2_wTjVz8REQf>+JQ|l#FU$#>j!(! zAh^X{5Zjs+W++?KRCoSJ7j*`pG_ub7K?ciPkuZfol%W=nDRb2jO;&&ux&T>D=jc=a zP2ju4I}vu$w&RN-7e9pD>-BKAzR2$yi%YINnuydtV9D}Sof9Eq`jVUSe_KIcNE$sR z(p}Aad2QNvpLrW;+Gp1;PP63Cr@~NP6E>-|g>n~g#C@g3C(}M3()(w zr(Y8M)1EHwt3(bR9Li{0iFc?+e*R>ioiQ_2dbi#+kNXq)sntLmY}q6Kkp&5bAl~QOE*)K#w(us#Nxm^hlV%Af$bMQy)k7rCm!hY%%k`M6alM&+%d22%3 z!IoplUOcWfQnJLc>_www)MPigFQ5)=;FqxTPi#3PfqaqD2)^-kl%6q{c&Ch>SudRM z$n)#*#}F21I`j>no(3V@@4JU0V!2|}siS(}O-Ovn>K=uuz=J>F^nt}ClU82~M7J~+_6rp6gPp%g(O|MO55RE96U>7P3+Xnn`p=3CM z>;<4(mHE-*e7Uw_?uhL@f_&8ll?%O+-Q z5ol(g)Ss0dpVZ^FvduhX!FqIU3*kCLys|lvj5wJ|mqQUB9oi+4Esg@iMqbS10kacq zp7#u8K{>zk5HGd?9jf#9kw0=wp;1_2h#_pT?O?@@1F_U}#yaOA_%@2Gf^$Mol}BcS z+FH!<skWX;*ccE*6W~l14tW-kK35P3I-j0I#jYioKW1AZ9CkZ-H>jP0ezsQAspVXYk z8oSUVy*zp9SC1K~Ii+F<@iWoJ>Q?0jdm)^8Kz|`{hsrxo-ySfpF7Ul@p~L5^T^|~e zB592S0}EztSDvKungN#mt~_awRd_VTogjUQAt^KEab6n_2t-@rqXxeYOgq#n>{(>K z99lUMx=$Vl+;2R&-1qhdaqou$8AT%Yym#AY0pzlaJ zR51E}L_EqkHMJx#Y@OXEhz?fIl&i!kr8+Ino3b9~LoTOLl&R1H=bl@}{p;K|=Ld2J zDrM;_S#Q>N;-WC3^pZuYYa{nBtM}e{9N6dEGS5Jrhq|cf2r}Gxb*j3HGY+r(N2CJ8b*EWh8&H!|HUo&wA-fJt5CHN(A32SbzJc8Q)2}}bw`U?4Fa81& zSu|cXnErn2;ze_AhK;HaszPM4CFHfa9Q@%O=hy;Mn-}5?4L07+8W*FkpFZ>wlen1I zoOHg$o(`x6qwW!B^$1Ps+ieEykMWN5!H>_(EJZD>c#`4b6U>uk>4qw{R+aJawy~c4 zOA-RpJf9xJ1^hb@HtAZSo|%=}%a?tyxgqqQ*A%W%mEQ^a$AA9hsr^g_Bs5L@NisB! z>KV}f+;J#JjYk#7(~b^^R>6EPTYiSUDX-6|p8~uXU==y@5`U`lYoEzJ?KR8@)R~^_ z-1$bi760?PA@>ZOeq~cfZf;Vft&tvz+DPpX{$Yy3ZSN-hy49%5pb*!0mSXoYzY_Mb zU3YgFO`|)0x=$J(TO5ZCU$~ag2+|{xu+7k)?wD;d(x@N%8xWYIhY@FJ*iN3qOFQ%$ zAII9=_D}LaFL7KzQ^Eq-IvAO%gEyan5ubqhzA$VlsizQ_zm5O>M*&3iJkB+&4}{=+ zE!DP+_+;o63@M-0Eq$xJ;%OW{73OxNU~K6-jLsluSV6M@4FgMWLVnXLABeLH1HE@; zY(b~ubTXUokC_Y)KUxdf$kMY(>9d|y`Cq*-A>J3*NY>p_FQ-pmBh5FVDcDxQ%dF?` zT13qEo5pyg=&>S?p$gc~XM0q3l|Ef85nQnJ*;f=qqH~CwDSs^dNP}}9ZId>Mq-!nm z=sNm9@caAU2_>;qQgSXb=(iEQAefm?ASck1?9RBo+p+2qu08a2g66B3#9&Uc!&4J! zoakBEkv?^ahKeE}asFoP^`zPgvZw(=h|pE$1C2$s2DQ8a#`>=zCNp?P5ZMey!XI!1 z{V0<}MW{2>+#JWQhpq{x=QA4&rE%SIzlI>-a-9>fCa8qxw6**>>Z+ps;D~+bh?^Mkx2ID zZP^Dw+dq?gs!c|EmK%A$%fJz(;yZ-#U&x>Kwh9JJ_fg}&?8B%}lDD3OMM-DunU`ajO|xakSz`Sk`i}|1;n=0M(kXRNdp7YB1Rxc*`ETtjM6tvR=JV zM5g`uk>`CyBH4~m!Q{6F$#5Umye>tM1OV0Qw+#I|zShc_1J5f)dA9(=%&qLy!QvW; z-nJz({as|jb4D}ZvDcTJyYK$iiOL7!(mo)>aHc;-ulcZq4i_6wI zC3Qh;=LS2UU_9byh4ORl_-3X`%{^p;z@(ehXJ<-*dLEG- z%7jLsH*0u=Ht+qc_yrB)Wzeto_}Ijr1nMf^D;7QkKKA;Gmbg;dt@`=H6Fhjl200Z% zP8(M@40u|SjLJIXB;&riwp^&!5Qgt{(veqF?KOjUc1zrk?b71#)k<<(63#|C&LIB| z5P-gdf}wsoA8EMk&E&-R;#XL6Ejk5i7d8NvYkw0du>w(01=mr!%P8|hQf zHX;ccK|E?>Xlm6OCWWg$dhYxhM!-^5+|+lltZ(Q%6pG6gC(n)xo)NK#)7tz9=-8;= zFsG>X_7+fEG~U5*fVsH0ceQ#n zm82uj`Lw>H#g;y`;2W)2455B0v8&AIYGR(Vwvx^`1O>9&*sOA5WEQ?I`;Ai=rf$R0 zgiTEKcdYpwp8`u434_|K?0x8o_yhlFnWwBOtnhZ%q(g<>rhQ1gCVREg7Tv>PGjmIq z$D{*RZ1m~_XJ}gLu^_T34IIK$^XaZIDcsl_HEr_xr(4d~#O@)zW+E4BGhas|01qw z*h0qs;Xa>(R=E|mRQn7g%W!nmp0bgQ@xx!a=2`LkYK%eQeuVhqxvy3={U4q^_wsdT zX9NJDS`6-vDJ6j@$L4b1wk$I>EF9nVmy|lzn~3W&hQf&kiZGB|Bg6LN?wPTP?mKIw zWqYQW;R+*mVm(`o9|2ph`Z*Ytz$^U3{fULfS%6GlCt!36!aE@z%> z%r2r<&EQD+yMe!s=O6p+Kub3IN{r*%Y17DQ^3IbOXCU{A*AF{rJN+|h{B>_P!^!f> z&mc3=6FJQzbNajDjGL0PFDTSQVArE)vh9Pt+B=A& z-qUcXwUH6sB^PIdij=A7z6g{yCP9YvYdoHdBm?$pV|=Nl4j?F1u54z{<53WVe5_K?yIXLv5b`PH}N3{2kTNKV)<^-3a6@C1>EWra?hN9`yvgtL@q{h zu*s2i;cN0bO&)LE2|M;(i+VbdZ(0XC!H%aBYdxCndAabAq7>Qf;0v-c1M|35(fOHo z5PATs`ijD5LdUx9C6KUEHlTU-gzbq*>|?=2i{iEQ&!8qM&vp;5r?Xj6qQ&n<=~2;u z7HMrpeg!PJyf}vaJ071R!c+8f{WAW`S06GLN0i>!kI~L<6{)8tD$TFlznBfu$kWdJ z{N7?8-^S*ni*p7ZYK+KOU3&iK5W{s@mIF&fP%adpBfs7&toeDp_`!Lne9Nv#3UGMi zuQZ8YH|9cpgItRW7Ba~>K}{6tb=JzivyvT6j(=19mq*n7ZNc}{EAZvI>L0F)zsL_S zF3(C>SuDIH??)o(ihMsueia?l?N$r4x68r7#{V*2b$z{n|E11UWN>ZMbu0_<=`f_9 z`n`@+{1dsh%P%y;alQzXevbaewFWu@y^0S(quuHHP^6 z-JvBD<06kt@+DnUWYF2&hz^dRxY$||!A|c)L*kR>d3rW0*eAXQNzy;Qf$R*Kz!_f_ zmLR<*<3AYe&h)tVl(=2ej;Cy3^&sWPQqPz3=t^xmG#WBQbY2^H&00PB}($_tMTNvx~E zt{~da2ZKnT@~jHE!<(^cOmSYwfb>8rMh8U7D>j7FU9{$rTsA|~fO(x9e*1y`)e0pi zcjTo!oM;lOZAf#9=5reX)u==iWqIuITk>n>oM#HD)G{;#=?IzcY9{zKO$m4!V+Q8X z?|)Sd>5FVn5g&gOZD_%j{cG1?CK^CcZA&M^_it706H^`#Hqzpu|FT9jTxMjop3Lqh zwXwZ_rPbn&eft!-kk6|)`wGo{G|_eqNvt{U$QxFHL^_UO@-)n-;mqSaD^Ql zL#5bes~opi%(8QmW=zcNV})t*Y`($jWo4=cwlV4r1O>H?DxxB>W2ruQE=&sdr!N)uEH ze>BXzxhJDD12211m6{VY2LFs6tQp#NT8rBHq{LJnud+1$_(f?v{{mEC;YDq!k&CvR z#g?}($;_HDkA;rT%($+oNV1xmZEK)aYoK<&RLO&LRy@i|4-;#EqoG>_}AAwdp-O1Nx0l1~SGpy;jA0V{ck47CgIK*PI&@ zq#K*{KrVO`5#{Ki$=!~DUTf&k(+-$FHC?UCZfZYXOJSf>!4TuiHzvV-hXubi#a#Tm z`9G%??MxihF*k9e{QD1UHckq!-Z{vagDF$jPhJNVBBt9Zd|j_MA)_=+Y`1n_etMp9 zpnECDDdQO!)Kg+V_O6UZ@ZjaC?4a}0bu3Bvbb5i?zi*oWMw^-F!cH7O#Xdb|8k<2| z{KpEx0jjN;twMVT4rAjFHmOviL^L>Q|n$MR1jt>DI0sdiEu%euNh~YKI>lcjp>E>y56oQMrIr!#)H=&~7gM?#qffd6< zD>l{(EtU5pE2OUg7Mtpd=gfSD^o_~0L^6k50&-65HB}*=Cl#-;nt}XGR7UbNc>2@h zqL0tvs$%4F?pKcW%}`3Aw8yW77pmF4msvbLg^6^OeLo@yki z&~;1h;Du0k-1O|_54-Hx=k+q`?6)V$GwDCEh-vU?e2Jg@O*<&SS#|mZFB8gwre&!y zw>~QIO~LP)zL{O*O$_b;F@q@%B=EmnPn0QI`W>Y)Y3X$|cXBTNhW~6BPcPz}R>s)Ws_*z3Pv5?sQ^-UAfw@T9s6N54!sIY?DoAj_SS6ZEO-I{HgO zqNz`s3bIs#06Yh5@ccQqlp`y4|@H2x%dZW1!7SDYz zAKuFz)%h%yLrnv&A}$}yUZyS7bn@K z0HR+_=hiSbOxVxX-yh?e8h^~rs<~u?ocD^FPJLmt5d~N1CoHnwn=^x&`rtbJaQTEdE+VMj;(YL$b`&1}=8mwjs7>+Ou zmW_TUi0A6lBq{=XI#PKT`wj+>w0x-#P(b{KWgPptj4LD%pdXq03tXL2>7gO2s zeKN$-DXFGWX?m8~*#+G6_<_eVWDK7@aYFGbNToeR2V+j+i$LkIstUrPgP#Y?`Z~*D z<%QZ&(ZKvy6QM1DRxFg%Iw<@q^Rr$ne>Q;Y+0Y%^b$EC;x% zxwPh(`bQU4-{aB94Fdr9pI?DlmuQj0_pkT@Y7|A`u226Ibvp^$s>Z~HTaO9%tHOMA z%wJ?vr48My(a(>UtC`L;Kqh%k5jFl?Hth=zYIYb!_fE5^8F5&urZeNoYP7G$Xu=$x zmt-D-U^eIa{*rKxV*a)5<0{^R_Klp)>p+^O;Z~FPNhn4@N?EZdA71it}mEyfmJth(JKwJmj zYb$RgN1!<011sl_^b6EKo+;tw3|5ol6cyV&ZKF0;P^#x_VV3Xj=e)5ED$n=+ylMN# zllpYFFzP%6o2&K(?tecM?PYxar?kHO_JvuQ&j+)r(w`!FE%JO*6>EQ_CDmpKC}?(4 z$1-+IrbK#M(88%bGb}|dHbSY+>vvGD1SJ%Z$gfh&5oGE2=%)woT7#3Lk8v3rwihMc zLYA$mcA84=ZblFp=t!06hk%_;s@PgaV#nP#%4@J7Hj&PUH&rupEKDuJKX~bD$DTm* z2@WWIVVcNY354%fk|`@E@4(MO+jSKroRh@)&ws^LSDzLNhTQ$sY?5J}y2(*~CYx70}|3^h}w}G8cH>|Mw|KSDoUgF{IM_wGZrpiWBpEiweO_YsNwI^8? z#1E)4RqnQp*-a%3QimoQ-?e@&t`Zq*d<$IF>vsX;xr*#6$#QqAO2D>uPOEScuSlti z+V-E=)`l;j<`MD~qL)5JDtudajwZl#v_(0SwSDQ2n((<#88|yNl(8%~-Rs_k_Md!Q zbLaZap?i8>(MTbOx7mr>zkG$WFDh#r+E?N{(F8OHdSC+{@8%v(yF3PpthL!c7kQCU zO&!6ghi7csWw$1rvIkQx;8^LOu`IsUPUp!Jh}63Zq5wwh)N~=#<=?%}(5fHBdXtM9 zhLoJUq9G#c6uQ$_UAea?Bppw~Gnvno#Sk}3wTz6L{2skov^B7T)*Ix9^``-#@}G5?Tf+x^^#Um*J9(fQ0^BDeq7I?49Qw0>eaEsY_2aIEEo>-b`J2#SR?b)0ON zZMUC&fL3O1Rj@9nw^nHJip}I9g*(8T1efCdAVBslwK21VVPfpi-P6bm$aDB7& zb&tQvdrR~|%yarvKdB0B*K@VcQq^fv)yxcV>6qr%S*Hnq1T^&%`98~whB-t}TQIQe z^p_6wmSP0|g`_gWZ(GxR7=jqG&1H0ljBOG!6QO-0Y{|zwR$H)0St0f6VES##%)@~Q z!~g}NV=eM|3XXg6QkzQFu8EXVP(Sf%pT}|r-JR#JW=0&zI|-xZ?eQTSfnh7e@}p~a z4W^qRfwAR!hdcUs;qK1ev5$%KMj4rxl z^xg>u(IYye_b@?54bgiijLzu2ccK%W(R*+2e1Gd*YyWZYANQ`io_p7G&pCUaee(ZY z?tK$(G*AW_sWO?d7;#eS4fRe}Iqw=Q!kHfbBtPxF-TpS&_V`a_?EobcaBo$ACiYSC zuO(_*2sN1x9QYu;{utIFHun(n$>MVI@7=e~mJ}cf#TAWT%iTK^#p9Hi?kR$MlvwT% z2~KDD9}acOufp{V2H49srwRAJXB%w1A8i*Iu-ure>fsyt|DCu9o)*@rLWk%D&M_wT>qK^)g$8G3gr*dsYGCy`#Ww*{tH#C!~wya;$W?)$trPv1;v(Spo>d5VfI z(>~<3m3Pn235rM_SUzX-RAtP*QA3Tl*7jYU42*Wy*lZ-YdksLN>1-(7e_8ZDELiti z{=9+^lrD8BoMp3e@H2-eN=ixk#zSzil*$Jo-qu=3_ zTgMdkBSYI7lFeJW`YpWJ5penTVB4TnH}I!uo&xQxE> z3mSpv%ai?yaGx-)7yFR{Rw9QJwIyfnQI8^`uB5 zO)KqBii@qjl_ZPt`^{;M{LRZr&4hm#2Y-+>su%trh~gW7wvEP&J~-l$`!5RiXY%(; z^7rrZ)Ko~lm4@8vCoTgM_NO^!Bjsi>48lwQ>e{}~A=w4x?t^mG26rWjOFZZHgJ~LF zj3`kP%6&dtQ{ly7Q%kU_h%ecdx-+kP)9;0Pu8R|AkkWSgS5=rJAB*;7Q}#QP_unM% z)BN6VjeC}xPPQ0`>Qsk-n<`b>o+EX#X16jo8$#csP2tf=ABm1E*`+je@wY3t->rbxq)hQUg?zJ~R`Ao|rOM<6 zb3WHftL>^`yP4n-aA0!7C9mtg<#zAD@bWL%o@j$1uWPo&_Fn(6Uu<$J9iPWXD+ixp z@ODx|2ahYe`8HecuueqspSREz678-i{%pb`tI`u|yULx4`X^!EFp1C)FJ145p%k!R z6v45=kMBBV7K%pG`Tz8SgMPxV?^%?W{tnQ))` zc8JxzPv62*(?@>4j+m{ZNc_e}*kt97+GK5Ka*3XZGeN(qAt9le9U-E@91Ya7|X=*4eWKZ87klcyHP7qu(w>$zuM$F z1DD0T^0Z5-jq>0D*i5yq_d}>t4ar7nv>R!EYp|DOxcNTSo`ntk?3(y3r>tuX)c0!0 zbE33SGZq3 zSy&HD4QDyhCG}u`dZkB221j6kot5;(Y&`R`i?t$`NyJa`zGlF_BH@tJJTWI=t-|9dE^Huia zT*)#0m>rjkAs9hcQhQ8~75ca!VcO;&XVaE|2CT9^xS3^3iyC62L3S72|0_44Y3ZLj zh8=_ggb`e*4$~uMp-8dGh0KI?lo#MX4?sALkA}&W7wbPSK1w9c-9ND&4-NZUulavo zr;Scb5X$Lq(4T+w^znHlfUQqddi`!NzG&y-<`ns>9lkwk02LrwsusPqDB6+LTPfO( zoDZ*F%0{Z`TzkC?^rPO2zv)<>AZTw?n|p$b8o`#+(IYnhqpl1PpkhjJW<7rt{(O1=|Tz1C-w zim@zE%V)0ZoKCfHo6lv<1)j0j?YgB>K*HI)5F}_eS#Q!dZ*BdnD(h0o-`==GGM`sj z0R=K{w;X5mq?*LXY;{S%1a z1=}GT^fu*kw7Ht;TJag;cXS-O6CLr6o$C8XzI9zU-s(|sci8vhx^B})gMIeN@C9|I ziN*1-KUeIG_20JdiNS6})NT z_JHX_$5obLW7)?#74WG1eLdXodLXrEM`ghH#}kv)GB>dM06#4%&@1b;RXfyH^5-hY zlkZ{z^Pdf%lbyedo&OnwzJ3M-25YwQkA1wgAVk~rH76N}>zV{4?MAu~(nc+i50C*j=U9j>WaRaLh% zHTDOWf@T{Dt5i#sfRlAuP9+u#Mr2ogNUI6|9r{;zz)71|71)K&qDBtZcyfHIefd9{ zxEnOeG>}hBhR-nO@YBzQ<7AAPtk81-U}!5`BK;nmx7|GcsMiaGn((I@5>PUMA~bUqtThSB#?xK1WC$ z-fspFqC6tTjW-AAoQmBM!5kq^?~rWS0`IxQFQt)iKAL-sCX&=cT#@>Dl17qiA)d%O zH6jyo5D-oE`2ayO{8uD1hogI|tMdv|a!a;N2Rii_0WSca2`-fX%KO2w`OVF_ax0a; z7f=jmv&*Dccg0$Y`U5vLqM2ZIDn=kALg5Z#O2lHE0>!`aPSu%D4rO(<<8(wOoOfJU zE~KEgBAx3vUqxaVb1cNeBmd455f$cV+oD5DLSv(8t2Wz^j+B~`0RL!z*~rhPyajJs zUKbui$2-VNnm5`Sgl&~!iYv-ZBQ=Z4<|CVemZ8sa6~%*y5WfS)x!g!K?{Rv<@ZTZ^ zXYoW-vSfSfE?~Fj-*@2MV0PMyGj!eL7fk_zR`lse);jaoXB6F)bJj@ z5A7D{$rHQ~sf>fDm2B382o^eN%Ci&!qxvkfm?|oo?mSKF)kz9hOgXz5m+F|IH%dYW zh;DQD2hlLBC;C3-6Q!?*e)toC(9d!B8gu}!$jADduapzM2=~~G8@cn2nXfC7{l=f? zB5%TAuXKM%?jEI(tI}P6utyr5zzpPpor3{2`=p>vOshd5H-GK^7R}U*FN-sao`Bnf z-+^^9#Vh;9R?C}uV5SX| zc8=z}Hqc!cbT4ej&3D3%jtz$2^Z!c1>1rq@H`aq(J6qEEd^DYe;?=yq4hqV2&3(JT zFprUVn%dFr)|gV{Er7AiLny~uy3obW>KQGqDf#^G5@tyRIZ%$WE*7cK;|y-%>OLzfh2C~PcG~32+7}S zfhjRVEk`<;kUlwT=n~x6!eGo@FTi)8GG=+L5_CcTDE{!n9&bjg;%K1s8nOc4Uu!>7 zuU(bL+i0uhQCra)JhAZ%C_(boN_La1f~dyGT@v2LCWS^<;uo{LjWw0qF&4}amb+b* zz8&JC%R}yaIg0;ma&r3?&rthmvJ@BFOB?}BOI;-^#}%eC%Ihb5_UJ=)|NU|*uh&n9 zz2aJw!n)!QO+B81Sd0GBiMaaHdzyFBezDU2nXYJo} zj;{3#-9Ls+u)!RZ|CBAnx**op{Nq7hp!|pZli@}|zO~tWI%RpwR!MmFG8{eBKKpuo*vwvEqT2xCd{)BX3($_PH>ui||&cjn#>A$tqHJ*Hm1 zh)m7#HzJQhWUs~RoJ3y$+Hdu7aWBSLiUBkXrKBYp4RxD$SSLakuBj(`Yci~pC4Lo% zu>`h;=uZ{RDx=uF2jSx_n`6rF`Mz&~Gaz{N8q2Jy2pjxFkU+w#&ta<9YUJ8SW0v9_ zGNT0_-FXC8)gDT5zj9Xz{+t1s&NwECJ8|JK$E4qjs)>tk54s=+-Dg-kmxtFUSJElCiu zptb_4LB`gKTkCgPqKAkUj7$j#&^Ob{x9LQx-MoxZ{0)vWGI9Ls?LDpXQtS6Vp3$`!BD(V#4~sQI0^5-sqeon==@fd0z7HYzj|8@HrP&-~3wK={*CrTK;*Z?7%6b zhCS;%T@x448B6}10)u;mcaof;R)TgXK|i=|0Xn4h`j9G+byIakSYhpE@oUyANM>GB zM(3EH(GCU_h_NDx?H%=N-DUwN+sWG^a*Oy+HS4Nimo9NSTsLh)+{g5%Yg#IEK^!YG?>PhVO0vK| z*7%kGpF$5m+s09#iwk(_+ua;a^b!??nCn5f&Z-RzmRCE&$-Kii@vK+qyWwc;H2H0 zh_tt}(?%ayCFwaw16|+ptQd9IMuTn;tOsMZc25*zj`h?V z`0OUh@%0Gg*;yFE>0M#d`}9?`<=kH7MyFmpyV1AqUq4=(r0@fde3_VqQH}Eo() z3*WO(eXavUBn#=lNRhyA8<(E)TtG+Y22Ue9SBjC5uXvh2vfKE>wf8dG#Si?YyZ=Pa z+3nza)3W3Cp8x?L15Wo;Xr+DRvaI31Z|z3%M8yjy@!lX6A_(IHIkS!5&nU=rn7u(w zY%|yYXx+SWOjM$XbhS7z7Y(A`wWa;DUw8b6e9(?gkh zd_ZO8Xzd%D6443oeh)T^^KMSn>=)nmvIH3S_f);+9UbTx5BHk$Y8ew3ej!rs!M!t$ zH34$q`Z5&V4R4?#KhT=3ldt-O=qDHRcWkH-hVSj(q?@jekJVJR^%J_KFph0to8jA# zdhU5C=l?Mp;xu2ZG09B>X78J2buG?Wu;^h&gQx6PxemAl z8~lnXk{a1*vE#_gXvXK;xzl!;!7RYDbQXU=Z1kM*CRsD6)Ev<8gVZeA0LS!k{VqW> z%k;wcE00$;&$`mcQhpbxS3npIQxEI?!T)N2tERKs|sI(WZi;xd7VbkT(n0o z?0wj{Hjj|fQhqtvtzp~1P2hI7F|u18+0BbAZENuIz16j#V|u>zrO-a44T)ul;oQ6} zJ81^p>9d3|#DSDe05&fSimXnXx&W-|&zMF;*)|66Tw(51iDzmCrlcjF@cGUT{~ghC zXlkVOVo|3A>FnctYc75^Yh%B-+}bRaNF%Y!tqfiQbSP6Q8}1~EH41Mx=-akcV+ROa z8%Y-;1D)=t>z zJfmTFq1b5l_u;tdCgb6eha3f>!Me;Uwui@kVJAhgH;bNGlKjji7W z$3=2&HxP|y4ro!XJ_J9_??b#;Fd*XcE)6&0pNMQ|iqVz>e73PA3e}T}zjLvpEz&}k zj^@Ms?E=M#-9hy*zO(Asr5|#E7HnBsmF>95=zxh#gvOO^y1#GCLA!b5j*xs++auQEQ`lm|Prb;h^>cz^7U#9~{WAH<*fp@eo z8Y|zgl0Mxg%Y8T=d2vkw!JC@;@A7y16_(z3kN$vXWELR4QtaQ&y4ZyN>I}gNK2v?V zah3SLUWMxRPvYYw{4HXnR={kLUq$6H?>%{>LcAMcN{@D*<0;m@ zTahLChDwO1klkNDyn=L1owGS%KlX^RV@27g;fe!QU8_HfP!RWr5@-juP@Gp^@ZqyD zd>$tQa^Hw{Yb`V5@Bw<>fE@5c;R!Rh6%84 z1D0dX65m=`M4MsgtU8CpExq3(NPmxENbf0hs!ow~2c;95h=fj=e(?@Z7$zqE)~`u5 zN3uY&_anbnX>GsK#^zhUUH-|r-e-q*Rg!c}M}*#0*@JZzmsvaV|DHGxXgJBUe<=SA z8bCBDHVU>&jT#3!fOd26mQ%Cz{dbZ3aL!HL3DPYTl5msu=;+&2!!YopO7H?VKD3*^$w z^o_-rUV}8IwQ#w-Yei(P?jyd{94YMXSN*1QH&^;Pi*3VqZHfJV2!4rjSxv*21J%23 zY@S*^>mUUSh8%OWR+pSk5tZ*91B{NI@d}$_r8i+|h5Ihtx<*S&Izh9V2HSw}`FPyse z_J@?I5~gY*6xphRtq{pIN}6S#a>Q=@eQNrrnnr{F&&%?bVlNXpj=a*@gDwmlx}vSL zVyCv=4TSdQ2im?Dsnx6T?y+%t=Zrm|y^ECcx5j^cr+n>SBYr1s&UNN~`+ED|?mu8% zJw@ooRT~lkK3zyURElJ_Dt zCTw_JG}#FA%o_PouUn@^3k5lAN2CD2hamy6%Gs}v2PG^lh$r~O}o zMc%Hu;fj$-J=~G)reVp{PPZxW}A_w(wXL`dMYBp`@a7~j3O(y%XHSf zBSoFn-iSWoIppA_GEfCHW9y!K8{7!TyU46PL<~6#grp>{&G3@jeRtNF^gj2}9-`A4 zYEb{V;r}1iVF|fEW?FU~CT;+Pzk@P} z@r%dXJi(B|cR_5)7PYXIdx|E_RT+$|p_9cYRDMJrJ8cmEk8|&k9Q}~KR+97Q0lADj z?5a^lc)2m7uCYH+G-!oZn=T;hdFG1AKr&B{vW(e-h+ukk7e zK=bp7iCMkN$Xj$_#(!jjr*rDQX&Bbp`2r`0i^el|y@V7CJ6j{n-|cW`Fe4F%Zf0eV z9b)dy6AuB^j#+^Mxm^EB$^N#7)$t8M)j(z~<%~^{>j!e#eopd01B{ zJvF2^qs(|V>W^*Tw28fQ)3xyx8Q&p$f&V}!nQCDlwLWTF!}A2NVxrf_^s$|?YlVr{ zw?AQrPEaV}Pn*lW?c;0b%3cwqVW{^wQQpYZFehUc;8Xn;I1nk?LX+Bk^BbK8@axQw zPL`Nz?BSCh?r**JkXGQPbM;s65Yd#e8~2TCY-=Zq^vbI&al!bl@@*U0#Gq&tz06}T z<9+q#)Lv_DsW&BJZM#=r-GD>cAuVU);fGLgc+Ap|`K*D=rAWLMLo2 z4K#TE5fG}I3oZV&1*e;DvmOH{5#=CoAZC5yKRuGe?##mX#SPbw0H#4lZEu7&PG2I+ zW8#T+J~&oj#eEkI(gk?-Ih&c_-KI;Q)DN)bSu-zJ#~oFMab93{+M?1E<>b18+J}gl zB4~z^_septkdj`tM&+!-N9j5Fd6n~B(3n7q2_5V~t@l`lW^X9NR~OdU zcTvmBI)B3bTShiJl*TazU3`AL`s>)^mm~DkZvC%zMh_3w!f8D&0*WHX{<2ju25X&a+2nGqVGJgi z(Oi?gJHZVO$K%jnfHvAD`?cyE5_^csmdGBNU;C@ z;(cXY_mXLI^J0S_x6a72&+Uzm#0KTVn>D2(!?^M=G(CF6vv>Y;)nq_j3&#br0E1|q zkcnrEyP3>bUaO?@!;Ns|m}|KEDD)hksT1#Mpi^h$8WxT_QeT^(u11!ij9P%q-#?+n ze~%ixEi~UIliqyBe0v`Q=dI>;{S%X}XO`;>mMUFdOenM|&{IIkx4%Xgnq z_?lu_Sa=jspAN5Fkwl6t7O$kw3?u9Kujj4mDt4^JozFkLxt6%aK=ZDmSd4pRgn`yV zu6PMXdTG8yK#fC@7_>=Cqjw}JnfaPynY^DxQi!=ZGW-RvIUnA1)bi05vOfx`o0t|~ z!L$ugKUKBaHXHniL3*M_b6ra;UpM z;Joj)%6*{eih(UaYYs@v-%grmY6yLwYB&W~)E}G~)Kqc~&X8N$4vm&s;?LMk_-XhQ zCyXc!jpoSAI8Ln8hIw()e;oSDv-G{Sa>{RgDU{&Bn?T%0HJR&Md92oU1`7M^;m_xtMhS7;K$zld*<_S@;ZhIRFfh2*(-h8caDX}Z6Wzj0g= zd=+x{=tqQp&^BTbGRRF=aBYCx6HLo{plN%3PpRenzFleyFDl6i+NN=qer8E^n||nk z9`u?QGS-DI&$UkT9|T?JSaq64|AF#5yz`g7&-qLLvFEo#c7-VLH)fZE68;+CPmJOC z8UT*Z`p;-n(UtP+mnv$x|Fg*IfF}}i#%x+YxK^bv)Cu&P1748i9s6XLx9#T zEbTuo7tH9l?+M!Ia3Nruuc0e39NPEPhV$S&W2>~9mCAzs)uP2-OU|1#i)^jqY!B`y`VHw~S z%jdY3B1_QgP1})KjLIMUY48dM|GQsEw#EOLXxE4iaW|Puqs-^Zf^|((AbHgVFd^WK zNkEkJ+-MT-ydQkc8C>QJa`tV@i70C$D1*l0pyNOpX=yN_G@nq)6RWAc9(nTSxB7@m%%n98L1@R9FY+$zHPe>n0PPMQSg zRr&S$TlIdm+7^*(!Q}nn?Ei^u661X@C!dFqm4huf&r>@Iq$!rtki%nQ^0BGN`05TN z2D#UA0FI6o(C`^lQe4hNc-zWe@>DmpjeY$Z=4UL&<&r6B=mJZ>rb;dj<(vYZiCwl9 zomMWJH5nY+IZ03%E7^HV?|j zQ`Uk+P;nt`d0Z~&3W380gna3rNuJE-HRnctT2|k5_=1NOb$Xpj#uWBnfK9a?axd<1 zTc#{`D(7gJ{Oc&eV{-QK4iUZyV3MTs!sf&Ht5RIkyVWKq%2Zp;I=7E<$ncN*sk|7%O7C zVRTuMV{GJKc9)apYh)n@Rw3#bnLflf?RmBM4mZ8S(j?865#nR$7F&;vpQ>JuotQ2N zm<%3~aR^F${9y8z@S5JE4Eh%RV_!OMexye>{y^b;V9@||;;+R7OGBc%AoAUOZ?is~ zo~YP)8F)OP!O5`=sVo115IW?nZ@Q?~YNXwU=x2qM(Ivd z_q~NKHaP3F^yk;S-rtF8ihzM>>N9;Y;5K4U6p76+yTQ)+h(-ATG3e(B;Ani1=w0U) zPV;v4V?0$QmJu(|p*`d(uOe;nDeg!^N9jEm#86&@>%fHD*_xgMaE4&=+0$#9f`QNQ zj2L-eazPHiy0Ab7Y8aROUlSoy{5t&sJxHju;97(7!~c0qnu_yh zeml&_>`|Dd!A)uN{x=K!&^Rgj;UiKID=}HIOLfb)Hfr&ng=GVw4BoC$yB& zE{O7snf(3hmvb&$z7q=~h>p7#1zf&zp7m=PnoXE`6tfNic?t!|y?zWGn9%eP!LZ>+XrwEHG-sXP%Y(See zr5?xwG=?L>&loqd91eXB=M2bJDqwGdW-{b0TS)2(85ygVK?jn0HKAI&XQ%NXJVNH} z1AvW?vvU)=1=wkD`P29aZ}k1K+7B%}d?KCKi4#&G$G&Jgwo7}{f5ba#BNxNnP~wW+ zjhxVnJqBd?c;!R*?!8P#&yM~NFJ8(FxE&NB5W1OwBviAsnwQ1hoFSyhWt!=7fB$u5+z6jBsOPT z=Gy}BY+d6KPn;T;KV*>TE`MNU=y{$I8O-Hu&tY-yFMjL68P{Hh$>IP2UDvL&9p;Gi z%M^9!QWc@s`^!=^If-@5vVN4SUH|wB5!@%VGB%gq3Gpf}23jyj&hls3YEZv7p3;5D zMNj|UsM1`3ZUkYg#FH#k3Tk;x7#RqBKf)EbNSt-`a+C`Rp?iVm<0SFPy15S4ijoyi z22BxXg}p4G`7fG;WY#2`j(FCA9lop!ZYo2#V?>4A!B|r;76lt=_ytHCm;F)K$D$d6 zmbF-$Fyug?k~oE>(mM&phe6A3{g~h<3g*!>)*V%)WdINHFUrk!GCc@1Sm(_0WFf^@ zK#kS>l*zXvle_h6jjyzJe|=73v>&Pl>7kTR9s)RX&h50^JIShLPAgwlBtCYSm6e}uF7do{miQ#1pa zgmwb4kr6gI7`{5Zt)%EnKiku(PLO=X9<1G}@QP>(ZRdBR|H4PjuC11U!P_I8oKG?X zE>Jnfb1=d!uI$7${c)Q_?#2rAXivrZaaM#Y6T?U}Bh-DHu5F3nLQnnF7y+Kvt|TEz z;r*zA4OzLhtc$!Ijzd`gu~^zeC%J1BYR1qlnh#J4COjx%Q8Iu=p|yyNy9rJbdkL2~ zH8eN(X(1&2Ub`){=VqSnfnJr0i!DUK7_tFE-{lW1km&?1cC@W=D0h=!>hwo+>RC-$ z%z4;7nPz0Q!FF20vT8Nw{!gWe>3;#Qz2~PNfEsicf1#e&HUg;3OxuI@Z zS$hJ|{CTvP<9e+iMOXN@HUFMoJs#p+zWUnVY{}Y z{x7x>k@=r>#2P~s$nt@4V{C<6;+U$k1m zZ{v6;dzQkvD;Pawk|7?5}L*X%$WGz71k=i(1wzP ztFtOc$AE@?xJfjY3dYim1qRX3a*Lzx^xKB2J)$D;=W&501pd{|Tgnt+M~2pec7c{b zWv`7M=|2R2Wb8G31!a&7KDwdjber|A31+vbE`=;uZp?%BJ_60k-D5*x&=KOlDf^W! zXo&EXS>2Y}J6z}Nd-`&&7loRu`J(?x_%B=Qt*l;CME`6Ry5wB3He~1hcm;zB6@!P= zV#|DnN1ZEu8MaXhT>(uZ_urr8(ObvEPo-mv5Mo=1qNgU`RMRpDoc3D@qCX0eAQKGJ zrYODmRlBMG`lb#=3?6nER59qc-KvUKyMe9mZ?PL*&DV_?iC#8I9-duYe^4LVKf<;D zWE9+(7r8hZ*xFBAGsUtvwgvv1*Lt{G74xq0ut@<>OG9?F%G0@7_*$YEIN2vf2c3r@ zRw}PHUl>JCIR0u?`T%8nS4w~{ykunEg2LcIOh1 zFR&yC#<=4PnEWASG8-kx0zetVzRw5E5~>V@O(EHwO)9BCeiq11BO=CZN*e9N^wJX(aUC!g~pX%r@_w_u?ahC zj9S|$Ddzk#mujw6Hsk$PryCVs@Cj_?yu|6koxl$Fa4^#9efud^eT5%i()e2UmuUjA zs4N95{gL{|BEAU<+fmp@T|vfU04lEEKu@QvuL2aivp^UW{62eX`z`!ul&4uazWXgs z52^S&!T{BOzzkvMX}x?Y=BU21E$nUS2bM5uoC8-CgfrX)fv$ON`0&;6MSw*4cufo! zu{Z%D<*Dk-Nytf{qX{APk!(#p6BDFSv$mPTRvW*Gfuo)&aty`Tl`FFUnHzQeF)30_W}SJ94xH zj*cLj$w_kny!%qy6xVb>F4ki@FUzJ9dG-JKJR*ZnRt=6iQ}lsxS8}MYBcLnz4oC!bS2(2Pk~} zaIH)-N&RNr$jhqoZ}`V-(T?GmnWH5OEC)T(dm)>mwoaypjA0+@MkM-z$t}FilzkKMtsVNDf zA7|YW3R``=OolV9bYM6zfcsw1DbkoUGZ3!rSln{Fd%f@@F8w0eCjKA~Kl*1EidP)c zFFoWCVx~4pZR%g#YOkj>9M?Z7pmcs$FD3j)yAr^D0Doi5kNp(RO=XHud~a^lE#tZ> z8o`O&+c+Ep4Vt48KnX%j2)+`(XzRsGD%G_c7$d3$L6zM%J@o>eSuv3BvfwqU<3nT_ zA{3qFvu1a1&HszV&T*WAXx5Py_3FYaetmEHkypGiWYbRSQV=5sl+@4yFfDHpG7#_G zWV}1M4+eI*t0;=2r^(hYW-(f2s97%j(vF{l#sC|-JqWI>o8;daNX`&;-3}g9jdg9Q>ipijx6mb1_|s~2MhN(I#YJ#mOW;-z|@Mu++k-v>g#xK5{1G?YEO z;{qQUT^KsrxlMHDQg4~%Q%Bm@_wOfOZAA}qmtM^^s)dYf5?Nfq%0p3~VSg!=9Z(Qg z(pd({mqQ5E@y-;t_wfCOm-3C(v98b0Sdjy_WOM7>><0bK+Wb`qJvC*U+F3&!e>{r) zR1Z^8pz~g9O}O?fD1429q`%*mful6~+vH|17=d(k-~T6i>U~#tU+FjCzsdjnW-Kmn zF{$XG21JFL>0OzPQ&#{CAwQshWrN=)*RNIZcvF1$c!FI2u2>`$amRdc^tzlq{%0#F zObXhC>mstvPT?Z-14jn~ZmSls{uE@4+Dd7WuuR9g~ z8#nn>ZJNbb@B+maWyQ+VMa>3=$sZB!}{6r@VIcZ=!BNJT7)ta5XnV3_n#6HOccd#Q2q+2$2?KLxrT-l<%6+ zvB88;E7Gt6U%!z{`us1efTcT%k-Z=RQ`8@&)O3-6iVcIh*dA`RY&$6tVg;O*ZUT`yqn6o+HHbbCOkd#uohtbEs{R z1*BS2T#9;U%K0TREB_^(eydSu+<%v9mj&AjoAx3EXjK}if=2BdI2-A`#m%0+xCc>z zzuRJ_#(S3I;Y?{Df;QNSCSJ2AIx7Dm!X|t!Z6HJ(1F7IIX#(F|ep(Vo_j$cfI=-dD zWxpU#=Aeh0Il>|~I$~6vFIjgXxAOXBl<5*%o>k8*YJ~4B4-NCx?S_E;3u?fOO#Akd zfm#Uh63|g?!e{c(kgP`qFk!js{V1r`C%H*U3_a$HbifxGr5DUK%n+Faf&XAM;ue!J zihK|YLxUu4kmou1l~>+c|6%^#mgKF7s$i17!=nyzWI$gAxi$!}et$!K-F%1VZ3Ik< zQkv}%7!|;WaQ)XRrdu>|d~f`bFguL9f#-8kZTB^5mL$;&VXcc&O!43*YYn76X(Q$W zDDpfbKgT-lnqV#ieX&}5F6{NqowdP9qxM^s}eaZaYbtpTX;FsTW(K5M%Jo%W3Q%U~NDN5yA2i z1`RV4tGV>Mw>HDE`Y!#ilZqgWI&BR2H}YB${5}0nRNnLEEBJZbI1ATDjH3r!-`vXX z-3g3%o12RYSj>I8TLh0^z>`MYy`kaq4xuUHJRU#C3<&67W#))FhlGtl~s)BmRMit`$+YE z>XsG)Vnhi9Jt>#$;5BsC(_Mm16=fGQL0~2HD|1Nt1g92VXrG^#XV%aPbBNCL(A)}t zD%Vd^msMkB%xl#Ziuq+4U61FQ;{%sx6kX;5C>~)6X^}PcFISzZRztdnYV|`K12QPQ z-Dku__Z=j8K3>GaQ|A)-54SCl0x4NaSGp2(j1bNsLn7@8|5_z5HlM#$#H7H zcX_JtsZGUzgk&{M*A`;Uroj3GT&Sk?wQ|gsHZ`A)n&gpG$p%a;@Soe^i*!2+|98=a zN2g2|wNAoCC(Z4}CoeTRTjlM$WxjR*6|s1Ivzt#`@fM5;Ojxj0`?h>F^l(zD#z_tJ zaK@KqH-DL`*sLj{^vySTq1oY5_aP#eeu5vs0%E&#B9(umM6N506A%#r>x6~l zkEaWN2u!HCx)q4HcQq{qOGi1oV^!9i%WMTX#qN}8M$yJ7At7VXyw>`dV6&S9SGtH* zR3sEVJLCD84%}bt_4xz1f4A<$@KYh05Amw|(nd}+|A#U8KvZwomMqCAC{2yj9U$lG zVd}AT)S3&uS%;8JwpMX>DGZ%gFf}?=A*BeXfsJzWX^k;w$Nih!RsRvLtSnrs4$xXe zr;G$=#}HRxWX|(HpXf2_^e0bf5?V*5h#!f&MBO%qM_D!0J%O)}{T%Dmmk+F*qXOJx zB~!1O{Wb97VTxjSv%Vm`0SOq=&Vbh}S}W3q^G_9#sIAL-umkk7XYszm(Z zs{aS$Kpem0*Fx&I*3_fad+mhE#&s{-uwMN0f6Y&8$&Xbb^>xeEbGxoSK>k`bPb%a_ z{37m%T;w&BDgw^L_O`d`i|YO5P7qTHZaGw9J1c74AV3+)_?@BKLhn9*k&i0w^H~3W z9!EI7iLuKu7el*hdSAaq?2p%3(I4dMdu%sZ2#c@S0zZwKdj6X9dKUV2Z}^hFwI5Qh-f!oahIFcK<+(FO1bDSHB&5kn1Fi(?@K zH{u9RqqCsFL*WkQ)A&{qaT}LHIfoYF>|UJSO2Jj%StjT{gS_r^?AJw18zrHEZ05S zX^sGoN$0iy(LzWNg^T6#-mCKc+H33yoh^8T?Ncb#Q0JX6n7F_-wos+@`C}nR>ARE;-LWNL47ee4Hgue?Q+jq+780f^@A7?t@l#7Z&SOf$8ZQRro z{WG|oD8yk2usuP?U5NneJ)jFYVZc-PI_}i)n0lPFaJc%YORn~LW}50#1b59J9?V(q zk{_``xTv^Donqm~p0m>2OAogLIU%fcf~Aw9i&Pr}yVCWP;#IN`qDi58?~*^;i#&1% z@-uB~U)aCT=;HM8sJwkTUG7kT&2eFQ4m_$IYIZJC*!#^J`Qn}Kxph73B2~8My1<*9 zs!o1(w2v*EuaN41!ofj$bnaLWEWSEL;51l z11_qCzDJL0obS^`nTuK*zgpxJfxE9l7-?PnlLGG@pX-v3;zFF?oj9~Rab0)d_v{!a z(jEDkpX$4)j(Cx)!bPqM-;gVZi<-#4mE3FzbmIQ#T-Pl@%{x*;UyxMQ{M6DZy>Mwj z3iYEm5x&336N!s_kozLvy*;IK#dvMg*kAc*K7Qu%^ln;SV7l*5oqC{-q2n(~PP#5e zxNvoQJ1>Mn2iO%?HQsIaR0#bc5dG#N?ic~P(&d2=5IryMND5DKgI4jBXA($o-CmoB z%ZMel+kq3XwO}`P|BTZQ9GWOpVHAFjfI~Lm>$o^sE99UmT#kFTP||j{15Th*Q0E1zklcL(*KzHk zB8Tt2; z3kt-*AHE|FOx%?e*s8;iyZ~R7%-`ti;)NVl!`AAB?Qr89*urV0XDQthS_GX3a4LXbCqN@)T`ZIY@3+xG9pV7Hd^$Bba#T^l#KZy2bg;06` zzh-n}Q3nLv&H`t)z}bS%7&h#{d<8V7g|;;t9aO-Re@i?nSj#AE5erQ*=+p+?^0ilVIpa3I~RAY*$; z9y`r#Pke>ktqfbk7OJYR$?sMKs(=eAr(Zm5p=~Mq;^4U0dVKsMFDspT*OFc1xV%$K z{d}~z)(ef;t&l|H;F6r(Uf?h0W}vFTyMm zcpQOUr0{bJ>@62z7FjqU{7-w9IrlX?&X;+&4v3j1UX*~xs4srK$mLA8@-duD-|a;b6&W;mDp=|g8{g|LCXC_$;P z8!1B0k&bas)sKIs8XI0JrXQaMvayG*%L9)}`&%lOaVhSf&uJ80rqA$!F0b}!({6bV zo}UX&r^`iQ`XB??jVp;A*N#?#d*JZQVO%PXyZ7*LpMIUXkkjc!Zj7dn^MOn0Y7Q-2 zNLRm-&0CXtlnTz{z#`|@~JHB#CSz@*!fSbu}Z5r&(PhR}uE<(+9w8txZ<9xc3;T1cHlp;K7vSw`17 z9qkrUe{lz4M%I4a`*uEafBqS7gaX->TLy!D;GD5Czho~lk7oBn71%tU4vw6F0JpJo z?#cdt`YC1L5z+*yP*cN^?M-2CO3YKk@z(1i*SdK!9~^Jw=gHJ?WG`NLoN;?yUg>QL zyAsnj?V@IhBWph3P`VN`fsQa-XFUD}x^kuWFPzRTU!l{0Lf4K&g?Z#x0_fJ0lDg1e zEFp28%rnPAB2Yz_WLVf7p}S!vf?TAIaz<}c$DQ2~cw7K-!FKK$!(oPIR2Ri zN^}r)xSB6M&Uid;@SDb=D&Y;l!p~nkQV#=3fCL_ z9CuF@eWl%g_2fbLy~=I1SLsSkcTw|9q2p2{U6DTBZEkp+DS%ZtgYep!-t_ zn*LYQ>kb#S@Ykbt7}6IhdR*iVuMjifYAJ*5pMdC|%l7bv+}JOhyBBhgU$~7hApss0 zN89pcTXdk))waBFzeX2FdXvI;8b@%ppcBKBl)#i$#EuiA4*4Ug-mKoIN2&&zs`z3{% zbyn6wyY;%LZAd-U$`rOudRD~z75)5*qJ2M>Dz4*Lc&t4;;>=z_-ax&;_3*xT%87o* z;-_9$^>4JoY~KkjJvPVnVJLJ)MQrRQvOOd*=}FBDcN!a}V0SELpgxe#ZOX zg%oplYN?F6ps2jBbSl)h*Dlhv)(A~5j&eXr&N&2B2qXHFSQBwl^6Ibc#;#eUzTfjOx z1*)nw`vcY5i|3laTC0dh>7pP05YILhxG;L>2>7w7Ht(VUOVTQYB=DQ|AI|;vP@AdN zmFP*T+u-=;#INtA?3$c_4JCawJrbv}`~?*D+B*PiTL^5<77}D3Br@RV2+)}Vd*bfz z7^i+V%XvF+p=RE+`K!l~e>n8vBmX|`KQ}lYZL+so$v5ea3Auj>#v0lm22NZY_y5Xq5RxWPUj1q!ZzNUe09)Mh)ix4gvKk9Sb*6!^7h~mOEqTQ5U)Q!-m{7Dx)3zD7rc%JRnTUSbrbls;>vdpB z)5LA;U0J|xg7^lPak`K=DdgUVQQQIi=ZaJm5?1pw33>ZMH^Z(w;eAa2z|%V)OZcGJR9xVP~*0_TWA z7ZR_d0l%i)AYBOkv)V@id$Oo2M?}{ou8k1gu$qyY+_DK>^t$&6JfDkxQihYnPQD_| ztx2HiG+wxkt|;6|BCf>U%3{1*Wxxq7(H{?SmiYN+-Bp51OF}2s-J?cNy4_uQWZ+2d&g|NcqP>#5ZYX*-KD-Ca<#K?=U)774# z>lz1fe8%kvoFV$-)unJdvFZB4Zj$Kg#C=uba0D(i{h>i2@Vt-FUo5G%KVE1GorU%y zh0{M{$M3p!B2;djGg@JDgt4}9>*KXqxwu@DP>2#!-t{N$b{TfQg0ocN=MUP;iJg4K zaa%wON}NhT(siLT)-ur_p2q>XViXR2JS@_Q({VX^E^;z1>(+#pKp z7tc~e97Yk@^X`R9C*UdNwHSMmvTJ`Wj}~GLDLf7x&H}0^Vs~QmIAcu{dTF}QL;tmm z*t@7(R=YldMEvmZbKd-0{Ec+o~ zDHRJLjsQyq0<7u~DinS&fc07p@%BvOHg*>f`%~gn#yIySPN!P~8(8aqAy#@~SIIEO ziMQ&2rFj-A6gx2DQVO^~3ap)y&~dBB6Q_SxT^CsUUQ~Zwu>-E9lKtk3$LO|LsVJDU z0ZUdW#F5nRW4m;Ay<3q|$l0_<;_TL*Ts+uO7P6c72%kN|XOHmNtw`ze#{!hN(j!oI zUNk@%DYX}3E6{tEhfb3Lpd`*7iL*!Ilxu5ZcNYCmgpmlN2me%=J_y|mNZm)h%l!Fg z-1+tB!MfaF6I~CMLwn@VZuK~D8JB)0O?j7N72x;I;0`8&o6u{RoUj0!i;j!vLMPU@ zBS^mOk#9R~3WQ(KSxCNgCI9!u6A2inrI_^pq zD|NQMMEDbTCBk+Vk|#PX;fN`(gz$1O<1%Qe?pFL1{)v$0K1YRwsoW2xmF~l;(~BD0 z!ePnhRe1U^Fow4M-kzMM@L@_c^j7${r6$sRvF(M`o zAFLGSXVg{paxv(1$NL5o;Z20;;*8_+&V7u>KU;}Qoo(U%gHCrR<2{1QDA8o_K@j4b z(AJG^3(tIm?)!n-b`P4&5?wnj4FFgF#G~S1*(H7nbuVeDb z{GcQzc_01@}Jb<@?BTGO~t@ zEF2?CgscgK3H3PcnF*|s8QH9jY}N*C*o66+J0c7gh$ktWq;Qgjdz4NL3O%h@y)&}& z8?=HWE|+1ajZa$hcv8@rL*YrNe>O5l!AhIxfv$sw!j-rlEhEzvE)EJ?(CNPJC00D? zf{ZYU^@RF^U^Fh&7kmFYd`4XOeLi11>xyk;!J2#m`Ne4G z@XZvWmjgVX1I!AkGm@@rNkmTy<9M{-h7$2RMo)Ow1dM0`hR;*FE-Mi|Dd?2tV)F-C zRd7v=T(a5!gnC}b-2ez{~M_pV^~M-d!RN$L)Wy z1$R(iVR2_>-v%}1hdY_R+W(1HKQA4)uK*<*~n$O8cnSdj?&4g-+o!M{A ztgAE2+oYFU;?h^>#Jupdph9t*h`1HO8p@n(1N}28>?V~o#Dq$knS9I?sL8X8n_uJm z2c5!kC-{xF*SM4}-u?u3|LivrG~;xHaa3n>x*N@vpRMKy$NksG8)dt!xa)D1EG^PA zLv!+F0HS-;qPYNY89fMBbM5G)xaXv}=S;ZJZM>%2ZEX&x&4*O3V|qVY!j`0^IUyQ+*xqevlLl?fop*%MpG4L4~=pzGtWT2M=49C^r9T~ABW3&843?old zM(kF~GelF#@HPe!Gl9K504XAl44m@S;4JV`M4^s^#>%%HE9J)V=62*D=!bZsw?FfB z==^L&IzR6`?F_b1()esT6`C!-YE}(gWJfJzS}n;oZ#)Z!e&)=Y%}{6aUU4>WqGq$g zY&P$EX7j#hHfz2vQuLqAC$KYShK2q#35zpkh8Ks+fNKzil0Pm|H*=B0__`xSTgo9S z2iE|o^PeSMU!7Rt4}I<94fM4_uh{bK3)c_RAmTGm3W7}c)j zj#IZQ+AzBMq!(CvQl5C67>x?*`bAe6)Zdogdne9mbP6)JT+2oUg?$@mGj2y1ZcV9T z3zOS)d9?tqi84cu&2GY(!fN5(l z4^cbXPYBTsAhn~thY+W~m}kHwoKYIiVVND+jAIy= z${-n{a2=x?ODDgD?+iLO8lT(S3DN!6i|61~XrlXMZ~ivfLe1z83%`WACVQGtvmMYS z>mHC20}DNdbOtq}Kb%n-&Zq}3&c_+gRBFbM=F2&JDpYvhpud8OwK;rxGowPA(VG=E z)q{nI(l{xL-)yJPln!V{+cTr>xtJl185-jTsO&Txd|E;k?7gY+b`v>eJ<+K58u zCjG!HO@KqGSD$jp0z4kw>+y(%Ut=H*t1i;8YGyY)XJ&#}pwF2hJ-sH*tn72!D&u-l z(G0WyM(Fy?+3r^@gt*7Mj*C2VE96@L z_@M2k%1aED!BAYwb5G>C$46i5i4YfS&lPLW!k*CK+_+X;-2NV|6&HDFr;sA!|$%@C@r>cWyd%0xDy-Sqw@q()w$~NVFb$c>J>z-SXVy zTWW=_5@|**rjQox9#5ASxxoNB3kiIW=g2}=wdTmeo}ameEF3?k-A;_gXuuwH z(Iq`TbQ}YeIQ;lN!+IhoK0anxu_6UdqgzPl1%+Ehw;=gMkbJDnzbKLMiOBd^ZC~g@ zYCc^tRK1w-W=Zp*LoHzoE@O9uo-8DPKECC!V)eQn&)yejK9g3j7gs)Wr3|!%1-cAt zB(8hkSRM>g&|gfm=!#pRrzKrBC`<&Zg_f*2FVGaAoYX*Ux~O^J^<}mdW6TvT!Fsbf zdT%#WVixINA5&b^m%Y5d3#2Cbhtg49q&|Gf+UbX%X5LQ*PB&;O40dZkispW`g$O}) zeO3)$)ZX^92)#aQfmV$B>V+4MsW1E0s}mW^=;BDsTwk`BpT)s?4TcldXH{#x2E$n- z3YNlcoS%QT5?9;1&uXH2Plof5?sNWtb=b5kTE5tMV`)>>eDCh!@sG6AvHKxnH)@(} zQ8pAN^r%c;y>el+@F+L0Q_UtCt7XY*X{Hv%L`#b`?{wW{M@`bDRjGed23cg7N$7TXEfU*6wYlt zsxw(0*dvoL5l%w$v(F8nt2L`dBhs>LwsiQY=vJ=(u4dWoqE^tB1+rzOYgyBpcLS^W zsDDMtSLp7E_wR&GR~nHPjY!Ly)}s4pKA>LF7#6ysWMEzRO=D|D_Y$vZ7u^wmdmi-? zPpU0?61kiZF5?U?$DQtdx=VL)yEUc2y!bvUnQl*C)UtVTKE~|`X)N7XEBwA-H?~Ar ziLesEy>IrEY@}qvLr>?Ut6STDQF3>;=H{X%vF=U8#n%z6N9_@-duF4aMWbiY=$Ut7 z^SXLO+*WKzS8PaEj6pAIkoBl2yLa`4 zuj8uSdd6E+F#GW>;N5%`AH?rUDuVC~~6{ydIwy@?3Fpp}hh`0(fW>ih8x#DJQ1n`-nPxxq=aB`Y;j^%)Je86-+ zmnM~kPC<+R#jkDSN~p7t9pyQe{i+(@WgMwoc9P@8{~UL9po`O8XR@O_$BX|tKC39~ zPIo0;F8=3O9eIw`k>{i*9SiA&ZWhS_Czrl1a&bDxqG;g=PXCM+~<)SGfE$o@)SG(23EIyKv25LiZDN#uOY2zc1*Y@o=lqBS^Ljg&qvr zC|^7&IxQN`@x6ZFHnv0<33a7v`&{@X)SoE<-a-^*&9eu?IMr53ejm_JhrFM z-)f*x9=ZTWa3(^ndcgApmk@_8g^-$w-N-7@%P_#knzT*Y>lg3}X%ALd&l3T$iWEX^ z@V>T=dh5Bjp-{HwMR-I1p`ITqq@WAjFHz0%rU71gqW;Ynk5h<8r9vk5z3`@6WVu}a zh1QflV_GLPtrPkcbcn2%kISJoEyk=k3|es*v}QCLuI<8m@mR4!TsdqhJPwt%BL09-jVij%PggwutQaH~ za*sV@J29i6#vXk8v5d`=neiEWN|?8?|3Xb<@_oP{ZCnzz%h+ZS5y0E?NuRm z(}iQ{E7)P)`}ClInRf2Yil)Le2liB9PZf4;%?b~zrKe);#;p*F4j`-h-snxJYQAgl zM%>2fpVf;pK&VOz)k;0B)YD2`E)p+tA}i!tQFxRE^&`N(jI4QI*ac1%((T;S&eo+A zLX~<^tK(+{3h?M>oO^)ZV{~z}ntwg5U)OCvqid&QcU{10?xJY5?TT-$YLbG0|CSDny zOPY1!G7kSt&9>53db<_4U&lSpg4!^kKZs|&g|Extp}eG%e&ZAo=#0C1oK5q9E~E#z z&FuNSExC;R%#*~fd|n&Hw?*=551)kl z;hWG*;;sEfnl!yF)58yW|Kx4yzRl)kZ?ir2MfJ&+PTef>yyfih82N3@VoV{&dHfR= zvX?ZACWY)J%|gf8S(+&1oY1Cjm%Z^22V}Y#dM#t9 z*^u>(=Q1Fs>Wj)E{kLtJ9ADzH($dQ=abA{bsLGiUv)wI1uYg-qE3)KyGy1@37&}!KH!EPLX za2j1vZtN|ikU~h2h0q!Tzc1)?xw9*rf6%!>4kbF>@i-K!x5AmwnRI6ogGgG4GcTYU zi_R!7(y|xmFV;5kBCZ<{9U*N&wG41PK~**FB8Z5a$?Ces!*z~}e?m20x?8kA}>Om*A5`mL!_bKE>9)J|K(}GG& z(pWs?09^)}+Z9p-13EFTvpu3UD%bVQCAV-TbRDA$$sIm$I9+unJGFV?pydxbV~RB8 zQ{_|b42_Piy)n?uLbtiV=@it1x^&;bqk5pi8|Yp_g?Hg}H=<=2usH>&pbPhK$#zoM zZUD&;y-Sx|_&MF*{ve@l&H1^1(-@1WqSHv2=qzSamGI{9uNSA&+FQWeBhU5q7qztY z2p>HXM~}pDuLicR(2gssPqZTnHr^ z5Mz7TCut$fZ3@}x$K&2R>>9LiIWA9+-tlUp8L#f76mma*k*mi=*tG$VPX4?ZL2*NMb_$#WblXT&@xxssfL4(=K!Pu?yhR2X&m`6n-t}Z=+!o z@bk|&KI%Gba~53U3f*C};Vj%vcP3q2egNiYlmdsHQxeH5Kd>*6`=6>kW7O}06qwUi%Kp`c#!lT3_!3|rS7HYJ8)~Affbt7`!hy*txwiWhf z9DmS1(^&a3KqA3iq{8Lmex}jsy8nMxtql8{BqnrD<5@wKK`VISaJt=P(0=ElM#Yz* z@TI4J>E5=5z346*!EZ#|8g`g_Q7XRsk~j4e^Rs4Z+QTLg;}v*}J9v~>hnM0)o{-H? zcZPr%ct4~gk&C=d4{Ry}-3=GwnB|?#O2Vl}Hh+KnKL~U8i}VQp=DyiGeSQ76<+Gw- z3=6I!ikvahcmAYMkB(G?nd8BF>K026UcDVe+84a_R_kkATR-Dfqqm-7oksQ&T-N|b zeq3~zcR^z78h`(-d2bDuVA;b=A-u*EDutO8R&f7|Soklpi2KpQru&|Z;?dSZywtjV zbXv%lwhHmM>aJ&J>T%V=B!~$ z8J!3SC!#>%SV6~yGs%ne=v7E{+3}r>LfA%Ll$3vb&*E3F`Jhj3R|=``xp??NpZt*& z;@ay)-v4_~W{%?ninF|eUE~~cmKAbm$zruoC10}deqkwzA{;rcz356mUVP87G8krY z`kB_M!mbFEzrM$f`8_u5_@0L^^A%F?E5wR$k$a?z^lbFi<6Vrf|HaDqd%lFWVE>D- z;A-da9($1NCN#vV%y0lHiBa0XL#`76|WU+8L0^j~S^63cV+kZz67lC|O_sH2H z(J5TJA0gk+JaS4%ba`RpPsID+M{WX%s~fjN`E+I>o&h|X=7o;1zaDgMyH8`vd#2)I zYYXI~o`qu=*EVkB_dG%W>{}`%vdZ7wC`gX%@;O7bzzbs?PhRo9ZoG;x?XFKcnjm;xYDw_QV}uP+7R^ zeY!7p(S7KX1s3q@M)s^v_pgB4amROzE(4cp;C3iZXo>!ry2!`Zz=_)>gP~~QeuvR@ zMh!3UC|#OL1KVRtizLxk$OHP(SOLX?(5dA|=g2%@bX!C?Z0qoV_mvZ(vw#O2U>nB# zY#!&fx*F3<%#l(Xn4j&lg8tb_#!w6ZmqMv10=8vzb5ng(s2S*F20FTWTBx<{WI;R9 zF9BUhuWd&=DWZP{D;}b=&>VK8UoD(TVY;H|Q3jaeOpgnC!~A0M3``2k-P22=ud)F1 z6)Tmx+_}6^9@&-xQ|sG(lKL>>3i@X!^>FeP$D@$@%!@QwBTnOLMi+8q1b8MsGE@b+ z6O7jx^U?}&8=DHr!#LpQjG=ul9Kr1-q@MZW#_8Ah`9-~p8f2f3b-qtp;7gk1F2bP; za0@lFeP5FOJhOdP69bQb#&P~dIQXm^F66ZHSs@H`7POEl}TI0h3X}*XX4LKs}*v#x`-=w7pe3r#I1YaH;tnkkG^=M=u~eN zF4qH(p@1BE_uZ@~*pxAEZ51w8HGOFpZr3^=W-UCf5?aj{Y7D)4aI2oQ1cN^A+()^1HG@=Jp7{L=7RWX)J6aPPT8P7k440bwA1{` zNDo-HTxjmzf<5>Kzo4Igx#-Xtv`OrD?FC?J_zl0WTBy)B_*nuR-``LT(~&*Wunp7q z9V-pLBNO$DJS8Gl++lZ<7xl9`U%>=2>{IZfT65TmA8{Iwex{{+ESF&{Ib`_Cl`U4M zk&?|P7!}O4{lP`G{EV_?`tim>3a>Ns!;H>jM&~i3yqG>UFQkAs-@nDtXHILv3ppds z=o<=`a^ra$@C_a(#x|a>MVJ}Q!i)?#BjDXbtB%n!ZOU8-Z%SXh+ZEzW^_w$aY%AeH zI4UXRygvQBcOmz|^WL?=c$>>9EWc!Qk;fv1to7w5b1v#LSJuKf z9& zi5-6Al%)-DC)A0#CDP&Q0^jSOaggF#-y?)^Od&Ugg^=;v$wuYafaIbR7}n7oyD`3~jmq&GWrfs4`78-Y zEkyZG*-A`n+p&Y%LRh{7n^KT`c>EmE=Mx(sWj-IA{BR~+J~w|sU%uqltdM)Svr->R z57re@puT6D9Jh7i`8kvw;H*@_vR39St5=TiDix-LJkc)XP&>YrR0#V9;xf8qG?cw4 z(fQ)%Z{v9}(QzqG6uLO{g;>ZH6}W?riwC}eOL6IOwkrLd`k6K?g%H8ccB|~v>Gn)^ zo#Ck8?9|@2y6Egwrz$km}e&g>#Rg8^$O7NVMdBP%Ja zzsQC0BDb$Civ2>2xVDB_HSO2iOH23`o$*DQ7+>To zL%;5)m~ymUK@jl^m8YMUEW~l%Maq(eQ1SvxD*`-Qh>mj6#Tu!BAt9>}vd%?z1mIB$ zoJ$HJeH7wwt8k8PEV;7~W2X>Ww8G<}^9bT1-MJKE7y;3`SDmgV^>ZSH`nD;xYkU2u zPociT*V7B3l54LYNhnleE8n&gn(q2TT1vK8b~l$C_2eV$tfhUABYbvk+rBrUUG~g{ z&B%q}BGpzc`jM6^y6v-DvO<2uH$Un|PRiJMF9 z-uU|nHPtuY2E4?pEQOGDTkZfBVwr2X_*aOvtq{`D#b3=TE;rkSkVzXZ=v)VBEaPcoOhc{ZGg)e(@Rz%%v>+h-4(F4;4&`d#%CgdT}9_>jfJY5AvaGK`MOpi z9>l6Ddz=`hh{E%zIGZa%%M9Brd9|xo;<}8tH|sO&RWyb^!Ov@8x1o0-bUGI)++9@v zE``>sxPKDkA@4<=s{onqo48HW(2w4Y?%mtp|I?w?MOt4LazVd{^S##Be`kumk7+HB z(Tkf2EFBSC^yfs9sS7{o7#FcUF62 zHh_if7~STkg(sos-9&Yl!l^74%%%&+yNu3_vTEUa92ff9LcH2q$i7$j^`O%|{`jAq z{u!s!J%18AiExtGQ40I%pV2?t&rx~z>o5M{Hnv1)-8d6rI%} zhr`OULN53hq0Rv|rNAe1fS)p^iUv58f)?>%cg=<8eT++SHPi~%YxD>8*SXQdGO&%H zzJgmwV0S32b_!=A;OM%LqDWy^$#}uC5ZA_mYa6!;NyQCte#Y&%EfJbOXyhwK+X+2R zfvZE|*NlAxU54(4rD6cMlfrfcTyrNn?syZSIdr;(_&xye7*mtH`U&g_T`n(fKmoVQ z09U6MC7KkvCV8{7u(`2j`q_kPxkkuyGFUtU*7j$vbVN-=qi5iB>2?Y`zoL78GkSVo zmu9BjLl7ucTU|0?#(qbuklVwH8e}7b>>ijr3YCmscpOSe^rD8I-C z1-_2ZKBvI7Z$$1Ie#r>vit@M^>EJ{9Rp1)IZQM^##gSlq&#_e1b~DoMi52CedzAAB zxrw?*Ih;%8tA~m%@;YE4Op$+GOaj!*#&!AL!g@?@ltd)R6B-h~_sh2sx8 z!f+ORIIIxr_KTYA>-N@sgDRvtRAJLsXt!FpgxVYxZskS;*}~7C>Dud_bEmJm7veGH zLMp7LZ=4rW+PXOW@tT1yE`OVLM0GQ5sW`mpRY+NBMlh~dH#bQO^UT6L{RBj-7e^n3 zYM*)$#&e~a7I2b%Z&^(5(3y7Yky?9IqZ zpH`yz?C^W8>nMe8i&b@g8RXt)>B@OqzK(QJ3W;S?@Iu@+x~Mt39ynrzFBmP{8m@*F z6-{0(Eo7@)lZ0|G{+Y?;}+sB za3SWv!jy(11h}07tUY_w#r>tT+6z>_U8KgQm+Ri@B0c_k`|ESnLS;Lp*($^|Q3%D= zMT&&K^8W|3qf5L3)5}n>MA1TMbS^62&)2VkLvcB(dYRZchXI>G;A}xf;G^S(*gyBr zH=BDo%4BZV&|jRG1^Q<{xrJ=XtI35P0XP+bTazB=guVRe&2HQdO$17L7hMlqb57y= z8CA$F4}TY_nkoD~BQ9M2exfO*mnXhGg{qR1;vCsz0@2!^O;S1($J@9a3Xu^wlrHC| zex3a>i}px*DTJI-hza*1Eu4w?neVn1DluO%x=6{Pw|YP8H7+67)QbZ~--VmS`E+_% zh>7MRRSLg4&@9lJ#I-_JZ)?fSFO3(r&!O1)6|$Z#Zs$PL_KQ4(0WL-G2RErofi0oq z4}QTF+(v(p+p5X1iKybx0I9d0&+IzP>@AaB8~K?Z^Xx4{sLizY(nQf#>eMfV1E)hb zYh=j<73s&l3-dj0ww1Q>E8T@0L2b!| z0Jt2=`K7Qs!c1Z_i9z-#eXX9l{)A&2@ghOtN(!*?Erk3DOx)wd_{?h| zZa)=%E(2et2R7B@?A6ZJY-;t;&&LhOc_y}J!e3lpkQ z(H~DQJrnLdN??cUe5dgO)bSOBJw+iDnT5DqRfxCxfLq92EN~h*u)Cc>70#{1c*FLVxvwAF%yFr!Z$i-GsmGku%ohw8b|=O`0Zgbro=PsD`y};Ug~M&36|-=;WEm6^NDAF2 zdpa!-f{7!zf{B|#F4r$FOaZ?YbcEA_?l3gEBRT~fL=ydNIH@73I3q)ZH#msf=swBw zK%j>c_vplF{2UjjI>h{}C$#sVGyak;C%(ewqPJwxmVee0x4T(J|K6Z~){_`C^@TI# zo$kMU#HVn%en*;+KE(=m%CJ+0?QucnI?*Y>+hXBK1045M9-;zWlk@T5e09~nk-yP^R;D?F3o}fP{li18~;zbppYkfV= zcs-8GQ`ACk{R&@Yu```lCW}nl3nw>)!|ld<_=WRzx|u50^ejgk0AQa%r#ojN z?4)}}&~ew}qOZ@1E%%)T>vLdI*k1K`!UFV6yRsatJ@T`ZY^S33PN2*%3c$U%D?g_UgN&DlOmir7u`*i96^PR#RJOL?<<>{bf*x|g&R!AnQeUBp7oJK31^?8f^1i`qoo z)B2C_sTQ)8>dShezOqEvz81*pUwR0sQO&;7?A!ani`@BLl=QYEk!|n%E=r!--mqUhuOjgLyRZ$F*P_by zhP|-8!D;MHf&0P2?aS?tMfg zE+3+fOIwMHG(NjXeQl3x%pT8|eMI4RUDXQhJfh#DIW$`xt}k}(QQd89pGc@eH?wLQ56l@hZgqpnP_TFRezX^110WMN$a}oDZFSbEu}n(hkrfKhjqLTwiNYY5vERD3*ls@5Wd3;ITThs88V5Jn}LgX;_)JF%P#(8 z2>t9gBM&2lp0!i55j@6iy9i7ufrWmo~#U>7@MHu-Ysbxz14+V%@LgOGE8j>}$naSkWO z?dioel`)M~3Mma2u5LWaLPE%H)?5@0r$EKTMJgsP&ZIz%`$g_lcT4TskPLW~7$0o} zIx#5)>gaUqgLoke=t8=wn(S5z)oRV#^g=fPZzvI+0+-i|xHt&x&iJ@3`0VaQiA{xL z1h>)Er`>X)i$mKP;GekL5k7ysM9RYExZg;q8(@5H7CJL2?4Lu^#-q<=+)ivfjsV*M z;tD20_g7?8jC9&DQXDF^3O|3_OvV($3-g(US(Jb3t9I7rY&$P@ zPWQf$RH*fKXT8l{bNiZ7q1M}-I^3zaNVI!rG2B`9vX$I^cB@c})|N9a;8N&#{J~x@ zw)_wRw|+)LyFzXRcRiZr#FoqJIdjVrkEo)Llb_Knqj2kIIxZ~q$IrHmgVX2E9JDhB zZC#?M#?(mPdP2G|weC^2EwL7I^d1d|<9>0{*2gDP*XOqqJ!!Nfx9=A%;+#k@v4`09 zl?MFuGcVUCwSTOgJ&*hK{6i|&$$s#>{=Yn6|D+M^#4d6#H|`_muv92SXZ&r968dT9 z^6N$k^?jU+YL8?0UKgnb7|%{z8>3vLU<<4{_97+8!u*U|_lwl70naCdc8#c?Q7Rhc z%XbUuduzlK?|6Ms2OLJMVTG{19Oe4XFu3pGIDBy{J~rjaFc-1clIF6ha`cWFMAs$#S?52TH)D6lkA#ap81)H2c9Xgb)Zs zJ+)Wbz(P#Rh1hx)4kyM_(9wKwSqN1T5QPNxpd+x^8{Ph2fH#;+TqG}2?@~CK{v-di z7db%`VottD0c6A`=ps+bfE}TF^!XB%&=*|?wif_mL7lz_vKG7cbQif3AGt1CsC>KB zm4)m5j81_{v_fnX3MY%(B>+}&ap+SBso>&P3d2)N^WBMo%cmdFB^zlo+eH@QUPvJ< z87@-kRLCi-5K~s+aRkmM%^r3NVQze}aRAoavTGW*(xq+_cvKv2z=7ikXLkgg-T;SF z*e)4eERNhb9e8o}7|#moXPRf4jY11K9~8DH=yWw`=HII3UK}{}@l?_fo!dUn;^b#g zBM`fBC&G5R=OomL@su0bmeDn-IR`k5;}34*N@B|?yl!LOJS+U%&$!MPY8YRYTYi@M zr6LYEodsSrCAwtTT@) zJyN0OrKw}j(R_|Ft_{+_yo8I(ov6WpJIssNaSB_w(A+=d;dmjG%NKvuptzeWNcXjk zi%u*PNMHXTES)a$hFBq2&+aP*gOZ1_FUrXcpqOrrHDk|16&=5vtH-;Lf?Qzrm!?{qcj7QsGG4nYeQV{XtN` z7UEhk(O-ePam2)JiO`+y7zv#_n9$|&j(pop{m!JzTPrP2hcFr90{S_bG!ot$$DdqR6c-6yGH1TL41^5GAuTKm`%HQ ziD_#HL}-U%rvROI`YG%}sspAUy)K02r;y^$Md)oVy4>AMdELTAjm~VPav>Ku9APGu z>vvtqbs4mNPd{%x>Ta&TM^o}eitlwz_Z>!mh34Vh%`N0oKJDVWFrTRzw=VBK%2?AD z5D`YcEgP&k6FRLdnfBK?=1ToW=D`6!@VKh$Xnc}*oCN`FcUbn? zG|JB{K?3lasnVG$o$kCV8_wmE1mJQ69JGxxXyBJczFod+^IM2?=jbAB_AhEIZJQPX z(Vw=B2nVbJnh>54$BrH#2PPL)hMj%&Mfe&jTx$o9aVs$`shciCFK8;;_5%vUq_~$Z z8xWh3pOcNmUb;(A$bNotJ6-w<7&}$EmsOwJW-5i!Ebp-bSul$F^C(qZEGK#i6*rQOMzZe0+6Lt4`s!jVqx? zKcf>j;MzfDJmBEHusLIzz5!iPbjVXkQLJz~F0RfMQob$p$4l`JOlVTzG;w^I2K;)^ zMdxfL76S}<||x;6PMULkc&XUhhE#0Nm3s?YC7&4)_(3%tNz2qjJ-_T>|f z4qhQXy%1|Q@Hj5k>_SXn7rEUmY)*`lJ8&xn-lYGD>M72bxNkz69&DLU{|B*uzDSFq zi@&Y*XJiVslD|Y6Gt9?R;CxolZ-Et7=(p?U#0`55ejR<5?}M6mXSo~rgQ$*+I8U8% zLNLp*z)^(Xe(fPrzL@VyV*6igM$C^Fd6RbL1+Dq{EyMZQ+HK}Rp855^sFHnyANdU_ zUd+|>wMa)9|Y|2U2{FdYD#Ar7A-?*q!-D4n~9^aLDlIRKfqHPL& zGFQk82z%v(+YVAL+GKF$uzSUxWBC@~UODXcyUD<F=<<|O)GRLloj*(7l7{M*Zuy33h5HA$yV4~>ti7t zixk3G9#}=h@4FH0Q^@{)5$-fE(s>M!&0a5hDhsw{VLwWYl3*c>RDj!YVS81GYZAcE zKZAkeMIKrKw|>UUv%qSqLMS2&A=Lw?Q-IYd&{e|yK5#qT*_{|Jt^$`6P?O z(cSW$3rh3jsfDAtkYfe6v*2=2IQ%mV-NKcUan%E^pc8|X3w)g$qv5&G0-EHf+f(yiI!DB*A5-B0GkuT&Kl@ir?*L< zn~ec_y;~G!bW@Zj`e*M9dSp$! zUJG=2DRu*s*yqtb(@Q9U7eW<7bPDM^vw39k0x!@Val#D<(Ld{+<#@vvn6H4nFwl)f z(I1$i508uAh9IH&ikT{zo{eZwT)0xQ^pd$}(9)il@xCb6~?>i)~u`G6kBD>cb|57A|q{`eo} zXDQBfv+zDKFcG%bH`*)%Qykcy0NWo-+>yjQf$$P>;qfBDtDr!yZ#z}JUw36%JpBF0w?iZftuoYt=zhd};^hHPbq*N`O4%KQpKAoEvHYY|m zaC7*@mqN<4@pnDr&KigbI-ad(JX-@X$cAq>6>@hwe7kAJ3-*i`>_W=4!*`wvDY6bf zBnLeDnWWcv%m()0NQ5zhGw9qX^bX&TDug*LFd45TMwvEFxo4bm&tc!6Kvy5H9|N7P zgvfZ}20Aw>GvbLGID=dD>t52Ns{^`rBu~bd_lz%Z;OC5|8|#irV^4(X2rDJSeMaDM zT*{M$(#$`D$DNxFcpK_pEllyC8;!ek% zX@HrGXG^I2FRc)OBN38CrXV_kp7C@(zPwW;9b~4P8HK$m*%un#KPvd8m4)= zbmo!Li)z@5Y^*|OA#uF$Qwoy%ru~KhF>B0^+Oqla?Di7vs0v|TcTsD>ML32jJgkpT zPE-r2GAwjl*OE;KneQXyq%)&BxX9&kMh8&XyYZ-y+H_C9E?&q*ettTw10E*^kF13_ z-U3cljy9$<`_B34oSoQ%DvtJBGdrvK$x&Y+H99kUooSD$7kP{eoQlh^x7MvF9x0blUBQvJh@zXIW!H-i;9_a43S>vRm&syFC~Fs+%S$m*~*uqQAzk2LK&G z^28je#$lmy4zCn*(r?BkNkSJt_8U9noHN?@=~q@~o(sOHBZ@g?f>wwJ>Mv@yHm7c3 z0;h6Y+`~=#4FD!CrT)UB&`otuVgG|I*o`Zp7*#7nblF$WoGYPCcK{2if4vCrhO^!& zMonI%dbOS`{v66dvzm;U=klB9Ky>blGZDB2US;d_88L=elOe=K%|!LQaYCuxTaml! zA>*eTN6=r)cdLPaezu)1N6PYzd7|U8^A&EVyZo7w(SVLimEMX7cAr>&{1wv8U8Kxj z=myZ4){5MB1LN)G%~eIxZC z?NhxkJ&e>Ht!5q1^2;hf_Z^z%09{eP;yP}BR`3`DxlEO;doe2KopVagQ zhcV@)va!(PjgI1g(}jcuDlzFU*Us2U>?H0=Rc6lfHvuU*fiICRaSK;d+}|d#8HaFvtgE=mzlxSNqZ!`Hir)7TO^l(811_%8q+N8mQT&_&mN{2~v4 zw(p7&odVob0!Jc@{A@a7?$TpPSh(C_v_sxgE=VtG1H63&s*s!7!tDqYlD0hy&axc9 zvbyaGy^sPvYy`GYEwn7O$6R2-R8VD*E@%5Ha3SZ$?RUZp^D`PR0gp1)5^<5%E87mo zC+q`B9%DPR!?px}Q4_^ZmF-m7&eXV5Pdl^Z&KTSBgk7kqX3M)Z5MoWanOI7}GpY^w z@Da^%xn+U#iA2D9zx^;Sunl8(3Ywm`&)*A&GuCvsGotO^oT#``rEfo|Ua0kEXTsd+ zMLWG{XYSnTMce9*vd?_{Gip8xlfqhwaPl)Zi_WtP-4@z;6@E|9UrdpqusdDN>O0HS zPN&|P>$hw~XBnqda$oxqj^GTgVCwh23pNLTGQ-JDJ#T6aLbix$sXYG$FuRKnv${8wglC8DLo!5jP6C{Oyab8ZN=` z>k{RrLUzK}W_7?hgfPV_#97sP?Q55)T&bZchKV9oM7W-l~988B@Y9 zRQKGOpSI2}3*V)Tc}iIb4P)UP%FVrRXUg!B2yBY$&gBbyC4`_y zbZ*UGF;c0c`GY8*1Dhj2R0j^@$Ooxmw+_Av)oFJozAY=K_1TNjMcN9i?{Vn+{l~(5 zp2|K?V4p686l%u!qzx@pZ~EjGW!0N`EVd7B3P&5pX@p|5Q1hOj=_*ur`}BS_a61&| zJ%ySLzs!c-Z39_f_mIcm#uN6!y&PfhgdSCiJFE|c3e{mhX~zpwXOu*NQ{UzZ*QS?1 zS4qb{6qpp&d4mqsd}_lt znm!(PdwPEXx<62tR`_;DfP0w2?{z5d^Z;EYyxzmfN=0A&bGqF@@PLGJg=#Qs@ z4EQ<%bY;ZEbs@EG-{&|7%WDBI zBe+K;n{cha`kSJ^$-)nOt?ow|E~?J^ad*3pfuI_^sJPb;3BO|4so@&-WLT)UuhiLV z^n`xF797^d40;K^pt*2lDE?3zj6zKA7opw+j)Y>^4xcd;vOiwrHfh)qULkiC7b(RJ z+bsb7GfjYF{{04Zt#V$n^)qcS#+2RdhffIBumd>YZpNdy5}SsfR3Pf(X_E$= zs!1*OBbo&GiBYIOXnATbL@yiJcwaj+b$TnSff(->xzp{|MFa7fd1+_md7X<|t?C&Z zXQ34+FX8}~(v?!EUc{M3xV$4S<7Zq+fr4#4ZR1e7yeQZ!0h z0`*NmhjIle7c`$DNVF*YlpBpffTJ5-93G4T*D?CzwIR8 zQRsAM$}4pe@E8*pHUWj4A1=aDuaG-{LVtynOc#&KEAf1G+3yu{x|;R`yx=Rzp*TP( zgw58v_G(<1ue}@G|_CUMh zX{Qh;MukaB(|S45!AHc?4zU|C0~Mlw-?wXE+qSL?88~XWIB@p4Heb)Z!0G)*)EI9+ zp#{uO_B2wrQYI8jKhxLl*E;2E7Qqo(CKRZ|vYWubzQ_T%Z3A359G53cYx}j&LP|Fm zIXVirekKj;e&s>vqNI?ExYt%lX>9wM#&wM)^g%XiSmnBWk<_h@&-E|TG=A-DQ1DW~ z8&oLW11~t3L1?s;IVLQm0vm^X3H8sU8@|rw@#bbBtg2QV(-*4UFG4W5$Q^im)EBm` zbm^u%VZ_p~hQ}@Z6kh;3#8{UODA1+J+e9C8)HybR1 z7kR!@p5Gt+OcK_y{beBq+`{F!$wFsw$9Is3UvL|j%D`K?g`E5g{h4$VP{;}8L|s+r zdf*k>LTauq;to>bYG2SP-~k%oa0={2h0EWDR}n6%Zxwdsrg3%Z2B&_eb>R5+B+x~t zzZKxt$7@L_OvX5vuAafw;UfHE72<)ELR=^<*R#^S-x@kKvkLc1zz zt2JBtLrZxxjh`(;vx}PjkL?8(eCnWcorE48RBbqQc?z4>g#Gm3_!sNr_dTOlsO9io z$EmfH{G!Pa%-dQb#K)v5a=NFeIKfQ5IxdgRg#luzuI6fgPs|&FKxk#g-!o~XXb-s%{ zZ33=iJSqXrsS9x!3Op(f&qWKN*(pTd1^x-$jtkFrz_%IuHXhZ7dPrecd1>H$k#+@z zufLcgE75Ur>wOVD}?&55Q>#TjU-#% zY@98A#-RwbvDrE8D};Phh(otRb%(9vf&E&ev%i1_XY|)g;@50U$6yTXVjEzyba?}t_OuXp?e3N z8#J4R%{^+Q)@ip>h^quZH!SZA0bdsq`kq2ufCr8z=yW-`6dtFL7UG^Am@;JG@DP#c zFJ2>|E1}6PO*nQE!>*s02x&q%ASr}@KA^`1l?6b@)r?-~QAlyHaJg%6*I#&Csq|@a zkr$?R9qusQkcRbe|dB87J{hv%QCzHmJWl7;;hdNRq7DDd3$fFyef7aBg zk1oFfIa8d>6i1u7!pYhzvp?lVZ6t6iRLcWUS@1}xus@HH?RsRJQb-x%JDD_&^jC#D zS!hB$x}099(SLMM4_pb|&FByEh(1z-7Jl8K8oNh!=7sH>!DVcT&|hP6>u>OLaiAv! z_C)9|Iu0+1&JCL@qC+(&9I4(4S94<9^~?85fUiGuC+=xVfw!oDODS;UUO3!$=5$x~ zz&d(S3Yeemq`Q}M+paU}Itr&d0rxoj`s`zWXrBe7W+$;1sWL5u9Z4alhjOKZA<}#t zzQ;;p?H{Dz)mqYR9s$jlqzWm$+&7_=!U)db`iqjoTO_`6W`cHWzW=_jUlZuQF8&Es z+-@(JdnB>`2EV_kJx*_yhk8D~=i~F{OChu!7pd*IsM@*639tL)worv^dY7^go+U0) z=}^egQrM3X=Y54yQ}KT_KJKfG&;} zvU09~1wDQiF4AHUIP^h0N&vRU*i{^!@9&vTJ8eI`dxl7tCiBvtKvi#_>4#HRBD?$U zR3XegfJZ-*ZlL=*b+elU}gYJi-m>V>B1icaT{5jOh*0Bq;dYD$3j{#uh0~-lg!LD)3%Wp|Af+Y z<3$bmk)Mq>>AuzkjHEE^`En0i-tC!*YuI}NkfZ6J2s~WbaYyqTQ;G0r^rv?YTNv$; z?wM2di<*JTT>z%kdcy(h>h#l1d)iC)Ubgbs_mYT0gZJ{QLj458&XWcgc~x$&M@Ik` z9pMWeH_JMn%P+!xg8C`_k=S67+F0y-G+*@(A1-gjS`a--;3T(rOecBIs zh*vmWNVDp7=K0QB+lI0?o z*g~eztp&Zvt<#A*<#bs|7gC*a{18pya$gv-3a-!fkUG^Wqme6JFiOZ>? z5E~R=Lbuw2u2O;BS#Y5OCc<*bIQJEHBUQ_VZU8A43YRlJN%uIECd|ZR+^XtH!KQGk zCg4mu4u@4O`FHl)~gQ%B15xOr@ zMSG$_xu~W**47rjN?(fG6KxCd7`J~$5%VHXR!-#l!X&18jo2x}Cz^gU9HMrSUm_S{>*z(CDnN9miGAigxHkC3LdhA72VBoNhGU6amhJ z9(|@~(IeAqVE=*%J3SZHvg({n$;aw~6BX8p z3hP9L1ys?c4L{j#6|RJCht8xsha;r#XlquOjF-Eo_8BJ|wiA6=p+}3f;e|&()84Jn zUo0K@@gwm-_cPvdCAQ!+ehF=f+mb?0Xm@em6o;~S;o8QXP*;?WWzX>$;PIRFKo32d zloIo^?u@@=+&qDJ^^4l?MAvts>pRiv0Ufs|3o0^B6ny7o(LB2B16&F9;MJC{aJqJQ zs9BhdGi6HCe{uET2rj3)^0Q2@11}8|6Pha7Y0aIl*zSEJqg&eh!k$q7jBhIwr_mjJ zW+s>!{$6&ET!I7r6*Eo9TSkT5t*Im5!ky6MHauc=xLfERMI({II?#?GW=TQmh3#Xdp-AUn;=aa&eO2KUZALcp02Q-FyDYVU(@?m7ypD#IOsml z)bD40zZ26!_Q~rn=d(-e5s>Pb@Zi`_nJ#iQE>x5I zRHpF#9%Ju9NF}Fve|g%c&EG|bs!zA}H;kS)PxAqMp(6Oq{32KWi#!FqxM3W3t`Ay8 zn14~FZ7bzk(h*h^sx5@o1#mRu`Gbc@tt}8e zq|4~&()%Fa((9{ky|a`cBmS_PavP3{?gOL{ZAA7Id|Z~&-j$#sXG{(q`t6efL77i1#|5M zxb<@$83B)y;#RE?0z@H-*z+^@RPN<~?j_v70Y@`Ve-Nw~faec7g`LmbpEHIsuMm4V zV0UiEZPCw8sD}tOx)*8sM4Z8GbT8q)4|x8dfA;!|NBS>!c);F+&f=X;?De>i#FQIn zz&D|8SZ?5m&iHFdEMM_We>_Y$3UP%L`2E2dTt@eYfkOn~pTtr-{SAIC=yZE(2m3yt z2ieGgF+6b^Yh3V>>`8GP$F655m;_)nJd0}hU zc99Pl0X^Z-aRbo(fp@@&URH5JRS2B{a3|EW+{$#NJwu_Jg)Yy4(?6ST%{5OCy6DS& zaxM32Za9G{`gZ+t8%lH*+dYa$O~B*0$5Y#HVQ^x{^{^i|3-`3dZgeN$Q$@rc+(r+a z{mz80;GRY|7N0c&z6pI3ibCGgZkSb{@diJy0T(I54&TQ*_a-qXyn8tns_BdLUw)CQ zy+Vq<_i`#FrXN!CQ3!QXA#BdijMP4TSD+9I{EHFC-vlF){~RL!TKHL2k_!Qu3)=v&^?!+?hS!V+-e#yR<-;K@!UB z`QUC@BsXje+08Ce)R;cLE2N+>ZTDOVPw~K^^7iF#(`s9|nvoURy~O5X?#XRjVmE&N z8C4~@qg%+8ZbpGp_&P4vp1Q8BMGt7W6e#IKOV> z1j7eAE^r$-m&fqpqZdUSJ-;73?C?wOvI{AlUxcMiVaJxIa2ZGU�}th!Z_vil=FF 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⁢t6#xJQsyC(dxWWeR1|%P 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 L>U=pnm9-E6QOGt{ literal 0 HcmV?d00001 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_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..eed39d8 --- /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..b62b743 --- /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..aa04131 --- /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..c9cad4a --- /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" }, ] From 0e802b3f637ac262488a9aaf9642fedd0af50fed Mon Sep 17 00:00:00 2001 From: Nishaanth Reddy Date: Sat, 22 Aug 2026 22:23:18 -0700 Subject: [PATCH 2/2] fix: bound shuffle partition growth so iterative algorithms run on Ray Every distributed iterative algorithm stalled on Daft's Ray/Flotilla runner: partition counts compounded round over round until each shuffle needed a partition-count-squared number of pieces, exhausting the cluster. Daft resolves a shuffle's output partition count to the repartition spec's count or else the input's (unwrap_or(input_num_partitions)) and never lowers it, and union_all sums its inputs' counts, so a step that symmetrizes/unions doubles the count every round. Invisible on the native runner (no partitions), fatal on Ray. Fix: bound the partition count wherever iterative state is carried, via a cheap plan rewrite (into_partitions on the already-optimized plan, no extra execution), gated to the Ray runner (no-op on native): - iterate.py: bound_partitions() (lazy cap) and collect_bounded() (materialize then present at a bounded count). collect_bounded returns the coalesced frame LAZILY - re-collecting after into_partitions makes num_partitions() report 0, which silently disables every downstream cap. - message_passing.py: cap triplets, the aggregate_messages union, and each pregel step (fixes label_propagation, k_core, shortest_paths, pagerank, ...). - connected_components: cap the star step passes, label propagation, adjacency. - Custom-loop algorithms that bypass the shared machinery: strongly_connected_ components (peeling loop + active_v/active_e + union fold), hyper_anf (per-hop HLL), maximal_independent_set (per-round status), shortest_paths (landmark fold), and the shared BFS frontier in _traversal (visited/levels growth). - Static once-collected inputs (adjacency/edges/degrees) joined every round. Also adds tests/test_bfs_scaling.py, examples/, and benchmarks/ from the driver-memory BFS rewrite. --- .pre-commit-config.yaml | 7 + benchmarks/bench_cc.py | 91 ++++++++ daft_graph/_compare.py | 14 +- daft_graph/algorithms/_traversal.py | 122 ++++++++++- daft_graph/algorithms/all_paths.py | 12 +- daft_graph/algorithms/bfs.py | 204 +++++++++--------- daft_graph/algorithms/connected_components.py | 20 +- daft_graph/algorithms/hyper_anf.py | 16 +- daft_graph/algorithms/k_core.py | 24 ++- daft_graph/algorithms/label_propagation.py | 5 +- .../algorithms/maximal_independent_set.py | 29 ++- daft_graph/algorithms/pagerank.py | 10 +- .../algorithms/power_iteration_clustering.py | 10 +- daft_graph/algorithms/random_walks.py | 2 +- daft_graph/algorithms/shortest_paths.py | 14 +- .../strongly_connected_components.py | 19 +- daft_graph/algorithms/svd_plus_plus.py | 2 +- daft_graph/graph.py | 55 +++-- daft_graph/iterate.py | 127 ++++++++++- daft_graph/message_passing.py | 19 +- docs/usage.md | 11 +- examples/cc_on_iceberg.py | 62 ++++++ tests/data/README.md | 14 ++ tests/test_bfs_scaling.py | 98 +++++++++ tests/test_directed_graph.py | 2 +- tests/test_graph_subclass_preservation.py | 2 +- tests/test_indexing.py | 2 +- tests/test_undirected_graph.py | 6 +- 28 files changed, 818 insertions(+), 181 deletions(-) create mode 100644 benchmarks/bench_cc.py create mode 100644 examples/cc_on_iceberg.py create mode 100644 tests/data/README.md create mode 100644 tests/test_bfs_scaling.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b79460a..1f71f02 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -39,6 +39,13 @@ repos: hooks: - id: mypy # Static type checker for Python 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/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/_compare.py b/daft_graph/_compare.py index fea63c9..ce5db26 100644 --- a/daft_graph/_compare.py +++ b/daft_graph/_compare.py @@ -4,13 +4,21 @@ 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. + 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. """ - left = a.join(b, on=on, how="anti").count_rows() - right = b.join(a, on=on, how="anti").count_rows() + 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/algorithms/_traversal.py b/daft_graph/algorithms/_traversal.py index 2a061ad..8ee0108 100644 --- a/daft_graph/algorithms/_traversal.py +++ b/daft_graph/algorithms/_traversal.py @@ -5,10 +5,15 @@ from collections import defaultdict import daft -from daft import DataFrame, Expression +from daft import DataFrame, Expression, col, lit from daft_graph.graph import Graph -from daft_graph.schema import DST, SRC +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: @@ -29,11 +34,120 @@ def prepare_edges(graph: Graph, *, edge_filter: Expression | None = None) -> Dat if edge_filter is not None: edges = edges.where(edge_filter) edges = edges.select(SRC, DST) - return graph._orient(edges) + 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.""" + """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) diff --git a/daft_graph/algorithms/all_paths.py b/daft_graph/algorithms/all_paths.py index 4d38248..54745ac 100644 --- a/daft_graph/algorithms/all_paths.py +++ b/daft_graph/algorithms/all_paths.py @@ -12,6 +12,7 @@ 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( @@ -39,12 +40,21 @@ def all_paths( 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 = prepare_edges(graph, edge_filter=edge_filter) + edges = collect_bounded(prepare_edges(graph, edge_filter=edge_filter)) frontier: list[list[int]] = [[source]] results: list[tuple[int, ...]] = [] diff --git a/daft_graph/algorithms/bfs.py b/daft_graph/algorithms/bfs.py index 638caeb..9a86ba8 100644 --- a/daft_graph/algorithms/bfs.py +++ b/daft_graph/algorithms/bfs.py @@ -1,23 +1,31 @@ """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. Both expand the BFS frontier one hop at a time, -using Daft to look up the frontier's out neighbors, and accept an optional -``edge_filter`` predicate over the edge columns. ``bfs`` breaks ties toward the -smaller predecessor id for determinism. +``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 collections import defaultdict from typing import Any import daft -from daft import DataFrame, Expression, col +from daft import DataFrame, Expression, col, lit from daft.functions import to_struct -from daft_graph.algorithms._traversal import neighbors, prepare_edges +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 @@ -42,32 +50,31 @@ def bfs( 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 = prepare_edges(graph, edge_filter=edge_filter) - pred: dict[int, int] = {} - visited: set[int] = {source} - frontier: list[int] = [source] - for _ in range(max_path_length): - if not frontier: - break - adjacency = neighbors(edges, frontier) - layer_pred: dict[int, int] = {} - for s in frontier: - for d in adjacency.get(s, []): - if d not in visited and (d not in layer_pred or s < layer_pred[d]): - layer_pred[d] = s - for d, s in layer_pred.items(): - visited.add(d) - pred[d] = s - if target in visited: - path = [target] - while path[-1] != source: - path.append(pred[path[-1]]) - return list(reversed(path)) - frontier = sorted(layer_pred) - return None + 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( @@ -86,38 +93,28 @@ def all_shortest_paths( 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 working set of partial paths exceeds ``max_paths`` + 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 = prepare_edges(graph, edge_filter=edge_filter) - frontier: list[list[int]] = [[source]] - 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) - found: list[tuple[int, ...]] = [] - 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: - found.append(tuple(extended)) - else: - nxt.append(extended) - if found: - return [list(path) for path in sorted(set(found))] - frontier = nxt - return [] + 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" @@ -151,38 +148,35 @@ def _edge_struct_lookup(edges: DataFrame, *, directed: bool) -> DataFrame: def _shortest_path_dag( - edges: DataFrame, sources: set[int], targets: set[int], max_path_length: int -) -> tuple[int | None, list[int], dict[int, int], dict[int, list[int]]]: - """Multi source BFS returning the shortest distance to any target. - - Returns ``(depth, reached, dist, preds)``: ``depth`` is the shortest distance - from any source to any target (None if none is reached within - ``max_path_length``), ``reached`` is the sorted targets at that distance, - ``dist`` maps each visited vertex to its distance, and ``preds`` maps each - visited non source vertex to all of its shortest path predecessors. + 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. """ - dist: dict[int, int] = {s: 0 for s in sources} - preds: dict[int, list[int]] = {} - frontier = sorted(sources) - for level in range(1, max_path_length + 1): - if not frontier: - break - adjacency = neighbors(edges, frontier) - newly: dict[int, list[int]] = defaultdict(list) - for u in frontier: - for v in adjacency.get(u, []): - if v not in dist: - newly[v].append(u) - if not newly: - break - for v, ps in newly.items(): - dist[v] = level - preds[v] = sorted(set(ps)) - reached = sorted(set(newly) & targets) - if reached: - return level, reached, dist, preds - frontier = sorted(newly) - return None, [], dist, preds + 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( @@ -259,20 +253,29 @@ def bfs_paths( 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 = {int(x) for x in graph.vertices.where(from_filter).select(ID).distinct().collect().to_pydict()[ID]} - targets = {int(x) for x in graph.vertices.where(to_filter).select(ID).distinct().collect().to_pydict()[ID]} - if not sources or not targets: + 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 & targets - if overlap: - result = daft.from_pydict({"__p0": sorted(overlap)}) + 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", @@ -284,12 +287,13 @@ def bfs_paths( ) return result.select("from", "to") - traversal = graph._orient(full_edges.select(SRC, DST)) - depth, reached, _dist, preds = _shortest_path_dag(traversal, sources, targets, max_path_length) - if depth is None: + 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": []}) - paths = _enumerate_paths(reached, sources, preds, max_paths) + 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): @@ -298,7 +302,7 @@ def bfs_paths( on=f"__p{i}", how="left", ) - elk = _edge_struct_lookup(full_edges, directed=graph._directed) + 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( diff --git a/daft_graph/algorithms/connected_components.py b/daft_graph/algorithms/connected_components.py index b46d32a..2b880f7 100644 --- a/daft_graph/algorithms/connected_components.py +++ b/daft_graph/algorithms/connected_components.py @@ -26,7 +26,7 @@ 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 iterate_to_fixed_point +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" @@ -79,8 +79,15 @@ def small_star(edges: DataFrame) -> DataFrame: def _star_step(edges: DataFrame) -> DataFrame: - """One alternating round: large star followed by small star.""" - return small_star(large_star(edges)) + """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: @@ -121,11 +128,14 @@ def _propagate_min_labels( After star contraction that set is typically small, but a very large contracted graph will materialize here. """ - adjacency = symmetrize(edges).collect() + adjacency = collect_bounded(symmetrize(edges)) def step(labels: DataFrame) -> DataFrame: neighbor_labels = labels.select(col(ID).alias(DST), col(COMPONENT).alias(_NBR)) - nbr_min = ( + # 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)) diff --git a/daft_graph/algorithms/hyper_anf.py b/daft_graph/algorithms/hyper_anf.py index 7e9a3c7..17ed73c 100644 --- a/daft_graph/algorithms/hyper_anf.py +++ b/daft_graph/algorithms/hyper_anf.py @@ -22,6 +22,7 @@ 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 @@ -104,8 +105,8 @@ def estimate(sketches: Series) -> list[float]: out.append(raw) return out - edges = graph._orient(graph.edges.select(SRC, DST)) - edges = edges.collect() + 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() @@ -119,15 +120,14 @@ def snapshot(state: DataFrame, hop: int) -> DataFrame: 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)) - current = ( - current.join(msg, on=ID, how="left") - .with_column(_HLL, merge_hll(col(_HLL), col(MSG))) - .select(ID, _HLL) - .collect() + # 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 out + return bound_partitions(out) diff --git a/daft_graph/algorithms/k_core.py b/daft_graph/algorithms/k_core.py index 97d602a..66b116d 100644 --- a/daft_graph/algorithms/k_core.py +++ b/daft_graph/algorithms/k_core.py @@ -14,6 +14,7 @@ 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 @@ -36,17 +37,32 @@ def _h_index(neighbor_cores: daft.Series) -> list[int]: return out -def k_core(graph: Graph, *, max_iters: int = 100) -> DataFrame: +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. - Returns a DataFrame with one row per vertex: ``id`` and ``core``. + 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) - undirected = symmetrize(canonicalize(graph.edges)) + # 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)) @@ -58,5 +74,7 @@ def k_core(graph: Graph, *, max_iters: int = 100) -> DataFrame: 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 index 9577025..eb272e3 100644 --- a/daft_graph/algorithms/label_propagation.py +++ b/daft_graph/algorithms/label_propagation.py @@ -16,6 +16,7 @@ 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 @@ -56,7 +57,9 @@ def label_propagation( 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)) - undirected = symmetrize(graph.edges) + # Materialized before the pregel closure so the symmetrize is not replanned + # every round. + undirected = collect_bounded(symmetrize(graph.edges)) final = pregel( undirected, base, diff --git a/daft_graph/algorithms/maximal_independent_set.py b/daft_graph/algorithms/maximal_independent_set.py index 472952c..24152bb 100644 --- a/daft_graph/algorithms/maximal_independent_set.py +++ b/daft_graph/algorithms/maximal_independent_set.py @@ -10,11 +10,14 @@ 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" @@ -29,17 +32,30 @@ 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 = symmetrize(canonicalize(graph.edges)).collect() + 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" @@ -56,7 +72,7 @@ def maximal_independent_set(graph: Graph, *, max_iters: int = 1000) -> DataFrame .select(col(DST).alias(ID)) .distinct() ) - status = ( + 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( @@ -68,7 +84,14 @@ def maximal_independent_set(graph: Graph, *, max_iters: int = 1000) -> DataFrame ), ) .select(ID, _STATUS) - .collect() ) + 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 index 44ea8fb..3695ecf 100644 --- a/daft_graph/algorithms/pagerank.py +++ b/daft_graph/algorithms/pagerank.py @@ -17,7 +17,7 @@ from daft.functions import when from daft_graph.graph import DirectedGraph -from daft_graph.iterate import iterate_to_fixed_point +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 @@ -81,6 +81,10 @@ def pagerank( 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() @@ -88,11 +92,11 @@ def pagerank( return vertices.with_column(RANK, lit(0.0)) pvec = _personalization(vertices, n, source_ids) - edges = graph.edges.select(SRC, DST).distinct().collect() + edges = collect_bounded(graph.edges.select(SRC, DST).distinct()) if edges.count_rows() == 0: return pvec.select(col(ID), col(_P).alias(RANK)) - outdeg = (edges.groupby(SRC).agg(col(DST).count().alias(_OD)).select(col(SRC).alias(ID), col(_OD))).collect() + 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)) diff --git a/daft_graph/algorithms/power_iteration_clustering.py b/daft_graph/algorithms/power_iteration_clustering.py index 4b43b12..0f3e1d6 100644 --- a/daft_graph/algorithms/power_iteration_clustering.py +++ b/daft_graph/algorithms/power_iteration_clustering.py @@ -15,6 +15,7 @@ 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 @@ -63,7 +64,12 @@ def power_iteration_clustering( kmeans_iters: Maximum 1D k-means iterations. Returns: - A DataFrame ``[id, cluster]`` over the vertices that have edges. + 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 @@ -72,7 +78,7 @@ def power_iteration_clustering( """ if k < 1: raise ValueError(f"k must be >= 1, got {k}") - undirected = symmetrize(canonicalize(graph.edges)).collect() + undirected = collect_bounded(symmetrize(canonicalize(graph.edges))) degree_rows = ( undirected.groupby(SRC) .agg(col(DST).count().alias(_DEG)) diff --git a/daft_graph/algorithms/random_walks.py b/daft_graph/algorithms/random_walks.py index aa20a5c..ee8e976 100644 --- a/daft_graph/algorithms/random_walks.py +++ b/daft_graph/algorithms/random_walks.py @@ -38,7 +38,7 @@ def random_walks( 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)) + 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]): diff --git a/daft_graph/algorithms/shortest_paths.py b/daft_graph/algorithms/shortest_paths.py index c59af39..094b37e 100644 --- a/daft_graph/algorithms/shortest_paths.py +++ b/daft_graph/algorithms/shortest_paths.py @@ -14,6 +14,7 @@ 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 @@ -27,6 +28,8 @@ def shortest_paths( 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. @@ -34,6 +37,8 @@ def shortest_paths( 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 @@ -48,7 +53,9 @@ def shortest_paths( 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)}) - edges = graph._orient(graph.edges.select(SRC, DST)) + # 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)) @@ -63,6 +70,8 @@ def shortest_paths( 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)) @@ -72,4 +81,5 @@ def shortest_paths( out = results[0] for r in results[1:]: out = out.union_all(r) - return out + # 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 index ea5d42e..e855308 100644 --- a/daft_graph/algorithms/strongly_connected_components.py +++ b/daft_graph/algorithms/strongly_connected_components.py @@ -23,6 +23,7 @@ _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 @@ -61,7 +62,7 @@ def _distributed_scc( 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 = graph.edges.select(SRC, DST).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: @@ -114,11 +115,15 @@ def _distributed_scc( ) confirmed = flags.where(col(VALUE) == lit(1)).select(col(ID), col(_COLOR).alias(COMPONENT)).collect() parts.append(confirmed) - active_v = active_v.join(confirmed.select(ID), on=ID, how="anti").collect() - active_e = ( - 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") - .collect() + # 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( @@ -130,6 +135,8 @@ def _distributed_scc( 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)) diff --git a/daft_graph/algorithms/svd_plus_plus.py b/daft_graph/algorithms/svd_plus_plus.py index d686cde..1327942 100644 --- a/daft_graph/algorithms/svd_plus_plus.py +++ b/daft_graph/algorithms/svd_plus_plus.py @@ -56,7 +56,7 @@ def svd_plus_plus( 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. + convention rather than an ``orient`` based behavior difference. Raises: ImportError: If the optional ``local`` extra is not installed. diff --git a/daft_graph/graph.py b/daft_graph/graph.py index e6246ad..758f1ed 100644 --- a/daft_graph/graph.py +++ b/daft_graph/graph.py @@ -2,9 +2,11 @@ ``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 traverse edges read -:meth:`Graph._traversal_edges`, which is the edge set as given for a directed -graph and the symmetrized edge set for an undirected one. +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 @@ -164,22 +166,41 @@ def _rebuild(self, *, vertices: DataFrame, edges: DataFrame) -> Self: @property @abstractmethod - def _directed(self) -> bool: - """Whether edge direction is meaningful for this graph.""" + def is_directed(self) -> bool: + """Whether edge direction is meaningful for this graph. - def _orient(self, edges: DataFrame) -> DataFrame: + True for :class:`DirectedGraph`, False for :class:`UndirectedGraph`. + """ + + def orient(self, edges: DataFrame) -> DataFrame: """Orient an edge frame per this graph's direction semantics. - Takes the frame rather than reading ``self.edges`` so callers can filter - or project first and still get the right orientation applied afterwards. - ``symmetrize`` preserves edge attribute columns, so an undirected - traversal keeps the same schema it was given. + 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._directed else symmetrize(edges) + 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.""" - return self._orient(self._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``. @@ -282,7 +303,7 @@ class DirectedGraph(Graph): __slots__ = () @property - def _directed(self) -> bool: + def is_directed(self) -> bool: """Directed traversal walks the edges as given.""" return True @@ -339,7 +360,7 @@ class UndirectedGraph(Graph): 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 + :meth:`traversal_edges`, which symmetrizes, rather than by duplicating the stored rows. Example: @@ -352,7 +373,7 @@ class UndirectedGraph(Graph): __slots__ = () @property - def _directed(self) -> bool: + def is_directed(self) -> bool: """Undirected traversal walks both orientations of every edge.""" return False diff --git a/daft_graph/iterate.py b/daft_graph/iterate.py index f5971b6..e7768ce 100644 --- a/daft_graph/iterate.py +++ b/daft_graph/iterate.py @@ -6,6 +6,18 @@ 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. """ @@ -22,6 +34,58 @@ 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, @@ -59,11 +123,11 @@ def iterate_to_fixed_point( ValueError: If ``max_iters`` or ``materialize_every`` is less than 1. """ if max_iters < 1: - raise ValueError("max_iters must be >= 1") + raise ValueError(f"max_iters must be >= 1, got {max_iters}") if materialize_every < 1: - raise ValueError("materialize_every must be >= 1") + raise ValueError(f"materialize_every must be >= 1, got {materialize_every}") - current = state.collect() + current = collect_bounded(state) rounds = 0 converged = False for i in range(max_iters): @@ -84,10 +148,61 @@ def iterate_to_fixed_point( 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 by collecting, optionally via a parquet round trip.""" + """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 df.collect() + return bounded path = os.path.join(checkpoint_dir, f"round_{round_index}") - df.write_parquet(path, write_mode="overwrite") + 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 index e011f68..81f3af4 100644 --- a/daft_graph/message_passing.py +++ b/daft_graph/message_passing.py @@ -18,7 +18,7 @@ from daft import DataFrame, Expression, col from daft_graph._compare import rows_equal -from daft_graph.iterate import ConvergedFn, iterate_to_fixed_point +from daft_graph.iterate import ConvergedFn, bound_partitions, iterate_to_fixed_point from daft_graph.schema import DST, ID, SRC VALUE = "value" @@ -32,14 +32,21 @@ def _default_agg(msg: Expression) -> Expression: def _triplets(edges: DataFrame, state: DataFrame) -> DataFrame: - """Join vertex state onto both endpoints, prefixing columns src_ and dst_.""" + """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]) - return edges.join(src_state, on=SRC, how="inner").join(dst_state, on=DST, how="inner") + joined = edges.join(src_state, on=SRC, how="inner").join(dst_state, on=DST, how="inner") + return bound_partitions(joined) def aggregate_messages( @@ -78,7 +85,9 @@ def aggregate_messages( combined = parts[0] for part in parts[1:]: combined = combined.union_all(part) - return combined.groupby(ID).agg(aggregator(col(MSG)).alias(MSG)) + # 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( @@ -120,7 +129,7 @@ def pregel( extra_cols = [c for c in init_state.column_names if c not in (ID, VALUE)] def step(state: DataFrame) -> DataFrame: - msgs = aggregate_messages(edges, state, to_src=to_src, to_dst=to_dst, agg=agg) + 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])) diff --git a/docs/usage.md b/docs/usage.md index 9b91e3b..bdd9600 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -394,12 +394,13 @@ DAFT_RUNNER=ray python my_job.py ```python import daft -daft.context.set_runner_ray() +daft.set_runner_ray() ``` ## Reading from Iceberg -Install the `iceberg` extra and read an edge table directly: +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 @@ -438,5 +439,7 @@ that mypy catches, which is the point of the split. 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. Iterative algorithms emit a - warning if they reach `max_iters` without converging. + 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/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/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_directed_graph.py b/tests/test_directed_graph.py index eed39d8..16a0e16 100644 --- a/tests/test_directed_graph.py +++ b/tests/test_directed_graph.py @@ -67,7 +67,7 @@ def test_degrees_is_in_plus_out() -> None: def test_traversal_edges_are_unchanged() -> None: g = DirectedGraph(_edges()) - assert g._traversal_edges().count_rows() == g.num_edges() + assert g.traversal_edges().count_rows() == g.num_edges() def test_reverse_flips_every_edge() -> None: diff --git a/tests/test_graph_subclass_preservation.py b/tests/test_graph_subclass_preservation.py index b62b743..907141b 100644 --- a/tests/test_graph_subclass_preservation.py +++ b/tests/test_graph_subclass_preservation.py @@ -54,7 +54,7 @@ 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() + assert rebuilt.traversal_edges().count_rows() == g.traversal_edges().count_rows() @pytest.mark.parametrize("flavor", _FLAVORS) diff --git a/tests/test_indexing.py b/tests/test_indexing.py index aa04131..296314c 100644 --- a/tests/test_indexing.py +++ b/tests/test_indexing.py @@ -124,4 +124,4 @@ def test_reindexed_undirected_graph_keeps_undirected_traversal() -> None: 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 + assert indexed.graph.traversal_edges().count_rows() == 2 diff --git a/tests/test_undirected_graph.py b/tests/test_undirected_graph.py index c9cad4a..c739692 100644 --- a/tests/test_undirected_graph.py +++ b/tests/test_undirected_graph.py @@ -44,13 +44,13 @@ def test_self_loop_contributes_two() -> None: def test_traversal_edges_are_symmetrized() -> None: g = UndirectedGraph(daft.from_pydict({SRC: [1], DST: [2]})) - t = g._traversal_edges().collect().to_pydict() + 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() + 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 @@ -59,7 +59,7 @@ def test_traversal_reaches_both_endpoints() -> None: 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 + assert g.traversal_edges().count_rows() == 8 def test_as_directed_returns_directed_graph() -> None: