From fb3c8927735123015e7b35004b3252d8b5494a5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 13 Mar 2023 15:32:33 -0500 Subject: [PATCH 01/24] Bump pypa/gh-action-pypi-publish from 1.6.4 to 1.7.1 (#55) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.6.4 to 1.7.1. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.6.4...v1.7.1) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index d3409c2..dd7956c 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.6.4 + uses: pypa/gh-action-pypi-publish@v1.7.1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 3b3278635ab477f5e9dccfe21cfc0bcbdeca0bd2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Mar 2023 11:55:52 -0500 Subject: [PATCH 02/24] Bump pypa/gh-action-pypi-publish from 1.7.1 to 1.8.1 (#56) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.7.1 to 1.8.1. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.7.1...v1.8.1) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index dd7956c..6516506 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.7.1 + uses: pypa/gh-action-pypi-publish@v1.8.1 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From f874b837e66cc185c725915ccbd581d37c3d3f4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Mar 2023 21:07:29 -0500 Subject: [PATCH 03/24] Bump pypa/gh-action-pypi-publish from 1.8.1 to 1.8.3 (#57) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.1 to 1.8.3. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.1...v1.8.3) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 6516506..580a935 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.1 + uses: pypa/gh-action-pypi-publish@v1.8.3 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 888e092f2e86820a132876ad55c8a3957388ec70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Apr 2023 17:01:45 -0500 Subject: [PATCH 04/24] Bump pypa/gh-action-pypi-publish from 1.8.3 to 1.8.5 (#59) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.3 to 1.8.5. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.3...v1.8.5) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 580a935..eda309c 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.3 + uses: pypa/gh-action-pypi-publish@v1.8.5 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From 09776b657ffbd2ffaa52d79f94a1d07d6012d831 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sat, 15 Apr 2023 13:50:57 -0500 Subject: [PATCH 05/24] Add a few more BFS-based algorithms (#51) * Add a few more BFS-based algorithms - Components - `is_connected` - `is_weakly_connected` - `node_connected_component` - Shortest Paths - `all_pairs_shortest_path_length` - `negative_edge_cycle` - `single_source_shortest_path_length` - `single_target_shortest_path_length` - Traversal - `bfs_layers` - `descendants_at_distance` * Fix `partition(evenly=True)` * Remove flake8-comprehensions (use ruff instead) --- .pre-commit-config.yaml | 33 ++++++-- README.md | 15 +++- graphblas_algorithms/algorithms/__init__.py | 2 + .../algorithms/centrality/eigenvector.py | 2 +- .../algorithms/centrality/katz.py | 2 +- .../algorithms/components/__init__.py | 2 + .../algorithms/components/connected.py | 31 +++++++ .../algorithms/components/weakly_connected.py | 77 +++++++++++++++++ graphblas_algorithms/algorithms/dag.py | 18 ++-- graphblas_algorithms/algorithms/dominating.py | 4 +- graphblas_algorithms/algorithms/exceptions.py | 4 + .../algorithms/link_analysis/hits_alg.py | 4 +- .../algorithms/link_analysis/pagerank_alg.py | 2 +- .../algorithms/shortest_paths/__init__.py | 1 + .../algorithms/shortest_paths/generic.py | 23 ++--- .../algorithms/shortest_paths/unweighted.py | 83 +++++++++++++++++++ .../algorithms/shortest_paths/weighted.py | 44 +++++++++- .../algorithms/traversal/__init__.py | 1 + .../traversal/breadth_first_search.py | 45 ++++++++++ graphblas_algorithms/classes/_utils.py | 10 +++ graphblas_algorithms/classes/digraph.py | 1 + graphblas_algorithms/classes/graph.py | 1 + graphblas_algorithms/classes/nodemap.py | 4 + graphblas_algorithms/classes/nodeset.py | 10 ++- graphblas_algorithms/interface.py | 15 ++++ graphblas_algorithms/nxapi/__init__.py | 4 + graphblas_algorithms/nxapi/_utils.py | 2 +- graphblas_algorithms/nxapi/cluster.py | 13 --- .../nxapi/components/__init__.py | 2 + .../nxapi/components/connected.py | 27 ++++++ .../nxapi/components/weakly_connected.py | 19 +++++ .../nxapi/shortest_paths/__init__.py | 1 + .../nxapi/shortest_paths/unweighted.py | 45 ++++++++++ .../nxapi/shortest_paths/weighted.py | 8 ++ .../nxapi/traversal/__init__.py | 1 + .../nxapi/traversal/breadth_first_search.py | 27 ++++++ graphblas_algorithms/tests/test_core.py | 4 +- graphblas_algorithms/tests/test_match_nx.py | 10 ++- pyproject.toml | 8 +- scripts/scipy_impl.py | 2 +- 40 files changed, 551 insertions(+), 56 deletions(-) create mode 100644 graphblas_algorithms/algorithms/components/__init__.py create mode 100644 graphblas_algorithms/algorithms/components/connected.py create mode 100644 graphblas_algorithms/algorithms/components/weakly_connected.py create mode 100644 graphblas_algorithms/algorithms/shortest_paths/unweighted.py create mode 100644 graphblas_algorithms/algorithms/traversal/__init__.py create mode 100644 graphblas_algorithms/algorithms/traversal/breadth_first_search.py create mode 100644 graphblas_algorithms/nxapi/components/__init__.py create mode 100644 graphblas_algorithms/nxapi/components/connected.py create mode 100644 graphblas_algorithms/nxapi/components/weakly_connected.py create mode 100644 graphblas_algorithms/nxapi/shortest_paths/unweighted.py create mode 100644 graphblas_algorithms/nxapi/traversal/__init__.py create mode 100644 graphblas_algorithms/nxapi/traversal/breadth_first_search.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8bf0101..38b1dfa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,6 +4,11 @@ # To run: `pre-commit run --all-files` # To update: `pre-commit autoupdate` # - &flake8_dependencies below needs updated manually +ci: + # See: https://pre-commit.ci/#configuration + autofix_prs: false + autoupdate_schedule: monthly + skip: [no-commit-to-branch] fail_fast: true default_language_version: python: python3 @@ -20,12 +25,13 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.12.1 + rev: v0.12.2 hooks: - id: validate-pyproject name: Validate pyproject.toml + # I don't yet trust ruff to do what autoflake does - repo: https://github.com/myint/autoflake - rev: v2.0.1 + rev: v2.0.2 hooks: - id: autoflake args: [--in-place] @@ -44,10 +50,15 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black # - id: black-jupyter + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.261 + hooks: + - id: ruff + args: [--fix-only, --show-fixes] - repo: https://github.com/PyCQA/flake8 rev: 6.0.0 hooks: @@ -55,25 +66,31 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.0.0 - - flake8-comprehensions==3.10.1 - - flake8-bugbear==23.2.13 - - flake8-simplify==0.19.3 + - flake8-bugbear==23.3.23 + - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa rev: v1.4.0 hooks: - id: yesqa additional_dependencies: *flake8_dependencies - repo: https://github.com/codespell-project/codespell - rev: v2.2.2 + rev: v2.2.4 hooks: - id: codespell types_or: [python, rst, markdown] additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.253 + rev: v0.0.261 hooks: - id: ruff + # `pyroma` may help keep our package standards up to date if best practices change. + # This is probably a "low value" check though and safe to remove if we want faster pre-commit. + - repo: https://github.com/regebro/pyroma + rev: "4.2" + hooks: + - id: pyroma + args: [-n, "10", .] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: diff --git a/README.md b/README.md index a4dfd50..0136abe 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ dispatch pattern shown above. - Community - inter_community_edges - intra_community_edges +- Components + - is_connected + - is_weakly_connected + - node_connected_component - Core - k_truss - Cuts @@ -147,11 +151,15 @@ dispatch pattern shown above. - is_k_regular - is_regular - Shortest Paths + - all_pairs_bellman_ford_path_length + - all_pairs_shortest_path_length - floyd_warshall - floyd_warshall_predecessor_and_distance - - single_source_bellman_ford_path_length - - all_pairs_bellman_ford_path_length - has_path + - negative_edge_cycle + - single_source_bellman_ford_path_length + - single_source_shortest_path_length + - single_target_shortest_path_length - Simple Paths - is_simple_path - S Metric @@ -162,5 +170,8 @@ dispatch pattern shown above. - is_tournament - score_sequence - tournament_matrix +- Traversal + - bfs_layers + - descendants_at_distance - Triads - is_triad diff --git a/graphblas_algorithms/algorithms/__init__.py b/graphblas_algorithms/algorithms/__init__.py index 0e4c9ee..303535a 100644 --- a/graphblas_algorithms/algorithms/__init__.py +++ b/graphblas_algorithms/algorithms/__init__.py @@ -3,6 +3,7 @@ from .centrality import * from .cluster import * from .community import * +from .components import * from .core import * from .cuts import * from .dag import * @@ -16,4 +17,5 @@ from .smetric import * from .structuralholes import * from .tournament import * +from .traversal import * from .triads import * diff --git a/graphblas_algorithms/algorithms/centrality/eigenvector.py b/graphblas_algorithms/algorithms/centrality/eigenvector.py index 5a2ee78..5172f61 100644 --- a/graphblas_algorithms/algorithms/centrality/eigenvector.py +++ b/graphblas_algorithms/algorithms/centrality/eigenvector.py @@ -27,7 +27,7 @@ def eigenvector_centrality(G, max_iter=100, tol=1.0e-6, nstart=None, name="eigen # Power iteration: make up to max_iter iterations A = G._A xprev = Vector(float, N, name="x_prev") - for _ in range(max_iter): + for _i in range(max_iter): xprev << x x += x @ A normalize(x, "L2") diff --git a/graphblas_algorithms/algorithms/centrality/katz.py b/graphblas_algorithms/algorithms/centrality/katz.py index 3d21331..78de982 100644 --- a/graphblas_algorithms/algorithms/centrality/katz.py +++ b/graphblas_algorithms/algorithms/centrality/katz.py @@ -44,7 +44,7 @@ def katz_centrality( # Power iteration: make up to max_iter iterations xprev = Vector(float, N, name="x_prev") - for _ in range(max_iter): + for _i in range(max_iter): xprev, x = x, xprev # x << alpha * semiring(xprev @ A) + beta x << semiring(xprev @ A) diff --git a/graphblas_algorithms/algorithms/components/__init__.py b/graphblas_algorithms/algorithms/components/__init__.py new file mode 100644 index 0000000..bb0aea6 --- /dev/null +++ b/graphblas_algorithms/algorithms/components/__init__.py @@ -0,0 +1,2 @@ +from .connected import * +from .weakly_connected import * diff --git a/graphblas_algorithms/algorithms/components/connected.py b/graphblas_algorithms/algorithms/components/connected.py new file mode 100644 index 0000000..37c0fc9 --- /dev/null +++ b/graphblas_algorithms/algorithms/components/connected.py @@ -0,0 +1,31 @@ +from graphblas import Vector, replace +from graphblas.semiring import any_pair + +from graphblas_algorithms.algorithms.exceptions import PointlessConcept + + +def is_connected(G): + if len(G) == 0: + raise PointlessConcept("Connectivity is undefined for the null graph.") + return _plain_bfs(G, next(iter(G))).nvals == len(G) + + +def node_connected_component(G, n): + return _plain_bfs(G, n) + + +def _plain_bfs(G, source): + index = G._key_to_id[source] + A = G.get_property("offdiag") + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q = Vector(bool, n, name="q") + v[index] = True + q[index] = True + any_pair_bool = any_pair[bool] + for _i in range(1, n): + q(~v.S, replace) << any_pair_bool(q @ A) + if q.nvals == 0: + break + v(q.S) << True + return v diff --git a/graphblas_algorithms/algorithms/components/weakly_connected.py b/graphblas_algorithms/algorithms/components/weakly_connected.py new file mode 100644 index 0000000..eb3dc75 --- /dev/null +++ b/graphblas_algorithms/algorithms/components/weakly_connected.py @@ -0,0 +1,77 @@ +from graphblas import Vector, binary, replace +from graphblas.semiring import any_pair + +from graphblas_algorithms.algorithms.exceptions import PointlessConcept + + +def is_weakly_connected(G): + if len(G) == 0: + raise PointlessConcept("Connectivity is undefined for the null graph.") + return _plain_bfs(G, next(iter(G))).nvals == len(G) + + +# TODO: benchmark this and the version commented out below +def _plain_bfs(G, source): + # Bi-directional BFS w/o symmetrizing the adjacency matrix + index = G._key_to_id[source] + A = G.get_property("offdiag") + # XXX: should we use `AT` if available? + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q_out = Vector(bool, n, name="q_out") + q_in = Vector(bool, n, name="q_in") + v[index] = True + q_in[index] = True + any_pair_bool = any_pair[bool] + is_out_empty = True + is_in_empty = False + for _i in range(1, n): + # Traverse out-edges from the most recent `q_in` and `q_out` + if is_out_empty: + q_out(~v.S) << any_pair_bool(q_in @ A) + else: + q_out << binary.any(q_out | q_in) + q_out(~v.S, replace) << any_pair_bool(q_out @ A) + is_out_empty = q_out.nvals == 0 + if not is_out_empty: + v(q_out.S) << True + elif is_in_empty: + break + # Traverse in-edges from the most recent `q_in` and `q_out` + if is_in_empty: + q_in(~v.S) << any_pair_bool(A @ q_out) + else: + q_in << binary.any(q_out | q_in) + q_in(~v.S, replace) << any_pair_bool(A @ q_in) + is_in_empty = q_in.nvals == 0 + if not is_in_empty: + v(q_in.S) << True + elif is_out_empty: + break + return v + + +""" +def _plain_bfs(G, source): + # Bi-directional BFS w/o symmetrizing the adjacency matrix + index = G._key_to_id[source] + A = G.get_property("offdiag") + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q = Vector(bool, n, name="q") + q2 = Vector(bool, n, name="q_2") + v[index] = True + q[index] = True + any_pair_bool = any_pair[bool] + for _i in range(1, n): + q2(~v.S, replace) << any_pair_bool(q @ A) + v(q2.S) << True + q(~v.S, replace) << any_pair_bool(A @ q) + if q.nvals == 0: + if q2.nvals == 0: + break + q, q2 = q2, q + elif q2.nvals != 0: + q << binary.any(q | q2) + return v +""" diff --git a/graphblas_algorithms/algorithms/dag.py b/graphblas_algorithms/algorithms/dag.py index 3cceeef..c75dae1 100644 --- a/graphblas_algorithms/algorithms/dag.py +++ b/graphblas_algorithms/algorithms/dag.py @@ -1,5 +1,5 @@ from graphblas import Vector, replace -from graphblas.semiring import lor_pair +from graphblas.semiring import any_pair __all__ = ["descendants", "ancestors"] @@ -10,10 +10,12 @@ def descendants(G, source): raise KeyError(f"The node {source} is not in the graph") index = G._key_to_id[source] A = G.get_property("offdiag") - q = Vector.from_coo(index, True, size=A.nrows, name="q") + q = Vector(bool, size=A.nrows, name="q") + q[index] = True rv = q.dup(name="descendants") - for _ in range(A.nrows): - q(~rv.S, replace) << lor_pair(q @ A) + any_pair_bool = any_pair[bool] + for _i in range(A.nrows): + q(~rv.S, replace) << any_pair_bool(q @ A) if q.nvals == 0: break rv(q.S) << True @@ -26,10 +28,12 @@ def ancestors(G, source): raise KeyError(f"The node {source} is not in the graph") index = G._key_to_id[source] A = G.get_property("offdiag") - q = Vector.from_coo(index, True, size=A.nrows, name="q") + q = Vector(bool, size=A.nrows, name="q") + q[index] = True rv = q.dup(name="descendants") - for _ in range(A.nrows): - q(~rv.S, replace) << lor_pair(A @ q) + any_pair_bool = any_pair[bool] + for _i in range(A.nrows): + q(~rv.S, replace) << any_pair_bool(A @ q) if q.nvals == 0: break rv(q.S) << True diff --git a/graphblas_algorithms/algorithms/dominating.py b/graphblas_algorithms/algorithms/dominating.py index 60c3426..2894bd8 100644 --- a/graphblas_algorithms/algorithms/dominating.py +++ b/graphblas_algorithms/algorithms/dominating.py @@ -1,8 +1,8 @@ -from graphblas.semiring import lor_pair +from graphblas.semiring import any_pair __all__ = ["is_dominating_set"] def is_dominating_set(G, nbunch): - nbrs = lor_pair(nbunch @ G._A).new(mask=~nbunch.S) # A or A.T? + nbrs = any_pair[bool](nbunch @ G._A).new(mask=~nbunch.S) # A or A.T? return nbrs.size - nbunch.nvals - nbrs.nvals == 0 diff --git a/graphblas_algorithms/algorithms/exceptions.py b/graphblas_algorithms/algorithms/exceptions.py index f4ef352..7c911c9 100644 --- a/graphblas_algorithms/algorithms/exceptions.py +++ b/graphblas_algorithms/algorithms/exceptions.py @@ -14,5 +14,9 @@ class PointlessConcept(GraphBlasAlgorithmException): pass +class NoPath(GraphBlasAlgorithmException): + pass + + class Unbounded(GraphBlasAlgorithmException): pass diff --git a/graphblas_algorithms/algorithms/link_analysis/hits_alg.py b/graphblas_algorithms/algorithms/link_analysis/hits_alg.py index aadd77e..515806e 100644 --- a/graphblas_algorithms/algorithms/link_analysis/hits_alg.py +++ b/graphblas_algorithms/algorithms/link_analysis/hits_alg.py @@ -30,7 +30,7 @@ def hits(G, max_iter=100, tol=1.0e-8, nstart=None, normalized=True, *, with_auth a, h = h, a ATA = (A.T @ A).new(name="ATA") # Authority matrix aprev = Vector(float, N, name="a_prev") - for _ in range(max_iter): + for _i in range(max_iter): aprev, a = a, aprev a << ATA @ aprev normalize(a, "Linf") @@ -41,7 +41,7 @@ def hits(G, max_iter=100, tol=1.0e-8, nstart=None, normalized=True, *, with_auth raise ConvergenceFailure(max_iter) else: hprev = Vector(float, N, name="h_prev") - for _ in range(max_iter): + for _i in range(max_iter): hprev, h = h, hprev a << hprev @ A h << A @ a diff --git a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py index 1623819..9b28fa3 100644 --- a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py +++ b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py @@ -79,7 +79,7 @@ def pagerank( # Power iteration: make up to max_iter iterations xprev = Vector(float, N, name="x_prev") w = Vector(float, N, name="w") - for _ in range(max_iter): + for _i in range(max_iter): xprev, x = x, xprev # x << alpha * ((xprev * S) @ A + "dangling_weights") + (1 - alpha) * p diff --git a/graphblas_algorithms/algorithms/shortest_paths/__init__.py b/graphblas_algorithms/algorithms/shortest_paths/__init__.py index 9fc57fb..781db9d 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/__init__.py +++ b/graphblas_algorithms/algorithms/shortest_paths/__init__.py @@ -1,3 +1,4 @@ from .dense import * from .generic import * +from .unweighted import * from .weighted import * diff --git a/graphblas_algorithms/algorithms/shortest_paths/generic.py b/graphblas_algorithms/algorithms/shortest_paths/generic.py index f91c9cf..b92f7d6 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/generic.py +++ b/graphblas_algorithms/algorithms/shortest_paths/generic.py @@ -1,5 +1,5 @@ from graphblas import Vector, replace -from graphblas.semiring import lor_pair +from graphblas.semiring import any_pair __all__ = ["has_path"] @@ -11,23 +11,26 @@ def has_path(G, source, target): if src == dst: return True A = G.get_property("offdiag") - q_src = Vector.from_coo(src, True, size=A.nrows, name="q_src") + q_src = Vector(bool, size=A.nrows, name="q_src") + q_src[src] = True seen_src = q_src.dup(name="seen_src") - q_dst = Vector.from_coo(dst, True, size=A.nrows, name="q_dst") - seen_dst = q_dst.dup(name="seen_dst") - for _ in range(A.nrows // 2): - q_src(~seen_src.S, replace) << lor_pair(q_src @ A) + q_dst = Vector(bool, size=A.nrows, name="q_dst") + q_dst[dst] = True + seen_dst = q_dst.dup(name="seen_dst", clear=True) + any_pair_bool = any_pair[bool] + for _i in range(A.nrows // 2): + q_src(~seen_src.S, replace) << any_pair_bool(q_src @ A) if q_src.nvals == 0: return False - if lor_pair(q_src @ q_dst): + if any_pair_bool(q_src @ q_dst): return True - q_dst(~seen_dst.S, replace) << lor_pair(A @ q_dst) + seen_dst(q_dst.S) << True + q_dst(~seen_dst.S, replace) << any_pair_bool(A @ q_dst) if q_dst.nvals == 0: return False - if lor_pair(q_src @ q_dst): + if any_pair_bool(q_src @ q_dst): return True seen_src(q_src.S) << True - seen_dst(q_dst.S) << True return False diff --git a/graphblas_algorithms/algorithms/shortest_paths/unweighted.py b/graphblas_algorithms/algorithms/shortest_paths/unweighted.py new file mode 100644 index 0000000..3c8243f --- /dev/null +++ b/graphblas_algorithms/algorithms/shortest_paths/unweighted.py @@ -0,0 +1,83 @@ +import numpy as np +from graphblas import Matrix, Vector, replace, unary +from graphblas.semiring import any_pair + +__all__ = [ + "single_source_shortest_path_length", + "single_target_shortest_path_length", + "all_pairs_shortest_path_length", +] + + +def single_source_shortest_path_length(G, source, cutoff=None): + return _bfs_level(G, source, cutoff) + + +def single_target_shortest_path_length(G, target, cutoff=None): + return _bfs_level(G, target, cutoff, transpose=True) + + +def all_pairs_shortest_path_length(G, cutoff=None, *, nodes=None, expand_output=False): + D = _bfs_levels(G, nodes, cutoff) + if nodes is not None and expand_output and D.ncols != D.nrows: + ids = G.list_to_ids(nodes) + rv = Matrix(D.dtype, D.ncols, D.ncols, name=D.name) + rv[ids, :] = D + return rv + return D + + +def _bfs_level(G, source, cutoff, *, transpose=False): + index = G._key_to_id[source] + A = G.get_property("offdiag") + if transpose and G.is_directed(): + A = A.T # TODO: should we use "AT" instead? + n = A.nrows + v = Vector(int, n, name="bfs_unweighted") + q = Vector(bool, n, name="q") + v[index] = 0 + q[index] = True + any_pair_bool = any_pair[bool] + if cutoff is None or cutoff >= n: + cutoff = n # Everything + else: + cutoff += 1 # Inclusive + for i in range(1, cutoff): + q(~v.S, replace) << any_pair_bool(q @ A) + if q.nvals == 0: + break + v(q.S) << i + return v + + +def _bfs_levels(G, nodes, cutoff): + A = G.get_property("offdiag") + n = A.nrows + if nodes is None: + # TODO: `D = Vector.from_scalar(0, n, dtype).diag()` + D = Vector(int, n, name="bfs_unweighted_vector") + D << 0 + D = D.diag(name="bfs_unweighted") + else: + ids = G.list_to_ids(nodes) + D = Matrix.from_coo( + np.arange(len(ids), dtype=np.uint64), + ids, + 0, + int, + nrows=len(ids), + ncols=n, + name="bfs_unweighted", + ) + Q = unary.one[bool](D).new(name="Q") + any_pair_bool = any_pair[bool] + if cutoff is None or cutoff >= n: + cutoff = n # Everything + else: + cutoff += 1 # Inclusive + for i in range(1, cutoff): + Q(~D.S, replace) << any_pair_bool(Q @ A) + if Q.nvals == 0: + break + D(Q.S) << i + return D diff --git a/graphblas_algorithms/algorithms/shortest_paths/weighted.py b/graphblas_algorithms/algorithms/shortest_paths/weighted.py index a5cec41..8e6efef 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/weighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/weighted.py @@ -7,6 +7,7 @@ __all__ = [ "single_source_bellman_ford_path_length", "bellman_ford_path_lengths", + "negative_edge_cycle", ] @@ -196,8 +197,7 @@ def _bfs_levels(G, nodes=None, *, dtype=int): ncols=n, name="bfs_levels", ) - Q = Matrix(bool, D.nrows, D.ncols, name="Q") - Q << unary.one[bool](D) + Q = unary.one[bool](D).new(name="Q") any_pair_bool = any_pair[bool] for i in range(1, n): Q(~D.S, replace) << any_pair_bool(Q @ A) @@ -205,3 +205,43 @@ def _bfs_levels(G, nodes=None, *, dtype=int): break D(Q.S) << i return D + + +def negative_edge_cycle(G): + # TODO: use a heuristic to try to stop early + if G.is_directed(): + deg = "total_degrees-" + else: + deg = "degrees-" + A, degrees, has_negative_diagonal, has_negative_edges = G.get_properties( + f"offdiag {deg} has_negative_diagonal has_negative_edges-" + ) + if has_negative_diagonal: + return True + if not has_negative_edges: + return False + if A.dtype == bool: + # Should we upcast e.g. INT8 to INT64 as well? + dtype = int + else: + dtype = A.dtype + n = A.nrows + # Begin from every node that has edges + d = Vector(dtype, n, name="negative_edge_cycle") + d(degrees.S) << 0 + cur = d.dup(name="cur") + mask = Vector(bool, n, name="mask") + one = unary.one[bool] + for _i in range(n - 1): + cur << min_plus(cur @ A) + mask << one(cur) + mask(binary.second) << binary.lt(cur & d) + cur(mask.V, replace) << cur + if cur.nvals == 0: + return False + d(cur.S) << cur + cur << min_plus(cur @ A) + mask << binary.lt(cur & d) + if mask.reduce(monoid.lor): + return True + return False diff --git a/graphblas_algorithms/algorithms/traversal/__init__.py b/graphblas_algorithms/algorithms/traversal/__init__.py new file mode 100644 index 0000000..7811162 --- /dev/null +++ b/graphblas_algorithms/algorithms/traversal/__init__.py @@ -0,0 +1 @@ +from .breadth_first_search import * diff --git a/graphblas_algorithms/algorithms/traversal/breadth_first_search.py b/graphblas_algorithms/algorithms/traversal/breadth_first_search.py new file mode 100644 index 0000000..e9be539 --- /dev/null +++ b/graphblas_algorithms/algorithms/traversal/breadth_first_search.py @@ -0,0 +1,45 @@ +from graphblas import Vector, replace +from graphblas.semiring import any_pair + +__all__ = [ + "bfs_layers", + "descendants_at_distance", +] + + +def bfs_layers(G, sources): + if sources in G: + sources = [sources] + ids = G.list_to_ids(sources) + if not ids: + return + A = G.get_property("offdiag") + n = A.nrows + v = Vector(bool, size=n, name="bfs_layers") + q = Vector.from_coo(ids, True, size=n, name="q") + any_pair_bool = any_pair[bool] + yield q.dup(name="bfs_layer_0") + for i in range(1, n): + v(q.S) << True + q(~v.S, replace) << any_pair_bool(q @ A) + if q.nvals == 0: + return + yield q.dup(name=f"bfs_layer_{i}") + + +def descendants_at_distance(G, source, distance): + index = G._key_to_id[source] + A = G.get_property("offdiag") + n = A.nrows + q = Vector(bool, size=n, name=f"descendants_at_distance_{distance}") + q[index] = True + if distance == 0: + return q + v = Vector(bool, size=n, name="bfs_seen") + any_pair_bool = any_pair[bool] + for _i in range(1, distance + 1): + v(q.S) << True + q(~v.S, replace) << any_pair_bool(q @ A) + if q.nvals == 0: + break + return q diff --git a/graphblas_algorithms/classes/_utils.py b/graphblas_algorithms/classes/_utils.py index c52b2be..92febc5 100644 --- a/graphblas_algorithms/classes/_utils.py +++ b/graphblas_algorithms/classes/_utils.py @@ -119,6 +119,16 @@ def vector_to_dict(self, v, *, mask=None, fill_value=None): return {id_to_key[index]: value for index, value in zip(*v.to_coo(sort=False))} +def vector_to_list(self, v, *, values_are_keys=False): + id_to_key = self.id_to_key + return [ + id_to_key[idx] + for idx in v.to_coo(indices=not values_are_keys, values=values_are_keys, sort=True)[ + bool(values_are_keys) + ].tolist() + ] + + def vector_to_nodemap(self, v, *, mask=None, fill_value=None, values_are_keys=False): from .nodemap import NodeMap diff --git a/graphblas_algorithms/classes/digraph.py b/graphblas_algorithms/classes/digraph.py index 0bc1ec7..83e7356 100644 --- a/graphblas_algorithms/classes/digraph.py +++ b/graphblas_algorithms/classes/digraph.py @@ -548,6 +548,7 @@ def __init__(self, incoming_graph_data=None, *, key_to_id=None, **attr): set_to_vector = _utils.set_to_vector to_networkx = _utils.to_networkx vector_to_dict = _utils.vector_to_dict + vector_to_list = _utils.vector_to_list vector_to_nodemap = _utils.vector_to_nodemap vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set diff --git a/graphblas_algorithms/classes/graph.py b/graphblas_algorithms/classes/graph.py index 718264f..03a2893 100644 --- a/graphblas_algorithms/classes/graph.py +++ b/graphblas_algorithms/classes/graph.py @@ -396,6 +396,7 @@ def __init__(self, incoming_graph_data=None, *, key_to_id=None, **attr): set_to_vector = _utils.set_to_vector to_networkx = _utils.to_networkx vector_to_dict = _utils.vector_to_dict + vector_to_list = _utils.vector_to_list vector_to_nodemap = _utils.vector_to_nodemap vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set diff --git a/graphblas_algorithms/classes/nodemap.py b/graphblas_algorithms/classes/nodemap.py index 63b7a5e..2a32502 100644 --- a/graphblas_algorithms/classes/nodemap.py +++ b/graphblas_algorithms/classes/nodemap.py @@ -28,6 +28,7 @@ def __init__(self, v, *, fill_value=None, values_are_keys=False, key_to_id=None) set_to_vector = _utils.set_to_vector # to_networkx = _utils.to_networkx vector_to_dict = _utils.vector_to_dict + vector_to_list = _utils.vector_to_list vector_to_nodemap = _utils.vector_to_nodemap vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set @@ -95,6 +96,7 @@ def get(self, key, default=None): return default if self._values_are_keys: return self.id_to_key[rv] + return rv # items # keys @@ -220,6 +222,7 @@ def _get_rows(self): set_to_vector = _utils.set_to_vector # to_networkx = _utils.to_networkx vector_to_dict = _utils.vector_to_dict + vector_to_list = _utils.vector_to_list vector_to_nodemap = _utils.vector_to_nodemap vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set @@ -335,6 +338,7 @@ def _get_rows(self): set_to_vector = _utils.set_to_vector # to_networkx = _utils.to_networkx vector_to_dict = _utils.vector_to_dict + vector_to_list = _utils.vector_to_list vector_to_nodemap = _utils.vector_to_nodemap vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set diff --git a/graphblas_algorithms/classes/nodeset.py b/graphblas_algorithms/classes/nodeset.py index 1713a7d..b79895e 100644 --- a/graphblas_algorithms/classes/nodeset.py +++ b/graphblas_algorithms/classes/nodeset.py @@ -1,6 +1,6 @@ from collections.abc import MutableSet -from graphblas.semiring import lor_pair, plus_pair +from graphblas.semiring import any_pair, plus_pair from . import _utils @@ -26,6 +26,7 @@ def __init__(self, v, *, key_to_id=None): set_to_vector = _utils.set_to_vector # to_networkx = _utils.to_networkx vector_to_dict = _utils.vector_to_dict + vector_to_list = _utils.vector_to_list vector_to_nodemap = _utils.vector_to_nodemap vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set @@ -76,7 +77,7 @@ def clear(self): def isdisjoin(self, other): if isinstance(other, NodeSet): - return not lor_pair(self.vector @ other.vector) + return not any_pair[bool](self.vector @ other.vector) return super().isdisjoint(other) def pop(self): @@ -104,3 +105,8 @@ def _from_iterable(self, it): # Add more set methods (as needed) def union(self, *args): return set(self).union(*args) # TODO: can we make this better? + + def copy(self): + rv = type(self)(self.vector.dup(), key_to_id=self._key_to_id) + rv._id_to_key = self._id_to_key + return rv diff --git a/graphblas_algorithms/interface.py b/graphblas_algorithms/interface.py index 1a142c3..d8430e5 100644 --- a/graphblas_algorithms/interface.py +++ b/graphblas_algorithms/interface.py @@ -25,6 +25,10 @@ class Dispatcher: # Community inter_community_edges = nxapi.community.quality.inter_community_edges intra_community_edges = nxapi.community.quality.intra_community_edges + # Components + is_connected = nxapi.components.connected.is_connected + node_connected_component = nxapi.components.connected.node_connected_component + is_weakly_connected = nxapi.components.weakly_connected.is_weakly_connected # Core k_truss = nxapi.core.k_truss # Cuts @@ -60,9 +64,17 @@ class Dispatcher: nxapi.shortest_paths.dense.floyd_warshall_predecessor_and_distance ) has_path = nxapi.shortest_paths.generic.has_path + single_source_shortest_path_length = ( + nxapi.shortest_paths.unweighted.single_source_shortest_path_length + ) + single_target_shortest_path_length = ( + nxapi.shortest_paths.unweighted.single_target_shortest_path_length + ) + all_pairs_shortest_path_length = nxapi.shortest_paths.unweighted.all_pairs_shortest_path_length all_pairs_bellman_ford_path_length = ( nxapi.shortest_paths.weighted.all_pairs_bellman_ford_path_length ) + negative_edge_cycle = nxapi.shortest_paths.weighted.negative_edge_cycle single_source_bellman_ford_path_length = ( nxapi.shortest_paths.weighted.single_source_bellman_ford_path_length ) @@ -76,6 +88,9 @@ class Dispatcher: is_tournament = nxapi.tournament.is_tournament score_sequence = nxapi.tournament.score_sequence tournament_matrix = nxapi.tournament.tournament_matrix + # Traversal + bfs_layers = nxapi.traversal.breadth_first_search.bfs_layers + descendants_at_distance = nxapi.traversal.breadth_first_search.descendants_at_distance # Triads is_triad = nxapi.triads.is_triad diff --git a/graphblas_algorithms/nxapi/__init__.py b/graphblas_algorithms/nxapi/__init__.py index 75c7aa7..fe5ba87 100644 --- a/graphblas_algorithms/nxapi/__init__.py +++ b/graphblas_algorithms/nxapi/__init__.py @@ -2,6 +2,7 @@ from .centrality import * from .cluster import * from .community import * +from .components import * from .core import * from .cuts import * from .dag import * @@ -14,11 +15,14 @@ from .simple_paths import * from .smetric import * from .structuralholes import * +from .traversal import * from .triads import * from . import centrality from . import cluster from . import community +from . import components from . import link_analysis from . import shortest_paths from . import tournament +from . import traversal diff --git a/graphblas_algorithms/nxapi/_utils.py b/graphblas_algorithms/nxapi/_utils.py index db309a4..0bb9617 100644 --- a/graphblas_algorithms/nxapi/_utils.py +++ b/graphblas_algorithms/nxapi/_utils.py @@ -100,7 +100,7 @@ def partition(chunksize, L, *, evenly=True): yield from L return if evenly: - k = ceil(L / chunksize) + k = ceil(len(L) / chunksize) if k * chunksize != N: yield from split_evenly(k, L) return diff --git a/graphblas_algorithms/nxapi/cluster.py b/graphblas_algorithms/nxapi/cluster.py index 425fd09..8e61f9b 100644 --- a/graphblas_algorithms/nxapi/cluster.py +++ b/graphblas_algorithms/nxapi/cluster.py @@ -78,19 +78,6 @@ def average_clustering(G, nodes=None, weight=None, count_zeros=True): return func(G, weighted=weighted, count_zeros=count_zeros, mask=mask) -def _split(L, k): - """Split a list into approximately-equal parts""" - N = len(L) - start = 0 - for i in range(1, k): - stop = (N * i + k - 1) // k - if stop != start: - yield L[start:stop] - start = stop - if stop != N: - yield L[stop:] - - # TODO: should this move into algorithms? def _square_clustering_split(G, node_ids=None, *, chunksize): if node_ids is None: diff --git a/graphblas_algorithms/nxapi/components/__init__.py b/graphblas_algorithms/nxapi/components/__init__.py new file mode 100644 index 0000000..bb0aea6 --- /dev/null +++ b/graphblas_algorithms/nxapi/components/__init__.py @@ -0,0 +1,2 @@ +from .connected import * +from .weakly_connected import * diff --git a/graphblas_algorithms/nxapi/components/connected.py b/graphblas_algorithms/nxapi/components/connected.py new file mode 100644 index 0000000..d55a430 --- /dev/null +++ b/graphblas_algorithms/nxapi/components/connected.py @@ -0,0 +1,27 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.algorithms.exceptions import PointlessConcept +from graphblas_algorithms.classes.graph import to_undirected_graph +from graphblas_algorithms.utils import not_implemented_for + +from ..exception import NetworkXPointlessConcept + +__all__ = [ + "is_connected", + "node_connected_component", +] + + +@not_implemented_for("directed") +def is_connected(G): + G = to_undirected_graph(G) + try: + return algorithms.is_connected(G) + except PointlessConcept as e: + raise NetworkXPointlessConcept(*e.args) from e + + +@not_implemented_for("directed") +def node_connected_component(G, n): + G = to_undirected_graph(G) + rv = algorithms.node_connected_component(G, n) + return G.vector_to_nodeset(rv) diff --git a/graphblas_algorithms/nxapi/components/weakly_connected.py b/graphblas_algorithms/nxapi/components/weakly_connected.py new file mode 100644 index 0000000..c72b532 --- /dev/null +++ b/graphblas_algorithms/nxapi/components/weakly_connected.py @@ -0,0 +1,19 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.algorithms.exceptions import PointlessConcept +from graphblas_algorithms.classes.digraph import to_directed_graph +from graphblas_algorithms.utils import not_implemented_for + +from ..exception import NetworkXPointlessConcept + +__all__ = [ + "is_weakly_connected", +] + + +@not_implemented_for("undirected") +def is_weakly_connected(G): + G = to_directed_graph(G) + try: + return algorithms.is_weakly_connected(G) + except PointlessConcept as e: + raise NetworkXPointlessConcept(*e.args) from e diff --git a/graphblas_algorithms/nxapi/shortest_paths/__init__.py b/graphblas_algorithms/nxapi/shortest_paths/__init__.py index 9fc57fb..781db9d 100644 --- a/graphblas_algorithms/nxapi/shortest_paths/__init__.py +++ b/graphblas_algorithms/nxapi/shortest_paths/__init__.py @@ -1,3 +1,4 @@ from .dense import * from .generic import * +from .unweighted import * from .weighted import * diff --git a/graphblas_algorithms/nxapi/shortest_paths/unweighted.py b/graphblas_algorithms/nxapi/shortest_paths/unweighted.py new file mode 100644 index 0000000..f1700f3 --- /dev/null +++ b/graphblas_algorithms/nxapi/shortest_paths/unweighted.py @@ -0,0 +1,45 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.classes.digraph import to_graph + +from .._utils import normalize_chunksize, partition +from ..exception import NodeNotFound + +__all__ = [ + "single_source_shortest_path_length", + "single_target_shortest_path_length", + "all_pairs_shortest_path_length", +] + + +def single_source_shortest_path_length(G, source, cutoff=None): + G = to_graph(G) + if source not in G: + raise NodeNotFound(f"Source {source} is not in G") + v = algorithms.single_source_shortest_path_length(G, source, cutoff) + return G.vector_to_nodemap(v) + + +def single_target_shortest_path_length(G, target, cutoff=None): + G = to_graph(G) + if target not in G: + raise NodeNotFound(f"Target {target} is not in G") + v = algorithms.single_target_shortest_path_length(G, target, cutoff) + return G.vector_to_nodemap(v) + + +def all_pairs_shortest_path_length(G, cutoff=None, *, chunksize="10 MiB"): + G = to_graph(G) + chunksize = normalize_chunksize(chunksize, len(G) * G._A.dtype.np_type.itemsize, len(G)) + if chunksize is None: + D = algorithms.all_pairs_shortest_path_length(G, cutoff) + yield from G.matrix_to_nodenodemap(D).items() + elif chunksize < 2: + for source in G: + d = algorithms.single_source_shortest_path_length(G, source, cutoff) + yield (source, G.vector_to_nodemap(d)) + else: + for cur_nodes in partition(chunksize, list(G)): + D = algorithms.all_pairs_shortest_path_length(G, cutoff, nodes=cur_nodes) + for i, source in enumerate(cur_nodes): + d = D[i, :].new(name=f"all_pairs_shortest_path_length_{i}") + yield (source, G.vector_to_nodemap(d)) diff --git a/graphblas_algorithms/nxapi/shortest_paths/weighted.py b/graphblas_algorithms/nxapi/shortest_paths/weighted.py index d6bf1d2..a44a18e 100644 --- a/graphblas_algorithms/nxapi/shortest_paths/weighted.py +++ b/graphblas_algorithms/nxapi/shortest_paths/weighted.py @@ -6,6 +6,7 @@ __all__ = [ "all_pairs_bellman_ford_path_length", + "negative_edge_cycle", "single_source_bellman_ford_path_length", ] @@ -52,3 +53,10 @@ def single_source_bellman_ford_path_length(G, source, weight="weight"): except KeyError as e: raise NodeNotFound(*e.args) from e return G.vector_to_nodemap(d) + + +def negative_edge_cycle(G, weight="weight", heuristic=True): + # TODO: what if weight is a function? + # TODO: use a heuristic to try to stop early + G = to_graph(G, weight=weight) + return algorithms.negative_edge_cycle(G) diff --git a/graphblas_algorithms/nxapi/traversal/__init__.py b/graphblas_algorithms/nxapi/traversal/__init__.py new file mode 100644 index 0000000..7811162 --- /dev/null +++ b/graphblas_algorithms/nxapi/traversal/__init__.py @@ -0,0 +1 @@ +from .breadth_first_search import * diff --git a/graphblas_algorithms/nxapi/traversal/breadth_first_search.py b/graphblas_algorithms/nxapi/traversal/breadth_first_search.py new file mode 100644 index 0000000..0b2c6a7 --- /dev/null +++ b/graphblas_algorithms/nxapi/traversal/breadth_first_search.py @@ -0,0 +1,27 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.classes.digraph import to_graph + +from ..exception import NetworkXError + +__all__ = [ + "bfs_layers", + "descendants_at_distance", +] + + +def bfs_layers(G, sources): + G = to_graph(G) + try: + for layer in algorithms.bfs_layers(G, sources): + yield G.vector_to_list(layer) + except KeyError as e: + raise NetworkXError(*e.args) from e + + +def descendants_at_distance(G, source, distance): + G = to_graph(G) + try: + v = algorithms.descendants_at_distance(G, source, distance) + except KeyError as e: + raise NetworkXError(*e.args) from e + return G.vector_to_nodeset(v) diff --git a/graphblas_algorithms/tests/test_core.py b/graphblas_algorithms/tests/test_core.py index 7718ef6..5acd529 100644 --- a/graphblas_algorithms/tests/test_core.py +++ b/graphblas_algorithms/tests/test_core.py @@ -33,4 +33,6 @@ def test_packages(): pytest.skip("Did not find pyproject.toml") with pyproject.open("rb") as f: pkgs2 = sorted(tomli.load(f)["tool"]["setuptools"]["packages"]) - assert pkgs == pkgs2 + assert ( + pkgs == pkgs2 + ), "If there are extra items on the left, add them to pyproject.toml:tool.setuptools.packages" diff --git a/graphblas_algorithms/tests/test_match_nx.py b/graphblas_algorithms/tests/test_match_nx.py index 6c42d54..c50896f 100644 --- a/graphblas_algorithms/tests/test_match_nx.py +++ b/graphblas_algorithms/tests/test_match_nx.py @@ -130,12 +130,20 @@ def nx_to_gb_info(info): ) +def module_exists(info): + return info[2].rsplit(".", 1)[0] in sys.modules + + @pytest.mark.checkstructure def test_dispatched_funcs_in_nxapi(nx_names_to_info, gb_names_to_info): """Are graphblas_algorithms functions in the correct locations in nxapi?""" failing = False for name in nx_names_to_info.keys() & gb_names_to_info.keys(): - nx_paths = {nx_to_gb_info(info) for info in nx_names_to_info[name]} + nx_paths = { + gbinfo + for info in nx_names_to_info[name] + if module_exists(gbinfo := nx_to_gb_info(info)) + } gb_paths = gb_names_to_info[name] if nx_paths != gb_paths: # pragma: no cover failing = True diff --git a/pyproject.toml b/pyproject.toml index f1e4472..7811266 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,8 +13,9 @@ readme = "README.md" requires-python = ">=3.8" license = {file = "LICENSE"} authors = [ - {name = "Erik Welch"}, + {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, {name = "Jim Kitchen"}, + {name = "Graphblas-algorithms contributors"}, ] maintainers = [ {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, @@ -87,6 +88,7 @@ complete = [ [tool.setuptools] # Let's be explicit (we test this too) +# TODO: it would be nice if setuptools (or our build backend) could handle this automatically and reliably. # $ python -c 'from setuptools import find_packages ; [print(x) for x in sorted(find_packages())]' # $ find graphblas_algorithms/ -name __init__.py -print | sort | sed -e 's/\/__init__.py//g' -e 's/\//./g' # $ python -c 'import tomli ; [print(x) for x in sorted(tomli.load(open("pyproject.toml", "rb"))["tool"]["setuptools"]["packages"])]' @@ -95,16 +97,20 @@ packages = [ "graphblas_algorithms.algorithms", "graphblas_algorithms.algorithms.centrality", "graphblas_algorithms.algorithms.community", + "graphblas_algorithms.algorithms.components", "graphblas_algorithms.algorithms.link_analysis", "graphblas_algorithms.algorithms.shortest_paths", "graphblas_algorithms.algorithms.tests", + "graphblas_algorithms.algorithms.traversal", "graphblas_algorithms.classes", "graphblas_algorithms.nxapi", "graphblas_algorithms.nxapi.centrality", "graphblas_algorithms.nxapi.community", + "graphblas_algorithms.nxapi.components", "graphblas_algorithms.nxapi.link_analysis", "graphblas_algorithms.nxapi.shortest_paths", "graphblas_algorithms.nxapi.tests", + "graphblas_algorithms.nxapi.traversal", "graphblas_algorithms.tests", "graphblas_algorithms.utils", ] diff --git a/scripts/scipy_impl.py b/scripts/scipy_impl.py index 277cece..06244ea 100644 --- a/scripts/scipy_impl.py +++ b/scripts/scipy_impl.py @@ -43,7 +43,7 @@ def pagerank( is_dangling = np.where(S == 0)[0] # power iteration: make up to max_iter iterations - for _ in range(max_iter): + for _i in range(max_iter): xlast = x x = alpha * (x @ A + sum(x[is_dangling]) * dangling_weights) + (1 - alpha) * p # check convergence, l1 norm From 67b6358379e935a6aba038ed3dc2718486eef770 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 24 Apr 2023 07:37:42 -0600 Subject: [PATCH 06/24] Add logo and benchmarks to README (#60) * Add logo and benchmarks to README * Add supported Python versions badge to README * badges on two lines * convert svg font to path and minify with https://vecta.io/nano (and delete png) --------- Co-authored-by: Jim Kitchen --- MANIFEST.in | 4 ++++ README.md | 20 +++++++++++++++++--- docs/_static/img/graphblas-vs-igraph.png | Bin 0 -> 16058 bytes docs/_static/img/graphblas-vs-networkx.png | Bin 0 -> 108701 bytes docs/_static/img/logo-name-medium.svg | 1 + 5 files changed, 22 insertions(+), 3 deletions(-) create mode 100755 docs/_static/img/graphblas-vs-igraph.png create mode 100755 docs/_static/img/graphblas-vs-networkx.png create mode 100644 docs/_static/img/logo-name-medium.svg diff --git a/MANIFEST.in b/MANIFEST.in index b8af874..92306c0 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,9 @@ recursive-include graphblas_algorithms *.py +prune docs include setup.py include README.md include LICENSE include MANIFEST.in +docs/_static/img/logo-name-medium.png +docs/_static/img/graphblas-vs-igraph.png +docs/_static/img/graphblas-vs-networkx.png diff --git a/README.md b/README.md index 0136abe..b3aca8d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,29 @@ -# **GraphBLAS Algorithms** - +![GraphBLAS Algorithms](docs/_static/img/logo-name-medium.svg) +
[![conda-forge](https://img.shields.io/conda/vn/conda-forge/graphblas-algorithms.svg)](https://anaconda.org/conda-forge/graphblas-algorithms) [![pypi](https://img.shields.io/pypi/v/graphblas-algorithms.svg)](https://pypi.python.org/pypi/graphblas-algorithms/) +[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/graphblas-algorithms)](https://pypi.python.org/pypi/graphblas-algorithms/) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/python-graphblas/graphblas-algorithms/blob/main/LICENSE) +
[![Tests](https://github.com/python-graphblas/graphblas-algorithms/workflows/Tests/badge.svg?branch=main)](https://github.com/python-graphblas/graphblas-algorithms/actions) [![Coverage](https://codecov.io/gh/python-graphblas/graphblas-algorithms/branch/main/graph/badge.svg)](https://codecov.io/gh/python-graphblas/graphblas-algorithms) [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.7329185.svg)](https://doi.org/10.5281/zenodo.7329185) [![Discord](https://img.shields.io/badge/Chat-Discord-blue)](https://discord.com/invite/vur45CbwMz) -GraphBLAS algorithms written in Python with [Python-graphblas](https://python-graphblas.readthedocs.io/en/latest/). We are trying to target the NetworkX API algorithms where possible. +`graphblas-algorithms` is a collection of GraphBLAS algorithms written using +[`python-graphblas`](https://python-graphblas.readthedocs.io/en/latest/). +It may be used directly or as an experimental +[backend to NetworkX](https://networkx.org/documentation/stable/reference/classes/index.html#backends). + +Why use GraphBLAS Algorithms? Because it is *fast*, *flexible*, and *familiar* by using the NetworkX API. + +Are we missing any [algorithms](#Plugin-Algorithms) that you want? +[Please let us know!](https://github.com/python-graphblas/graphblas-algorithms/issues) +
+GraphBLAS vs NetworkX +
+GraphBLAS vs igraph ### Installation ``` diff --git a/docs/_static/img/graphblas-vs-igraph.png b/docs/_static/img/graphblas-vs-igraph.png new file mode 100755 index 0000000000000000000000000000000000000000..4c253d171e6218bdca5b5544bb40335a3e39ae63 GIT binary patch literal 16058 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 literal 0 HcmV?d00001 diff --git a/docs/_static/img/graphblas-vs-networkx.png b/docs/_static/img/graphblas-vs-networkx.png new file mode 100755 index 0000000000000000000000000000000000000000..bf9cb6942f67681ceab80149a271c61b5c3febe7 GIT binary patch literal 108701 zcmZ^KWl$Ym(B|BWySrW7-TmS&AxMxQL4&)?#WhHRyGsZJ2?Uqm7Tn$4f(G5ZyMOlE zs_h>$r)uWur@Bwi^qKB6(VFTC7^tME003YpDavXA0K&f^cmf&j-|4=H-2wpM08LeG zxz~Sk81}joy12Obx*qa61N-~Y?sYNv^(5?NBIvo^_odtaRZ|lT0>8BSy$%Mx4hK4R z`wc9^J`D$)?}aRF1m{i!w#>u)`uv|OJh!()9s->oBV8W9xLbGn-rwK8?)woSAYH$_ zzW#}ReYt^0AfQJ^e7&f7-Sm6ik9<9e+T7f_xVbodetmUt`qlI6^|B3~PVDvNh?kEK z4G$k4ot_sNiJS`Zdb|I+7I||NPDY5s$jFFF&iZoD9U2m%^+x;kdZ_>R?9)$&!{ZY< zbi|h_&ll~tq*Cgy9kADf%-6@IcQ^=y@D#%2Xew0bhK#6=6v&%@Httt`_+02EP06&=4AeO-EzIy`LcUj5EzOSYRM~%bVE>{^@iHFu zyk6Sx#^A3=EW$&IMxef)BHUz3cHN@o=H~X;XH>iQeA*Fsw>F?*?JMbGMsM{sDIwAR z``$#_+vgecFlLOaiNcpb*S1*W@kp-QjgXmq8$L6AR}-;ROTqqF#jCr`l9|hmp8<*e zhvIJE{W~s;R-Ox#aik&a>d`$L1@D%}gZHbH1k|m{++{vbJg6p5_gud|l)gDjmk|u? zJ{Wb2O0lEV({Ytz7cfoZ)07J=^LbE)vfwZo$JrZrn-|AvefHJND|EZx`TXep;i<0b zIn(pc*^l#~#Lyqh9l0LkRY5OXn~s^T2Al#e;VI7x8DYBY@r}M~+UoY%{fk}pu%seR zjo@V{i$bXN>$RVE@Q3n-&bt2K!QP;uy~z3>zC~g7L7vjy6QS!4_6^hf%l+R^Zem`y zZ8rk=8;gvx%Nz1q0vAuCH#)vVmGwRMc8;x|6m=~cM|^MPA=ffh9Qa|`qO2Y$E?*!n z`5a}H!+@pjmNrnIS-B9FBtiS!@wp~FE>PFRAV9j^%0AcF#=Tfut;i#hBmETsmAq4u zmD2WJI_VDn_j~}b{O1ay3h;l!|F2kKVZ#4+5%y$4CZ+Q~BwU=g(Se9Ege6>VkCXr$Jd;1WquGh&UFBP#_(nNETP&wr}Q7zl?PMuh@U5lN+Pu15D%Z!W$WJL~2#UzUSpa9}w8xiRJy2sYHFR`V8irhN z4#*3;3ks0!cTJ`c8+%^!i%?+@g_do-&RCk)XCVU@YZwmTlL{kDf|Z< zK*RpILo8*_Ag;^&b-K+~?#f**Qr`T!06Q89q>R?tZMa$A4&Wp~Nyqu=ymohV5NoMW z6R};B0l0)XDc3BUOy6QI9-XT4#W~^txqw0Ur;B&2G)K-RBY(7Edk4d>2u*Vxu^n7C z|JcSOj&1+2P&JD9IC(O5U3Oy$YQMKD&TpL(*LPPkq^-7z4W{3Q)2G;)W$j8W-wG|sYqT9?t^w|^NX)DP`^^wCUh6&`Q zB|uq<9*Ghv=XFP9J2X8C?U@OB9E`tJS*CL?K9-W6>`NwIjMV2b+X1(TEhtc8fx*!v zh6wolC?L7uJ7DV?`-LbAu2+$?E=d>)f=IBF{^0T?1A=fIfR*c3WsozECE*p2=3=ur zi*{#9>**EE!C1qAi6<`DQ*NM2L71=_@>sr{A0CW&A*E(Y z2foUh(^HzY=H~r8T|q~4yVi{k%T_fLEilwJ8_!XKrBfi}rE{*nqheHlH#rCZVtZ^? zr(TXC4`~`a>TmFi&&VolE?V!fTXYHMJ58OcA3FfHko60cJBR5sZJ=xVRH@5^MC#S< zxI@71)9NbdKqvHV5n(U-D#`U_L2f)Fvl+w6CtqYMS|_qE(JnO^3G3i6(?;<}{3I=_oadE+PXSNVnyT zB#3=AVt?9mc1uZ?lNS1DbfNN&i z{N3J<PTl9}SSd9>#@bnU!f52p~hgz6^5e1|xevago6-iLXd7}}ra z{g*V{;&%uHzp}`~QJi1^w*(}=))+8R+y=# z2BTlCbO%`C)B44IC>sLFiZ4&7TvSF(kdvd170m@nnp#mr^&;pGL>zCU;cuP+yn6V<6kA za(fo)q&@fO_2A$d%3Wqc4)cC>Gx!_2EFop02M-y5i)71YVj1{Yo*JsYT>=Eg7m*R6 zV|=wSCbJ=qGyr4)#9j9Wl}XoM+wKgmez1(J)G+fha*}k+BD>%HWtCrBNR7yC5unBr z+{#;9fKylP9fl~QfM$t(le{$ zYc7hj_W1~2qQICBZpB^ zjPdvL=60|GlSPL_NDyCoA}r%q4V*-(gxc)&=8APyJ>Um#+|d>bgVu|A;wM{YWPxhL zVxOw^fr#oHbTd(Aq`nanMP*db`#w)1Fd=+CYM6+c0$&d^M)X{E$q4oUeksyHCWfWNQ0azI?xjTo0idIIg z-v|Nq4^vejB&TT$Nb3x^g3~KEZd>DVbNSo(qMf#HZq@IB#k}2;k; z_`qIJ9Kh;XcW&@*2xBy}Q0cfK=bG?=A%6YZZ4%wnf5M%H1^IU2eiYkFf8$I!Onpuqk^>VYaD`i%OXd z-Sx^EZQf<{w$wr@_b|Cp2KN%aiKb3O(;!*j!$Kf?$2d$l#sj17MyqQ(c9jZyd`q!3RNDcb^wm-WQcq;hrh!SZwbJ7L z^XqEBaL*$fTNL=>FHUif((Qg+mLA|U9CSM(H6<2oEBAD402CJ4%&q{D{D1#83yK5h&)y4pwH_} zo=yVSzLUySmr*oQ*b@6VvsO?%%>W7&(w3CPCd_Sb1gI6CK0FL=_OMs?c{5!e`#~HEP8Jp{vm7lZZur?odpq zyCudsclpoadnRBg3OU7QnI=5h0NPb1K>-uP$O0=&%ml$h{Lsm63G!TqX)X^3-fAj2 z4;NB_pjFDWmGBvb%u-NK1F2%<=h9+VGfxc&&wWkoAge-rwK)L&qd}lb^c+nL+@#E?N&%p#xFOHEf)fXz(P*6QxDJx~K{X;Ii=4drKuB*r*Ur9)2~Asx$0-2KDz$T zq*iqxBF6>4mAmU>ogGX;b%cIlwy=g%x4N^GndorX!0k$k{j?r6pdXkFhmk<_nIZT~) zK|Sh~kTC|7+gmvx^(u&hvvx=iuj+L88xPXsxch!EiH>FbJ`co<%v4=jHB++SPb|@{ z3-r^q+_rz2180z*@5hH7b6~WX4WxLGEtF-nmJ26#sIO)u>{eXuxr_gkzjDg!J)rnv z>08cqqV!kRg|}D;*cE+IOuz}!{Hb{|ODi#Dzk5z7jcHXQ z>jvlMCo$^+xbCqTKA(iWEe)A3>0|h4pfof^E;F4EM`?-zTDUm!#s#n^ZAr%rG>GU0Svz z0 zD%fG1hUmxMdW4wVcgzqn&07jUvGn8BD?AkOC$kE9@nGyv!nYK6*!8pQ)jEm&yk&^5 zbM?iQRQlpe9;xT*3x`!I1GS6YfnH3}_+_ z|F*kBPif-4`1F7FOdP`FmE@Dx4sgjkVYfmBaQAItZ|UlfZKBR{s_BIKBE4(TGF)KW z*y&z=L<{#f5GAy*A4&i`L;@Su+~7Eu4DOO=C(;%Y%8t37@Ay?!av(d$smiHSYW&IF zEnL1{kHkLTyl2L2sS^y1&>7O(!*GEYSn{ezEp-9@JR%@Nb#d8xF+qa_%jKo}DSk5? z{M{iRX2QL40QgsTp4E}(EKs>Pbntu)8A@&gYzZMk8M*;v(>4EJ{sO^oEqh9rBF{Bg z5_Iap0?OQ>;=1*ziH#ZQDY2dKLd9o;^(ogeptq#Xyo&bnl{%(WyM1PMZ zW6Fft<&3GBO@;xzH>pV&1G#!)r$l=p_JI`1z|O(LJOu+{y3eRU(rRvNJf3TMs=Hv{ zbh0Yug}x~3n=(EWVDOtLII)w+1;A8-RDSff2A2An=+T4O$Fx<7j0ys1fp*bYx3}=` zXn-Dt=D9fD`EcX@SWOFh5?zJ&cSR4Y+oA{quCK9CpdgaNu*(k{APLC}W@~PM`Ur%p z?gC6j*j0x|jH=uM^jYGi-0VS57U5xAe=+^>#4D74iAWtR1HBFFRIprQISb-PJ5Y_F z=AzJkRf7ZyOr=1k18ruAXPsFEIb;S0+zVP*LP*iUham@-f)Yvk6#zx=+Ua8IS*m1C z{3+Lo+N#jwEnp`cofL-LjLAAXNDavFf!*<(-&sz1RkXEV_6hu3#@Iep-oNzgaM4kK z1|kVVaMuGZU?Xg+h82>{_!N)uRtFO01*uNG zW59uV@+E`wznZGTLll}U(`7Jh3y;g4=!?d)UC?{*!(Bb4RCt&EB{`z+z=2wR6q3y@ z!+lBp^q--i!;6Y87jhVHY~{0BoAu4|oD|yDipaGJvB2kwqOPcDSBimYf8f>yd6e-L zcdx``rjQ91PP>Dw)R!#u2EZ?$j8Nm==oZtZ06{P{%QV$*i1k8S7EDkgFDS5*lg;O< zS5dfDkSiWuYN!#7GPT1mgWweMR=o6=o!@z&f8nO1r!INyIjX| zfcg+%u|kL0r9M!LX{u7*h8g>3chh*a@7woXhg2qjB$FOU(ifq(2m^Lh*KAb#A(&2z zuL3|}ZVU3EvGvrD;DFRUM@{=>3>WC$IWUdz*N{xC0&#`Lc(K;);Gx(K$}4dc0!cb8 zup`<80?fjWY}`J6-PO$c?`2ejpbd{!Ync#Q5)nr%>6Wk3g#(kKAKX7|?ky1jC%!rK zgS#*tO#DZtIwc=f5{5;j$F+hnEB%bHNtrJ8Gv%=OWcOkFG=P5nTjB1? zSi7OYWK`X`Hx#&k8KUbrt#cKufqR}DMAIC2sLR8|S@hah(~9i7-;$BY z-{`yEj%8Wo_z4mu7SC6>gI5pS$D*d72mNT}} zB|@UsCPS2-0%&3L1G%WCJT0b_y)S$o>04gJx8DMq>;xO*o)%3*1n0jCXvmtiE??^q zfHebdqIQR}qy(ZFhgM3xg8+5XdWRHLs7+jWh+l;FHp`Af13qx{v9bph##S);8MM$& z3@^;I(wD{~N;qqWTp9PpX_Jc|vn$rC0i2Hp88M~=|7k%VY`v(`d_>du7JZp21eCah z{I0g*RA*{CtUMxxWf*d5B)+Q%46~;JL~8~Qt?wnI5|e~&cqw2cGTo~iA3s;jzY`Ng zn5f&Z&V}zvRmW_q1ftGrRs{%PY*S&vwO;Fru^qE^qSx$J8tr&p5tR7};KWd%EpT)? zp8;3>3y~;I{?6`J0B_MdsZNXzJZ?-VC}{OE*GHhEPpYGa4`5I2Ju(}W>SSujV=>2z ztZTQw@1ONJeT1voj;E=cG@=2T9fs&UQeUR>1C@@Df6JX4@A9nW+-E_)aofcqLf%&( zI{%yM))p++!ZS+luxI)CWxsfwUcmOHS4t0C+#oIFHaP@s}<9dI+#g58Bg_sf2G$D#m-FNAj6{(7|#k{7jMHKTmI}B zD3IB|RPkG&0Cb=V=2kf168aXYQw1p>69LPria`Yo@x<2?$)-v8pi9$Au9@b+g8aE4 z-Zs&T78fWtOr|kYj}BwF#mxJjd@9jGsdx@Wm{oZ8!$!)VeM%n7W&N9SL|~1@|CZ4G zOHI)mx_ypG1-PLVS8`aKz3^WF|L4bsf0OyGPFYf|2*crt`vW!t37+D35{VPMDG^+L zIfZVPOfi@X{o$IP#OcO4jvU1@D`w%O{-EfcP%RnikBZR;6+&)&BvWf$QVR!uxAMMB@rg&#n z%DeR~h>2cCNlW^hwN@+Q?5k$w#%j(V1zs;L2SXS=VLLmnqgIZ$A9x)k1!+nQ=B+2c z{^G-T_B*Wf8sidKKLTg)5v6!d3>`@oopsv3VVM?;-w-MK}%WVpzdVd)z3b++&u} zx%ca*ZKOjiM~F@*CQEi^C&9Z6DC=9IwoluHlN%`fVSaaU?6XwRtWa*1H?t+Jx|;_t;Y?-tjnpy@I14M_$dCxFrvmD+m7ADn$Sg5>YPUnml_Pv$5I@Blm(A26$)A_ha~l+BG` zQ!V@x^{!`u#4{L)sSi&KdSXrTpF?Y)3mkX?fa60}yPL2J1hLRm+Ha9u;FH<_jtJx` zJreE8KfS&j>_=K**g|ksvPFLeYPSa{y;!7mrSML}e!m$Y3Z;H+Gb@VYhR3 zbM!k}YeGs#D2wLgHSFpT%?ISvX~1!B|Co&L7T)2S<^`=_L)cw>wt@jsd73^p#jv@p zkvxuiagR;`HU>u>p8p#dGH*)i$plutjwPXX?4kV;mMzgV2Gyg}GJ3`?XrL2!BL+2k zlM&(xGXJed`HznGA6z|^`F7GMnp#YFwT(zXx^2l}sf1bLm8*Zd!?BOSB- z7yYG}Eupxef~x?G&(7O4L^lzJXH#L)Idl%n8v?!3OUlOxCJ;I%CLjSS-6PVz|`FkV4w9xs{d5Gc+ zTMF1iInHps+KBPsY#^QSCi^$%2`VsJ$K&@lQTzGnt_u#d0oR#g8-^i!<6iMcz*FelGxdYC?O$0+=gUX)_jtyM)yafTlEv~#>HK7 z^e*H;ae<}88Pcri;+bqqpN0@1e;fln;IpeqXbfEt&$M%K%CdXb3+TY>Y*E&=P}Jc& z)@^@K=6LGAHX8%+_q^o!#t-w81Crepba(t==&wL}S2gAw`QIDap6d9q>tR7xA5{R9 z-o+UhTioi;RdQLVmt=u;Nq`Q}-uG!GFDs{>qfH@hO9EENuF8J7%qrMHerg zdQRZLuieBXL|mcjPWR`up&%=2q|XDm3&=t{9XL0ZG2?#og^%Yhc$La{L3BM&;7BMw z2$wR`=?QP}{n=5d4+xcA4F>+g&|h6*F!dR1I0D9JWe)|>XxH1z1@0YXhx~S@%UH>6BNGzBS8^?9SI6=Z{Y>T1>BS zD~wqrPO3H7KNg%q8?<;A>opBJ>40$Q0(Xx!{n*z0lf`)e%X@JJ7J0jW`jeV6E^Ych90yAe;`*q852j3}7_j1jXr1eN z9%co>oT+N$wx!o%N=A!ynP`;M;l;JFl&664YD5Cy**Wg)YM^9tefZq}dXsfq9 zF)Pn0(@QFLSRpI?Etc0+@kOx8>M)-VO!}YJSiKg@_Ez(*3o46S@;Yp)Bi-qUjhE;Q z57WzQb6YoOdU~SWVfT^0TUSgw`t=Ku{~IKwGOhXpzcfBv)_YNm74RCE*y!Fm9GE(o zIJM_jcQlS;p{myKIMKYb3*ZeHh5t14FIY}E!o7YXO#HO`6dc7pR%@jM?QP1D8EAiO z$3EyZ3GM3!BO#Hko`k3Z9 zj5ovSQ5o>Sj3yNf)Hv2VCuHbEDLV)6?LO9XD3acISm{D7xxHBZ+lxaL{d{b^@?VHK z+nUDYueyR%F3EO2h)pr=|1Mm7SvBfG7yOn3z$5~-VH@Pk<-cnaYA%mPN>seP%L0{D z1%DSo5A5WEnO+=BQ0}ySMVtdlGZ`4O;aml%>)VuuB)EP~yV<#Ag>QL$p62fbTUZGh z3(##hwoWTU`kaGv;`a8?iLxb`U}2~muXwBczvfTSR@RQtokii$5rHZ+0)qFs@DCA; zZwL&2;GGyWJJ6n5HEqk4sLCK`ue3Cr5Bi7ZA?%Q=LfPW|o3)3epsTLdo_#v6xFhW) zw&_xTTjrb%pO3$8k9PL%_MZ&BM=Hkt-NNscg8f;r*$1G}2AGGCB*a+7RTPcw(`NhMOV~WDwM%mkhHoY#w3InwSWjj0Tl?Ey&-@zKid*1f z&!ZXTiPo;T)#%p_SA#CXPm5tcZW54Z%q99b0B}*>~3JrQ6&JqOR3( zceGdo?eO}-GwZ$gb(BW5Pdm6w_&4f{jSW-#_->v23LIUroGiaOvvWoT?ag655PpwD zmRuG_Iy6qWIL1$^f@uHw)mrYW|E4i@Z!0Gde~t$gvk6cqcFbmrGXz$j@9bhmUob!c zYJt~Zd`nPzvR+&`#GHTHDYm`q;9#y~u_az-zG3y8QoGc)4#>GQm_35N)S~^4CM+F_ zj&Jx#s3jMhagp>@YrBm0ACuCCF^7Q%P;DVkn zzUDTf1aTKSLV11YbwfIrKHD9q(j3yZo^pl0X=S;M2bt*@G>@V_znrSDpZ;LNZ=>;tWqr!G1udKkrowxI^z1K%89QB=LYnJ@8-lnXcAxgFF@XTDP4NLFbikk>oy|RK(rW+*EnC2Rb z;x2SQ4!V`<8jRHkb+EAr~QL~hlI)6+u=Uo&3I3wZ}xn=K(eabD4C92w*o zt@?RHZ?P#ANr>U-)MU!wYix_xRq4FnWYb+6vRvjh|3#?67;7Yu_C@bRKCxIzMg^qyzjpFkrnUHBH zR`CiQFc{$!O-|5pm1}w64%Nwlp_OYM{T&v>4_SgxOVI_1nt1g73$W*3Or!rk zJRFWidWVicq8wSW`7dPKqFeRe(R!zW?$}$h=L6@>%Y6#VWtif&^JL3={%{gL-Xp4-_=#FXMks_{^7v)%9C3D%T!H?Z6VpGN|}2* z56&>FI`v z+G#r***s}0H#D-J;a|8F>0|YfsS(~=#w);U(uG-7<6v}0xCkq0@C|V2H(Czp&nriX z;V!qRD}GT>wlh;}fZemy5C!cWs)XJeEHDSG3EAL-97q-We{B$QYUL<eR)t0}4z^d^MAj71sjJc2qUwKXrsr3Fb$nxmi9iDDA&$ipzQ4l)ro9NEYUR2|ea zd!gTrOMyLzAMBx;K18Eyv$-IGC8F381RouRnA+DRm4M&SaZuaGEW{ej%n2hn4}<%x z$|CDDn{C(Tib`kLz1RUuxxf0~Yr9N*4KMT<$|__Nx1A_d2-WEzW(=;tc`|y}!a|lc zaflT9Dr=bR$j0hCGUgMMT2?By{6h%XCNI8W3a?X(RFe61@NLQqwQ$6|rcm0vN7J-? zE`<^ww{5M1>4~Oo-t;M7EI)Eg68tuQ+q%nUVLRB1Z8g4ra6Q2wM7Y<%h<(bN^9~Y` z?*8^}KPBmE`&T5m2}w%+F~qAb>~bF- zf$L$Ut8fH-1ZRRQ)4q@uCU6IM6Chx3z{yOb=ifFp<;>`{Rd_X?;f9p1F@yNj&9Jd8mF2z)WLZk+ z;7pDDvL45im?1ZVVV^rZ$wmk4Xj8k_lxco&JD@_C*pK31i9p?u14VuqZoM6@#ajhs znHFU9GY)=OnrX|gTGETUDO2$y)y!x2ztxGzTX{c$%YAn&qLNgs^z{T4eM@@!{Q#e^ zX`|Tg@hkPUVI{{T6Ypg?FixU~JRAMkcqaCzO6VZMj{QKo$9Oenow_N|gjyxLwHb!8 zey4$w-(nK(izu&py0;?-VS;_=+S_y!W`cEXl103*!B#5Kr7rw>>0$R#z4Kc5SOegw zue(WgkZ2@#j*?mk0pZ9RLtfb%Wt?(9R?$wxU3GSK@F}yQX$$9Ra!L*}+6RBmsjKhb zf82aP$qpcGvUCgm@DNn>t|}L^r3`M&EcRL8D}E9xfzEy{cH~$`#uhVy#{CdAI?=M1 zB=>SW{vRng;_rCq2haLIb0B-Ih6?x|S$`S^zGu2pSIvrY5~7DW;gSRjsDsdrsWV0L znZD#uVo?_3lqqn$uSAiwve6I{B6J)b$NB`o*SeB!hJ47?pAn9XdE`@vpC zy4sj$K|D#_Iu9@4*Wb8cF{dL6=prK3fd%PBD6x<$a9eCjS0sbS^5}lXlB+~|lYbc5 z|FpeYCmdqZny_go2sMH^^bOrn`>aE=h{fZ_6ASEBI-}L2^)0l7Lv?5R4TFp39cj}5%T%qH z+-8Zut<^98HZs%=Mf2*P(t7{Vx{@vv&j?o1(6^r=II7+*%2{p|{f>QwHRDvlwG?R@ z9fRh4?#t+Z_T*pIEKKrgP9ud!7=3b-3UGe+g37dZnYg$> z_cP_D#u&Cevl`6_3rH|De^_D#uRbT9-D`_Y!`Nn@d1GxqGH zVYs$JtzwEA)0~kGTf<*k#ClJx+Z400IXAQ)&Q}&%(YzS3D41{^P?3F{15|(FrNJCt zfmoBe`c_Q+jVXOt*P@sAn9+F7iZiTLcbXLe8{hn7n9YFFz|L!QA>vg(vK&c9sh(XJ8ZP>Gayeeu_U#V9O04^-P~BKT4h1ipC}@= zQ;59ExYT(DKRGWia?-S`^`{1q$JbmPo}jD2-8{Rj=ZrclXwf*@-|48vd~eL`@P{}^ z-SivTn!~}XxX(@BNhZc<9cns#IxN}zRM^7fJbkoK)~vA**w(JOzeJ`BcBwHNqPAty z?G(Pu_x{O}f2{ej0D8w{BFpg$sZac)S zzC+SEaP9XEu8)Vew={PqS9Nz*E#`mw)6FhAMM3{Esc@oe=&OP&0kTMU*Xh&XlNx8u zrHG98IW}}JIYx{Hrqu*Zkc@Xlg=gXhr({00nKK^PJ-5T|ya&VK;u*rAq*+~!N_Ue} zA*0GXL0uobZKQ!SE0p5oE>wZAUux9OK60@PNezVckQ&_#BYn@u6i2CCqEa6!qQS*; z`?HUPFK~q4dmB%B22X_9#V?te(JEcA$0WQLv&~VN=`T>qM}#(540l+%H{tAy3C`qr zH?J=3?F~|^saO%}*E?LyA)=BRJcQI-k@fg>CY>%t+@g6&J8PdZipUS`9_Dn);58fB zE&~>G&?{_bBZ7i*!BghYVpMjV^Qt$-5VRu#`AoAA85V za&3BJc;#z6-JS9FtJ~VYy&g6LFJyErw83z{L8JNeLt3;VDCsmhx<~U1PLMYYmX?hl zVCt%G6~i3sAq9rDiNv1)X+Vvi#67hLHR@uZTJY7C60)W*OY>v(t_FDEv5gK3l4TAC z57YiWes0zBTw{OWUAV`~WIjWtFvf)#ukjRd*VyzE6xdlFIR)J8K7UH)jOFJx4)XpB zBM^7_5fP%t1;Ombj9^wQMDQ$W84C5|+}qjXZu81MrWJ1dR3F$}wmSd!iYlY;w8}57 zm(bpN=W-`xYglt?Jp!$I{`wb}?w|^REXF<2d!=D2_k{dbe9F;16zabNk@P^Vu_;H5 zB|}(-k*gFw2R7vdE+3qvfq0yiU7vsmd9v-u*^)T+j@u@P58R50;H^>zXy7gIaKV!) z6!bkQ65e0!a-vW#lA-b>GhAdl#7bAIlLzU>}YCyFC&z?NzZidul;~uLx`=| z1PwuDpew`*!OO(4{lZcPWkho5wIV zO3RQquPSi2iP|7(a9PFtU@MSOW#|3v=~Qb%g2jG=Y~S`=%p5*}P-C(M*9OqJ287)8 zz8pH8dMFXTtbY4EdaM>Or}0`t_xMnc`dX{^7mf3onkUF0&oqozx%b$H3zvhb4>N`j zKVah*9M$KYdx>O|B(P}sK#*|phzLH)Oh_P=KUgEK4AICSl(dop1;KxRI8RkHWrZ4`;n|BzkV@Q6Ij~ z=C~0te$b}W-AI1K!Ra*1cMfOWL7`B&b>#VG@l@{PeA{IrTh`PR)tr8=_-4lBHiAk& z%G~&OWL^ygcva}eoP_#(N9qlezdypI%i|7?`d&@_IeVd>hA(93Hwt9@0l`q~`*Lko zL$*Q4^jA`5%`-;x!>2Se01w%ycARYzpvgXo(;}hzY_*$9p2IJ3k+z z-Ko>nZ_lL9?zf>tM?3Hsy&_reg}hh$E(P61Yz$1K%~fwlxN(B9R;FnXD1T&TY>trK z-JJcfw;X)Bb()dj_~JHtFgjcVL)rcE~aoF%=gnX&kxP>C8eIb}w=bHtiOhb{TbO>gA)IWL*;pB-$642yZs zH6g9uA4L^N1hx*ExE~7nFv2cG7>jy!x~uhv=^QPrxiarKJl&ksU6js|HihR%%#`WR z*7Lw(T|Ypo+k(Wn7mkAWf+Ab{sa{$?6?py zXAwWhonW}rGg6(t8;b1s>ZqCoENDILA=TGHBWn)WALQrp@tCJ?qEHD0hy+BE zw6$nnXsWbkh6;};<5b?vUo?8+{84qDbN5m1xrQ^|zF<2qa*IoMn7gnN_`)c*e}~DZ zhvbvSZot3%bv0|z{Nd#|O^tgt@|C}sJN1m<;cf5LeOlK-t!wt`BS!3AF=j!0zIN;6 z1rg(z*k>mJv{Dg50oFG-=9-*bXf^`lrBTmCck3e<{RK?`N%k+crGE3#X=- z<_y8rH}HedPBx$PY+0TxILGJGSpzB-g*KZa`gIX@>*iQk+<)Ky9JpLI7VbV)?`Dgf z=PkO+VlYy)qAQ)BbMlEsL&nCed#|yJAyVCC3pYu>+}|1gcqiEddg;WfeQ(dMvF6<4K0um)pwA#sLWcDZ)_zImc)qY3Gn7Kxzo1Xr%x@ZrR~=aAGZ}MyAetYVU1zytRofNI^ku2t)wR3XO) z_Nplg47G%}261cTAr~m!t*biAD!Rf0zSeo?6FO>mVcMD?mrr|3GZcyaiD_KoGPP>^ zmlmep!~g#8Yq%%T7wTKd`aW+uie!~!JDIB+V)@a^^6FcEsO93a<8zR#!Yl4O^-kVb zx)xKL8{Ey6tZgl|9vgB=r8jY&n+gZ+8&2uL43^Hk2DNG8+`7XviyF>F@fHq} zj|f%}L(V?!crR2aw5+8Oc)Yo$A9io%U)hh^>Qy9lhrQ!)Foqa@Hi&e1Eq=V3W$epm zVlC;|Ro5EFnn}5zE^_cbyaQ5{Ln4YDl{90nqaxs5_(;VU`H}lVSFIfpSA8n*)9PHA zhZ*Uj_mvV{Ud7;|;h$bPo3;26coVuqTdRbLcTaQgTb8QcCovaiu;}~z3uLCao`z7_ zn7qA6&%bqfTuCTrdXYcXS9DO+&upEx$J1oUAOe zJtg?483IT`7L6CD=)LFU_J8{|w{eq8g6EF}<>(0$Havbky2TgjcGH$=eeKbcnta4_ z2)y6;9A4?4hR;O*aj%>@7;v~=(Kn)^0z3GrFiIRTyJ_U3?K81n-^!I6U2#6%41w!Q z&ZKk|{*c#La<1lxd|cy0Zpj*T>&AK%$-~*`QmW>hS>ixp0WQe@Jbbtyb#2S0ny?&0 zS~r8gVtjjCc#PwI$7o6ZZA2oxLu;b0ffXYMFVv)bVzEW{Uvn`(;7OF(d(S1&ZDHeO z|3;laEQm=t%qYUO@^oU?xAWWVc=tEK4t~*)-^@G=U%#H~*)yJlF{wR}zlIRefAp04 zJ!12iBD;%a{7r3EW--SX(j~6_b^O#oe=lA%QCKInoum+JJk@OR^qM?&Mw;d)ZNcb1 z3{0W0<`1ESiiTZ(lSbiaSs~pl$fEWAC7lVTxoHd}nH#`UQZL_alA=p)a%fIL)L027 ziHM%mPe~c*ki_EDc-m*Uci)Ib{3WBnfHpntG4kqd@RWm|kyyua*HfwUYi3z9g&WhU zTwpvk2QQ`sCVTv97&(u)c(Mnh@b#XEmD< zQNz1isq2ZZd8B5x2M^0#t4zzR4>sd^ncVA^PH*HFjx&a;F1|LORLGgkDY!Azs>z^08+^`pT^5y)*YClA=96NGXT08S zc`_y%B!sQgpE7T6W!4G`<#L`2fTXv4>lRWt-7Xl;MB85DMve$|#-$CsK5Juy$9lpR zNW#O9q4Uqcyn`pZ8ZpAzBbRCj{8cfS`eI3Ep;dC5vLST<_rCyEK&ij*>IkF2IrbaV z6VYm0AywD-`qZ|MaWo?A8uJtU=YAh=h6O%+(G_@ZpdI6LFz<_?$(RSFrvrd zIU9#7k^SRE;Ix2Mp(ruM^hT>D1`x{*Y#oU-gU}sx`LqE{HMmyunRS7ej#orO*pVFC z8SdkpYldYnt$~~m9r!|yDv0Z1<1VwM!u*>GTX?Nzp12WRy1lTli%l^#UWQvj5)_`2 zsmBCguAFAk;kewpQ&YR?Rb)&5I*d>|h1Un?@9JG)SQzJv7&WMtU{N8MmUl@bpjgW! z`B>wIo%H3pI`y@Mn_lJoc&T1_ zT8>M&Fp@Y<|4?y>@>-Y7Hk~I%4h;POIdfj*YJn=wA|U#K*(QYJ%!f9iR5l|lreoif z0^7s&&DfBHzNDPcPCi$#6TP^I1HNhwxiXdSTBLJGkGmo8c1=Id+L0PR3Y{+Dd*?(E z+cYq(EPuo*J`05F^-Lxw1KD>ZvQGZ3W|X)S7A?6h(>VKc!7kC>XVp49CaDMWb=B#B zMr-aaY!KRnS-KgUNeXfwu~kzS(y8K6v2aNgR-ZN!6tWM%|n8 z&*#UrmBew;W9S!oZgzjQ%MR|Vv{qJikg=2h6Wu2`Ft7^Gmm>?l+6782B7~~VjJHag z7Fg)+85r0pO%-*EG!kf^+KxRsg(MiQE$Q;AO?-`OJ^73HM3LmadP!X+Wxrte`gSDa z+e`H)ETq0>&{u;e!hgqBob6yEETG&?kmLBsn?!q`K|nO;ymnsVZfkJe-`J1@e|KO> z*Y76=>67N*))I_7S>8U9ljSgO$zZRSug)&8$0KM`KEZ`_v)SAt!4fXZxZIYO4ZoVg zr%ul<3C^t&@6Mq1i;w_MEotkGd!Fz4I(jb{?2 z4;B(QN7*>X+`dVUm}=)PwrnX2)t#frt{Bl*&%PHuTQ<#oNihU+eC*6qssK#tyX8Pu zok{%YkdRM{vsuPSQ5#iFjYja%?@f$dr5V#nOBbR$5cFFLyJN(U$57ux36Xv5uH?(z zmbW%m;qcKu+SX9wzI#r3`!9%ugoDV}!mTr5p2;xHj?rMgz?#9w6OZ zy(rR*FilB7;bJ?`eSo&L)-Q6-I{#vc+wn+P(fYf6^RV}G0}>6!7ZHKqY*5z_g$`9O zsCrlzBQ#l@r41zXSP;q~O@0p9^2VHDAs8ocKjsOZppK)%*>%Z&{83ID8ygIMsXJcu z`iMc#C5w(DjK#Uo-ci2O=t%9>=!JPvnPRFQ(fbsl_hqFt?HZ}wSY@d{jD1VnzZ3#X z&(<24+kh|_f94w+J#}|?cb92emWHagH~rBM3kx^>`va=>&n>J+93=uTVbjbL;WWh$ zClIwZH8Ac3K6@18ej3($lti)NyGnLfxEx{G3kB4Coi%UvFD~F%WuYG;?{Q^f$h^qN z9PT+xz$}QX>|-$vDg)$10?`7u#Ruc*hb<5>1dEd>^LZ}0k_YSOn9>)L7FQWp2r==i zwdw;v>Fv98CaX@zW>We=h4Hc1(S7XYRJ+$N;$>WO4-y{WYy@svjBdN=iEs(FBiP3r z%{qcV7>qHB1!FI=9a5l9JzpH~a?c&Ehmt>QCX|TJQ<+b3=u^Yvv zEa=|)%Qik+M%7{_ANNJrXkf72-Q5g#K@}uapU;aG;3`K{YS|nTfNIl9RhO>c2c43} zFLt^o_XaqE%$b!sQF^N^yeUd!!uCg}ORa~+ULNDz-O|$1-JNgR6w$3N6F;IA2=Z^c zf{5P;HExK#j(cf_kFAFRmvAiE;-K3P65YC_bW`{L<23g)6eQla^dOAESpE9mcLt8* zRC)}q0zpT{$0k8`J+!gQ7-Xus)F3*v*T)IysnF#KP?_QcR=v;|Q@&5rN!#fN#;zsD zWMgihr30=T^{AhAvWCI`O7DkEj^QbD(j|fH>s$>~E0kgdq)Hc5wrTbIddvNA zkln|Jp;7oPmNA1K=`bGLTnbY^B$!V8C>RNz3E~GvX|n6OdY=|TQph(i%Z||t$1)DY z3!CH|oh>+!$+^STs3Bn=u(A|V;1V2k>gymXMlSt_;X9ZbUhf(*0nBcz_6vTKFlml3G z2#GgR%H4V)oTO1TLdlY%Y;d)RMt)EylNLjE53>A~GF0su!L%-dE|$zPy?$~I_xULS z$O_EC)iCw7w3Mo-kV~gWGC4jlJy{cufulE%hhy+WQ0nR4YT!L^`en;oHC3#=FXBAw z-q47sSkwvM`ILRV8o=?5sju0sn5CZk?0wBu>zZsa_C}W6O2^~c{ieQ=+tadFDFfFB zg069$66x8kw;2Cy$Y5GNQd#<_*4=&*k)E+D>Pa*3_n)ndAi7Y||BEu5X*qZS#`!R*e~nF%I;Wf8oV% z(Kn3+OV+-ly-pp+Afdop-SJSDwF-vVEZ_{v3i#$PRL{ha1p_0x)`j=MbU;X9pJ2Ok<>YjA zsxhV}Ex9r9nT}mtyTMcNfuNs-TgqS4EbSYpfXCLOn&iFz-EsDeX|}ocysO0$O7hAi z5zihq#tvcjAm_3t4)yJ109TKlGp>tO5!NAyYJd-{qjf?xWHVb-a~OWO;b*b)g`dmN z&ew8J+CZ;S4V+pD+Y^ZDn$J9fIiTK3UZw0~GSR1SK&?UH+1Udz|@ z?IQgBd|ky=e1(p{C`uoWQC7}$t4cE6aaXGk-BfQr72JBclm+9A3bXCUTu7C2JDFt3 zbd`U4S3+w?8e3r{iy2|r$I)iEF|aB?v4vP}vVj20p{RPf#)CuH_UMRpyP@c;yQPdV z4fxprE87DDHKu8ld+-fw1IsX$t-vIE6uHur=aj>KjbP6WE)ar2gsS<=KI65!w?0y1 zWQ--E!l=W`4o6%USz+Pz8Z@+$Y~&bZpeN{QgCy@!&ShQo8j%-sm^5lWSvno^z!FkmeY@ zIvM(eV?@yDgE8qbozih1?PNxNJ1OVQnB`>-QT-f|bWOMHlQ39A{aBsC!hHR>%v{k$ zMGEEiIYmpL`h^-n;_g6eI^_i<+ z%ZSxJ2V1^CsHvd#9?0>lZC>oET^EZw-ElPK>6c%wtP=ynHM5(l)(foGmi&l%6pq21%Lm zYuQVao>L0OCv^QvpU$EPuWMHIH`OzBa+t+rlx1lQK^O(T3D0r(X?-orsK~HTjtR8H z&S!7)0r!bj0<6X@A=im$@9U)UWYL!H-~n6Vnr=~?ft#dC!l`fI3z-^D2g99(unAAroP?rkV5mwbtXO9`GHePQQ z#{I+%$CXwey4O5$ggO(H^xk$XVT=R((eRw*ikh;3Wz0J7J+}|74akh~R4|`GtgAEt zJ1TCE5k+|%BUeRZ3}1j!8i%X(1qbCQz?@N8DP)D?g!Tun`0utQ&yn|e^iU|PBWcGE zD9ZR1eSSSw-iIU259F-z=;*a1L`Y;(k#8djI_CaJ|ID^Dz$Phr3^I<`gGfn;Hbm z)WD8v>G_X} zt%%5QGD3qgD?WJxcy8|)U2nlT8a&{k#NKJe`9&6#Kmw2G`nm^pWgc(n+#8j+i69VU zX!V`TC6?)g+d0oLONL0!Ra-8GG(;~lfqt2^F@SSF^9uzg*NVeY13ZQ-Du597lNiFJya$)u)LZ-X7%kfJRU%B07n@hDTY zBWW6Qi!UC&;pwQ$m_RdM0mPRPLGH328S+Y6u6&&&#IUK2i*~zK5ee zdY7JkOC2U{lF1#x^+VTZHnVx!OWZ*4+khw$j-YmMFo|RYc$d`6O+5%bj{pdSB!x;; z*kC2u?%1iL-kr)5KFnV5D?5CaNFis<+vyO{%SY09kP|4%z34q!RuP*2{X&)@oUk zoGlVRddfU;W#YMZ$BpyB@Ss+7fDl$VVF7^nbHO@HBmHr z_dymkh*1dD0?|u?{rFI%$8hxV!&PBA832n~Te!Xv^6VTlIpLKus>qR-V@96-xq=bv zvNmoEx`^|H0jyo z%o>bnyuYsK@9zGrnuQu^q$HT&smR7_AWfqUq~1I=*BFj#0+yvT zQW9uMB5CL$CQ+{0lAYte%-&Ir+y`=WhEtO}+JlxZ9y}k)N!dwYQdUlJ32BCsVl8jg zvj5%5k@io{l97`kllIGIc2io1CF-xde|JEfH$`r5TV^;JOx4i~N% z000000000000000fT_rDv8NyGUENPxWf(vAM(2NE)*RW5jZlPPP$(`-rggx^!U!L( zE$K>!>nIA2eP9{}mM+#U4Is2&J1qrM4J<*OUI=RfTQbLmiwP!hvxH?VCSGhYGcob^ zJMVkeqCMUGx|rd)V6{Ab&+qwof6w#2B|clSC0p`;77V;r-_AAk`puZjntZe+-rAZc zwIy4!C6A$4T3r*CZr6nJN3k8oaDzva-X}4BY)iIeOSa^36AXQwwsa?^b4+)6_^1@i z+6I|#-M8@Hb%5%M*T7Osq6F?7LhEjrHGtCc z%=%_L^T6N%ZKeBi{p>r78xHw7Lgr_(3=0Ve(2}c`Q7up5s)KsA+8Vu|2@1`G5E1)- zE_`z}e-I^X+D%tTK-od8lins=yfRvjP}=3g#RozH+~y#*2}j_BG6*p|rS!{(O8rcd zNc*sKpNJwK3;$6Llmbf!B8=9=vyAI{d&sJ;cYx{-J)iu7_0WII4Xqpm86U~hWZ5@)v3Y8L-C5yl-BisRe&JUN+3QB=<0e0 zg5ow;Fh>aU2nK(bsm&-z#x{yg(I9TaAj|~%Zd;im9b0Arv)?Sh*BSF zgyGbOj)dSiCp@l{X^111v=!39NuQhLkGa<-^dG>;1W^cP#B(US1&&VfIkiPtcj*!K zQ>eKFv!45r9;ZB-QL2nQMW(e58*0p_oC$p5S-M4<33ScR2F#iK;ApMgrx!94Ww&z- zpKvrjQzm!gV=n62O6*|e=T6tP+YcIr%F0`ISo&juw-PM}OOsYW`a$=oI+c|hCYG7o zYlFAezOE_qUvEcoSmjROC-FNQbZ(lp+EaOb^`B3`!Rh?`b~na%#oPkn{b{98ADUZn z(sSzQsFpuRsu6ik`P%bF%6KsF=_KZYU?ftg=16sb`s=Z5qW|_+Y$#ZN)2>{3{n)f} z0{ZySXbxbl<;5Pip3GO}oXs(Kr>k_zWD~H1a>iP~gvp)()0r41uwJO=yJxIJiChHO ze|Z`c*I`}Rq$iT$?V06iaW8=xp*=MLbK6QEaFvW_@!#;yVp6Jl784m&OsBPUV-<{l zvTFGsZog=3s25vuFTKp1+V!{|asAybCBc{i9bKA2P!6Cf` zQzjC5Vkp39S6#CHWJG{!8u}x9gNhCtVFgM`b`aGQKx{dL8MyO6su)Td3HiisM6a$P zVApIv3@Z!Y#}8TM5JWg)E~wbv0kihHlKJ@LfL+Tc%gEd~^p8$h4gp3*15G7Obt2vN z1x#uf%RP`+V{TKf33Eqe?$I$G>Pr}h4kl#~DdPRakDPTGE_K1t9EOUYk0Mo*iJzh- zG|P5nM{_4voX_Nvp4M%Q`wqaayN`T2oO7($dE`@EqB{x30p*wilLPW<4x-wjmJK!PeOB~B&ddF zP@#)aPPSEFL@m2_srqGB@m<^DuZpXvU`;`#|B}c0)2XO~l@A@vztSYnS5JWzqS-f> z9ud;I%8gd9Mu3&8hnTYJ*wQN}4R_CuTIyT?wahQQ@(u~J?NGp!y>8<9p*UZO(Q)bt z5?k-V90b^Q`PMzBTTc&HGW#JZvkqGlApY%{Nj-!6R=lW1D^xWTO(zViAhx{pqu^(4us#~Qmi zWARFiP;<7o*VnisZ6E_Y))}XhMCs6lbPC3gEwqvV)pLm(!^W&d{oTN>!w$^|*XThU zVGz~s&S~z;K1=sQ@1Luhk2%!y&=&+4AXM-P#R4cpae!s)c7f-6;r9H3Vv!Wab$P~j z)PO={&`}26OpAkkbyri_Q}=QVmfoCub;3gAEmfZY3=u>ri0{G(0GFlV=)XdMYS^_g z95v~%Cs=)>Kj^u%F47tmB#0mawisUM8Gk4Nb~SBG2F$9+kCWIYw&B@()}Wr#4cEfU zeJH>I6Q0^>uYfoHA95U<_{t)EFT%|v`m8kCs2_ouu;s|yD)b%|hgqT74jjkn&!p)6 z#Z%>Olcg1JE9(not@Zdfywj~P&n?ey{3|og%H2qiJ1c(nq!b$?FQt{u1H()Lq!bzw zAjt=Sw|RmDuVu=&&37})vdjEdC%`I{R}jbD+xHd?4vJ&NZhdgIb*a+7B*+1V^I#%j zLr@A_8*dMLleXdIX~J8cwt%%i@J5U__tj;G8g9apm8;UU#9*cWx&ws=ZdWgyrt$c9 z@8-Nd>l$aO(!`#cR0vH9FuXxq)iCm!Wn;7zyvI#>`<+>0X#iUq$h=b{K}erqqo!Ok_(&f;C>^NwyI&lff$_fZnuJvFQxPkADO zt;Y#@XL7zF3o%=HgE4`+4|EH`tANo17~NVB+w}u*U_S{^QXi}2L73e52xIf(RXPFo zG|V=_v98F|IsuBb25c?ffGx^;&JOTI$$-Cd^uYl*#X#DSA6&!R$141yBtUXr`-RRv zI{ME3b8z&$*p2^#?>^bP>?#%;NgnpZDdc#Kj4|thS($%5Zt6nYD^lusO~5XjBuRih zwcG}bV@lh?0aI6L!0|8LQvweFBz(S(xoalT=_ATQRGk2AIssM~5g;SWY`ie%mITP} zdc4}QW>KwY?L8zw!DHM9IMoy&PxBL?WpZx7=c~pQo0|52&&{F3){m!qi%WuNlCct= zyFC4(^O|W_ZUXFI@ppKW=&0sk?dfSY%8-nuFUOddW55m;Ds`p(`D~Q@TEdAp4 zDSev$GJ~l7eFNu%rp%!gpRbmedxkQPrq9yt)%4>Z9zHe=XU@TKEH+8fU@UoNw}u$2 z$j&Q20jgtCpNADbAo|=P50gYr;r+xjp>86;Hn^x`XEgtoVdy#?6&eB7t@sz0+YuaR z-2BvL0+cmU4absVet&1KW!L>aUyZyYMn#@(8wLT&{P6bmbVSYDnq30%Xu*3BPX!fy)pelF9bb z={{|nCi;{eLvB%B_t6&>U;F$s38?2Mz;5PSS3%qi0`ytGhzQtw0b5)weD51_-f6GW z32^@;c8q?0@OPytvl_CFWAEUCENz`os(zeO3<(fMEsctJ;VuvtYHs%9osLZeNN+bJ7C?Z! zj(-eT9PouvI&rP#f8m0Hj>WF4<$g&!hM|%0b?$Xt3??^?SLFayukH30ZJM=5`5Sg zcR1QMoZ=F{!Kb$xZIgHH%CeRp=2(Uhj0C~?74h36V3YU zH}))%`QC9wOvnT@Ko!G9c$t>~53v*%iYs~aOYowAEt9r(7S1~r*e{O2xc)2MgZlY$ z!r%x2YKxmjTuIR_D*C-1k7kz3z<;TeZ-%SmcE#`r9Gj9yVDhMb1Rn9O@`%GGY5&z( zyHL(cfNV72ump3V?j%`C|7|TcNZGq3187r=?%p_D|0O)v0?!%B85BjHCadFSxi#5n zWDy{}9RcIEM84g}tIn=B#trvvGxR}#(MBG{-Tl1on{UwEUeOFFB8Y31fbP8}KGKV| z4WzAG13vrJOW$7o{6y-U$nM>SjxIvfc_x_7=}JAoRBcwmS2_`MD2oD zJzw%3`op4uNHzg7b@*{(;f4H{@HiYTQobbkW@(ELtz+U=V~!)RhiF@0!uiHXPJor6 zbErgH_O8ee+|}Hx)t2?*!k?_oZuaHGLjPf}aCBxjKIUSX#NKShF57$rhF3BKh@)HJ z<67+X%=ch_CcrCb&?qbo85t`L*#9~IapUl8ZKBI3kbiid@;3>P(o4G1?*R8fS?~i+ zAxPDsm%P^<;J=~>H=1VW4)(kG?*I=bH8#=I)OYE{F5VKz4!USU)9FKY@j58Ab{`L# zF(5$Nioe06vw0ghtpx-doTMi7PXrpte|TCB0u*G4Yfkwkh`9dY_T}-(j!SNGaNxOa zG)in2P5B5=%C;tay6*7lNEZL`n5d>C=QH z`SWxmTg@PO@96YA@5C)4g0;-Z`~)a1oRk*svPKZZ2~ovZhXK;>Eb@)LjG!`yya{ajsEU7fd4NqLUc;u95!0OL zz6@Pp=bn8%72J(EJDZ=@FvSedE%0|TGos1*wSfUu<7NT-YY0n10<8ka1F$*&=}M;r9wVTPC~7Stusoc(yW+@($2j6dg6t%Oa!-|0#8BfW zI=G4*8N6p!q!F0{t6J;l&=$~n8MK^R;|_^tTzFr6xz$cW;OlsdY4!k?Hh({S{Y`${ zki-^eq3N`cY(6LF`&DOq*yWO$macaJPcLYM+e!_?f` zkBScWlZH3ro70?Ic`vrl(;@grTyBPdy~4#FhyDDGOo9E1Zp{?<92M<>qGSD>jjvk& z2H3!>ac#}QILu7>j$XF6lG#F5H58xS2`ay#sf!8d8 z{F+a=yCuxH(UwS|y*L(1P&?noak{)}5xZKno6$T zV12F=cZ=ZpML4`!oStt6bs19;&0-T7<620gC*fV0m7CE;s<5__j|f_j6Jyy5w;f1* z&f)m*_nhr#Q^Ico&iG$DRu0T?{<|mB1)fA5d=)5+g7z53}@ub zlVqfVJ~EE8>EkfVoMzs%tSWfi*toV$rpTMM(O|5dZ4sPh1pAL!NxqZa9|4vTEYy1+ z>&2Z3D~wHz_vQ{jKv_FI?6Z=QddZRZr4zv~{ER zD{)!jIcX^3N4@P!;L9@$y_Y<}8f<;^e8Or+yLKb$MQVEnw)4Z8k}yi0+na5Kf}zIx z@mhBQY;7pm0+~6Wj5v_wTqbmwEC zWf0F`qhSg~=P?E~8$GQq-gPDXvqhKXiUFmY=_``M-DKZqEEi_I?e|dqD`~mwJv=#&3Dv zv+zE@&5wv$x7=w$BYIhMirZ8CffFBtI$_UP9nS#bx&x$6*W3#ahpoOqBOs$(MiN?MfsAWP{(|G0XFXp;)4mS-H5vIK0}$uIsVz8 zaHvupCO$D3WkdxfNa!J{2U@z0q}dvOot2$S6a|qsv)GIO$;`eizR&^d#nPJZ!RWng z24l9tkVe#4V;ob8Gw>G24&PBe@+aagm*{^2bj_DO3VN%}trhIF)VR}SxUw3rc~<67 zUKrBm9|SC$mHAKbT}b50Dn4*FwtoC;Wzlmz4pYQMs(t-$VKlsFusiB5ITH`mKik zajvK3$$m>zVq2Rzuf1pD{{n`NX59|c<($^jrDm!85k z0s&{4`cY;U--g^iLr3@{~nOf}ql5iVD`m z1a(F+Kp2Ms8Z32etAv6$gB!s#Z6p|!QraSr05eHrU_s)K#ZXCzB#pChr3oxtV33%& z7-KXs@%!Grx11W(sN+OqPvZ2^-k$f}bI$$leYfU*RjXRns#Z0ZtJYPus#UFO|4WGi5Vyu2_UTa_Q%UKp;d@`do_ydhm1cb58;SE8+x z8NMejgwdI3Ym8pxo5Up$h_~TS;*f4d6ZIY4h&TkYTG3wg(|A4=yKV3(zXG9jVO8J} zRVv#{_+FWv-$O>dS6iD>)V(8U`fLZ`wIPGm$Xu?QHlW40<3b76@<2*eVK#3S3v zdq@}(kG_XVjO>HPq{>;mtv6a<~f@uBv z%iB;Ma@|R*+s)OtUODBM3TPYLX#(WgYh?w88ykMVKX2i^UB7>DUCV@rOS}wC3dWuJbC+>Yl#RjCZiMm9BZ*p5LG7!C6uGlzx?CxQP}m%7Ocv`4u~bX7Wkj zo;ENZp2J1b5;$U)U9E5cS@RRvw5c)P<0ds_7P@iaBz=;H5T4jL+BfCP7?O=MszVC5 z!jZtWFNk*wmVqJckggXs6hgMybQxF_2BO8`?n{naoM%6cI}T?mMn>G9F#S1%|0q6X zKf9DZ8r(*+rsPf(xm83>exN2oA)_Zeqewc!)KO zsTHo7MEX@wHl?=)lk{7j0tW7AOU~kO0g81(?AVYM8o!F0*g-Q| z2s_N;GDj3bO9j5?258VlR0{##-`7e9?38eB=$j_r=nn zk#1W$_DucIm9YaJSa2d!ix+%BsI@=}9B&1zO4qaDl4lx9cD}LUeSB=`G~V2IuZ5x7 z_{cY&)I-Q`YOCZ~=%w_8DU+arq1saXTA$MVHSf_FJU;U9#C`|%3xKUoa8I}ug4v~# zhSKoeKqM=>Ttxe>MN$gk*1Npy$bvdim7E#HDO3;ipg`iqXUkF6+*s> z!VZRup4sMQOKoIC_D9z4&nvfwIN12p?#-i-IZ@(hbGF71U_OEGJA-XbnF((TF2F zH@agzP+u$pWI?d|Pg&2-In8GANA^9zEijIR!RVbdJr4;pM&wX&L)~|Bx*`7phT%U1 z;@}F7tB?Cj#DrlPF5<#lkr;d>cH|&l_~{!!!3<)|N%|!ueQ1tJN07A=%$d%ROw8oc z@zIS9;-PFpWS?m+Cx(X$AM+i-WS{MOMz@kqExE^<^IBjqn(=(H71H&AMA`_n=-j8f z1H=7F;`KrT{#h_A)3U9Y=rP*U<}hR=n(dJwL}RaO9rSqaj5sfR9Bwc3_-!>>2pce# zEpylI8a8hKL7ts;H<7c0w_g6Sg} z>TlGvgwank4Rig%Ij`9teU2)zoJM*dz(izEpXXG`IRrD_-#Igm=aSEr=QOWHJ)LXL zbV8{#PN|Q6X0$8Q<{k)0=yZ)4SM^$RrdbkzLGB0P!Ex7p+)heW0M@e}IqK+q1s1dT5G@6nt zC{37(24q8oTp~YNX-X0{#&B1r4)l(SRilB5rjSR>Sw?u>rf4Mfx=jf)jGtY>SB!t2 zB%KG(Z+_I2G?9KxAC4Js`fM-JBVgBhjat2obUAJ*O-{ZbszwnY{uTXv6VcFeCaH-` z!!rdy9~HA0{kLB-UXYzS6dz!e_F@9YNz>7Zb=7SR0S>{N z4(HoK!Q8lJ5(Sj8NM#e2oJu+)4Pzodf>V>2aFWVdrdFu|k2K4~AkcV6n$J=y`f3Ph z(J=z0kcIkodLj?TyY-XOq#dunYWi+QHjA_@99<#_c>3AX|LdfQir-c(a55D%BsouGsr*$;Rw zg|l)6*dFW*1v)rawvKgW0}BH+iGEI-f$ej{N61Vf@NBvzgi1*SrhY8_b%^YEP%i43 zeHV=~R7_{isF!mHSiVu!7*&?n!%SsWtN;TiuF;x>MS&q`y!b9*1*m&XC0P<57cNZi zW*@k$2h9)%X_U`zOrx!m{fDJjvw9h+m6RlG%&IZBS^!=svYBGENfMwi5?UU07-ET- zc{QO5J%|MqJg~ggGDHuaq}anHU7(1l7*3|jE1kZS61_>ataD6q)UkSFeM)a`9E}CZ z42_9_!f5Bogd_$N>1akbrS?u4HhIqwpiH7(m>Ag3u2Y95z=T^RMUU#25RJO?e5Sgq zH{@t}+ACNr_-SJDu1|1t-zUIB_g zu!ksXl|=m|L4an?)k)t9nf@(6=^x1VU57a6=UAp~>iR2vFcCNx!TOM;Lwd;-Vrqntb4f}aSTOv_DB}~Onu4g+!|@&KS1XLs;pa1YeVx>c}%L}mV1D`(X3VB`W_>i>*XGxQUx=jsB=+l^myEB zNPmM6j@w`0O5(1q&<&0qR&H6{ zk(ia>PZBy%n?@H{3b0559KsUHH0L92@2z*0e543Aqe0lTRyoY1ULV3}V4JmH2b1!I z+AbrCzPnblV2O~kmI^Jf-!_%(q&-Y3actf_f+s-%Lq|_Ejl7{ELK&mFZbn-Wpso=y z>(wC~+ym_80}l59&nEiIi8+L8okU#-9bA7_SX?K)G0mh4VK|eRLdP!iL6SFnfX&PG zCyAL|V&cVw~cYZXIp&07a&$n5S*f%m3Eso4xEPCa$r0>11>s+Ik=fP#S+r6`tv(gu{`bUsP z(usX(_h)1ppiYy9s5v7e>v$IH>zJYelj?_dHFQr zk-vIPKaUw(y1uKd8jjz!u>Bj-v!Efhon3#J`Z&Sx*C>~#1OYw_7}5P-5#VTN(d!K= zo~(nSy8iu~uxYqQ$JoWZo!%;Wy{WC`+7vd^9}{a5-WzT+7KB7|0q^1CC@nGK+aUyx zHzb^!ZSI{XiJ8mR%NX&A zKOjKn+cE?wBP|JVXpFFR*xCMc0(@rpvW86p)JefWSXVc~uP1^4&xf$&mIP>+svqZqi`%-Gl^43 zqMCL)lhPTJK5UYn=o17e5~B&Q+aW;SV?Nz{%_L^{I->3M?#PtdGw9~mJz@wL0_5X| zs1J&Yn&QUBL<&uZ_6k6m&}C`}Q$|m(0X`)Ga&TR9wWB@N4BOnFp#CHQ+Ch}b3VTS{ zLBNLk=u-2G#d<-27M4Yg?>YZ3378<&na5+uIzbj(Q$N3-u<&f7g=8lmXJh6%HTd~} zipL5!b74kolV!3!fP$L5dW~B(>4+OHU40C+X#>g_(eq{V5(&6T%>Je=jGgy zGm&D4xnKQ)h_Rr*)1e+5r6G)=$`v5Viel@QLuzX8OOx6h*a<)SZ)4*cf1ppVh|ORF zqmlsm!bjgeZs>;9!Uh9=u|lq*U^`;xm?;l-b8RE|-Q>uUZdq9t_H4{~zyX!i1;W}cMVqZ1py!ZpF;!Uj85R+taFMLOwtl9B{? z0^=9 z1;OP(TcxHD?SpwxK_3(>H6RKpqA2)%cTO^6)RDvpKFoQrsopvH?m6dvbM84;ZZ!!| z_O$ZUR`^34<5e|fDZg0LJUx|}D3^eg8SEKgBjdwPfS{c8rcH^}=-};sx9L^;hH*2I z0Wz>vnEq%zzHTG~B(HM(+HrGXb+Lv;fJhgzb!tnBncAR?QSRuf{*lCgfT7Kd`DIj>Tzu=m^ z{yE7x^ygs&y98%!s6%m30^CXf@d(4Ddiv#=AV4xN$`Vqf;=Io zylSFYzEZc88Xy*B5dm6wIY0>l04J+;c0jBVe>IP2 zdN0Sd+oasc?lVRFXIGAV_-7b4{u7JzzSwknQIWNU0PV+dw~dh~Z_bxHcCZRmq?$ET zuOt7_=|u!6j=>5hs%^}CD6+otj$uRXhoNg?5r*7YHNMDt+>DblV?+NwRJ+=)hHpE1h{9-wOzEOhT7s5akt-|0on*~UOYs# zYQ76Fwz9(3rz&_!A8b~W+*2hpHZS zF*y{Amn&uy#NUI8SJ3&W$`JxCAeXyzh@`7#4!@_Gtuodvh0Sc;L$;(4_qh7B9^)qz zR3ncLsrvx^6Q~w#3*h!Nl6~%j6fj3BjLD`~ihC9ebeUDEmV(&;!^2#SE_tg4lUF$!lO=48?Fg3gv8~;&3f)x)cUq~21uh{c z>%#4EDTgMPd003r|0H=6L6WobBtfKlxgYow;( zBlE@II+2YAIpzT7xhzhpqSa_U^QNE{mFV*7@cm;+m;n@4?EGiumvrJl;$H|O@CRX- z)&mS$TYF3DF1E<#q8eb0B0vq}&F%HYGui}GZOj?XfN}+6sY$ALp8L~aJ`D46dFJ0< zCm0J~$Y~bt1*YKkX|u#QiL)`Jp2Zqq9`>LJ zk_^Vs4+Ll=R!qHq#oVqKUF<#a`Cxl4dMSMS`fWHH_M;}eQH21PTx#ZNGrsmy)8)w5 zuL%%M^!@a-*B_D;prW!3yV%q@Wl-q?XY_8yw6=Lbo&b%;<#AnaJrn~a>`v>g>-sSR z96HM9TX47w=hrsqdiT@I+Ji9}{W%wT+2Ysro%1{Ox$Hjl-O($qgJQi$KXmHdQ6Y`( z>&^ot!l2=1R@bBKR;`*y`!N9&#uf=YKLpUc6PzSpP#u|?)nDTceH3Unwj+SJrw#60 zlj&~nIAeU>moc9?i0^K<@8^RG#-tb+Xh~-`A7uT!h7TrjYkQ<0UkLhKV~fD{V?94O zq>PD3j!x>-+a)jhp$T#iYW3*+CLRG!>$jIP=aQoqfDKyg$aJ>>&$elC{n@#zp77aT z=aqh|P=H@=!w>=)Jh=(}Na7i!QkRTZGy;NDS{r?&%vc<4_3SeWqsVur&31 zj7@rCcvkN+l%*5M7y5wb!=KwfeY| zt%r1|0H@JTFXcjt1;Xnatd4pVToY8V za%KRnfO9I6jGlB13u7&!nIaffg|h82ilV4B)S2RmwfGo~VFcs#5|;aB-U4niYFCTI zgW3p{Aw4yqYz4`+EvQ<67AWp}ZpQDwVH@M5oEWc{am8Ec;dIW$_c$aN%584W(}-V* zVhwhVhak`|qv^qna&Cr>aXO&Q3YDTh@E;n47ns=y$xfSfxn=5(t^Y}yh*iTbDAcYP zqMeEgs9ER`4&rS4wCUSbU|bp-H*xg4OoWx^s-+k@Xm>VzkfS9_^Xxi5mf1iccOzkd z?%t-XFaR3s_R;;6^>)LNWcNp#1#;?H!NAqNBWZK*w=9W2+c3;>IHag#(fR`@&e{h# zU~iQ8(a^P`&UGpFV4-nHe4%Z`NMj1BWrLDDVF??gWOk9wT6Tv0{n~{NgRy$~Q02{Q z`Eg~hh1yav=o}%(dF0DFY-G-I-9!9z*93xOKi{Ms4Cd&-i>&kuCh&Cgn_pYS+_e{STnU#$bBsPuMO8{NBmEyCFHf{ zktRK=v^H@A;kbRK!sL)KFj7uw5K9c`P~yFb&x(`2barBj&s#!(JfHbBT3O-4LP_Tq z0wnCZzX!=95O&BG=yhwgJ`%T7x^YY@D=kG+mEwVg=P;u~xQh1uBW^OhmGSQ8I#F+V zMGf~5MPW-9BRevGA^AnTwxLyCgz_U&#sV}X;UaZ8o*;7xhKb6dmORWiK1x3MhQ*%8 zTzBQ8a*w#db2tOfjdiDoUsTzw&v*#c0!luT4&kT`o`r?YX(8*sI7I#bsE-A-vgM?< z6_lnt+EykrmjX(2h0dg7`{wQplGXpa=Ex*hj@azyye7+x2oUI7kz?3cHQup~LWt~KOy>y%5_~y_=v{Q<{&jwoMZsX`kct zbf&&U7aPR!#yuHgxmnewF1Y-~A;rdV8N~L|Bk@E{l6NgFQcC)0^xju`_xkEW8c%_R zo&GlWjaRqtLpy=&yjM`k-Dv15Gg5w?&Wg{_b->WC7d#K@N}VVk;laSEo|Qu3HC^4k zw;P%mH`Xm&g%ppNm%Q6PWw<8`pUBqBzxP_7aAXzxvCBJFY1?152W8!VC-oMK?r&JX z9dq^3lXjj0AN?omoFAxb#t7z>IyGnRhZ?O(%Li^**pGF2_N21^Ms-FHcEUpgJTuCGM zB&B#Yw9@1C>1X`7r*MenqVEj=HeoS{*$Bn&v3X^4-2KO@8oM$L=2%Y|Z?o5L$R|acUoJA=-ZIC^f~1ijTCay& z6}^m^5hTVwi)s@?+}1%`wqJ54!RM2-;u$g^ULhb`K?no-mIm$*V!qE51m^ca_a=JK zpm9*%p8LKlIpmE3p+xWucmo4W+UNTn;N-DMVQ9TJ5`s00pEqn$PGPxr}(66wnVf$k)-%ScgTqcOEhW39sSoMI!q%WU!zWjg-!---9^1tG*(7Bb*kmfRd1nLB+ ze#6R#pfc&Qxsw{7KT-ZJLiHn?KQ}N=0wR!84H%O{2d51r9xh$;@+2E{sA8RwvKXUo zt+s$^xjjYp=cdh>cYy(xx0Kz*e-khm?c)w6c!VY!|>jcBEVrXBr zPLTq|4;h11p9_Xk_#gIvW4(*6vk+lIhrrls#z?Wx2n^2>^a13@GZj&bUllfOG8aI~ zIh4ge2MC@-$&dS~S%KvQenQj<%lx^A0FDENOd?uysr6nxZI=)%?@fldPn~h5=Ph9) zu#nogHd7bNxn2)4bnG>ePyhJnwz@15aowneT?51AqRk7Io;lMtC_>L`k@3lA06)c& zfyk>5GELWcbRVdBwI@)tp`Qa|&3~9*e(s1#jUX;5=$@rT79N%~|X-n#3a~M|Rt) zzPoBEi8gncar^|aCb2aO5v`;l+3x70UTS3H!3+K4SS$`K*VsXR>?LhOX<& z`#Y0>lQq!qHJn_y?Q)izhgT=XglqZ=yxPZ zi6+f0KI-w?b|A}oxlTJnA=AX0_iAbb=+?9!UWYYj zz58Odm!HjrIj`}DKqj?v)@#f@+IA^UEl|bRy%OnJ>9>0kL3|lLjwGE^w_?3n*rAO% z%dhDjI*K#l)?$$<0_i-F^G~icWI#`Sw|RC;Is@z+Q)+DEk*xf5Z|!g5?GhP3{PgI? zU`0`M<&tZyN+A`$TCe97ucb{-O3*Ia-1C%YtP5KyLM@hykw9A)QuB9X=Z}VUF zFq#uZC-qTOc5y;PeV6RkE_ds`RD7Mca}tV$H{raln15Wi`j6=Ge3q*G<_oD;jzKw( zf%8AF__I)o5T#G<%*7e5=hHL*Gf9(p&`T|L;q>EXO1R13?>c{U@s@`(A*eXnMN!vI zv$Wuo2aXi7fWEasRxsGZQ>bnKI078A6ke~}^ijNm3Mf#5Y*68*PDY)u!T>$J>99ba zREZjYStPF3_~sW_+Q?zFTds(bY7$h>~&rZ;INtl zRYu*Zd{R1=!c+fEFDUO)Ghd9}Vu-oUiKQ^NPikEBls~S_`Xj?97ZoK6H;ssSH+A3j znQgBdaF{l3J5H>Wehrq1w>hqMTXIS<>aKqYrieS_l;~b5zcQ%4wDn$PZhte#Im{sG z8t?vFRlXKtc;%kWNuRih6Wolg^hxwYBaPO zmldV|#eT$kxSVV{q+5bLt|u%7NQuNBTqz&iG%{-cCe4;-^eim`1(wVO0{|LnT0JUM za1i*r(hz~thpE2VEB6QXW7vbND!Wn8(gH^i2}I%}&gSaVM4B@e_?*%ef|2%;>Y4%z z$21A8w00SAVvKTD;%#Qq*B|;_nuH1``H?lH668WL-U9I3xuFho$YPqwQ=*jM0HZi?`CH%sZ9FTzxt!A z%Z^HcCB1x}H=cFp=2$lHlq8CT96-rM34uBTOAKTX&g<*p3)f~Z-9KJ}v9z?bdmr&1 zZ8_wDJSFtP1@Vz0Ai-^dCvCQfw26bP8*Hir4|rTzdyB;63#-JP&7n^+F5DaqA_9_S zdN305Tr|KirlB6R=U0&+2tuV9T}gx0(50!FBNIn(6D|UR!XX18$df;Ma{a>@{!V}F z^_cV>*FJWw13sn8r8BnREjd5%SntO-X8{V*#jk)VJ1 z=>=Ka^G=%xG(d6?j3J+R_aUpO$SL`n$iVN5TLCcW7ZI=ua)DYuIKCz3#s0gXjU-CQ zdUen#smeBGpVN0_m9&cseg%Sl@q9V(MxUCQ*H_Qujr&r{1>`bqaa0aA$=C~Xy|x&& zhoVEYZtOJ`QL%of0F+niBd`j7E(Wk!@ZkaF_^@3uv9tWt%6WnyWO9Ksmc9Kw)Fcm9WLcl;y8>> zU^OpJ%706#i!)saAXze3NK?44v!3P7Tjh&QPr`ZA-&-10O>jDvsD5a(MAHjE`LBOX z=IQFa;E&n(-BIcM<};)va|q~8srx5mc+OY_Yq+KfJsMe2@(2)~xDsQ5cM#rf5~3}E zr1Pck12b?4Ha$bDd}UF7H+XtuoX~$NR|*;n1?~`=oeht2d`^iGKvTxvBXT5YTjF(i z+pCR`G8jBY$$B)olEMiPSxU^MawJjW(_K+05A4j?hu_I3<1Qp$yZaSi$c}FGO)+GPXnrfA8>mPIEZ^%Gd2kF zpRs)~hsf}Y^=U9+rK}o!zLpW=;Ps?88CL?CKAaRv6Rgra;Q+?l95C4{JivD{R`Hf~ z6bt!XVD<&kfRhrA>oN+Wu_T@NI%&#Cx<%WT@&4Ql_HGli(;e5c%rNJtHlTC7xUUfA zCX^wyUW{pS0r{~joSs{VHygQa#Wu1sMBq$|cXTEAa*GC06d>}gUWf{GV|Y0BPMsYDK+s)6Q&)u> z%~vi|67SXP0=(9_Vhsz3bK9R`B{@PE{_~Hf*mXEzjej#xDlwpL$bj_RX^&-RZi>LT zkLRCDOhV0VT6oH@S%i?3*hY=x#V)lpd`Mc0_t)b<%zZ@yu;F@ylIbr^4%{h#)uEWY z6yv-awjt>hSXQ-1N>taI;a{`Scj2sv{^~s=An{zlTpUdKCV~h%_))k=lP7i(=Bl!I z%VcE9d@DkrVZuJ60A`~oplP0s%ixP;ZGJ=!2~2N0165xQuwh3)e|DZMp#^NnZn0ySV$1BMJ4h6_RKxnmmj^@NMxSXUx zW)WI4cdc$KF+U_U2S|sI2xLDuZkR*|%J#V_=84k<&Qjb{eo2CxAeo|o6@xWW_k3ct z9~&KA*pC7Y+nyq*Hc@a-HjKI%B9xmk#4l_({*S}J9lHAI+1FyX8ja-Twl zFqNB>m~WDITWq^>iCvj)<2;AxDcbFc2S9@5u6HkOMhL8iM2fn)yRfMP!<+cdpAy$hgnj( z1Se(o17;NOl4s8zwZ-f&AD^6=y$?c3%O^DnaO>$xdx=D!j8BG=xRvbibxBVZC?0Xn zf?0G+ES=Ep{+zp{!t*a+{R~jBF9DYFc=0axixwJ)1H{^EUC`F_O`Gb`9cxI^@Xe52 zS`3^?3XPBn>}_QKzH}9`q|Oa{FboN>)RxaYOj9bNr*>f%{FWoQwsriGUPXA#`;< z|5XkVOGPTY|4z!!Wdo?i{1LP4o=>x&<0-`#WiUozIq4ntwhj1$ZnVi_VJbrqb{?Z2 zp?H8SPtStJb@=$rijh>c!L{s~6@hN8^Q`5>_`K#tlQB8yWpOF++R4o_CPwPey0Y<8 zsEgt5X9N6@vc;B{CNCUncdN zGuLC(*Y|z?-j;q*@Oz6Vj5{NslOhPbVzhnplG>&JN9)wEu~Wa$nQ>;T4AfuZkowyp z{(yTZi>COCuDKVmQx8*FHyTKmAYY43GXN?gfu;ARBNzRXzUBpsC81SL|NC?x1{e;D zi?yt;p?#68&=&9KmQ6lT`qp)gt~x$$JNLy%KjPaGs~1M1Czmpugo@OMSDBL*WWjIwaFlkGcB9JUp{F(Gg18^fMD`>H@MVR7kxopJ>_WXb zGwPvXJ>{7RFwchn&x6Geu1*@@HU>HTi|si4g6(<+(FBp9V|henCf`7TZBH!zsNB-I zMra8#UQC!P$G_pkk4(6WGG69q_V}eiIQ=ery&Q>FGYG_8p(9~edDST=o<2TWmP7h8 z*9d5UWQc2dusuT$i8bi=z_tn2-s>Fb@{3us-2USW5X2s~nKQBV z1e#g2On58Rz7H#j+NB3vc4<|OD#o9-9lFW1qq1jD-=vyu1G!{hbZ$y;+yf5COjn4e z&C#=c-H%Ry-zi;GWts9uXH?lYNn+P&C)j?c;(lc%3aG*IROU7)})2| zwK=qwZdBH>#lN&EJSs*kAs2nhrGpE8zamVY7;Gtcs@OWriTf%%E7CYsw8Vt(jH)X{ zxzB(`n_dldb$tMCyH_o(IEtkY`5q8+FatR_TER!zHzMloJ6JJZybfy{<8!n)D3&n@ zbLZJ4^eLtD3L|PuWV^*XwDrTp3n8=Xk@&92*{I{OE;0hS&wTQ`FcqNzj*42EOs#s! zu1szkMxEFWa{f_(Bq>ZgMu`d%j3iza#1lLtP2cbVH)Na!X5MNhPFeU*yzSGcrvR^i z-%fm?C$5?mcdNU%%XjMmr)z8H7rr^x-Z^kzZZt!2eycc`H)@CC#Qr)Qy8uM(6oZCE zp!4iAqvnIJ&SU0W#(&uN)-f;mPmTJytuA!R2f>KO{_wTSzXS>#b`PZWqAMxAwyAlKhuzfOJZiq<}S5HKC zTYbKv1MHq}VSty<65ZgbH*8=dpE;fy(1}JkNW07510ic{000;p$gQsHkbD>eYi`J( zRO3z`A^Ss&L8=u=5uSGo;pWKCOCM=L!2371S72i#D1~%t7?xMSv8(0(6NMJ831J=f zA6KP75bjOh7hp>LgawHn1k#cbc0qp74+cKlpshbV7bINO_2}3l5)tX4iqc2O1Hk-$ z7LvPfq>u{`AVvwe5fKsj5!uoiS9d|@k?FIfah>S z$89lLZS`khOA_$&E(XCg6=sBPMY9_$B3Cw<_@V7bc#BcED}wXpo6XZwwLne*6AQjf z#1gn*z#RE$4pCTV2fJyq;i}3j))F|S{;4yM((oCtaTwZr)OP@KdYygPe_42tgh`l< zM^#i3Tx6E~R>P%k@kKQyty)p(k5meSq50dKrggbG8}m1iG2P#(Uj}wxuHtIX_s$2RE|1M+K557 z7YNn7N`lNh_R|Y!GaQ^_9=ls%3N_uoKcpPvbv(q;1g8yCPLxSrG+OH zjo<>vf)WEIKVU)Zgc|uX<|&hi-!19HGjmkj<0Feq-S|c z1-sFtKF^FT{B|HRTBz_N1tFL5Vna_Wd31_^=|{Y=B6nuv&qE}e(IlP}Wt$Y5%`}XJ z#`bNxFY;yRA=aJSzc13xdEo&O>6omZ3R?sRVL6%7va|z}rrv!A83WerA+YJ|T53Yh+<$wH2 z@Y>YH0I}SROrg>PAK!zg@R!JF&&P%1UMF3mkB}L$w&um1?>lVm4}sEiWQfd#FW*Oc z_glN#pcG<9IY&q=5@4Z|cq<2TYh>!;3toTZKK_@#XYTb3l+D-#Zs5(a^f;6;Mg8PDv3=#|{|@gAk1qxb z-1j3Q$agaieX5@A5}MWIb;~L`5Of2ajlu$PJvpU5DS+_M@oL)m!cvG|V-IwHvfAb= zVgvPOjvHfeWPSu78PTCHs+&3iqDPmx4PM%NEi*a*lUiC4CV-mcPWn@+IsRB7uU*RQ zHs+zp%@*U_n8E7@`?waLFY`z7tZ%)o%dBBRAK)WIyJ!tW;1Qp`jL84aT>YMjj0Yp> zoAIr!E9Mqgvfm za?f;crn^%ThV?hwfr4MJPVG7}BiQUmp%6We`%NK8*SNluJ07HJ@N^f^Tk_c_fOpSK zCJ`o#WaY;PCxa;LEA>I#9S~P9YaU|sii(AhLaYd2rejc$HLX<@>&$sgy`YDkr8$-A z7c+}LkXVkuBuNi>H@weH2QD2kW*{PiK45II_sz)^f zwzO(Dvbxer`EyZN_PPIM1!wsE2zqzSW!I4dZ!`xEGc_Tp2Q$f|j5Sf^h(g!CQGDIo zYsF}S>Wb!U}2X@nNlB7VNaA}Z@o`obp!D&wT4fL1*?-#*a zB)FV2IS2W}{?wJ3skFwotui=8f;m_uUy;CnKN2haH6Q)q&w7MRg-nc&LuHrBmeA z@xn*fw#D1(2Mz7f_3gp5oKKuZLK#%DX%GMHVb3#b{wzInh(T|R0HkEaZ^+)(=ARWXAyWB zQ3IRQu279#S-u|SXu|!HBEDT+)jF@vcp&+=kiBjx3S{To+Fp^t_+pAKqhzP#12uW> zIfxUB%h{}3K-nalm{ZbIZ}HnzBFoa+z7uwJ@f)Kz?2=(pe4x2#w7vzGI=d@Kx!qfD3SI0BGLr&gruK zP&@{Yy`*R{zR4fLCpP*sdpcmN#v%USML zXck9JI{_bgwpnA3`f>N(Jw`2F!aabltlPC>5kH8_SeIJ!)lY&2k-Nz(mz52N&8uN? z`0{Lv{x{TZ~}Hvjvh zUK>5B)W7O%&bKeeBRxCg1bmf@>1WT#gP|wg->K88^ODHy%%j3Cb3wye8a>^%b4h{OK@bA8{@Szs|9+dNVL7Y+Q|O)RKyHj0$THa+E6(RO zeZ-cH&&xLV%>@diW@3MHRrNH(?|TM^^abY=X}DKX&(GoS%`C~hbCN59^YLApF?zlvz5$=pv6rp>As=Prj8G`%}| zrw7IbcXJ!f4~h~R?B1NyC=-+R^dHE|&g$ROPvP(Z8xMCPt z+DFzZ42TW^KM~|;`fb`44}YgxM4{FRcfRT?2~aR`3iMMyG_n$u__gbj(*SBW%YgSQG37IVA*#r_gM635lOvTO)0W<>dnsWN^WT-5#iz1C=-lf?jKn zTt*dTF?gg#Y@Gid?~^zska4$$?5^^wzJY30$7X&=MQel^p$_gj@i*5t7ni#DlvZIA zxR@yO3oFEgAN)hr@_v&>5|HJDdPI_&sv%KaBLMBPVtD2d#G};0Gm;rhg1pQm5s6Vn zTtrT&=Ky1;q$<|-e-aEVh$dS!pTVZ`4FRSbqs3(f!Bh!lkdjY51Ib;cCBxc?fMzs# z1XR-%WjmJy_?!0u@q%*R5OqqobkibF9tZmjmQlV&$eT0gj}Kz*);CYxC(@RvAU&q3 z@D`@npy3Q%VQ)@c5Ru+R_1cb)w}L>J@p+}FyJc7Rf-9h@N4r4!M` zOb_JJycRB34^900pq;JS8c2K^r(RW|Jh@8Ai!%PLVa^3y0&+`QFngL-7ek5ZKlYp; z0D5t>IBRc*8D4%hfUIOCDMaxRjY2-5Opb<5qV|e>qS+B35?k?jk}98#?<(h^vOST( z*9AE*!8BJ7Mj;;l7Ve-}8LorMRb88J298zBdEbGtJUShbwTBZ^7_~VgiP;py8q}~@ zJ~DCh=tdz73#+#vz%mBMsgJ_g+hGkD`^2NYgtHWX;Ne z5O>W^hN6q0iZc9xk53(6m%3ywCTKbwUOsl{Vp}j>F!BHb!lMDKCy@adoFT zQ4Yl|4l*1EScE4f=8(1$UQl*h>gFO`m$4T?$WEs|VdUp1!UsFoe&0oZ@P)lAO9BX~ zb88ih5lYh4JS+J}6l(MYMfBs!2j{Jc98EcgQmpl=#|VM7`~B>Y|S)VkM&^#RbUS_)xLUIU|RLrSnYeCn5?m>y_}jqPZb6{MT5*Q zA|v(CcdX%5xyZH)j_By?m1i{EqL0q61Z6Uey5zo&QrIxo)F#+pNd6!R)*QG&GU$b; z=ef((#wPkebGmfH*mQzLKMYPA&nfmSGxj((&PP zeiB8`H05Z@09?A)mq7^B{wWTtYR(+;yjFm_$~x3;AssLH2aD*gxzL9#Tifd$vP0O0 zK$%tfn6#|~jsusGFkon=l6rari+$p3e?Xe4g+rugIiF|A^gk8#kYZxc^gAmGs=G!< zt`4!(7^~#MRP9I_>IqRi^^pqXj297c)EXZMK_Au>xjpV?o$zCt`el^S40*R!z8E4{ zbPButc^AtP4A_p%qWU8wP#yhdPC0pNbAuPQ{W&qaJVdGoKMp~HoVss>CQhCG;nCt${dAIz5c8R}fE2apJz9N_9he?$<~5CDo5ZCnYm zZ`Qm7c++ees2nukQH3W6=JI&%)1umu!p}i)iQYw+VSv08*&jpqnnJhD*FZOk0jAZ`|$D*N%tze`Up&9`*~kd3>U$-Amd3 zLq~FZdBn(@g+Fv?<4#{P9EULe8Q9qS?cQCW`inXKygyD{gA~2C84reX$&;3fZt!*7 zKf{G{43si@etng}-qZdO^*mq2vy7snHT3KW<@cFH!{;xr2cjig6T0mLm3fR;r{Bto zX$@gp>VUa4(*~hmhQ^aE4t-pCKb&IFm`8et+v!u>Y_W|~;4C-;Ty7N2Lc>UEW1@=> z350yTxrRR_T5l`k^*{D9_zkh4i&TE7mkVau0o8>f5loc$M7C72d+(TwEyRY4nexxt z@DB+-B;COvT-^1RwPm46x>-ioEUzKq%y9eK@WkUyVG?eAhuaa0SDj1t;bdi+NcZ=a zI`}E@VHIT@6#>qF6STEOfEQ_IT~60U=r?E7|J;yv`CwrLLjGeZ*(lt&E`q_)B4RW! zHtiX;vZWbF7=;OUy3qG}(%f;dsH(M5mQK_p1zwzq-?)A6^w`Z5A++g)Z1*9&f;!b>R2gmPvO+$|j)+A9|%2AD13G&m7@Cr<3*^QHW)};nTo`mTc0~=j@Dp$54$5 zcvRBG*UgjbKy=x44z-Z_31Uf~9P&8qHTxJ>TEqB=*T9AWp;KnlM`Q}|LlMVt=|Z!> zI-`?jTvu|=Mu^CPAd1t8j7=JO#=o{+sZ@Z2X;cYue@5}a9QKmuMtu84Z7gNjh98w1 zhKZ6sO5Eu~jE{qfE|e-|nZV15_k4v6xfhjeK#d5=W5xcJPE1`wp6xffAU$jL8)sF{ z5$iP{9!s!PuTQf`ULk}-vL?>%N3jS8>ixeI_GQM!irR+WM7pFC zJQ{tsK~hFXtooms9&aU%VA`Vzzm31404^ybDq>d!CVa?%%QCe$PFPc@Oc}zkr7Ol1 z_Z5CXyG8;p8zAxLt%zmsTPaF~S2>t^M%t+V!~x~5>j(fSZ1v`WhKiVUkq7VSMIq{_!D?s3^^$%gn^z~8oty^2R7Yv{R@3*h7 z=hW8hKWcC*BenEv-VFa-TPc_Yr~O0N?_P`oR7Ik{wB3JvM)UU%jN16RpMV#|KPC?Y z7!JI1Z(tl*Uhl!yQW5x2+HchEbo|UO(n%OD@1;#~)NH%v z=NleHphx_Ydb~ z`^{I>GPes%^+!7x=QyXbBS4-5j;XV@dDSi7ih;SE*ZYw|rzeTvgg2OVoCp6vLSQ2b zNYJo~ckrmC?XW1oLn-8PwX`gND#*H+kyX}?YYz=~VpXV;u<)GY7x+RfEkxs4^r&OZ zFt;wCQPTaE@wij#(>`*;4O}Fj5$NaPcwF5GZ5>sy3;I6g4l+$oZ005&fiwD+s?0?& z@4yIvU)1aLuW%L)a+X=e$5IqUrV_L<4UyNWIJ($L0CnhRXbdjqp`FSa$zj6PczK~a z%^eYhP7jo&7wjo8c8xNpuTWX^W&xy@7_Z?1xt15=z|H8GHEJJ`!_0hkZkb`VCezrZ zCzIBWWrdEhnpY=U{M7ziZuv~CtE*9SilJ^m-Dhh7tg_?-(68&wwEFSI8=mwry)pr# zZFt)BVR3htGXZ)cPM@8;-)H#AhopE&pGC%!cZ*}0=Dn;dH0{ZB@(!uI-g~GF^P&z1 zW{uLT5Nus|xmu8z@ih2(O`0DxO_4D)z{L@u#MGGTq&n zBs@sGZ)7DTj9f0AZdjbM^-vH0>5-+}=sv6ijFuY>P2$q9kMSoh!)q3{_UEJ{wN?YD z-QIZcZO;6dZ=+(danbb{--3D{vSW-$A>u+Kqh!G2 zPMv}56@LLhVq_}_U?Mqk6oQv{Z*cGdNG<;4sbUmcFLoMhpy4I-c?g_9Qdta>_wv?O zNil#E@fKQaqqDBYHRyW?V#0~u_5dhLR8t*J+V`huE5UCElB+rw3|3Zu6nfTE3XV`a zVSr{8$wB>;Q9D1(yGDWJxTGCGJ*8u~v`9c?Lfa(!8`#6p`eZtz_x${rFqav8GrgLN}hh=@1q$_Tb^=RWjnXQqDun{QcKe z%i1%K*jNhJpSqzMP_!OWVPp3u6K^G@hqrPy-{}HPX?juaoz8*Qx{&T?o{@60h@Cbl zI>bV+URoTm0H-H`Kj$pfsIM$;Pq+L=P=T5F`El<9r2I*vHS(WNTd#0-HhUYOQVa=L zAW06`I0XP5cu0Qe;OFo}kV}Jr6;qX)XcT{`(|Mr^Q<_&&^8Fa$*d{2#TxIQysWc|4 zi-mQ|D=eG6<+KrD_}Glqm0!6SfEfCqlm#3;2&rpY!N$L`_ZRaF#*oK2c}R0()Lh18 zZ*wS$2(@!+SF=oe%DaI;&}h-xRfaNY=c%+ilf`_^uCj}`uC*rTZ^kCsQN3(4_xxEn zvj%;DP5XqIgMiDfCa1o50Q?v5*%*v}I%a$FiipLw?FUf8z>}4S5#B|~U4^6`KxP** zm+s&A>wY$jvvLxsSY;4t5h0Bycjg#5c6~nRXHZ)9y}!nTfVW8}(2l$>;9dl!aZ8W# zHsObA>5JLlkpOgiCtM%`hLkZp6gfGhWBRW5*P4zh;rFutPE9n-{O7ryx{mHU&uEv< zE9NVEud*v__OkN8Ze;HYmdS3F$*^@rR*DJGIpX!7GiTaml{MX< zJ*b%8O9Lb+qKLy5pt8DeQC`}hov)#Gz6Hk0JEsc^gurq zdb%3L+6|jl*S7##c-F*;T^(nY{YjAOaEWEE@&XwxelFiTWEq#E-x1>-gNIvR=JG5Q zvzqGqa894*9LQBt= zsUN0_<73YSF@SFNYNHyG z^p1hJn%@tiLatx`!`NAb)zt*+dhLz7ySo!eaM$4OPH+nl+}XIh1b2tv1c!~=j|T}5 zBsc_jznnWhgEQ&T8uWU)tGl}DtGE0DpTd2;++KWMY^a>n6ONKNXoq09zB$);rKi$i z0-Wh32H%Uv@Zb_t7MV)R^gv(Wp~I@)4&ix*+JxbvOJ*-Mxc02lO592tVc;~5KGsvSiaLdI-c^5-{!?d&eR|YDcpkdl#O>W0k)B)AR5w6r*9X+j_HO$9v&&RZy!Na7>`>gd*C=(F~jCCr9MB;}*+EPN;9jc7k zl2p$s7BNEr9MF?=j;v&Z6k-Ov-dFySXxZ>FO%%os#PKV6v5)sV^EFxRk9POl-Ff{( zp;1iR_wOM$MaL!Z?YWGm{FBM^%F~j1K#ygdz*N`Sk11ApM+Zk`ug8#-kV;=w{ACdR z>HT(!6L0r(M9Rx7Ar_43T6H#0px>*()XsnTjj!%M%|%05?7bd5a}hK%*?|D6$>)^^ zrtE0o{__#%$C%0|obpc_g=hf{yC~M0nG`4Y1NgB!AGokJwco5B^E{Q&EiGTU05K=_ zkZ&N>z~3G;Khc2Dr3Fj%OU}CEbxMh`_eH~T72@S(FTMM7D3sOzg+6BmaZ&ahKnnT- z1}0Ky77yEF53`ET(hnS=$RH0=1=}KP;JqJN`zqJ&V7`tw^Hwg345x4uIVEi59CtrC zY{_TP%;9t7H?XPT_>4l&j1xL!__VC7VeH1aXmY^E%%50S1-RxzxV`_6fD^Dc&>WEJ zV;2#OMc(`Wd5$X6+6yd*t=n_Ovz?v*EiPD^VZ!iYr{0WGhMyVSJ17nF{U~2P<52mcANSkxheDl9j5=?c6WaQ4$MWMIY@poU3qR-T5b zXYF8FL;%w1NIC==sv1$di>U$mD_|d}K$U6vy%-)Jf^-Fh$`qs?SY{dmIUb~Z&YLwy zGokBnP<3ztE|tIl8uz?a$$PqFtuZQX_-1k+3uh;h^k8RYAj zY|uRxB+*VFfJf^tG!SIN?8#+WNlm^qQEuSIX`Vt;?k;!yK>hqh33wZZW(eD?x^xx$xji?I@uDa)aTn>N!|*(m0Dzd`&c z8sNFS3eZHJf^yL!y`pO+D+kG`lgJg34804t84!@N|A)ly?Mf*;zwS%gR+XV#;-B)s zb0LLbXw#~URk@q!`t|3d>QFa6ZYACpCIKKms zO}+p45RJX_q}LmG#vu8#Jh1)AiRrdGDJjVp>w%_3O$r$}M)zl&#u?+b#smIpO_!f23NYZ&g z*w_inY70WAO(+FZ;bOz1^cx~`e1W5mmiH5bwZ(_CpeuyK!F^Gau;5Flp)LG$FK<7d zWLvhRJHP$ZdBxEB1Qz-(00LW_$_@$ktjTZ3A@8Sz{t+J*;NY|-G41AitoK^U#69qua z5F=>BR12?leIC!wvTxyw6{LW{jm+=cK(!E+37UxV}^%o_|=C2Nihj2@j?K?IwfMil8kOEJhC5MTL}`F%trvBd#ntWw`rw zvC}I<@ianRL3H&tWsP(mR;rN{JvTs{PV4!lFFT^KzH9_?0Uy}(6 zt?zoh;|+Yu3>-R0!2!A0a~1b<`K$y|#~qp0o;OS*YRo)O57!$$VjKbJJQJY;@9YQg z%xApO;Z`{W4|#wWS%0Gcn;F)BE0Q|b!w4?9{tyH3O4T)WbQkNS`HS$NL^gpFPmlt< z44zk7y(Ti@(!o%(Qf6*$tsgURx0FiG_+m7*asVYY*HIC@>J+~D0}?xNI-Cb1IJ6nJ zIgJ?I75Jz+&NvglUhpq> zgL5wcQDta68kRClnY2;nwh?-JT)Ya#bTze$^edgMh7V;u7i-wMA?>m7v)}dt7^`E4 zA7bugj&vmTuIUX4b#z2N?3m)~R+pAH>MBw*quS1&143d(NJRN?ukyDW<`BVkNo?2w zn}qfG7PrH>eAr~RVH*;KV6LvaOHtsy3lWlxQXHbL_mr@A`W7!%Iy_1uhbku2#~lSq z?4KrFsXsLX966D?fB7Ct(n|dOWaCq@m<4ys6xk4enr&c z%0^ahMdICAIQJ8L*$`NeK-AnGh?){maFm@%HbJjliXaz1*nd-ykT5C4)=czh4ND(T zYi6MAm0#!Ma7&Bul>1LDFbmXRaYmgCg*KuK5V6kIst6!!}KZ^SxJ zU1pE+nk3`levwN{CO!m^LeMSV%|fdtzNmvq@z|L>8f|j>(yag5&j&B|{dt-{HtQeH zJw@N(e_5tkszgW9^B8>bD!eZYKwo%d>mfg}DNd#1_C(D5eI*TD2K=W!VC{BY`>B{|y=Ld4p#}f%ofMAJfgG~SYSJ=xc5pXeKJ{7hxpB8`xP*g4Trner{zWepl zs)(ziLgoYVw61}tS_u|GB){x?jotdB^i1>NcMA*8BLa*Y|C9^l7{9Rhxz!u}Qz>Jlk4_cA5_F2;<;pCgtMN+TF? ztoV5RAqcbQVW7Mi2LqKC-dBgINigEm-)jVjh}OuabCye8grOPi@<*XA`Z8-TsK(;? z>-l*ZwPLvRzUpt}5v*S{-)SEjpT8(#iG1X!b0S<1-{7rW2}#ZDB170qNN`kwjf?$yzeibP?#o^G*zcpNMjx#h6oF zo;iqe^Y%KKL1dSKYN^)8XRI6=20$gQQ7f0^0KYT-0;Cuu3e?e?fEwqM>0?MQ;i1)I}z*N!g;VszxbF?)Pb{M7qh3@V4PSw;ZMshLJ5h#9*) zKrq=Jg7p2`vrEBwCU7zA>WAQMKc{}97|n+#K*@DWbEO-)?jGD~h~fSF$?H%tF{RaF z`9%gQDvJz|_`rFF7=9!>X!u0heie>3J*OSg*UjWsQlebjoPM&0^<%j7dKu-zzl)uc z^dRiqHvErogTKvsOE*H$7Xs914MK2RTH?uU2msye&XAS}%bE459i_IHA1Gz!0II(B zNWLhWf4b=RT>Su54j6_n5LYj5!rDv!wxJH(`dy_KXOw<3+In94+6r(og{^dtpCuHHxmBElf;kAPzQO^Wel@wI5*f^wQg9upuEx6#D zjs7x4g1}gF<Bd+rt|h$A@X>qGycQG;k6rYmKL@Hmg;rd3Fl2@=VnYkAe<%AE8)f?V6kNXKgx5*lDLbTp;e1=F4=-@%d%USw$3S1Q35M93Z6v^p4Y9O`d5J?R6Ksd&LZRTr_nS@T> zt?7-X5&CgX0ou0gwcT{8(Jdlo78FsH z$J>(gWQnhpd$9cr1CZO%4;VJ92c8qN0Dpc#L!IU=;^Lz4h;Z;<@fwL1OAul2jBZW2L z-;b}OQ2B69F6}>8a+Z19r7R4Aynp_%r+5D0<#5Qp5DK@N%EX;?%g1GN1v~qI&uhyU ztJmK{Y2+OApt)n8>(GH#qV*T$Mwt;1pYqt+aCN1@m}gat^ce9i+dxPrjgyp;N6$YI zBhQl(o5la+W*2>}`MB9Y2TNadFS-HfX{3AP!-4zB_ZWQlp&w-C%$vVp3ZCwFU<-n@Giyns`y;bOBHyxG>11CU|>CzC{2 ztBEHndMPI)x08U^iBupzh!4#z6LfH=v^H!G3*Ek8`FSP0xvuq;BpP^|@%Se&ADIvC zedb^R5T6=W0UP`YM0JhhGwi4+{pkV{)V6Q}RjdPpHnaoUhKBz27mUcYwwKp^aDo!c zt3UImCTv0Hz{V8&Ijv`q(T9ufYC_2O{!e~UCWoA#JDX61$lTgNUXvHJzx*V0bPqtH zEio>AGcqmaYv{aiAVwwsgY#!}&^Q zqGthd1~J{HU+YaJ zlDo%#z$;es`tgT~u{~v^53X}efX0T_GjRAnks?q=$8_2(`%Kisi4x0Je*r91NvbIv zN)0qMaV1F=tx|^cGns_JB@dDR1!oM!=#^9=Lj|MjlhX(E6 zeCvkmeP}B8i(NyBr?vt@!Oup@O~eti*0K@^PY=1exB%&NiZW4+Sysxl>w|g*+_+`uCI8kMMQiuOvV=zS7voMZ*0z&fN54w9^_nO(S9e}g< zNudG;l9N)AmW8ffTg;z->Q-e1ML5Bt`KU!Z*Kh^zzy&Pz$Ika_aAo{}5JjG%%**+* zOfzk$2+?8Rp}cQ7>TR571eTOVon*SLGrd+5zO34PpX86_5Pd19awQBZ9XQcmUi}CI z5s)h72rJ61|0*xo~|nm*+EuV*$LK^rpJJC zm16BPn_}z-OY7jJ^GR=l4(Y_B&GDjo&jCdSJ=}__CENr_t)rD`0c7Z$TiR^VjFzUo zg;KbfOSYg2z4iAp^V$j&hfnJaK9&g;f28Ger7I)<^-m6k!IP0$G2Fj*f9@4OcWP4q z9=~APSqhpFcIejE+8WF5Oi8U0UKs4f-dmjPc0gMrRt>qL0Iq+hlVegs_ogT?S)yW0 z9K82!zwpnnJGh>L$QdKcXZ|#+G^{L{WHJKl?cPHv>d|&nZ@hq&BSwT{kkRY9JxU!i zP}JKgJ9n$Y3g*({+7s*3->Ay!YiA>3lx2zrWbPRPHaq2i&mR25v!n`Ebt&4rN)M;? z&xWU?StXg^9eTv*co#>Sd~vYAj;3xnU|XoM|L!_Q7o9bnGaYlU?mA~0e6yNdwdmK(J;L){h3QTZnaTjcYS)|Dn|3<^e5op^|C7cr)~~>OS10mHd}O4 zy-rB>T|w(yS3fVw-BC@4^(bybuk-J!G*J*V?!{7xw}1`Mo#7~PPMmf zeoHE#{B9;W8D%C}9l?sY8{rd72U2|pC`BMtBg`guWZ1!s^_{&l2zAFF)tBuVN8m(|- zm5j9P@93!j#AdMNo!ZPSIoaMgS%|<*|y8I2&TbyU?^&rqKW@k1VyP2r z?R>8|ZM;}_5}VxX(}#>%stv4GySG_fTooUo^l$(P@<}9G#n1aS-G=PL%T(9|N+Y^R zxS6X2nWyPG5_EzX^wT4c{My24_U)GzY)mJ#2vJqBuQ9FTy`NlS*5{3?jDcE!a@+!= zfTJT;mr!k<(H&LQ9$Dx?9z0oZ8A7KCarjG@1PDS-=zJFrQ7K;uD)a)g8$8o{yetWzYYf;=b zDz*Er@$#p{;RC@e7ojgdrd5-ZjeU1jv7cvG;1X*Wlcookj8r-faP01@%*0~Vx7Pjf zIv5wA9y=bFfh2YsGy-C{uwQ9`zm2%n_G{-UAAjYvqClBp1M`QvyA$iK;rBS%RA7Ll zgw1WJUPIj89QGC+otSV604pI<6$fOELYJ-DA2{tgl16rdSuw)e^L!W>zK zzco<|(bXX|c>pCB=*qz|O^%n(yMYN}w=7p7V7lN%9Q#KvMu_+D>x?)|Stki6)v)vt z0ocBkhou|@5CtrJBSE9cbqc9?kjwF{`Id%4-fu1uAjCMJUW^Fbg6_-%xHWThgxi0V z%3JXH1f*wD3zNc8)*Af~c>3(u!4zC;qh0O+3t^D~d8t(}^yhv3^jGlO^#>rtjSR&m zhp5egWX%9tJrJQaoawYK&}Av^B@(9EG#f$-g!iyAr-~nRY7pl83Y}(d_bVp*!DHt$ zOW*c%p&IQK7CI?LtpA-b16x)}ZkRNo_*+I{nFhjMI9(Ktko*3dctV1}{_rCct!04U zjE|H&Xt@%4vgJnoDGCeqjA?WbY)7$K92JcA^meW`aRr~dfM>$s|2wAE6z!_zUi z+$KtxK+D)o2ah8)P+|LXQ_KKi_UdK`?xjnN%xfgYfOtW}5rk$gu* zBi=y-)hni|`QHGh^IyH@K3L$=e^Ref8#scrqs?kcx3R}8zzr|1KE&xLfKBV<$Ck`j zO+Q0_{w-AldiV$6Arr@DFeYySR3c3*+)syv5ATLl?gbCuFHr`!i0-cbR|H(}3ETz7 zDcqeL*E0FWeqEmOW%Oq}D4Is>n`nk{v?MixEAd}6;?AgfBz|NVx=Uk0+kz5dwCP9H;J!VW1g-H6~Ztu{Mt(M&?@3MZu1QwDd*> z7Nv01WX#{*A2;Krf>x_ArZ0owME9V&rv&!%lYjt<8!KVnKrXOUj0v9u3veP0`588Q zHKIdE*1H{r=vF|~wHE_0#skHbd-c;Aoo4QWV|>QhRibUGq@be;*B&~7P#s2fdI$9{ ze_f{#&yj(|!8B>pj97wKLJZd$6xM+HyG2{bMU?DIhndA1id>?dg-BKr&3<$;Wjsjr zRK1idYtkx(`so>q>LpuD(-{*It9$w9XtbSSTSqU25*;S7wC4Q+B~=cN1R;{nkHI4_ zP$GeCEr+7J`TGr=XmuP?D0y06qC0!d_>hSb7ahCOsgOQSWWnlLDh1=xyi6ohZo^Y1 zQ^FL>C6cygp2`N3HVDGYddkJdf*hxLsV5&lf>edz`52Ui;B8Q#rx#%76V=@0ieU>B zF=4El9G|hoX)?m&?Krk?%Zgm^geoU0FG&-}hwq8$MTS$?c#v@b;L;@z67zQFsOe#r zo{S2l7kA)Tw$SBXS}5j?czs?3o1D>uwZ1<6%>O1@@|+(?iqG^2D2&~Je_~NSY$nC+nTX~MV!)W zg5C(FA&V0k03xENi05g$EzA%Hiv(;FWQ@B8uye}M$vNCejOIDnX+$PpM#}a8LUcwl z%{aI?CDsV@P`dXV4yC_A9f12z#5u)!ZV6|qtf1`fK0!RRwVBd;YFl&5YQz)|98Ltl zR*0r-?=1rNR7T6|$@9(0`G{mInJF5iRNtt%b_`DRC|sdUE7ICZ5(I4meEdoaB6S^Z zr9Cp#=9_~!Sew6(HhY2z;9aT=#n4-2K^}x+-yA z-G!vUuUGPNIE97B?YM_Qod0x?<7Sub8kvSf&L;^j;)=CyYwseV+&QTwrcgy~}`Jmj*`Wb#nnzuH?xqJim;gR+(d#U_`3+k1lw*b((D zzeZqR7ti!GgJu7mVwi|u^qmeC|!<}RR zGgY973tH(}u?jC%mad3BRF&7!T&}b8?~uz}CJMU{vt-Qe4f@>_U5w{8#3|1R2N@f3 z)03Fj5U5W1ldFn*ZX1vTFRsU1QwCym%GPQNGv$OL^VE8Yk4MC*E9YgVgu;4(B6w}cMAzx5b zR>*-_UI~rzp#1|&ctmO+rDr9esIZ&nE|q~|tP&zQnmj*qwUx_wjur{O8ZutdZR-S@rZ* z;+nj-4*uBhzxvjLW)-#3fPX=+q17;;;tkBb`oY4<$_O zP~MdTxbcB;2d3yTnT$Gm zCfZ#lUBZb>kNE3TX@o%xoLC?Mc`aIs#D_50zi{%Khx!RaX{ya_3DRlP(HV4JUT;TJ zEeeH#-#^Q&0fzK|A+a|*DWsNu%S;4chq_MM?_EvbMewo(zj?Xf0$U&m&e7jpCu)#!Go?-9|6q4=r0xA zt(I?Y77croK$d;tlhmK=mkd5YYc+6zm&*x0m>19B1BnlUu&mf>J{CH=41 zL1%9pb%`&AkObGD>R{EAjl|#nQL&KQ z{~8M%MLAUuHWCfBJ-)SMFaTP?^TL*vI@3+d)okNk0@2ektF>uqc(GM`6rj&MXTh%RU=UlBU}E|K;W|YXwAe+ zV4aSq$|PE;QL{w+y%l*MM%ob5EBagC_{UCvI3XIbw71jX#>RfF>-N+?nH)MPha12W zfdP?;5bjL6;b-P+oN(A?ym_qMfgq?)i)3gbT7a_fanq*LSwiHg^}5O3jg?ZQ4WXV(0SN`9uT5C~OeP(g^E;u{UtsjalzW2*y0jgKu)Zw0Xi%3O zI~<8LER+IK9~Qb~=E?X%dWrhaSh)U1fNg^-=>s!$>*rzGR?WvZ3@H9d2#QO6D3H2@ z&w}>?6m*em zovFctnPKG$Ay40gBYlz7U*sCfyV#8J9g^WNLLh$RcCL*l`Y8W(aKJ-!&e3*8xh&-; z7g&UfPxX(b4~0LR3+a^1pD8U~zk}2lKXBTh!HDf|_d?(;InWuqW^@?Yd2Nxi6tjY~ z3n4vHh9@cb@Q|sCguk6D_T%T>rQ_!*h>u0`v2)ERjMokdr6}b@7v?FH4h17~jz1w5 zF>plCv~gS->@K~H|Ju+_<1gYJhNGBE4HAa#-Acx}oDJIYSK%W|-^pZ?LM}>WobsjZ zOQ3F@w8uOz>08+UtV(}H9!Jd4xI0&CI^=GmKwHXD)nDRTJRKFvgrRIyYBG)kbs>RH z#kdF}4XS|Sa??#;rx5A!`Yl1g_{Mf4h%%tx8%`cv(A8wx)?(i({-oAkV3^6X=-#3G zO&b+_`AEkT-;&3k&SPA}SkBy1g9>P7Zr}X*VWig>QVn+nE~pTZT--QXz^)c0GGnjx zK5s#aR@*#tqR#?8Fl>UY9F@>6Yc zsJ;X7klpcNwYo=m%VJsGG@M2IQDZdhMP3#=Wt}sg!}2g#F_O(TZj;+EBi)U6&hjqZ zNL-CnSexOJubZ?*$ffB`G`8OLEZMbFKSQJ5?EP~5pNkboicUo5so|clV^~WJ>mCwkC1{`x6fSK0abr?nu9;^0H%k$eT88_ zt==rd-3SWtNY&qY_H%#|cV?GChv6qt(FfCAL%DNPrWs21qy%tC&i z4sKf7QmJp+gbQgD*woNz+ZG1nie|l4>n$y~cv0UdebA7b6vcpT7TTH4_AkPq-s|*v!HkLYwfGhWytuY?*%h6g^4rtndSWTQnB-MP~PM(1q;v zZL>6$-K0g{xTxkra0%6?>X(&<8%xjNmll0x6KVjFp8`azqHpl3N!Bo{Bczow`Ir*x znHJq6g(uXIVJ?0!Q-Dw@F<*|1<#B)>4Ian1ZW#na5CJrAdm+hZ1q+zMrmqm51O8D@ zXhF-Uy}<}GB7}3Dt}%eZDIZs3g8A2j>t@)!SSp$ z=seZZ8|-LZUtHHkpeCN9U-vOki=3NiXip%Mn@Jd8owcmnh72C_0Zd-AuCPns%?w^3!5})2U+q!7EEq`;SeolMh{YQ+7JnauhN8Zcgbv~4L{%?P= z34krn|IGh^%^&t)VZSa0Z!xv&Otv5X4~Ffz_Fm8uT>yvCsqmXuF@d3dIV1}V5S?zG%{ z&ljjeD|H=uNJjn%p&xK`4fy-ASuge`4JaaZQdk!J1i{MpODb} zlgGdBnz}U5iSgkVk@fGEaSLP(wiOqTZohWpjTgDKi}TiqRt3io5x7wA}>3jP@*M&_!Xm<#Y#tMuRhmcUjjxA9`EvOF(( z*H))nHI3%8AGDDMUrc*Cg7KcOe|H^SKc%f8EE$0ALzZO%UCmFCePedf~7IWsz80sLbKxB3^~%$!)ckQ!a_AtdC!Tc4EPAJhLtF<*C7n0S1*j>$czpL<#u zga|;S6n}gr9RWd|eW<&;ckh=pxm#9+P5g7UT!5><=cm6arif2h-alQtzCeRz*v?3A<;hE7*b# z9z7gn4IM2b0;eZkoV7;PeWtW)YfkUtU z&IKM|5gT~04ASsWv>dX3^cQ|%vGBT4qyn_qOV(-& zd$94}fYFn2hh8%TnJ-#RT#Ad!%(&t_fHvb}7%9-$)^zqX(lh8RDUJ`3ec_qlY780i zTgMQiKnM8$0Q-?|@i}(W98Abao*%o`VWB$KAl(rRNX6Rcnq0We6>GPkrAiNB6zGXI zU`K!lL7VH^2&^`0x(S)JBy7g(!3+5^ap%TNQ6R$& zOY2U4450c4pW^ew!VDb1QOd}wSVp9)L^RpO9i<7EuCk6Qh7<@a>c|erlqnNh{;rm+ ziXP}M$x3>D0AXI99SQjRlZj#+k2t zHLZ`~1Q?a9s!}&h-E=gu(232e)`&>4)b|AhNMOve83l^W^wzzn6I0=hoHMA+exXB1 zcR1^K>*~WNKYnMPD}8jV;|FYLa*Q@a_2+^^hV%fB9q5mrjXvza%EY((i1Dw?ue%7- zP@z%{hwfS25A*ErBKzA3&}I_sc+j#mFP?PGtZ8O7tzkurZd(FV|Y;mDHjzv#g0 zY9Y|QMp;}E;5D5;%z~l>Y=%>(I@-+&?OxP~S*R z{Vv*j_n&FuMqhI&04jc3_V3bl0T>WPy!)T2;@dd^`ZWSeRuxEE3)EoE542#Z2?&@C z!m%K$Mfp>RP~Z$Pky4((5<0E191ie4+qrCdKOxSH5hk7>#^Gq97Q|?-iP`H!u{*7N z!&^r8y_LhXYMIaA{k8DktaYrH8$kNHhM36B&+>Cs&;htU<%07#v#CrWtLgE_%i!ia zzSYdh5W^>ALdN7U#>dYgs@rHXeXk9|7GIWx`#xAArVB_Yz(bYvaAUs8!Ta<4e-=rprtZ8xZBs)Eo&W1)E--kV}llvRzkJ0 z7o{D(@Ma3^_TPadjA1Lo87(3Umus0(J)OAAGVN#%RkplgcGaN<$}c9OxgWlFr`*iJ zpS9Bl(F{^3xdVjGuJN9%N2mTu(z>jd33*j2St|t(YG8+c2UuH(+oNqEpOD|;loq91 z=f2x01uNnJx$&SM65-lvuIN#>EKrt@(4Kp2xHghdrC^Q>S7+A`o(qpU2Iaz01|l=j zoWdw#wBqoZIkb^KYEisqgX3QRWrGjLz#L^&%Cm8TA_7OvlCoOwGkN5+`0STKD|=hf z8p3+M2HN6D3CIcCN(ya`npzgK$A;}IU?;(+6IbmoMv46^Hhp1{`xlCu7=a#c(6C7> z?nv3%Pb-c|)4>J2Ir>}&q3L}S4S2XRVJTyF{^lm`b?&)ks{m#m(Ebgy89ZE1WSPNOr@Zkwg0U7Zn0h3GAT*hQCT16yW2dsR|U8U2t&fe~zIW`@xVO>(=&G@H%mQjKI+kuzRI#^5<(N7Rt z1qripQMfo(_X&R#;i-}8kg+uIOP-pQ=r9d(2^> z?4D*(!2L296iGWek$t1yEe!}gHK&+ojV6XwXd&z25v=~itTKqH}WR85y$i>+5I-PUPcp^$9i zID=6splECUknCbaoF6{}Zh(`qIUG;QL%aEqly`&j1Fm+rH4cD_d<*wyOJs1hG8%dm z*_dG#5LL)#Iv45pe{Uo(#BU7d0M(->r;i|Wv5qxZXR@k|~oLDUOjBxD2>TWDT1JfAK zRZMe@b^QmX=?M2(K!PF@@{?1`^!x+j6J@S=xg8fszUiV!jEu;UW|18i()&rIAwUkO z7>nZ=>j_dh;c*P?5!4u#f;4WPpC2%o=$~CUIGhWbkWa6@e!?)ow&-O#c_`eLhlj|6 zH~np3n%7af+GQ^)j&ERumgY1}llSlph4Xcb<}L6EE#>B2nx~5yjkDr_L;|4NIYdL8 z{?C8Am1d4j3h>05`yW#ue~f(;X12NN!YSdeDS(zB2_*Q+ZF6rmM}SP~aEE`WZ+zWy zWm#*FZ43?u$I%nF_Rre(e^9=<^$g}xxPdEi1f9q$=Z1W<(yQ2JU}^D#5pxew?@a4L zyH`s8>-DiT>$7Mx`>@?5a;)2pmo&s}K>yx2h1Kysxnqq`Gm*6HP|{rTYi?}M;2QTC z3A(+qcUHA#Rcy~G=ia{&>6mIWtVkfeZHoS@Onr7LdjL5yd zc$s3h-ptAIhUp9}9mxgviep%lO6Y%|!*U+Kz3k?JL%6yq|5i*}8kUQi7IbCRnnu;}%E z*|n+LQOIhs<-zCqo|kV4zKt#>4+FeTgg2snj#=oT5uz_6H{>SvE3X=Ry789q`#mmR z$F7m`p900POy?zm&G@dfV#%o}8^6$mcVoRrA{mhF^N(sP77=FkGsP=lsI`-r18DYMft6ust8(G}4C-5-DI;DEic=kNKVe@_%Gxl^?!dJZwyEeeL(7u zc39lZcpiZ%srq33|F}Arok_3~n#A)kgcESkQMJCqym zM;65(G}iGnVgS3C;0b06V*9DVFQok1v8TfK7{B2f(I2(qwH@tojm42L7mVb!Vovq# zVRfk#Y7l zG;_UrC@J(&k`DfDw@tq3pSg&nJPE+GU9Q+ne6NfCIbZ2_o2>;(dMW30z_~<8$DX7f z5W%{N1M89v`*+8D93mnjD^9u@Wg`V3`bX#_N<>klW_m505YM^?c)q*(UXQqHA{c@; z^>JJ0dhqaN?DIFS;K(SAQ_beD+b%nTKlc8LHm`wB$yXWKi!gxp@d{{VpHMRFCW89s z55g=+X9xn)vV0#y!b6+)PU3L~>zM{eaz`pf!@qcixA8VL2T(4}kh)E0t%a{;?JR!< zpt|g1xJU=^o9v)$E9-g!b)y_vfd{ERPjlucB?slbewiLY0w;O?3Y1D3Gz}}`0Nq*Hy-%KT*UJL8g2j|8)8!Vx?w^cjK!jsNwpZ)XUW!}Qd_LkQhX_)g8!gD z;R2`IqrES9`nL3OF>qo1LS%-E8(0ILH(->Sp@ZA+Bq27!og*M0;cB%qqm0gl4 zU*qQC*()*tUU%bzgT0OKvwmq>eZs*$j9cj~BW!9HHcb3}VRA=Qi~X_a*K>L}@by{u z&qa;0F{I@uMo$Y!>&6wa!n;?}c$A_{*`lNLr(=X$>>Rhq%QD1AaZ`oEf}?f1V54K-ke;WRp(GN2)=)<<0(XQ!!ymlAU*d9&bQbaZE@J5d--XsjeCP(9|ekZ<~*gAw{giZI? z3r+min?=dci>fB?v#R5F3Bm>yov|NrU7EZfQv8uMuBrZx#sL`F`&KY>z(k=jA{cSW zFh7&x@9YvK#~5#*Uyv#EHdZjh>3nP`hQOeTs)sdH=A7Wz_FZip9kTB>$TT|QKlum~ z^jAfD6Kbq@s??5zK0_g%7~1(GLenlpcUj0~v5t4zmSL# zGC+OfmEB6Y*G$X_Xv(HUwVY3)Hm&6Pwgvk}6&?|0zpcBAM2kjUOgyMGnQnM4^(s>( zwZPT_Q}I{o)H(M4f{NPv5aBk!cMr+c4Ri3^Li&@pwZyHdMLx1Kv4Y+%2>Q)|0L z93iXX`9v1gg^RSJ-1`+Yq5J#oV*hDlWdPX^-+0X?zsu|O*3$cjUznN+y?XMr0yQ0@ zeT?9b#cdcz_V342b8~abLM_?+_eWDWQf{Fifqn+<0V8u3DO&E2`;l@Vh}d-DEr!(e zGXAJCR$OFQGHapUTO}-O0Axg|{t`~@{m3MHNDLNcJ8)30uLkb6i*riFtG{Q4O&jui^I!HeZ#~4j`UnAu=>{X< zKs~zb!=Rb^)WespXV$L&j+^I4kpLMTXU;<>RDi`)_@MXxtW4eCnh%EMpEbA5lUt7y z841(X08KjLlrmn*XitiQ>eOZ?w#>JtyRaw^5=F1tdW-%r=L|A?`9E7JLs;hP6sz!P z7qFW-+y=pP|CT)HUZBKPi^KKY7#W210##ksDq?o+I}s{x1IG?W_1qpo5J|135Rxo5 z_>POmFSP%8&}lfOJB4Q))<>+QM&1J6jTE`n6o#{w12kFtSHk z;^UbKHIaqzV<;0u*QAl!$in;`&?|0?=yH8~wk?12uI`b-9!g`ojXD>jcmpX z#1MyU`J&Cg+0C@^gFMi%o8;e#g7Xbk6*6-Cn*P!=*lb&qiG6+2L}b31K%ZQKD$*w) zU|l|c`4~1OocR%EHR-5mD+yu$_7|QD2GX&%Cwb+jH1r59)VHOv=v-g=WVda*2wohXbT%=fWD&+(k zY*AlJ<8Dl;J_1_0_^TPla7LpT6E%z)e5bgfCFL0BCb^ih2s=ECU)fBIfrD)F`hWyS zxxq|RLAIOmnSqye+}Z&f@+>sf&6**Let_teC}E|H59@kuDO*(Ipcfs2P+3>uRHVec z)?A@La zG6)ORasS$UIh z(E&}01A@GhivL2q&Xa)p76UcFAa3UxUS`y-;N^wE%G zaPBT{eM;8YP73>}0MNtn;jbYjFZq>I*xwCLx84yJNJlP;doz@RC~?I6Bj zq)!zLU=u}{M{?cT))4@d8IaD;ju$G#yRt12Jgu#f+v4MIE^Yf*3?h zhX(|GEYAbsY5@Ndls5cj2T1~*bc=NBwn(c$uIehlKMN&%7UDP)u9ZooyU($<;gw7w0 zGQ-9*mWEhh+J~7Di9}dCx=+~u!9P!vWpP?uEP+iG=j_Sd{AiI<#;wh=Q{5 zz^i_`vM|t)6L)DK}7fw~Y5527qi`KF{29q$3qn6}%)vZ1c^jx>X-O+f+ z&;j)a9|~{F@*Lk~>2#2hn)r;;(|QNKqyPwd-C6lLT!(;NJBJjn zdUnNxIun^;k<>2et~Bt#8(^i$*3;ez-DZ@F`0NS;;S)E=Km`apPa|wJB6hKF5Fsgp z;}M6>w8DyYg_A8@!pgXy80ovPKLh~#RWcZo@hd<-#f>C~oJuY*RiPdf)}E^cm~8Ng zlMXUKqoZzCu%ntQ#{h(o4Bj18gB67*Y#N#p5uVn(UJ z?N3Us7^llplfMyY_DD64#Au);th<*R!w7NL;D?WFHL>!)IRN3l;4%J$d4$YA-jUW> zD;d|=)qWJ$Gs(9&8n0ufqo|vHlVo16pn76KpF0m@T17E?9=GS=k#G?L(3|^!WYd440KC6hHhKjuT-ul-r!!9N=7jjC9mk~v7(hraJd+T-)$v@6Q z>qka*6rT@sFPY}TJRMZs%=gAX?KMpQvF64_%62V~-LH*gk_%NouzKIxytnqLD$Yj`RtLaSktxw1qT>&CqHkM^|8Wi-!;(V#wux5ETJ&6Uaj-sQzH6q7s-==5`Y$ z6fTH<>5rz1mFoVAT|@T`OXzZXLAC7Lo4bX^z;Nx!P?}9GetOH*-;Oqpvi<#^XPtNU z_oeDgf9ROH=UvPi)w>jsfi0fG3gmI49`s2H65eCbfLY0m=vPg)7BA{TJdSI`j`6ev z*EIuC4Oy62xl*}rVu*q8_`T61NDzjGG2QhEForWJW)xvZ8!u5i zI8gPclWG@bBSLgxL*r8zXmUAwSftSKFkTFo`ns%x{Qs&pEFH__m=(Ob}LW5A&f` z9o>$yHEg~CmsH#Cf6+oT<-V*|gs9^zVn14#r4_xh5B8uj%#0BVuN2=OSiUwAD7n!a zAJd!W+B}Zx5I_Qr@iZ~!G!h`i=;)j=0cV~tpVLZ7P zYq9jj0Cx7XO}jzApvfP9wY3T1)#p*_do}EG7a;$RcJ{i4r5C<^mrXI?zn`rL%S^GQ zZLaf-5;cb%H6Ye|Gv>M{MHx*Pj9H`en4%r^s!sPhKx{SF#l*neKgr^O=M$0*Em*y0_K zROYdlOeQ`sKWL3}LCeKVXG6T{`EcAMgJ`~~s{M-d&5I`nAc#}QuF!w&OP?D=Z0HyA zWHlqS1GoH1=12SH^&hL1?8Md3h_q4EgeTfO*)ETbPW0pCRIGr!uCUQwpww_CakA>3 zienwaKCQQ#>iyMFq4b(JPeieOnW29$0X&5*QB??XFqRv9s}a?8f(e~l?o1ttYLm>& z=V$9rz}zRX_M5pLBTbl?EQ1b#ZpZ6F5W$^%pRhtw$t}F>Ilw09=3edYsQyK?9ozYezZGMEY$m==0{wOqhCeb=ot9+vuNrYK z#YNfs%vl22%b_vQ*`ty5or2LO=noq3b<h*9{fJH&g`_GEeSon|H*VrSbBQAI8YW-q1-8fg65l zJ(wcoJ;)$7=`n6srJ1x9RKfWSmFGUt1I82T z>BH)#Ya$s|s7j^FHYs}pz$C|i0e8RSw5YTC_5UlJByoNBW)>NAHHVzR*k*Z=dwX0K zOuG9S;(hmf=sOrrAn5SoVdG^pn=rQqR_C1l-3FK&@yL+q%HyT18FMdW?V9g_iQC%6x(I`^I;RHS}j;>tb7!_ABNi= zNIJlzF!1*k(Oe{;_<)+#KhpPsr`MvR<%76Jqr}$ zm=+eZt7$cn!%5to$9j!5QVhXt_fqo%o)gpbsmzEfY1iq&1d#7AfXp2bfT6j?2{;}H z2uc0iV-b2eR5=eihWYvn6v8NJP(0>63}!ntJSSimqJyP)$iIdTB!l6AbYDVn21Yxq zAq}ii1^}TX_*%aRXOU&P7ssVWN(?a2ArDq*ba-tinEyNn6q6X-CSwqVo1;?TuPU8M zp1bDG^QsXYu-^W#byu$<)~<8DL1xG9jJULrw!B9dJkkshC65x`!RX93X@XXfi|g`C zejw-_!f?)pO=Q?Ars+_+^^#%vK5i$1 z7Rkvr^{T?--~0Rt;-|$fj{_$$cV~TcR_XKEYA@HaJ6Ph~R0#w=H<~@LK(iLJXwfl% zu~7f#sOI{3(LVGQpg95{sND$(65J)^$%k%9$_8Mxh#c?CHW#69nNXD+aH79!sd<90 z0)s-uOA?j#y-t1zA*1&h{D873fcnSL6#u^I{>`tP^Ptz*-45VyDSw5&zA()lXl0h5 zH5uJ@>Rj?eo!E459TxUqE#FFlwEmmwB%fJ`pbsg10g`A|UC}-CkW;@rTl&5Yp9&P+ zok?Q6OX`~(_kjlNd?GU{%^b90Q(!)o#)7yd&2 z2rfOQ?g^TPb~;?*x@CZj-^PqeFR3Vx$m8fC{o&^)wT>BB>#pMfydQZgvSos z$ef!euXC|+agE1H?w!+1Z=DEZd^DV+?_L?&$RG2>$0gRf&|Uu;e_{K%!rIP8mw|M; z7CU0}AgTTZwMs6~*iys4&q%pW`}-DD*hT((KHSc*&6+`&Kf&kCm--@M5xQj5Chm=9 zJS`jqJVmhGmmNwgTDR_YJnXq^k7{%#5u-+>ZwEBUfB&A*8?(33R=~C-JYRgwqB&mf zCzP0vRg~IHjr~D5Dior(B`H{<5nn_nvMv|2Ebt@jC3Df?=|0{jj}h6}==m#Eo#s$^ z)EJ#Ub`>lV;k_*dMv;%Pj#2?AQ=WN=4OKV|yEmY4DDbN|(UNjD0*)SvuvW=rjg|p_ zXF>)%w%Yl@F*<)W_1zZqP=7^~IZ5;Nge?n!;+O$1MPpVKzQctEC(@^BiP~##OiA|d ztRLm~C($f#7+alN?N2!l&kqB|1J}%Ihxgktg0&9=!G2u8K)2Q{TEJc&0^8tkj~2Hp zxp4DwvcQbx2Iw^E(C}{wM6H`kf{&sIS>Mf7o+y0hBJPxc=)JP6 zRiVBs5R)Hh)x8ZRGJCBp#JZIozCsEilnG2*Io!bkDxTeAU6SwI;>WA`c{BXt&=FME z6Z#3=Y?vKosQ-w#W_VDjtngj2k_~ql;*)Xk>VLQq%^KVAQNU=^-G0K$!wk)tU38~U z#L>AS)=vk%)T5NJ=8bCAt#x7nh;ylgwcpJ#$^>xIq3SyeFq39Q%vp=QCyiw)=LmK4 zHte#qMp627U(&a`S!Ee57o^b9>Zkzt`Ji7qqApRhD$VBbRw?FVSGl8Dr5bw{J*qZC zLk(lzmtY-Im|rJPRt)HhpAqBe5&Z#(HwLUhF3s@kp`!02QY+W+^Up5-0LM=`u}c539@@l)#*kh zO?Jlnt5B!@(M5LAhdJgaQK$bnXZtu?6>MrP%BU)ff*MtBwp@$HdkMK4v_n$`SIIXj zvk^g{WmG_TdyuQh&`7g((odM#W)xOeur=mvUl53|?mV6?zc(KB4+P_HFr8?H2O?Nd zs_Trj0$GT3r68#1q7bMqCVsS1acPWh(10{7iK!OxLqDXuf~4Sxxzalhc6KK5^Fq?_ zg-?_IYa*7Dr7xXpovdWycd>rdFkk|aPaS2KPQ<^r%2bKbusS7So?iTXwZ;B$coCd`mam=;UD&su~P>u6y#v6gdHCPMD z8{lED?b9_+EcYjK=-9;ShDWX)8XYFqa`Vxt0ckMuyIDq9tv$c>Ym5EM~?evYqP zbTBnK!cklSG_{e0App(7^M}IR;i&PKk2o$a!2JqwfrNxxF_3UQ1>DczdUelJe_(`V z>@v*0egU+K_PHkH>;ttA z6@ia^ED4MMpGNkTpLa?3O=5+M?@5E=4$oYF2x9P&tNG8?v3x&m2O=C)<7~rk5&-lH zh*VL&h&bKR#j(QfmO(BZ*$6*3e%_6De0ULx&OT|GT?JgdAnu<5&)U!*P&Z_|0%`>L zRZqXJ%We4V6>xhmASGlXq?o_5PcDWk>29!D)%t8yn#|O{Qb5)9#sB)y6id;8T#~9c zI#0#w^MHR>m2CfH0sDFZ`yn5K^b&OyQZyP|lT)?s^J&$F^!quPKnFjckaH6+bpAuTmI6&sRMFw^S$MoF*uDBa| zio!ivuJ9`0*a_QzY{G**)H7_)`QZ@%Rc&Fu>4xgg>e%r>Vn`Dc5a4s)3nfPW7o!-n z00+NqWzA$wgjJcKUQmZlXZPX1I)9UHg6uwf);>Ne|IOhNu1JldIFUKRf`IU^j*hH2 zzj>o(1EjyB3a5H&)s>t`UJ{i|fLS4>%W4PdFk2(PP9eGq_rCn}4%u`?A!=mbecYmX zVkAbbzjf$YwTl*r;)X?$(e8ddng+8Vmg!snlVjkj@jkA4J76nWC>0YB)m? zVnNf!m7C*@k5eCG!;O)`~;5tQtShY} z%YGl%{jW7UD$yUl5f#~;FHJS$0`OyESJ@go^gw%Er%AXhr?KThA{7SR^??Jd2A75T zelva&K>Qx}6(D<_XhJ|haN6EgJ?Jece`Kh4h$e-glkS+uxc-LEP=zh!#7rr8F|tcr zDi;9Y*PaBTe4ab!XoZ?3*V7|BDlI($mbzq+uPfot#SYx&wVC4JEszhYquJv_ zO}1_l^k4q|;ANFu*0QjBZtz(3?`RdFb;p1#h%*&}(iCB3zOQVOf=V)N+}3AC`w9(? zh#+<26oYw8Kxi%E6#QNDh-axoqhg4Z2k~IqMa;mMiSx+1x3dNYqb>V#!*Fi%#X<$w z`_lPYbp`$deO{3Om6H&ZTRvSo$UFwipMdZ?0cT3T>ea?ypo0@tfAZ0Gi-w}!!?0w{xmveVA z;?X;eXltxxB{0_LIjpKt+RFm9L+G9w&4Ja8@BO1#Wl~=>U_F7u-f|;gGjDK?zVb0^ zz}Ck$pT4@hkS7iVNHa^Jn`!|gvhv2K0>g3^S)U5Dq9vu2%59VW13`g}99Ri+FvX=8 zHUJ*^w($ka((#vzA{@^ih^yci10v*A{`avoRtzDjM(>3_IH+@)0v`4FPjMxNTl>gy zJC5Y9s---Q6FTy0T(2x=;L}2I1@x)G{mIx?O^u@0Ef^3l$_ZqZ{n;+M0++}^q6=!H zQ>X7R{3sLIz7-3)x7J=7!yV4X@_I8$HOL`_{E?c>(cA+E&4;CWo%`DkAPU=Qwg&fw zxE|d7z9aQ}yik`xhPkqkaF1+k(EPR%KlDBxDf`7%MO^uBUh|aVXZ=Q0QPsfSPlBTs z3l!^^Y2)H8SMQpYuPlgXFhc&3b|hLZTAr8?LIVpm^tsSC!ZMfb35%cwHg9K(IMW#G zj?N=W`oq?U8RU2Dj~ZoQjEGXCb8E8Gs7HAz=N1d*z(5G71gmduX$=v32 zGgZod$eOOzxSNW}@N(ryqM?54au@rLR1(hU;m}3cFzKjzrcSMc*sI=Nb5J2ur%~o7)r?7~qts91Y`RM0gt+-+JT12KJ=>yI_3W4Jy_A!W{Zbp=B&BA`csgU|49n zJMaV^QN=N3}Qf2frhEfP!TRxAOFwaE9gZ@@|lO z{nT*mr1Jc(q94;;Jix(UHZ$O>w2km|$@r-=|ADvh@4Tpkz_m!no}_%H%O}Kk61FRY^wSUY#;ro>vx( z#g6p>jDWuV-vL3@n&Y`V9Uken35h-itjGw}$f8?UYC0ztmnH(c8*8dvuF(h*Oq?{H z7OW@(Hlo9wyaf^`Z;uh3?Sjc5xcw)PB5(&j>NBAxIMS&)rUf{{8Lu~WOMUr{IavIQs7ao0C)J8skr=0LWQ~zv2=b=7}2?D9sbAw zru~s~=p^}t3*GTL7HS+Br%Z)Uyl3ncen0$C;kUn@xm4~iek-XeJ{z1Aw*|@D#wNOS zF@rk7%uLJLxL40YqRuP{YjG2vCm=!luD+iW1~S!jcG%<$tlE#9#t*m4M*dzW6)mJF zmJ)4oas}Y$^7r?*a;}z8%KmHQBs-|1sv?YQO8o8e&&JP~YhKsu%8HDFKugstziOTb z6$+ueX+_@sb5IQJ7-*hIvoS(hGouOaT{E7$*xga1L~vSDA>|@oTc>_`40e4y3+_3S?`lCi)~pD@ za{uY)a}v3!VFMyqjP?=7FB6tdwrFwWDV&>FKxYYjo)%D>?>{+E;P6$0N0j<^XCwvP zeEGVl#g;%EP`LZljV{;v7_%&F!SG-D`(n0E2I#HCyYjO>*-2dxSlo}VW# zS};(w23MWUkyjaMn2tW5d>A+%`bq9*D1M=f5P`h5P%yNN7s~Y?U+n5to>~8;WFdEg z*e{OoOq5;FeenHY5?2LP-LWRS8C$-_-Sv`y@JG1+Rtzd+{sJKus?gv~#@V<~B8Lxq zJ&FH7KODroLA)oJ&b)M4)^izGedcT~9@GGRepUc^4p9J((fIma$*(-Ulf^oX4r{8h zTJ2%m`8fnP0`M;@{x4F6!z7tbg+wn#ERmYpOUh*oZnYnIvdr&`ii++K*)Yf;N_!-L zDt!Spmy(3aS*Z8=s*1oPRN-CzwSWIbOz8jKSPX(X&&I)@{3D4xBl_i3Rost?#!CjS zXHx{Ao_m0S@VL3YB~TegID)b&4~U5x;vlXGl6k{qumHKP8Tn7nKsiJBvGAzp*8zO@ zFFp8Xv2aBbQ7H64jyxj7R*6vb35S_3~`mnPPwj!CcGYKJ{F7o zdC3GbYQKH@N0jv~elI(N4Qh9PqUWv*Tv>7Hh!=k z!uvbjg)GORVCm^o)jsP?jO^^@3WW+4?vL!(X=U-)gh-&OiS9UkpP* zD2y^N6tg~$uDaYSE=dj%C1N__JaIa;|HKK$I>eYn(v2zTI<-)!SI}wp^wMp|Q44iqTfj(u3*97Qkp%nws8R9dt%_h!O~ECJv#rs=PP z{Jfj;EamsX+rTAwt7&uNIwZ)ppHPkoHigWJd@>1ud8bs7ilERy*SBC!sKwbcZii6? z+v0fhA|^ojb)I)(e3Z}%4#?RQ7FLt| zkeW27Jj-qoi2@)Zu)1IXoP<#)5BD<^Ddu9DiCFkS`kwK_ffAw9ep1psMcTm^-$G+; z;0cp_%;(~?T`Q_Ku|3~Ed0=Q4`4D1co}7co*~eg$j}|;~%R>%*`*5$zvG`t19T#@D zQQvY>2ft~d9IoRNJ(>{5X}~k{U=ZO+hk5z%s=rv3Z~~v-9BJ=^C!Z-( zyV@VMjWKouGgZc8H&yA?FFL}Ruo`o3pL@h7VIXB$)ID2Q42b?F0LBN$R{^#b_J778 z;DKW3SwaTMU8>6`4TWFWl1^AMf(dP-ZE6G@14A|TZ#a`19eO%krcu$;`~;irs{Iow z?@!q^9!RNApl!!z+ex&F(POnVDssIxgeXPbG+pI05~O>9^oAC>mphS&0SG(?XfQ$5 z-UN^ei4vfEc;(0QD&`n^LUR8vZ@80SFM;tnR|Z|&d_vm^wCav^RTyM#mD!ogLp2x zjQ}Ddf0PUkC`LHJLZ9+G$3gw9xO$yg%b1!C>2X?#{G-}!5C_m_B9fjo(2{MLMmAu4 zk~=<_MFbGbOoosstp+)E(H0V|bC|Ywc++C#_%g-}4mRj9Ap%xhb$Qymp!c^r8%02P zS?N!icrD&d;aB!Uvh20btaD5DzLdc4zW3M~z$16>TRFut5A9qB2-1ov0F|b~S2_XZ zmd2(*ya>xQ`h&@=Fo5b=LUascBTl(GR3qew|LUHMWEp$NnX&kV4>O4vFko#s5URjR zU*G|lJRPEaK5~4xQ67CyDMaBEQY1*i6EVgzAx~HAhv?71S9^d$ttXd4Tp1&owV8c} z3w|lEJS7;b5&j1!fjdm0ow_xE8a^jDzL!DFgGE$ozzl3YC+d({CiEpg4aJZ;wdBwc zFzUc*ZWAn3!T2Z$6_d5AG$T8YxTTYHpQ=b z0=`u6rZS*Znw?>@#*-c}s9s_A=ZrVLpnwgE2sq|tShJ$38QfKhY*sIq@rN%bNR|n; zmui%nK24Z`>YDq2bPiKUw~VaGYYJOMg6f1kpvSa9>10Nd>V zRz;&;U`))*p~HRqOH;@$=<&T6Ab>Z5)0C{=_geyA=b^DyO@vEGTzvsH?eQSRKed5HmCd7VX1$oX6)Vl|xRI6QQ+r!ux|02D*O zum+-eZ*t_v?-8I2J0_CrkmR7C>iMDT7Oj~YC~i_QB7#!JZt2hyX?|YcnwX9K-1I|u z<$q)UT@yzP)(g+gq>({se-3@%Bm3F2lNy)bl~>yL`5bILcSp z6+X~Y*e>dN5Y+=K>nIA|%S(~r>%Dq2F=~cn zUru$}o!ig9Vl;7|xc4(J3+=WE-ewyz#O@cdIkGWL$pnxi%pG~Oup5dLENC-Set1$X zUCCFU3n2SLY_R}(miaCozXF4&-R)*H7IJcj+_Y#|0I#7`{eIylthzqRxvnwVpJM)( zYQ=!$7U>8mt5bO&^oaC6$KWUOo2ZY`+cDIr0(a{@65EbP5+Gqi?kU^MVjzjYhKcFv zdfgd(qK2tK!DEP18MxL6DSBN#{}}N>RoGL$pVc9l6*Gnd`DpLj&rnQ*z}~SMD^Th# zSr%2Knl0bXhpp( z*nueqKHTlnsWPSONea{*ij z%i(A6cn6qQM&c(R1!8!k3?Bg-{E${?atl;4=*KadnVWkmf|a2MJd6^PrkU`ZaSrLY z%yw3sQY?%aLqOunjVzrEm_j&jntqbA)Ij|oZw}JJr+T`h+t20dU2t{kp?0iVA^)v( zPe8M(7v?m+pTO$Wm`rpqwc6*=!Q>A_2=Y*4w@Oop%!-Q?WC1lF<7x zpI_<%-z^HuZqS<`99p&|%nymampKWnLg2pQPo*VyiO~~!gk|Sr2ln$}$PS222+$9( z!5GPW+s&QKM!gEkQ3@-EHBdR5Zb7%_v&e~`uw>3to8P6=;RAp(CfVtE!Pj1Lck>to zf|(T8=gyYp$X0wZU+;e^ohC{D@>&mXs+(=0ov}(?wr@_)ktf{1wk!cJ&)W zYXPIG(mG3H<2v5Egj%k5Jts6gNbY2f$Tm0U)I>GMM3t;ObW0Lp+9SK!ziGbwNzr92 zbo@yEEQ#)KmVk0{lwydm^;v(RtA+bjF_VY_p5?UxzY|yZvH)B2YD##~uu8|5-nxXG zMY%arvUlamzcqZ`$trChTgkCzCSlI=LQGjc)nsS<8DA!`FaC)B4YZ z*JaB@KS&W%-ob1T0L#p`fWPais8_{pxe+L^lHAlBgTe?1)&hfv_;rJ$>@jb+!CHz_ zW(TLGc6~(HzQ4E7O6`RHF+r%3nywe-)n|G(q|JEt?ex3(#~wPXb86)>R=!lx#&Wc` ziLheo;J_1h+LTE2wvLSOjsaslO4=>#%JXbKDr~cYN-bmgw$#_+-PM9kw$CBhIl*H| zwgH;6k-R-5IjrhWEZLL!ZMDYdwN!OcQ|{k-Yt_`}Qa+0If?&g?di4@b2L*Hbi32-r z2S2B>ArQIbeq|x$bC}bQJpnG|$=J)Hp#D_*jGH#iJ!u6_&~(}N!;}U=fPhUrBr<#C zC%S24=s)XbU8F@2foPp+?mSm%;x<>1q}z|@?q;&FPh$845LEP^J4ltKRR%DX%;{6Tn5R*K~RLA9PG8` z74Qt#?@OqDKM`Z{xw*!zj`2$LFXJyD1rp_exd0Qea8W8%k7#4E`K@&X>^dgQ8v{{7 zRE=O7Q0uE78X8*q>ts1|5hthhYG&PL?3q+lC|<~bbDFWkKPpQA*Se7OYIGK_WkFP^ zq5Uscs%~hd4qd=0q1jbk>mV#}=vLLBeR)bD(oW_Zm2KlBiaH58C~76MT&aM~81LFg zu`)tatW;eYn~k_N)?jw(>7SIXUm4L3HEvk-(3q;T?RT28M()wkGQvb1Hf)29Ih7x< zpVB0br$HgK)3Fhvxc}9S2k#j()wNLqdA_Ue8T)6wglK2ub!~WBecccZj&g>co~x_P zd$!-}mS=M~E*oE9sM@!REg13PAaJkbv#=9Y2Ir&!DzR zaX&V2%+IK)5A*_6l5py3YfB)h3DVf328ldgl?p^Y_My*k1GA;sJ>N~=Oq!KNr7c=Y zX0sU<2+C?;2rMq@2ZBRETJ`82z#t@0y9j^Wr__B#ve}^8a{6TX=0X;L ziXFL5bm4vRx{a00+9PIu&#QeZdBz=6h&~~GGVm?srcQ@kbxI>BjCYM_z;&l5Kj>Gn zz*V6cU5@c}C@Tuyy`D%e%{)Kv*H-Ptlk5W_GPajjLb+`y@Rxb7uapcO38Dh9bj9Ps zukn-cU&^;KIlTbp4Z?fU=MGS|^8z5hi{z)BklogfsBf3-35oLRcFm4DkBYJwPZ)W8 z#HE?)P-&d2YMv@d;Dw=QSwaPOU<-hH2~ytI=MXEYzPZ!t1wQSs>Jo^qh+(IW({1S| zXq;Z7?Z$XnQ*u-B4ri3}ztvavFr;_|!#z`hhUSRo%nc0hnID-Vx{qbY@9)XO?Mp%NjeCzXn zYoz@zdALx4^oDi*jWly6-fzr|hq5KvWe3b&MoS23tjJ@giuAD1M6c%H(W-OX)-ggl zrG}}e@2Xb^3uHKJtOyeA)C<*ueY)ZPUzcIAZ^U>p9BBon`85h=K5Gc@%vi9F{)i$e zK{(`j;1nngpZMw(Mbe_;9wU<^(gFW10)S13iUCXgDWJx*t=mP{mWCYFbNV?U`uiFj@<=^W^ShU?G$;kKP40;@>pD40O{X3 zJHi+8OrdjnwePEi`Wus!$Uqt1*fv#0X zDm+RunDVR+Pz(QPuB!zK6MRKS=6hV+VQNkc7JByLeB~Ua8zOPeksuU&c&}O;R<%9H zlDrpy+9Ot9vm`Tu#eYcp`+GZ>Y9eqo_zdf7j%sW=i$4+NzIx5uM^OreSMhWubzc(C zoNdT)X|$t|Fy(!pm%wBl(&u1xh5a-UKbwy%aq`&3R$O`+++X~Uc#u&s^_)Lyo*^K% zI0Xo<1pQRc)sEG#S=Q|$-z>)p{B}lAa})3IAcIj#+3d)vJyl?0iCe9&sDvtAgE-Qe zuDi{LA@rC?qk{DSwAJZy64D@i#SMTIv9?K8m zK>$A|3Y9E zr!(;HZxzB^i$>5@3R_iF^cdORv6in%Uz$?IHbhhV$PYpcrv++|#aHJFE;AGzLfdbr zGHCAFKnLupg8Pk0+7mC^=!P%hvD$_<+FpF48?U!b4V7;lb-UbMcaGt@tJ`=@&Hc>w zPXBK=>8(b0kRA~%g7a&6_FKpD zGBH0q@P9U_FONa~KNb8<^(O9V0iTg?px9muC2NiUvH~2Btm4=aelHg$Xi2Uy$&5By zYrtE({6mdX9Hv=>GlSDopLg@{-xEbt=2@pf%WF?i{d2>F{c{8_E>aG}h@vzE$8aW3 zG{q;!0KLD>>O(SBhkP5Mo8dhSMsZw+BT(;!yC?nH(2uUe8ik6Vy_5#yUF&|!_ASAQ zWZym|Gv#s+c3dukWN%`&ctdk6k$Qc4`QM#XFsp@clSf|{JiT~64O2_h(Az_knApMX z8_}jA>T*rLW5kyvA%LT@lXpu9M+QV6CMQNo6&BhHDpgxvW}K&Dw?`nTVG|pQWj-b- zgu>WM#RTCb=s`l5VIStC;3-vzC8avZVoeg%Fs2ugX5zyu^?6DXIjLGnkVKFlNlvrP zKn1J7%n)r83>Idr+6l0V!5q2-FqF}ktb*v66Qq5WrW1#2dRVlP5nE`wTq-q^-arWV zR$IO9Ozpd2H^?auXD~6-N!%mPvq|o*2}M%hl_GOdg>emP?uDH_>|N{gNYaK{r(02I zJ&pC(tb)BI(qCDcOmUd~mA+?3Uf%yi<7J04*oDKBN=8y@A$8`=U0_)JObR3aScqw= z!DJYXW#b}E=chpcGaqs+#Z`h?i(^*8uS;c(CLU|2Q)1pFyT#uIRetyR=BRcGvQ?)*G8t2d2c;e-l{trXv7+qHv zgyC~z+iu+0wr$&1)0j6lzO-?J#zxavx3QDPw(X?hmp|w4UT4qRGqYyR`@DC(PENgy)1e?6L0WmY0dA=3bq_`wMBLzS8ab= z{!QXw*IDvTx@X$PbB#X^agGtP%Gw-#vQZ%_1eF6;&M)rK>|g1+AU z<%8728yN3GP;vJQyx(OZrW_3r;2eFsQOZgzB4U-GOx4dY405lDAy4jl?SYBR!gS-o6&qtj|pmSt13?BHXW1l3c*xd6nptQ zuQ&Fw%H@8y$KVVpSP`(&Tp(BDcl=jX9pzI~E5rgHJ=J6|wxU9wd4U<=Vr--M+3_3` ztC6gT^w+1e6aXh6qD@V)ahdy5iSP_1Z1)k}fNs%5pL3cq7IeLXlKzdMevL`~3w$w5 z#PPm8t=ZK04(&5*A#TK?%P3MKPUQ_6+jmw|(O|#maYft=9YF(oTglCW0;euY4+ZXa zn+!~w)wT}_h1b0l5|wbDWR-7*mErO`GtA~bo|G-p=VDlRsm&#D(ZRymj%L~$%!TKO(22_SGjQRO->RvKQ~GYg9Fyw;%@X!<9M z*KWgngO6Zd1GnDn^8^FPOvC>t%hyU}XORfTWumHV^mR@y)Bk4Y^C$Sj#o6i{=A61% zZqz{8zFd*6f27cwe@BqVwSEI!<_Fwiz{9-l#lN2>c6Ytt(|v~~(v(@6KH?TfxyC4E zfCY3zp!7axg_A4hqZC>UgXvFSF}vngT5MLk##Rx< z_({;TiR=;#VNr$C#-H2)CtF0B#3`M+eUzLtW<5@ER&HwPi`hD{=gJoGomTT0!CAlA zyM)lDluFsN?N4^lf#r3~k<4^^NWp3w4SyYZp#@roIpZY&6=k*WlM{CYjpA$h>K<^d zBJ8|FIr%ULDmAKw7+}p_8FQP`wjh-mmoinFOk?;AV8xTLxc{%*=SNB|fS?XlR-s_Q z!~h9EI>W<%$`q0^SRyh*We+wPEm4%Vjd97qn}%9NOsJLHkVJfA2t;q720)!vaEb)r z4FY!}9{?k@!xu&tZ_sttaLDj@47C$=RhHNdh6)$tR))R%wbFI9-9bBfjdn7^an4vR z^|+?!!<|={KV&8HyF9y#-v`tkOWC?~XBvXL)gzy%xY+|BBRe=!)#y7hYPUZf3GIB-boeZqr+ z&tWhx(Z&-PQfGSa*ptf{aL=}$GYy)OX5p%P6R!BhoKZ#TkwTN{;ZqI*2Pn9O6bz~x z2MNOH{&0DZL0eMaRP_lkJrR$kt3je93k*;QrMi2W+NBeCMK6kI&FpduVG^RwvP*E` zq!I1Kn2s#GZ*i9Gw&dNAWRfP?BvI zkAUc>+&@*%2lRE?NSL`L9kz4bX6KKAQoi+P!g0|qICn`=1E(u`mWbnsTv@B4om@co zKfB!)5J25@QTU&$9wJzD6Mp}XsNUqgK-C6zX0Xq0%N|?D2Aq4-w((|>frtlF`FPL; zyPCqU&?lAICjKT8$H@(M9QS#5R)kiBS4ucGjsJcG{5P2F&Gc_SuX%0`3cR_7KChzH z%|NGh`vjw<@rOgP5d)sf?Ur9$m8RBLQdR7+U0tjehLzX%m1!Q{2@s zv`~&(c7X~^08Ti2vL0OiLP~QAh?dQ>KVahp``AJ*{|cnO)uTicu&h_?sTPL99qC7{ zfn$aR*qvc^;^{I-b8a4aRQ_C&n>rpcl>a;q*HO4oD(xELI9+y^ajf}*hCiJgOA|?j`5_kRSz@AOJrQkE7&W{e3v#OK|nX!o@`RX<_I@z$4O1sF8zE^Vmf@kf+%X z_zsnFnmKSUa66qvuF(0f54q<-f0FFr;b%%68w;@B!fT(lF@HODGpP%J|J7Jp$xk_x zWpHS=R|V5O7>$7#ws78iJIh_jr;ArFL0z8!au*RhqAx4cDn5n0n=CmDA6YS&=_o zAUj+{OBksWhxvLO@3pjOv~eexYOuS41kvnjFVs>Lqw*tLVU}x0*;S0 zIH?MDjU6BBNaRW|zzR~E%$g(ZCv7&my$T-^v-__uUlqAU%m;LH8)%%~xmrAB#6aJ= zt_gsQ#lx!lz2RqMIX1f@15~|?f(WCrF$(}K-cGjoJe&fTx|wzY{0C)#-31FnlPGlK zXSdW2qgyr3=LB+|-;u(9_pAAGxV9XX5`f@+7budNj&$I+e=y<6PNOjtXudLlX&6zgbiIL+y zmV9&BVx{|95iL1!MMsvw3J9(AAI@xSyVCE{H(-FVNQX0%>EC+JcU}Isxr1y|=8v|K z9_0Ke$WSY;z&Y-Je#rkl^KLd#^VxmGM1Bz0TPWA<%zF*mkW+b$PhDKdk*@o69@kq2 z`m&_pj3e@aXj3j^@62_JSNGfx7MN8$Nihem+szb3>o=W-Ar?fzCeSTR0H4n2$v%A7 z+Hku@U|P*69^htPhF8EUfYpiiRrOy4X=>yR$V~?>-vVm7i;~6mWkq<=h&=_9q52Im z7NCDbQ_(gT6{9MoBUjY;O$pF8sVFba8GqHv-~lxS89b7L$45YNHmQgEnG{}Mhy-(o z-{XM3u_f+5a?F~QHzG4?>wKVSQaXQ6Pj;On&0m>dd$pZ7xO@rt!<;Mzt%IDRDVfvJ zvR0A878Fnhmm|GkRyd8=+P z@Egs=s*k3!Y*-e{CfMK8?@J81i-zW^-#%sBijPTo^< z4;xHA5||JKRmUB&A39l+0ke7XFxV&bb_WY=I#UncKLB+ZXhOVkFuJ`XvO)lLj8}Y# zk4=Xanj1GPpgy%7-F|22QB4CB!2iI&CMPGqd6ZlSiZoRmp#auig1!F^bYxWdKVcIA zRSJgxCHxdanVT!|!`a-#u>{BNyqYEN0LF83-Hm+fx}eL}H61WOA<#{!9>op_8oisx zJKt=Fi=!HwVM=DF8oipw0x*6an;k{Ko;t?{ZU&Vj2OAbGGUS;jgog~Ewbum`s45X~ z&D=^ayw1(-RC4&^&1W1#YlC0*`*B8LDoWDhuY9%VdobhOWMB})2qv(PkAk_zkd)BZKkt z>kxqO!1?VVUn?vyW1jGN3+DtM^zUpmuq{4v#A~a)C|2dW`6rNAmyBON|H`=jUt~aR zEagoJ&_afMJ}Tu{y@^cJTs9bMxsSWqk0HnuMF3Hih5s?tG;4tHNZ^3jkkKtnB96LL zaHel-GtE7KrVDwD2K{Mg!j%2#PjHb?5%x8*xv(OANqX4!%LS4BLa;GhF#%jN->OT8 zmBv^cb)4!M@s4qRT`x7xKm3h3TPhl3J>iw_dC6h#8bq;ZgkQxYAw|>H(X>_E9EyEap zUNHnhgGZGfK;8}jEze%ndL9`Q^!4q9eukwt-5cKE#9NUX^xf{K>fAUTxXy0Gse@MmbHV8AF!VJZ=Iq2 zRdJkssUesSLA3$0)G_g*^s%t|ix(du?7vYac$=FgTn3kOA0+q$41Nv2UZ>fv#$Ukl)G(EKX=K&B>;3i<0!G z4_0l~e;8-~CkgUTXp#EwsS9Cr(-%x0oZiJ$(ea)4=09yGbhRk9A0h$~gCq~Mxw8vM zCY|HFCyHvS@-HCn);@r`7lDEi7g2BA_dIP_%G9|`$BuS*^ z8gIeK|oGtbuO}{yC34-aF&w={b+%|Dgw%2O>pT8UH&w+Az|w_dPAxVR=3p zjVpnsAowQ~&N!~@Dk4!idcA_2(_ge8<(pBr1%Q| zw|W`T-T}HAh_803pAuD1>{^5~bas$@-x)4;%; zYh!H5RT+Q>KF$5$^C(D>OhRVwr2RZ0HA^Mu^S#3OMAPkvD0`sWt=*6_qLDHpO${B5 zTMV&_Z@Z@8iAx12^jxh|>Fgr8Q^BdfLmtP;5RP3Cb8Bo`zGi3NIWu`?6hSS_*7prIC{JC<*V&N}vmxZ&r-@gNG*J894 zNc9Gb=0%hn7P>L3_xU!dCGKkF=MAdZ>U#U!a+kB6oV)j?3U3Rm9`j1nJnyF`!X5C{ zO60sP;zl9ySj`I0Z|>n=#)=eN(xMM*YpbhM@tf&Npf7CFP9s^8qP(e2P9IFgrw4H* zuH0IMMi2i33AKRbU5-X1B_j)l)^@^3;$U=Vmp7W~?pEKUw>(32XGu^&9(HBf^1RaM za^?+)mc>tMI!IR}fiUrhAP!TI*ONLu6YxTWm5*ww0P7ZtBDU(^LtBKbYZ~GoQY+>x zFY#liLWv&Iyv)k_m7fgWo3+x+QjrDqxyt*mwspCzdjF=`l3EMZmg+JmT7O&-cvOG%nOFz~lNxqt@Qs*Yy}pT9&M zAgY|&wF<=iPjOM&?)VGEtlmOjf630dO#)PMt-QrfkqqJ9iHn}r_K0U+b>~7$>kzft zxZ!V*nyDeUf{&-fknU_pCjAgqfc6((SVF_Ee^dg_4^XnWqo}q?7=;egmd{3+aZfza z0lF;QM#I1)0PcF~E~mvFb&uKuhHGi2JF99x(9J9T}rF->?5+qKuRvSE`adNI0_AJb7(pAKZz&|#a0cs=Ir#=uof0H@V@zM{XMwv zdN`|Jc*}|jk^HcO0h|s!<#`5n$inf`9SPL!qeEOgTL;>GwPge=NarU@6 zeZEoBr^YYzfROve{6|XOZq%JHUH>0xjZ2cuHx_r0LL;b7+y3qi!I=5D5&e={-gCO0 zWX{MieU+YX&Q8U}lRxJn={VnG4_%$i&WcMi~kp z8kJ)EpRm29>zzt8Djt2eWhh$?v>k!_S}MGORyZ8CuthnbQs1Dva&zOFIy-M)dHOF} zTxSSDLOj#Ci0Y!_=ue%d&btrh*)$5vpQDX>%?c*0e|8MAVdFvgdb0uavKuKE%Sd9Ga;aRwAMMBP>?Nz>V;zwA4bG#uOpKE$qUoT**t zy;(!_IopP;M00ics}#eWBTvKhv5>Y*!g+bZ3&e3|SexE6S#hBX7hIt4qT4^U`>nrV zKLD&ib>h{S=9|vQ@mAC;Fk{)iI&3kAX_8wakI_G*`{T2bs*GLjXAJ8N;s~zuQUWg= zSJM`E_FUanVG43H3_y=b=>F(G+p6IRlAJ#hd{noyTa#pPPp%>R9eF%IbIe(&eh*4~ z(dHXixbb(9ISiKT>aK*mtH~TvEVgbS|Gj@n)eRJ$W0D(!R_pJw$J(6fBELmG&Z77l zNBOD`N#2UB>X1;rG#A^Zqo?= z(G-;bo?Uxjiwzc|2vmLlng=w!+V<~%z$5_b@yIcX-@ZSFjprf*t%ddP6a*`<0-}Bq zdd)~YAMeA@q5R=`b@B{J;tS9@jn=OxH$lv9NQR*p-`FB9@%nB&2d9aqeNmxc6_rWK zdD_I4)4gK)r!il`X1zRP{dEg-w&}!sG%9w65&NCvVPd}G!R6)~LN)Y0}*K}A45Sx&XDPZQ- zw?P6N0CITSNNU}-^kQsOb;<&~a*Igwc*n+@n@cbJzy!z*9WMWzTu?x}d;!>EN(3e24ZV{mfF?de_<=|^AWR6lU@kw!?jyP!?!=)Pn$9XLPJ4yZG(O` z7Ry**QhZ77bAuPg(@B3!=;KC{G%}1!_>UsbCJd8~Ms^)kiW4=08pJ5JCuF1BA77!x z334v5i2v1l%CPWU3-s1ziyr+!L(uK`=cZL-16#(0Ns?5q@Cw?@w8}C%1B619u7*C^ zU|s(lHtlLn?Pu~2_-i;Rs&EA~DVJlt>Tn;3wA#2G6K@rBbZ4qX4_`79R3KWM0*k`uK9$B2{*g`5tB7a9I zIb&8BCp0w5i&3L}Rfp}9JW>u&V)=@1t-P$~Ff||=IA~>q^+7&+UG}|9$Up1c*Y@4S$a$X34QFyWuh-B&tbd zAY*%k6{GIba5wt2SH4~F`!iLcH$vhMniKyPpCCtuz42)z2zI2G)$itu7b*Abq2lQ~ z{j;-;V0Wkymi4Tq^)AvH+Lg#yGeP0TnrCCb>N{ok#{6}if4CAjVA zT*DDP+))dhFsg)Dg917;Zq~DJ>482Oil({615rK!(gSs+0fxT4zGKU*p`wfaes4P&&PlDKYunat> za9jlVFN=5Lb9JOujj{rvFn#R+LvbG~$(e^PWkM@0kVPDKUU;+E#oX3$K)>U!%Yt`Q zp8BgCpK2ip@&zmxap7j+%jr(cSXrr9M^A$nN+Q!}q(B$jiIb;Gx2Th@&!LvJ`ky*q zLG2N7t{BJt57e;^|CSy^1)_J#9Fc~!%%oHSnSF07108~t(Y3m3g{}T{`it)NjyVH9 zhbb-?yGn7xbXpdhdKI$eCuDE-d8-nW-lFH!^k*r3NTR2C zL%VlZh{+R!M>v5f@W8oX{n~*B6Wy9>ypZRe3jao>UUrBLQ6cVm@WZYkb-+5E3SQmo zMn^415>%angQNzd{1I7t4#(GE-k2d~ec27an}_+%Q=h)s*z!Rmz%wz2`IPhD7Svy($WZ}wV$#qE6d12Y8;>?hO>Ekn-Gp$fn&mV zv@0?F^3Z&+?%(pj5%`pQ^PKbijgEI|8mio9et7sVZkd{HNQL*`VNSQ|1{yf=6mzW~ zoMPUkiY8!@*UUvTGz?Nx+PEsfGW!%>Wg!}P8SnwH!SxLmse4U5CVP>y35n2e8nfG) zE)Xg~KE)KMDw(}JD}KAx3M+IpYizgl@%tV!A-?Zux3XkV-k$YYvL&30dYh*1cwosb zs^1!(W4sQRxbTZ*^eI~?tK!asXBxocg#O@^2Emu1Zz5wxn~IVN#`m{fE5ebOEo(Ba zc~Zc2R~nkbr_Z0LQK|6rU@tb8XQKX|VVpGOy9NMc?qtD==qT#u#`LZ5KdK&S0%fh; zafmh=!+CDh0|S2ek&m7ahG_mOP28=hk{>i}X$x8ibY3UlXZ1ghD=KQ2x38$w$n|fc zlAq1<4p{$h_thM6U9t0FLekkLfyVwKN7n+)%^Oeh`e&8raYYsB!uS07py@brr?5yG zeLtboy_cC{V8;-u`SMrBI9Liw;pq3k)vt`QtowE@;JD1kN}{mSjU`7q;;Z;HwpMQZ zu^HboJ6vu7;6$HcI08g7q}hKAsS;!7S){~Zbd%WKqPK~m!|7Fj%rov7W?@5EMV zV&C4R<1LCCy^Z@QLNJM?e564rXxiF1N&?cZZQlxuj>s?QHtRRDlZXF)&)`75_SEBf zu}?_W{T8(%;(=FvwG=C6!o40*yI$>Uz0`0gv3y7Vs%7DmQf4Rn^ByBx1kjjG0}yJ1 zJKZX58yIcH)a{W!$Fp#w%Xo+4Cs5(14}}0J;A3BSshs8%z#$f}GTG@6x*wI(UeJ0X--%4AMS!xT?SvX#KD}UrVfM7O z>M51C8s}{FU5|O2TL940a}3o@)Hd4UIE=a^?I>{!RoQ&n`Nc5BJLl;+k$Tp-7a%O$ zCzhEPo=e*mZj;(3F?$lwIo54_7?fr#aOt`j_;=iV_931tR?&X?D;<}Mr-t*Gd=j%V z8;AFkN0U&j!<{%Ani^m+RE-d*ydrn*`IwM20;km!~zdskdo0uKtfRYvM^>`6r!>w%c)= z;KT$%4;QPqKJehs`Ra=cS8?#zfdrL3!nXJwf#Lw#aa0j>@`(t@=A6L9j|{9|2;d-q zcclS}TI3F=h7i>7g@Xr z<|q&K@qF6=_~sA2Z%qk~5wo?k{Gr^kb8N)#o@}q#fJ8@>(DGu2lVR89Rv18SmOaY) zdX@rkCIRk;^?QY##A)G|}6wAPm@@i5u;DdihV*i4rh1-}wT#?b(#>UFGd3LAhyxwij^M zN?7`7+<=sOs;lO>Sg%OqpE-F-nWadMmu*ScMh4&mz1CQp}OH~V%YI{OB;OJi+ zdt9$gPZxkutH^6Bu}}sA3M@s~#{T8u^~Y_rVP{Ca3y7=d)E-sc@77Vsh26Qw0JAg{ z-r2Q%r)=M2cr9@9J8w{w(C{*GXI1^@f1tbdlNccd8~?`o!(7 zK^lmJ6_wz*qUkAVvC?-4tJ{$MDW|{BD^XcHojVXp5iKgE1!kuhg)U9vUWU*=7Fp6~ zPu7cO9knWVB~wFUtn!5x7u`>9>qoMF1JV{#W1$oS)j?S%w_qeJ5P}1dfbAuiEoGq5 z9`u4K8Ah=~yu~U7+U|`-2d19%%Xa`!-D*=~@t-<)7pHQs;MCt-1ilW)!hHUfNiQ1X zw0>@$Jt3RBM^-wa6w0b7zIGo!#vT0-3!uP>J7B>4J+}gWDqB@1V(NXU>WBN~3#TUw&ZNAU$RLRiodRjp-!H)lKfg_zQ4@pI zz~x#MJ;EnpF3UznJeSLPwf5aQJSI39t`%RVBw9)8irlK>3n(O1@`-LV%G~Dd7+aW9 zp!(9LQexz|X4r?eA+{bl|5+_SZBCIFyikscb?sLVKh~q;|E2n->9IG1)b5RDmT;TP zSFXPKVZ7wXjukG^HLN_*xBm8W8)7$7EB`~#9LEPIqyd1Cg$Jke9pD z5Pu~7E;BfsvADQQw4DzFJ9MFK;v%BNV*NA)4WqI1*kNr2P`)npJ<9%L9*VRf5jp{o z^$*j7HZ?vzi{^sGK=LI@n9!d3|`-)@r{2+M{zclUg-Irvutaw^-fAhK)a1b02ocM>}kH@ zK#kB~DA5-1qnRLlWdejS`NiW(2n|B~>!P6W-i?h{0^j zQ8YZOgp_LQ4X$@DR$yzVg)oy?JG&;e2}ebJ>o3(p|Aj3S8#zDQE|cgvGas~3f9kmVZBQrEb!s-;J(`5dXAD|XXkVf z81r8#{QFSYBJ@dH0-ORO(;+1Y;f}ZLIDe0#f{9%Via|jsylc4dj{_=ECFQ*%K}co9 z6BU^SOw~09rmUp)h+djgn9K=Vo3d5;TuiSbd#=sezFPC=BG~ekXmvA+YCFW2z(1Ft z^`Pjh+wKVO3?w#Cxud0DW&ojGm5rxIPTjWtnI=cm7Z=rB2vBAYNMTql`LCm&`_K6D zj&uH!k~Z8f-#Ets5oj}%v55|&cT<_pWVwIeN79Zlr`oiK*^F6}JvOfB(Jb|IvG>YS z>1^eJ4(kthNUEsglcdjJ^rVsNs=x@F+t!4~9SW&09*4c6m?nbJ{1S z!kLogCC&A^@en~7@Ve-7X(HoT3jl0keL%l8TWselM2Gx`D z9}b00wNgLdEQeTwN0DV8t}~6n$%(bf`0&!FP+LmEA#@5@;#fXty;R^7T*ZZVm}>I)6=gL+{Aty;a` z`rR)5vHupFnrjTNwC_8UuJ21Pd_U};N)y_3WCJsFZ}(yw2|!U$Q>1W^f^xuZsk!xO zC&^?SHkYJU|X7lqW zEajjXOyOK^Wtful39!G$$N9-H3AWXiF8lW=Powqig`x#JTHw_oMsWtmF&mJ*+WPw9 zV#!yThJU#ab|M&#W#ehW^}A_PGbQPt;LdDg@&C2w> zMv{>RJu;c^=?67FrjYff0}#Yk&`WAfyszzP=%x};T@LZP)p36uz9lE?BPxXss|{HY z@dmpqtU)HN@tA6+tbfa-sug@bXTXPdDW9{XS+^>eXaHg0akO8oo%`@NJQ%{Wgnw^; z+uUaR{3r|Le^bEii3Hbu{$|AWWOm4L>Zxbhg)XxMg;RCs`|sa zenh%k!D(;2t8UYknov$g(l;;K*b8&yhUG#$rTD3Z3c0hL;9O#Z~@SjJpO??NWX7US>1K~yUhu0q9p~mOlUo2Up~Raw=!s#>w%EtryZWgkAT?vF-|~ z1M0L&?v(4!)ci+y(T^1qqW7+bK$p3znT?7O;oSK*M7BS463|9GvF_kQcKTldy{rei zbNIf0LB1LJ32mndYBc9+)X&p_l)u$j+KncITp}F5i()S4AsEjO_s&)3-XVeCzk(0r z!l`5q--`0kSQs3nDBWgX0%@sC z06PB|S4QCCrVlrk30eZeoX$qZ?CCr8Gc_6aiU&oiYKPV&&6(nVUIrti`Ot3A0{%yN zX604VXCxHxe~^#GH0X{G&9^8fGJ`Ui8Xsaz|2gder`5E*Z$~^+DM-g= zu$kbY`v2INkvWCOWzt55wEk##)U{(7pL@$sqKUzqQ3H}6Q-=}gPdy}2K0xHE#?sJ7 z12Q}cIt1e3DDYCs3PRY#bBp@`K5wvoJxY@xcxFei7EFG2_e}=S^Nl?INge|62NQ8O z%hy?J+jft7)tq$S%F#u?(@vc7kYa#t{m#dq*>0IP%oLh!JU7YSygwD$B*Iq^50jcRhN|1O{g z&s7-f8{naCAG*G7;l#^W%pTwnIPaz0EiHGPW7xHj;f>+v|N4S9>98~=E`te>PDE+m zK416qR!9mxTTs2djcS^YyVW7RFxEp1Jm_*hnJ#~ubU=(TsjV&iZoG!XTxE@lpE@Ru zq8%YIH{E#`P8$&$iA?kj2Q;OnK2{uDf%Vvo965>mNGF`9Fs@a#m7fKFhywfa)urh~ zFvj%@Dv1kK0kP_Fz#@Z49-MHA89|{#_XpK}Zd<;wQoZOmYbu!*eaV7rKV7~Nc|M=a z#%87HXMm!O{)%K$TKQJI+Pbl$t2v3`WTA&?WkohXDWarw$2QhR7zrV|atN$e#ulP< zYD5!65a?fa#E{t^_1FAKq<|LdZ4!_wex(b#fml4OB*6;9 zeUq#X@5m)?B%v-q+qEd9Y~uihf&T%iM`xVh0E4G=+MA)SA=2}IM9!=hL2ivS#HKoR zaC#FP%m0E{$VCuHTs&dU+N!sP)y+sMk4MsH0}#po zGL36c?&ozHOMWE%x%o-g(5v5r755rq)4+J82YEp4pFV^VFE8B<6J)p=k9a_QBDCyLNOw# z;xygp5J&ei=b_JSa6WxOQNn@UFk&ffWhIQ^nQqsdgo+Q6+AJaP6mpH7;~J`flQ-(e z9Eh{M6IYn;_(r(|$^Q}Dp#P3}cd}(N_P`Bm^P`d7G*ij$;1LrbNN|yCNp8zpdpfc{ zoVGtjQ3oK8Br9Fx9WY{Zc$Sj)aoP@a0=0Z3EirvBEe1P=@l?54;bYgpa8#k`_0K8E zmvtUS5L;DmmJc=4@RHN14*50~F)7+D!?9=&%Sgi?92nz(bDewLv0fEg)*Z!3~yK)B=$4Y)9!0r{Qstzs^ z)_xR-yMreLrjm4aEEW`gx~@1>J#<>gzyJ8auSRDn(H0SXA-Ovk7ZDcAN6V4kT>1Hp zHfy+A0mv-Up(=>gp~^%=>;W%2!1PHe(!$<2w8fUnC?9Td4PXOTA~pj~Fe>>VuItkX zBn&zjJaC3ET*>Y__qY0bauTXEoelEmKe18MI%0~%co_aZpBlqX5)*{mjC_J`Bfr%b z611ONIXMwUc5s9;Mo9z5GG+jt3RW&Ya?^n@mldxIw$o}TvgoJ6#|)}^S=~b!krq;K zH;*m3(^tw#Pgx5~%ZtHbaTrJs&v_S(ZZr@Mtw+M5R@=6f``Q{b5dw`4dCaO=5cC0( zX?uMpzq_p%rgN{fY4goHJv%r$JeE2nR8V@p`ig+`%;k#;?q*w40D_*W!+tg&H14RK z`y0X&-J(idQ#PK` zOfQgo_hzJeNF!8LgKnAj;2bP4dzZXerqppKNAt%#R2nNS7gw9N? z8PsM$)h+bQhqS=Y7D9X;IIF>TnvP8HNP!?xu?Gdf{$*MTZGz;lgOLWC2-cS)a(B%v zNd&5%Va(uF0V(3*zOU)2R^2Z>$R+>c9hUHR3~!3>o8m*c#RwSw0}h=M37rlAF+Hc* zd>ZSoO5XgswIzwW`o3ZXLT`#~0r!m7e7)_z7kIJmdN2?N22X#jBP}GA z4CW{R(JdD`nBeG1j&>M;rSNSJpvb*|T_q1|K-gq}bp*#Vm_Sin9+)kkJ(oY4{gi^t_lk!4AR4fZ<6i7A{G(`DD zad2L_r@Eb*@ikjj<^1ouBoJ?)2#hU@87njey5Gn_E4$RXn)RaNFdKB$Jmx|V&d+^$ zkWU^tNGH?VW$_V%F>`4F#1v{r@OO3#;qd);ppH|pLakKtC%VcYO@Hng;UZkCcpAHZ#4QU7A8p|>qPH{-Sk<$|wt?`r|0~IsRhU74xdDFk zi-W+^Ezsk=?aH{Ho2Lc3{_pInzp9G5?Y>+tUDAReQqmy}m+tOHTDrTrT#yD)N$FNv zx}>{PQc?jy0SO6-$M?N4-XHMR*uU%_&KYNnz0cZX&AHc{B{Khv(C-UAq_$6?FX&U? zNdNX$9eZW0y^xzgqwsyCwoTYr6@Q1^3@`sM{V!PxAlQQCwZi?)IzX4tXq+y{^Nh!TT=~ngTl*Xp)=Adz3llltSdx} zMMxy0E<;?sCK4O`TKF*CXyPQ{a{0;3l@TyTR}KWrIbQuD4%uzyZ7N>kSQUGOxLsZg zQT9Wo=`3R~(WFQ|2E(+LPYF1IBSW2ujkvq644ch@7uJIZD{!i)fPV;Qu9eBW*z9vQg}asQ)vXs>C%nG=@2XAUf{O}2AFTf`F+EmtfM+(+YF}p5pe2zA- z`Hnp73f5*2Uvuq#F1C(gOT^`ZIj8ZSmtN68B@0(#7@CyY@=11z~0Kz3)x#g>iy-B?|aLokxa{>jVm(>aiE^GHtk3 z&dw7I&pT1`Bv^!ib_xo?x7VUYLtyV&-FL{{IG8(MY|;(M4BGAS_J*avSj~*wmGw3~ z6?%>DWvq@GN1Q`o^3*H|OFmvg7An77V#tJm{l|dm=hAzBAfSMm%bm@k;qf~*S)c0a zetY^l2-7lJB1Ab0yXx`ZtyYz)W1*MWYT0P!6QYw!RLio!G@HZ;v~P)o^nS$XQfx~{ zU9?)~YPE}@?7a1aKs$+KP-v+7kb2a2k+**VgQSguwV|QU+`i{)nY}A^=7F@%%Q|ZF zAVura82jD*b&adP5A9**OSCkm58E}kO0=LH7c!GmDY*xk=_D&&+JSh}0J{#2Xy0`G z+gJDmLQK_aep*7!h-28&FYBBI^Z{tHaNRvCHBD0Zl4}YT$U+T}^0hp9{D+=T&MX~D z8vLd2DcE;;%)$fbX$5M_L{jZOq`(_LSg(Yy#%&P;F-cgz>@-O%!Ga%sI#;fZoj(LF zlTjsVvsD@&zNm4wANvi*TmyLsCE}=vI8m?Z^CTz!3*a&~EdW6z-`K9O6I3Vi$jQhj z+aLY0#d~s@P2+*DVYU29R=_iX!VwY9@`Np)uS>=Pw`S>rf`+MS{(Q+c+idyZ>7#Ya zV5jwdAQRn;wnql|qN{x7=bgWn;iYUoI5&#bn&+i}vX+dR68 zFM4Lo#?XqIwPiXp=-E>QM&XL7Rl6fX?ua6<6L_W9v_~V_TA|PIQOtjHeg%AYDq%wV zs%nt+j&)-wTzZKjPlkC)zO4@5pF_JL@WC36<}0vx&#?dY=F|~!wD?paBBi8jVJ>6b zSN1UHLhk7Fw2b!aJ~ewFTG1bEITMr3FK)MgN8w%Hu;~{LTWxL|{~g`O=R)#01YYK} z;{cA^VswSmRTePi_|NAuBpqd20Ir}JLWW3SEi4#yT5-WI<0S{jv2=fnUxK`PT)gU| zNZ0a;iumC8z~MJK|EWTG91(q;$>}cWKic>xW5OqlVo|E2?!$hFu4|e{V{A2 zU-}rvgscx@+?y(Qo3j^mg@CDPNyE=br*)l7u{=C&{i2fG=W<~g9-}J6em=D3?}9E| z-=Rv_CWhqhSI`eW>$rny$cMkRkqPTR=ET9jGXy>MYl%}wH-vCT96D+w`Soc0GAF=l z*72>0xgx=ErGq0gOPG%7G?X{guc2M~Ozqh61W)=R(pEc`e=@n{l4zPgd9HTSQP7nX zVx1e!z3BeT1VYdspQ~8Z)4gp;Y><~j4ojt_jJ+%^hC2uW%#TRJXILuHBx6ReY@o0% z78DxUu!V-o#DriribeYsH#V~I8P^Z1M4z1(k5tmf-KHFxcf%?Kv?BN^>AmbzL2kLqR;OfK@ug7(2JnXTr7UG7XFTl9`L=>BMLQi^^-?Vi{jLbllK& zhUGUK@4b#UD~F&#OV(1Bc}H-GV^=W@gSYPl5WhQKr^Al4h)@UXvF~&cH*7$T z1yHJ@ny*<@*xA!D=_2ALBQ{xNfwhW4xX`3b#?;s=^Lg0NwY~fn*1P-K2)G1uu$CsZ zc{;^2kCjnB9os6($Sq|9_&UXQAdUyK;Rtl>UoyAz%WYfgjSPd^wpfq6&Zq<161arg z5VYvKgc45QD%l`60xkgqCa~j2s}kbOcOd*H%Ho1o28|u|o2p3Xz|AkGJHI!?+VA&H+A25EBL!N= z9m#H}fp<&w2W%|Uz(B!0;CHQnRc;p8KQMm%ZHr$p7AEcDzfk?(A8gUtIGze!0qHW? zBZl^lMHl9rv>2aF<}fyFiDl9sG#1!#fik!&w&TIK!4kQ2erU_FVCzGA5Q0z3>%k=u zSLv!-PgKOPpj*}-d?tt*ZHYUTKl7`$FX&*T+lN^7rq^Vxn}79BDHj8D{a1lL;p1(O zm$*UHjgMv%Q^OBa`2$s&N@z($B>Am+U+UUL^;}g*%Io+kMI@E{otiYaua1K~H_e1<`st;( z;2RH&qwZisy~|)vw{p!s&*yC^GYP!hN12>{HIEG9bB^a6?9nRBu~tPmWW+$T=weCt z7pw)GLJ`ry%7DBovqj9FSr2L?XW)Q?5ChMK{y=>sGk!#T!ltQdHd4u^bfALR+pUPz zmfqrgU(a5I0%Yl;cDR778F}$-TmeowYMN0OKPk4z zn&w2?hRHr!ry$krg}?1>%7%X@NLuNNO{{&WO>|x+gb2K3`2o=)H}sM$XWN9qY+P>d zVBJuO(MX2ARw^SOcAG9OLUW~SC<=IlE*BYZiFu=z6vfW=kUJX~p0KJ2za1-KnLKvE z_DNmL-|^-VYwF>?j3ZGS*Pkvi_!t=seKv&67WwkFsq&f9Ulh)jo;rFtr@yc5gzM|c z3|eEJ@zv)4M7=d_8ZpL|J478}k%>E9_xSm9tkU-P5SPp=&qvw1FH&; zaL_d%psA!5Ss^ZBou5LaG(+86spS)L&R<8mTVN}IJp&x`_dNvu2=EZ{0n# zC-qU#;w8LS%qJris~qbv1^g|BRDhz<}8KMg`0rX zCZkSKH-*ci{djaHWyIV2s9e<+=bHH)4}FH$E@g$wEN|%l4XeNSKjE+AA3HrC*XBT_hX~$rNH0e)N%+Rw4gHF6;dGBglJ-`s;lf zLeh|2tC*1MgpeCmLiUxh%>G+i61VP;f(pGJx`uq@I>C=#WvM7SAjjC|3~it$xHj~^ zkAKh8jI`vG>NRLYy3nzIs5VzoE#*wz{@FJ(uT_&BSA0As6_{i{*Pg_LOLbQu({;bep z?o31LRFooBN6yco^8hlzfkK`XfGF51cgvk|K)f5iG1S7>{X7 z-N^y5~y0KV3oC#lpgK}036Oq!D)_0-FD()AEykS8Xw%2pMS-;89 zY+p99I@6YXc(%%zIM3m1Ub!nLRCCSbp&Z>?MAF2fpi7I;3xQw(G@*t7u6}fTSZEU!*XzYZ>xFeblRXP0w{XenF^8Jgxfap2SQ|a6ho&jg8 zB>5Hevb21q_~pO%T7$w2e>)TE0_5mhHgNWkr zzSqMszxq%RO1csCg$L7{BDSX{aJV4&LnH^*+mf~@ZKKyl?h(jS36js=J)hKc-4k00 zVxZBMtP_Fc}g)<-I+^&EA7QILhR5={| zwvj869B!zO*&?kT!at}Sen7_xFLcI$S01u#ds6L7? zpXmcDb5tJ62d(D|dG?s9d4ba)lxNxdh47suc*jVQ^NI3adE*{DC;(r+u}1h&PQLn{ zIeo{C6Y7f67{(xPt z7J1T3FpdRcY1*dijf2Y#`^9(bY$vmZn2=%LZ&o0CYhzg-JuApN?M5yHTQ23(+j#ws zBFr$mvS=_~No#ph!`UGk9sWlZVhUxp?2x=mu1pnEujrwmWEDoUJ>X<;z3lYLE92Za z^l|r)xHPpvHIB^3^OpK({`Vuyk*_A1;6CZyPrcPOQOSx~OS2}MRnof33alM=zqLnE zwGJ`mBFLSVXui#hXfs$kZh49!scjAtjb zh=Q(%kY9+F!4RcO3ME)~E1Bw7JAPHmt|2QhQ1YX3`XFT!kM{m~P-#&C4vAROo^^aX`AQXPWXW(k2A{S86B z@Q3{e`qa`ssM^H2%>uMCPH$C#vd6hd>T+NQw z;g()pg=!31x|6CC*wa?LJ~!5fQQ7{9#oU0%EwsN9vhjky@;juowl}1kNcV(Wc-h94 z3h|!BBD~g*^6o)m*}`uc1*OV*_Tof4rl!}w*$tBXMRKseQw&;xzLEMiFmnxME^dcM zJA?|r(&#mx(9K2t$hWULkR0+8E#( zz#uFOFV})$_5@0C;7Cxp;X0|7aXQe21%$n@G0aI+wx+}!o#(w9+AqH@2|$jx;UcV& ziLBIdAbotez~C6>WNB}m#}@mSGM=UpudBy!@sjn*AoG&on^!*@RWkb!c=oH#wn?6g zV-W{cG_!3i^S~MfAu8swda1Nd7TDsxy(ly#PSy6v*uW~MlYhK&O+B|cH<2pPEPOuc zjUX5Qpi59VwN22n;9oxBv+YdDr-L0oXYvQNFPok(d&c#9ZglN>dI7wvv-U4Wkjc3% z>8xFfuV3GMySz~(+si=&hf1xhFSi>$J?e!?DG1sP6}bGCMCyAmfce)nqB1^TMil)U$NmrrFbXPn7-e z)j-l=^x03v$rY%#-8%MLrsO}H8E%Ah0q18bovUm4_hpB-@V*S@;5V`X=S740N{xlK zkY$#WTMu|QK{==IV~jw=p>)0@cPD@KQ9OaL z%UGZLKau9r*EU925{3Ib65f7`#r1k9qqTSv{<>YdHMGz^7U~CsI_qdA=$QRubLXmy zUTV~?O>g7cc|br^{TAWdy*ILrdt`I zPGa~fToi!2NP=aTyi;` zQdNGpr%{l?YP=lutCf?@u^W*%-zmyvK7c30|LeE)6W@8kh&|~Xp6Z3rfY~*R{-HzH zTry)cmCCzf8>HESvz;Xeg^Gm!Lz zV`ownjrwX!R2k8z!usB)X(CpD-!hl6^81{;9I$MO=Rcg1f&H*=z%IQE_uCv}6B#^d zz?Urei6JqC14vr+b4|bOfok>F@1-^!LrAJVeG?bmoQ>7nV?k=zSCOgnwLqhtw*i#E znj18rc%#!TT4+!_Za_ukA)m7quKl4Ap;hvhD1Mz=r!0WT1i~Cmn6lBE9P?6}DP8Yhc@yIk8OC1F`F5}{3MUtRoDVGxT?Vd4=>KjMbmgV#Fmg*EJPpc~6u0kc(~`5KZOY=f&e z9ZQ~U+LM+en5>9#E~v(S94okE1pk~6wJV;g5J z#M*?90M6^<-*~g(%HEJWG7DTT7XaKTh!q)!QB(?EMHp)x+Q-far7HN9OTn=!PQrA1 z{xKsVP%b(!Pnv5)#;vK#cc&9YKhszI!q7`q1hLwI5zWOn<5bZK_TKouRc}|O7Bins zK?}D|c=t|HdKHn(Ml0sq^CQN3^Tvoc0d9;q$hx!kEo7I#D!p71By%slLW^vpOvV&F zML%C#tv>epnGZ!kC%RRT-@L_O67p^*o2H3S?}mmSqZh0uKb``%EKC>Cr*El%BwuPn zJI28GoEJ^T25<7!<3;Z7u38IvhtzVoakTzjLDyoQ6q0*dsb_q9IPF%2Q-I*ZJ@h3f z5Zc|&TUru>mCzP`tvuM=nC__zhkHFaqxpcQJF-NGaX}c;-kboDiftS3N*dM#heks?tzSda51~XsB zevl!u-zp(B?QjV|JVgTV;XC1`PhNW)jOI=`@tcE~A z^bW&rQ0KvMT`&F0a=mInPl=DOx6;Pn1q# zCi3X(BZY7R3BTZA$q~Gk* zmV9fF*V-FxCjO|A^4EL~D6At%X`gNp56?0C-er4O7?ga@JErjeA>@1aEGi!zKWvP( zR&xF~O0LLcJ_tS8q^%OR;SjGYuc|uCMnNRO)>(E#LQ=m`t;k=Gx6j(#TdQds& zK<<65^9%;jX3W1YqC(&^?=W&gk`FLryr|pr<1BuK`7AJ%&M2aGqFH91InJES$@H1ppq zcg3?CYzEEymGHAXM=xUAZ<0yI=eL6QEUAU#`N+kH5!lW~i$Rc`gB~D;=jy}RIRzK7 zldczSe-yJUI`sN-puT+C{sFgersJkJB1YZ9&;tk~x5YZmuIkxvfhv&&*#}~KE~^ev z^3O*D2F(j`@}HTG^FMhI8xW{I#oyL#)tUXk{ap%`m!FYfO9A}w{NP@p97UAp#t zDB1ITK8f-W&{(hZkuk}|NKy*pD_|d9CCbmOs9R9LO&WCls`J{2(((sV{1Xl`T{kXl z1Rrw@Ut2~W5gWod~(E1Wh<31UKvn|#~EZig1oRuVS?w-sP&zmjZ_RPgaWzrkgvR;(*zY_!%ULgH)0M&TZ5d+Wzj0vLWW^THm$S; zGK0kF3TwbkW6?C9VfV%6GT3qHh{xJkm@>qR=K!?OA6}gDZkraf91%X7I%2CsN#}_( zat0bHqx1W}vuqUDsZBBCxG4cC4&`gEZ+sd)TQ6-DhljBud^JDE6?cYht65qkoq%Az zT<#P2k|tFvnew1=Idj13Gz_4m5u+~AWXaY3Wipw~!Fbu+)@;3zSDXwFF3dUsyQgr8 zi(#dDT+%A6Ifpe4Qd)*IAIuW~mN_EC_1&!t@gw4jT#fV=pnb+I!1a&9kfd zjCAk~e_pnt&z?=xBC(oi6|N!`uu`Q+|6R)r*Kmz>*D)2#nHu(g)vF))ofhlgz*m6; zZutPu@#}o?Z2qyp6Z2HfjoZmjRpDfga06cVey3TEqgvbGe`4aV#M$+cPKB!{St5Ni+`kf1Q83LDpYilT#oLiP0nv@+^1IXKR2xg zUZ3#zL(Nx7NuhWAC2^ldLWXl^UAS;*-|sM*Qmh8d0??O?JLg%>h#@-pFn4RYV?sZE&b2fFW?-eApx!ASOGbtk4i3m~{a7aDQ zOCA-wRdPR?S(ZtNOkZ?}sl78{?@DJ={pMb(`1xx|(X+PhJdlpNf(R3P=`j7}=|(zd z@ru|PT_gNJ_wvB#gjz^<>y(Uv8H1FEn-#si;5WVQt$o z(UK%svuTwZskJO<9#|Rpg+8Pr6UZsC76(+Nc^Gq~$zi~q71K28p}2s+>Hgk5HT5s|YXz$gG8Skq~XPEa4x zt-5V)+&~w7gsi4pgzoyvtMnx!F&}MLa5!&&5As1gv=SXREeq`5dvUH6 zHoe~^4=`)p+x`R=mh#T?5!d!51tGU Np(v{+Qzd1N{2vHR6|4XN literal 0 HcmV?d00001 diff --git a/docs/_static/img/logo-name-medium.svg b/docs/_static/img/logo-name-medium.svg new file mode 100644 index 0000000..81b7b01 --- /dev/null +++ b/docs/_static/img/logo-name-medium.svg @@ -0,0 +1 @@ + From 17f19ba4c4b6779693901e00978b298c40910c7c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 May 2023 13:35:05 -0500 Subject: [PATCH 07/24] [pre-commit.ci] pre-commit autoupdate (#63) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - https://github.com/myint/autoflake → https://github.com/PyCQA/autoflake - [github.com/PyCQA/autoflake: v2.0.2 → v2.1.1](https://github.com/PyCQA/autoflake/compare/v2.0.2...v2.1.1) - [github.com/asottile/pyupgrade: v3.3.1 → v3.3.2](https://github.com/asottile/pyupgrade/compare/v3.3.1...v3.3.2) - [github.com/charliermarsh/ruff-pre-commit: v0.0.261 → v0.0.263](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.261...v0.0.263) - [github.com/charliermarsh/ruff-pre-commit: v0.0.261 → v0.0.263](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.261...v0.0.263) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 38b1dfa..88e81b0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,8 +30,8 @@ repos: - id: validate-pyproject name: Validate pyproject.toml # I don't yet trust ruff to do what autoflake does - - repo: https://github.com/myint/autoflake - rev: v2.0.2 + - repo: https://github.com/PyCQA/autoflake + rev: v2.1.1 hooks: - id: autoflake args: [--in-place] @@ -40,7 +40,7 @@ repos: hooks: - id: isort - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + rev: v3.3.2 hooks: - id: pyupgrade args: [--py38-plus] @@ -55,7 +55,7 @@ repos: - id: black # - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.261 + rev: v0.0.263 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -81,7 +81,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.261 + rev: v0.0.263 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. From 80ba68be3b881f83c92e43f8a772b55376ad060b Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 3 May 2023 09:45:22 -0500 Subject: [PATCH 08/24] Implement `google_matrix` and binary operators (#62) * Implement `google_matrix` and binary operators * Also implement `floyd_warshall_numpy` * Remove `floyd_warshall_numpy` from "core" (still in "nxapi") --- README.md | 10 ++ graphblas_algorithms/algorithms/__init__.py | 1 + .../algorithms/link_analysis/pagerank_alg.py | 67 +++++++- .../algorithms/operators/__init__.py | 1 + .../algorithms/operators/binary.py | 156 ++++++++++++++++++ .../algorithms/shortest_paths/dense.py | 13 +- graphblas_algorithms/interface.py | 36 +++- graphblas_algorithms/nxapi/__init__.py | 2 + .../nxapi/link_analysis/pagerank_alg.py | 20 ++- .../nxapi/operators/__init__.py | 1 + .../nxapi/operators/binary.py | 77 +++++++++ .../nxapi/shortest_paths/dense.py | 22 ++- pyproject.toml | 2 + 13 files changed, 401 insertions(+), 7 deletions(-) create mode 100644 graphblas_algorithms/algorithms/operators/__init__.py create mode 100644 graphblas_algorithms/algorithms/operators/binary.py create mode 100644 graphblas_algorithms/nxapi/operators/__init__.py create mode 100644 graphblas_algorithms/nxapi/operators/binary.py diff --git a/README.md b/README.md index b3aca8d..32039fb 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,17 @@ dispatch pattern shown above. - isolates - number_of_isolates - Link Analysis + - google_matrix - hits - pagerank +- Operators + - compose + - difference + - disjoint_union + - full_join + - intersection + - symmetric_difference + - union - Reciprocity - overall_reciprocity - reciprocity @@ -168,6 +177,7 @@ dispatch pattern shown above. - all_pairs_bellman_ford_path_length - all_pairs_shortest_path_length - floyd_warshall + - floyd_warshall_numpy - floyd_warshall_predecessor_and_distance - has_path - negative_edge_cycle diff --git a/graphblas_algorithms/algorithms/__init__.py b/graphblas_algorithms/algorithms/__init__.py index 303535a..37a47a3 100644 --- a/graphblas_algorithms/algorithms/__init__.py +++ b/graphblas_algorithms/algorithms/__init__.py @@ -10,6 +10,7 @@ from .dominating import * from .isolate import * from .link_analysis import * +from .operators import * from .reciprocity import * from .regular import * from .shortest_paths import * diff --git a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py index 9b28fa3..518c09f 100644 --- a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py +++ b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py @@ -1,11 +1,12 @@ -from graphblas import Vector +import numpy as np +from graphblas import Matrix, Vector, binary, monoid from graphblas.semiring import plus_first, plus_times from graphblas_algorithms import Graph from graphblas_algorithms.algorithms._helpers import is_converged from graphblas_algorithms.algorithms.exceptions import ConvergenceFailure -__all__ = ["pagerank"] +__all__ = ["pagerank", "google_matrix"] def pagerank( @@ -98,3 +99,65 @@ def pagerank( x.name = name return x raise ConvergenceFailure(max_iter) + + +def google_matrix( + G: Graph, + alpha=0.85, + personalization=None, + nodelist=None, + dangling=None, + name="google_matrix", +) -> Matrix: + A = G._A + ids = G.list_to_ids(nodelist) + if ids is not None: + ids = np.array(ids, np.uint64) + A = A[ids, ids].new(float, name=name) + else: + A = A.dup(float, name=name) + N = A.nrows + if N == 0: + return A + + # Personalization vector or scalar + if personalization is None: + p = 1.0 / N + else: + if ids is not None: + personalization = personalization[ids].new(name="personalization") + denom = personalization.reduce().get(0) + if denom == 0: + raise ZeroDivisionError("personalization sums to 0") + p = (personalization / denom).new(mask=personalization.V, name="p") + + if ids is None or len(ids) == len(G): + nonempty_rows = G.get_property("any_rowwise+") # XXX: What about self-edges? + else: + nonempty_rows = A.reduce_rowwise(monoid.any).new(name="nonempty_rows") + + is_dangling = nonempty_rows.nvals < N + if is_dangling: + empty_rows = (~nonempty_rows.S).new(name="empty_rows") + if dangling is not None: + if ids is not None: + dangling = dangling[ids].new(name="dangling") + dangling_weights = (1.0 / dangling.reduce().get(0) * dangling).new( + mask=dangling.V, name="dangling_weights" + ) + A << binary.first(empty_rows.outer(dangling_weights) | A) + elif personalization is None: + A << binary.first((p * empty_rows) | A) + else: + A << binary.first(empty_rows.outer(p) | A) + + scale = A.reduce_rowwise(monoid.plus).new(float) + scale << alpha / scale + A << scale * A + p *= 1 - alpha + if personalization is None: + # Add a scalar everywhere, which makes A dense + A(binary.plus)[:, :] = p + else: + A << A + p + return A diff --git a/graphblas_algorithms/algorithms/operators/__init__.py b/graphblas_algorithms/algorithms/operators/__init__.py new file mode 100644 index 0000000..6e308b5 --- /dev/null +++ b/graphblas_algorithms/algorithms/operators/__init__.py @@ -0,0 +1 @@ +from .binary import * diff --git a/graphblas_algorithms/algorithms/operators/binary.py b/graphblas_algorithms/algorithms/operators/binary.py new file mode 100644 index 0000000..b2b19ca --- /dev/null +++ b/graphblas_algorithms/algorithms/operators/binary.py @@ -0,0 +1,156 @@ +import numpy as np +from graphblas import Matrix, binary, dtypes, unary + +from ..exceptions import GraphBlasAlgorithmException + +__all__ = [ + "compose", + "difference", + "disjoint_union", + "full_join", + "intersection", + "symmetric_difference", + "union", +] + + +def union(G, H, rename=(), *, name="union"): + if G.is_multigraph() != H.is_multigraph(): + raise GraphBlasAlgorithmException("All graphs must be graphs or multigraphs.") + if G.is_multigraph(): + raise NotImplementedError("Not yet implemented for multigraphs") + if rename: + prefix = rename[0] + if prefix is not None: + G = type(G)( + G._A, key_to_id={f"{prefix}{key}": val for key, val in G._key_to_id.items()} + ) + if len(rename) > 1: + prefix = rename[1] + if prefix is not None: + H = type(H)( + H._A, key_to_id={f"{prefix}{key}": val for key, val in H._key_to_id.items()} + ) + A = G._A + B = H._A + if not G._key_to_id.keys().isdisjoint(H._key_to_id.keys()): + raise GraphBlasAlgorithmException("The node sets of the graphs are not disjoint.") + C = Matrix(dtypes.unify(A.dtype, B.dtype), A.nrows + B.nrows, A.ncols + B.ncols, name=name) + C[: A.nrows, : A.ncols] = A + C[A.nrows :, A.ncols :] = B + offset = A.nrows + key_to_id = {key: val + offset for key, val in H._key_to_id.items()} + key_to_id.update(G._key_to_id) + return type(G)(C, key_to_id=key_to_id) + + +def disjoint_union(G, H, *, name="disjoint_union"): + if G.is_multigraph() != H.is_multigraph(): + raise GraphBlasAlgorithmException("All graphs must be graphs or multigraphs.") + if G.is_multigraph(): + raise NotImplementedError("Not yet implemented for multigraphs") + A = G._A + B = H._A + C = Matrix(dtypes.unify(A.dtype, B.dtype), A.nrows + B.nrows, A.ncols + B.ncols, name=name) + C[: A.nrows, : A.ncols] = A + C[A.nrows :, A.ncols :] = B + return type(G)(C) + + +def intersection(G, H, *, name="intersection"): + if G.is_multigraph() != H.is_multigraph(): + raise GraphBlasAlgorithmException("All graphs must be graphs or multigraphs.") + if G.is_multigraph(): + raise NotImplementedError("Not yet implemented for multigraphs") + keys = sorted(G._key_to_id.keys() & H._key_to_id.keys(), key=G._key_to_id.__getitem__) + ids = np.array(G.list_to_ids(keys), np.uint64) + A = G._A[ids, ids].new() + ids = np.array(H.list_to_ids(keys), np.uint64) + B = H._A[ids, ids].new(dtypes.unify(A.dtype, H._A.dtype), mask=A.S, name=name) + B << unary.one(B) + return type(G)(B, key_to_id=dict(zip(keys, range(len(keys))))) + + +def difference(G, H, *, name="difference"): + if G.is_multigraph() != H.is_multigraph(): + raise GraphBlasAlgorithmException("All graphs must be graphs or multigraphs.") + if G.is_multigraph(): + raise NotImplementedError("Not yet implemented for multigraphs") + if G._key_to_id.keys() != H._key_to_id.keys(): + raise GraphBlasAlgorithmException("Node sets of graphs not equal") + A = G._A + if G._key_to_id == H._key_to_id: + B = H._A + else: + # Need to perform a permutation + keys = sorted(G._key_to_id, key=G._key_to_id.__getitem__) + ids = np.array(H.list_to_ids(keys), np.uint64) + B = H._A[ids, ids].new() + C = unary.one(A).new(mask=~B.S, name=name) + return type(G)(C, key_to_id=G._key_to_id) + + +def symmetric_difference(G, H, *, name="symmetric_difference"): + if G.is_multigraph() != H.is_multigraph(): + raise GraphBlasAlgorithmException("All graphs must be graphs or multigraphs.") + if G.is_multigraph(): + raise NotImplementedError("Not yet implemented for multigraphs") + if G._key_to_id.keys() != H._key_to_id.keys(): + raise GraphBlasAlgorithmException("Node sets of graphs not equal") + A = G._A + if G._key_to_id == H._key_to_id: + B = H._A + else: + # Need to perform a permutation + keys = sorted(G._key_to_id, key=G._key_to_id.__getitem__) + ids = np.array(H.list_to_ids(keys), np.uint64) + B = H._A[ids, ids].new() + Mask = binary.pair[bool](A & B).new(name="mask") + C = binary.pair(A | B, left_default=True, right_default=True).new(mask=~Mask.S, name=name) + return type(G)(C, key_to_id=G._key_to_id) + + +def compose(G, H, *, name="compose"): + if G.is_multigraph() != H.is_multigraph(): + raise GraphBlasAlgorithmException("All graphs must be graphs or multigraphs.") + if G.is_multigraph(): + raise NotImplementedError("Not yet implemented for multigraphs") + A = G._A + B = H._A + if G._key_to_id.keys() == H._key_to_id.keys(): + if G._key_to_id != H._key_to_id: + # Need to perform a permutation + keys = sorted(G._key_to_id, key=G._key_to_id.__getitem__) + ids = np.array(H.list_to_ids(keys), np.uint64) + B = B[ids, ids].new() + C = binary.second(A | B).new(name=name) + key_to_id = G._key_to_id + else: + keys = sorted(G._key_to_id.keys() & H._key_to_id.keys(), key=G._key_to_id.__getitem__) + B = H._A + C = Matrix( + dtypes.unify(A.dtype, B.dtype), + A.nrows + B.nrows - len(keys), + A.ncols + B.ncols - len(keys), + name=name, + ) + C[: A.nrows, : A.ncols] = A + ids1 = np.array(G.list_to_ids(keys), np.uint64) + ids2 = np.array(H.list_to_ids(keys), np.uint64) + C[ids1, ids1] = B[ids2, ids2] + newkeys = sorted(H._key_to_id.keys() - G._key_to_id.keys(), key=H._key_to_id.__getitem__) + ids = np.array(H.list_to_ids(newkeys), np.uint64) + C[A.nrows :, A.ncols :] = B[ids, ids] + # Now make new `key_to_id` + ids += A.nrows + key_to_id = dict(zip(newkeys, ids.tolist())) + key_to_id.update(G._key_to_id) + return type(G)(C, key_to_id=key_to_id) + + +def full_join(G, H, rename=(), *, name="full_join"): + rv = union(G, H, rename, name=name) + nrows, ncols = G._A.shape + rv._A[:nrows, ncols:] = True + rv._A[nrows:, :ncols] = True + return rv diff --git a/graphblas_algorithms/algorithms/shortest_paths/dense.py b/graphblas_algorithms/algorithms/shortest_paths/dense.py index 94282d0..394d1b4 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/dense.py +++ b/graphblas_algorithms/algorithms/shortest_paths/dense.py @@ -1,6 +1,8 @@ from graphblas import Matrix, Vector, binary, indexunary, replace, select from graphblas.semiring import any_plus, any_second +from ..exceptions import GraphBlasAlgorithmException + __all__ = ["floyd_warshall", "floyd_warshall_predecessor_and_distance"] @@ -8,7 +10,9 @@ def floyd_warshall(G, is_weighted=False): return floyd_warshall_predecessor_and_distance(G, is_weighted, compute_predecessors=False)[1] -def floyd_warshall_predecessor_and_distance(G, is_weighted=False, *, compute_predecessors=True): +def floyd_warshall_predecessor_and_distance( + G, is_weighted=False, *, compute_predecessors=True, permutation=None +): # By using `offdiag` instead of `G._A`, we ensure that D will not become dense. # Dense D may be better at times, but not including the diagonal will result in less work. # Typically, Floyd-Warshall algorithms sets the diagonal of D to 0 at the beginning. @@ -19,6 +23,13 @@ def floyd_warshall_predecessor_and_distance(G, is_weighted=False, *, compute_pre nonempty_nodes = binary.pair(row_degrees & column_degrees).new(name="nonempty_nodes") else: A, nonempty_nodes = G.get_properties("U- degrees-") + if permutation is not None: + if len(permutation) != nonempty_nodes.size: + raise GraphBlasAlgorithmException( + "permutation must contain every node in G with no repeats." + ) + A = A[permutation, permutation].new() + nonempty_nodes = nonempty_nodes[permutation].new(name="nonempty_nodes") if A.dtype == bool or not is_weighted: dtype = int diff --git a/graphblas_algorithms/interface.py b/graphblas_algorithms/interface.py index d8430e5..206d19c 100644 --- a/graphblas_algorithms/interface.py +++ b/graphblas_algorithms/interface.py @@ -51,7 +51,16 @@ class Dispatcher: number_of_isolates = nxapi.isolate.number_of_isolates # Link Analysis hits = nxapi.link_analysis.hits_alg.hits + google_matrix = nxapi.link_analysis.pagerank_alg.google_matrix pagerank = nxapi.link_analysis.pagerank_alg.pagerank + # Operators + compose = nxapi.operators.binary.compose + difference = nxapi.operators.binary.difference + disjoint_union = nxapi.operators.binary.disjoint_union + full_join = nxapi.operators.binary.full_join + intersection = nxapi.operators.binary.intersection + symmetric_difference = nxapi.operators.binary.symmetric_difference + union = nxapi.operators.binary.union # Reciprocity overall_reciprocity = nxapi.overall_reciprocity reciprocity = nxapi.reciprocity @@ -60,6 +69,7 @@ class Dispatcher: is_regular = nxapi.regular.is_regular # Shortest Paths floyd_warshall = nxapi.shortest_paths.dense.floyd_warshall + floyd_warshall_numpy = nxapi.shortest_paths.dense.floyd_warshall_numpy floyd_warshall_predecessor_and_distance = ( nxapi.shortest_paths.dense.floyd_warshall_predecessor_and_distance ) @@ -112,10 +122,14 @@ def convert_from_nx(graph, weight=None, *, name=None): @staticmethod def convert_to_nx(obj, *, name=None): + from graphblas import Matrix + from .classes import Graph if isinstance(obj, Graph): obj = obj.to_networkx() + elif isinstance(obj, Matrix): + obj = obj.to_dense(fill_value=False) return obj @staticmethod @@ -127,8 +141,11 @@ def on_start_tests(items): def key(testpath): filename, path = testpath.split(":") - classname, testname = path.split(".") - return (testname, frozenset({classname, filename})) + *names, testname = path.split(".") + if names: + [classname] = names + return (testname, frozenset({classname, filename})) + return (testname, frozenset({filename})) # Reasons to skip tests multi_attributed = "unable to handle multi-attributed graphs" @@ -140,7 +157,22 @@ def key(testpath): key("test_mst.py:TestBoruvka.test_attributes"): multi_attributed, key("test_mst.py:TestBoruvka.test_weight_attribute"): multi_attributed, key("test_dense.py:TestFloyd.test_zero_weight"): multidigraph, + key("test_dense_numpy.py:test_zero_weight"): multidigraph, key("test_weighted.py:TestBellmanFordAndGoldbergRadzik.test_multigraph"): multigraph, + key("test_binary.py:test_compose_multigraph"): multigraph, + key("test_binary.py:test_difference_multigraph_attributes"): multigraph, + key("test_binary.py:test_disjoint_union_multigraph"): multigraph, + key("test_binary.py:test_full_join_multigraph"): multigraph, + key("test_binary.py:test_intersection_multigraph_attributes"): multigraph, + key( + "test_binary.py:test_intersection_multigraph_attributes_node_set_different" + ): multigraph, + key("test_binary.py:test_symmetric_difference_multigraph"): multigraph, + key("test_binary.py:test_union_attributes"): multi_attributed, + # TODO: move failing assertion from `test_union_and_compose` + key("test_binary.py:test_union_and_compose"): multi_attributed, + key("test_binary.py:test_union_multigraph"): multigraph, + key("test_vf2pp.py:test_custom_multigraph4_different_labels"): multigraph, } for item in items: kset = set(item.keywords) diff --git a/graphblas_algorithms/nxapi/__init__.py b/graphblas_algorithms/nxapi/__init__.py index fe5ba87..5ddc1fa 100644 --- a/graphblas_algorithms/nxapi/__init__.py +++ b/graphblas_algorithms/nxapi/__init__.py @@ -9,6 +9,7 @@ from .dominating import * from .isolate import * from .link_analysis import * +from .operators import * from .reciprocity import * from .regular import * from .shortest_paths import * @@ -23,6 +24,7 @@ from . import community from . import components from . import link_analysis +from . import operators from . import shortest_paths from . import tournament from . import traversal diff --git a/graphblas_algorithms/nxapi/link_analysis/pagerank_alg.py b/graphblas_algorithms/nxapi/link_analysis/pagerank_alg.py index d40506f..22e977e 100644 --- a/graphblas_algorithms/nxapi/link_analysis/pagerank_alg.py +++ b/graphblas_algorithms/nxapi/link_analysis/pagerank_alg.py @@ -3,7 +3,7 @@ from ..exception import PowerIterationFailedConvergence -_all = ["pagerank"] +_all = ["pagerank", "google_matrix"] def pagerank( @@ -43,3 +43,21 @@ def pagerank( raise PowerIterationFailedConvergence(*e.args) from e else: return G.vector_to_nodemap(result, fill_value=0.0) + + +def google_matrix( + G, alpha=0.85, personalization=None, nodelist=None, weight="weight", dangling=None +): + G = to_graph(G, weight=weight, dtype=float) + p = G.dict_to_vector(personalization, dtype=float, name="personalization") + if dangling is not None and G.get_property("row_degrees+").nvals < len(G): + dangling_weights = G.dict_to_vector(dangling, dtype=float, name="dangling") + else: + dangling_weights = None + return algorithms.google_matrix( + G, + alpha=alpha, + personalization=p, + nodelist=nodelist, + dangling=dangling_weights, + ) diff --git a/graphblas_algorithms/nxapi/operators/__init__.py b/graphblas_algorithms/nxapi/operators/__init__.py new file mode 100644 index 0000000..6e308b5 --- /dev/null +++ b/graphblas_algorithms/nxapi/operators/__init__.py @@ -0,0 +1 @@ +from .binary import * diff --git a/graphblas_algorithms/nxapi/operators/binary.py b/graphblas_algorithms/nxapi/operators/binary.py new file mode 100644 index 0000000..82e8f08 --- /dev/null +++ b/graphblas_algorithms/nxapi/operators/binary.py @@ -0,0 +1,77 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.classes.digraph import to_graph + +from ..exception import NetworkXError + +__all__ = [ + "compose", + "difference", + "disjoint_union", + "full_join", + "intersection", + "symmetric_difference", + "union", +] + + +def union(G, H, rename=()): + G = to_graph(G) + H = to_graph(H) + try: + return algorithms.union(G, H, rename=rename) + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e + + +def disjoint_union(G, H): + G = to_graph(G) + H = to_graph(H) + try: + return algorithms.disjoint_union(G, H) + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e + + +def intersection(G, H): + G = to_graph(G) + H = to_graph(H) + try: + return algorithms.intersection(G, H) + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e + + +def difference(G, H): + G = to_graph(G) + H = to_graph(H) + try: + return algorithms.difference(G, H) + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e + + +def symmetric_difference(G, H): + G = to_graph(G) + H = to_graph(H) + try: + return algorithms.symmetric_difference(G, H) + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e + + +def compose(G, H): + G = to_graph(G) + H = to_graph(H) + try: + return algorithms.compose(G, H) + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e + + +def full_join(G, H, rename=()): + G = to_graph(G) + H = to_graph(H) + try: + return algorithms.full_join(G, H, rename=rename) + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e diff --git a/graphblas_algorithms/nxapi/shortest_paths/dense.py b/graphblas_algorithms/nxapi/shortest_paths/dense.py index 4b62891..cc86eb7 100644 --- a/graphblas_algorithms/nxapi/shortest_paths/dense.py +++ b/graphblas_algorithms/nxapi/shortest_paths/dense.py @@ -1,7 +1,11 @@ +import numpy as np + from graphblas_algorithms import algorithms from graphblas_algorithms.classes.digraph import to_graph -__all__ = ["floyd_warshall", "floyd_warshall_predecessor_and_distance"] +from ..exception import NetworkXError + +__all__ = ["floyd_warshall", "floyd_warshall_numpy", "floyd_warshall_predecessor_and_distance"] def floyd_warshall(G, weight="weight"): @@ -17,3 +21,19 @@ def floyd_warshall_predecessor_and_distance(G, weight="weight"): G.matrix_to_nodenodemap(P, values_are_keys=True), G.matrix_to_nodenodemap(D, fill_value=float("inf")), ) + + +def floyd_warshall_numpy(G, nodelist=None, weight="weight"): + G = to_graph(G, weight=weight) + if nodelist is not None: + if not (len(nodelist) == len(G) == len(set(nodelist))): + raise NetworkXError("nodelist must contain every node in G with no repeats.") + permutation = np.array(G.list_to_ids(nodelist), np.uint64) + else: + permutation = None + try: + return algorithms.floyd_warshall_predecessor_and_distance( + G, is_weighted=weight is not None, compute_predecessors=False, permutation=permutation + )[1] + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e diff --git a/pyproject.toml b/pyproject.toml index 7811266..df17600 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,6 +99,7 @@ packages = [ "graphblas_algorithms.algorithms.community", "graphblas_algorithms.algorithms.components", "graphblas_algorithms.algorithms.link_analysis", + "graphblas_algorithms.algorithms.operators", "graphblas_algorithms.algorithms.shortest_paths", "graphblas_algorithms.algorithms.tests", "graphblas_algorithms.algorithms.traversal", @@ -108,6 +109,7 @@ packages = [ "graphblas_algorithms.nxapi.community", "graphblas_algorithms.nxapi.components", "graphblas_algorithms.nxapi.link_analysis", + "graphblas_algorithms.nxapi.operators", "graphblas_algorithms.nxapi.shortest_paths", "graphblas_algorithms.nxapi.tests", "graphblas_algorithms.nxapi.traversal", From 3cc418043431c14c2f0afbc92c2ab95ffbcf1f7e Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 3 May 2023 14:10:51 -0500 Subject: [PATCH 09/24] Implement `.generators.ego.ego_graph` (#61) * Implement `.generators.ego.ego_graph` Also, clean up shared BFS functions and move to `_bfs.py`. * use external images in README so they render on PyPI Support and test against Python 3.11 Change development status to Beta (was Alpha). --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 4 +- MANIFEST.in | 3 - README.md | 8 +- graphblas_algorithms/__init__.py | 1 + graphblas_algorithms/algorithms/_bfs.py | 150 ++++++++++++++++++ .../algorithms/centrality/eigenvector.py | 8 +- .../algorithms/centrality/katz.py | 7 +- .../algorithms/components/connected.py | 23 +-- .../algorithms/components/weakly_connected.py | 75 +-------- graphblas_algorithms/algorithms/core.py | 6 +- .../algorithms/link_analysis/hits_alg.py | 4 +- .../algorithms/link_analysis/pagerank_alg.py | 5 +- .../algorithms/shortest_paths/unweighted.py | 62 +------- .../algorithms/shortest_paths/weighted.py | 61 ++----- graphblas_algorithms/algorithms/triads.py | 3 +- graphblas_algorithms/classes/_utils.py | 9 ++ graphblas_algorithms/classes/digraph.py | 11 ++ graphblas_algorithms/classes/graph.py | 1 + graphblas_algorithms/generators/__init__.py | 1 + graphblas_algorithms/generators/ego.py | 24 +++ graphblas_algorithms/interface.py | 2 + graphblas_algorithms/nxapi/__init__.py | 2 + .../nxapi/generators/__init__.py | 1 + graphblas_algorithms/nxapi/generators/ego.py | 11 ++ graphblas_algorithms/tests/test_match_nx.py | 32 ++++ pyproject.toml | 13 +- 27 files changed, 289 insertions(+), 240 deletions(-) create mode 100644 graphblas_algorithms/algorithms/_bfs.py create mode 100644 graphblas_algorithms/generators/__init__.py create mode 100644 graphblas_algorithms/generators/ego.py create mode 100644 graphblas_algorithms/nxapi/generators/__init__.py create mode 100644 graphblas_algorithms/nxapi/generators/ego.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d22022e..1e6aa40 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 88e81b0..892b0c7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: - id: black # - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.263 + rev: v0.0.264 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -81,7 +81,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.263 + rev: v0.0.264 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. diff --git a/MANIFEST.in b/MANIFEST.in index 92306c0..c69947d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,3 @@ include setup.py include README.md include LICENSE include MANIFEST.in -docs/_static/img/logo-name-medium.png -docs/_static/img/graphblas-vs-igraph.png -docs/_static/img/graphblas-vs-networkx.png diff --git a/README.md b/README.md index 32039fb..821cd95 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![GraphBLAS Algorithms](docs/_static/img/logo-name-medium.svg) +![GraphBLAS Algorithms](https://raw.githubusercontent.com/python-graphblas/graphblas-algorithms/main/docs/_static/img/logo-name-medium.svg)
[![conda-forge](https://img.shields.io/conda/vn/conda-forge/graphblas-algorithms.svg)](https://anaconda.org/conda-forge/graphblas-algorithms) [![pypi](https://img.shields.io/pypi/v/graphblas-algorithms.svg)](https://pypi.python.org/pypi/graphblas-algorithms/) @@ -21,9 +21,9 @@ Why use GraphBLAS Algorithms? Because it is *fast*, *flexible*, and *familiar* b Are we missing any [algorithms](#Plugin-Algorithms) that you want? [Please let us know!](https://github.com/python-graphblas/graphblas-algorithms/issues)
-GraphBLAS vs NetworkX +GraphBLAS vs NetworkX
-GraphBLAS vs igraph +GraphBLAS vs igraph ### Installation ``` @@ -151,6 +151,8 @@ dispatch pattern shown above. - descendants - Dominating - is_dominating_set +- Generators + - ego_graph - Isolate - is_isolate - isolates diff --git a/graphblas_algorithms/__init__.py b/graphblas_algorithms/__init__.py index f9bbcf4..09418f5 100644 --- a/graphblas_algorithms/__init__.py +++ b/graphblas_algorithms/__init__.py @@ -3,6 +3,7 @@ from .classes import * from .algorithms import * # isort:skip +from .generators import * # isort:skip try: __version__ = importlib.metadata.version("graphblas-algorithms") diff --git a/graphblas_algorithms/algorithms/_bfs.py b/graphblas_algorithms/algorithms/_bfs.py new file mode 100644 index 0000000..31674ce --- /dev/null +++ b/graphblas_algorithms/algorithms/_bfs.py @@ -0,0 +1,150 @@ +"""BFS routines used by other algorithms""" + +import numpy as np +from graphblas import Matrix, Vector, binary, replace, unary +from graphblas.semiring import any_pair + + +def _get_cutoff(n, cutoff): + if cutoff is None or cutoff >= n: + return n # Everything + return cutoff + 1 # Inclusive + + +def _plain_bfs(G, source, *, cutoff=None): + index = G._key_to_id[source] + A = G.get_property("offdiag") + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q = Vector(bool, n, name="q") + v[index] = True + q[index] = True + any_pair_bool = any_pair[bool] + cutoff = _get_cutoff(n, cutoff) + for _i in range(1, cutoff): + q(~v.S, replace) << any_pair_bool(q @ A) + if q.nvals == 0: + break + v(q.S) << True + return v + + +def _bfs_level(G, source, cutoff=None, *, transpose=False, dtype=int): + if dtype == bool: + dtype = int + index = G._key_to_id[source] + A = G.get_property("offdiag") + if transpose and G.is_directed(): + A = A.T # TODO: should we use "AT" instead? + n = A.nrows + v = Vector(dtype, n, name="bfs_level") + q = Vector(bool, n, name="q") + v[index] = 0 + q[index] = True + any_pair_bool = any_pair[bool] + cutoff = _get_cutoff(n, cutoff) + for i in range(1, cutoff): + q(~v.S, replace) << any_pair_bool(q @ A) + if q.nvals == 0: + break + v(q.S) << i + return v + + +def _bfs_levels(G, nodes, cutoff=None, *, dtype=int): + if dtype == bool: + dtype = int + A = G.get_property("offdiag") + n = A.nrows + if nodes is None: + # TODO: `D = Vector.from_scalar(0, n, dtype).diag()` + D = Vector(dtype, n, name="bfs_levels_vector") + D << 0 + D = D.diag(name="bfs_levels") + else: + ids = G.list_to_ids(nodes) + D = Matrix.from_coo( + np.arange(len(ids), dtype=np.uint64), + ids, + 0, + dtype, + nrows=len(ids), + ncols=n, + name="bfs_levels", + ) + Q = unary.one[bool](D).new(name="Q") + any_pair_bool = any_pair[bool] + cutoff = _get_cutoff(n, cutoff) + for i in range(1, cutoff): + Q(~D.S, replace) << any_pair_bool(Q @ A) + if Q.nvals == 0: + break + D(Q.S) << i + return D + + +# TODO: benchmark this and the version commented out below +def _plain_bfs_bidirectional(G, source): + # Bi-directional BFS w/o symmetrizing the adjacency matrix + index = G._key_to_id[source] + A = G.get_property("offdiag") + # XXX: should we use `AT` if available? + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q_out = Vector(bool, n, name="q_out") + q_in = Vector(bool, n, name="q_in") + v[index] = True + q_in[index] = True + any_pair_bool = any_pair[bool] + is_out_empty = True + is_in_empty = False + for _i in range(1, n): + # Traverse out-edges from the most recent `q_in` and `q_out` + if is_out_empty: + q_out(~v.S) << any_pair_bool(q_in @ A) + else: + q_out << binary.any(q_out | q_in) + q_out(~v.S, replace) << any_pair_bool(q_out @ A) + is_out_empty = q_out.nvals == 0 + if not is_out_empty: + v(q_out.S) << True + elif is_in_empty: + break + # Traverse in-edges from the most recent `q_in` and `q_out` + if is_in_empty: + q_in(~v.S) << any_pair_bool(A @ q_out) + else: + q_in << binary.any(q_out | q_in) + q_in(~v.S, replace) << any_pair_bool(A @ q_in) + is_in_empty = q_in.nvals == 0 + if not is_in_empty: + v(q_in.S) << True + elif is_out_empty: + break + return v + + +""" +def _plain_bfs_bidirectional(G, source): + # Bi-directional BFS w/o symmetrizing the adjacency matrix + index = G._key_to_id[source] + A = G.get_property("offdiag") + n = A.nrows + v = Vector(bool, n, name="bfs_plain") + q = Vector(bool, n, name="q") + q2 = Vector(bool, n, name="q_2") + v[index] = True + q[index] = True + any_pair_bool = any_pair[bool] + for _i in range(1, n): + q2(~v.S, replace) << any_pair_bool(q @ A) + v(q2.S) << True + q(~v.S, replace) << any_pair_bool(A @ q) + if q.nvals == 0: + if q2.nvals == 0: + break + q, q2 = q2, q + elif q2.nvals != 0: + q << binary.any(q | q2) + return v +""" diff --git a/graphblas_algorithms/algorithms/centrality/eigenvector.py b/graphblas_algorithms/algorithms/centrality/eigenvector.py index 5172f61..e9385f3 100644 --- a/graphblas_algorithms/algorithms/centrality/eigenvector.py +++ b/graphblas_algorithms/algorithms/centrality/eigenvector.py @@ -1,11 +1,7 @@ from graphblas import Vector -from graphblas_algorithms.algorithms._helpers import is_converged, normalize -from graphblas_algorithms.algorithms.exceptions import ( - ConvergenceFailure, - GraphBlasAlgorithmException, - PointlessConcept, -) +from .._helpers import is_converged, normalize +from ..exceptions import ConvergenceFailure, GraphBlasAlgorithmException, PointlessConcept __all__ = ["eigenvector_centrality"] diff --git a/graphblas_algorithms/algorithms/centrality/katz.py b/graphblas_algorithms/algorithms/centrality/katz.py index 78de982..8087e85 100644 --- a/graphblas_algorithms/algorithms/centrality/katz.py +++ b/graphblas_algorithms/algorithms/centrality/katz.py @@ -2,11 +2,8 @@ from graphblas.core.utils import output_type from graphblas.semiring import plus_first, plus_times -from graphblas_algorithms.algorithms._helpers import is_converged, normalize -from graphblas_algorithms.algorithms.exceptions import ( - ConvergenceFailure, - GraphBlasAlgorithmException, -) +from .._helpers import is_converged, normalize +from ..exceptions import ConvergenceFailure, GraphBlasAlgorithmException __all__ = ["katz_centrality"] diff --git a/graphblas_algorithms/algorithms/components/connected.py b/graphblas_algorithms/algorithms/components/connected.py index 37c0fc9..fb2f678 100644 --- a/graphblas_algorithms/algorithms/components/connected.py +++ b/graphblas_algorithms/algorithms/components/connected.py @@ -1,7 +1,5 @@ -from graphblas import Vector, replace -from graphblas.semiring import any_pair - -from graphblas_algorithms.algorithms.exceptions import PointlessConcept +from .._bfs import _plain_bfs +from ..exceptions import PointlessConcept def is_connected(G): @@ -12,20 +10,3 @@ def is_connected(G): def node_connected_component(G, n): return _plain_bfs(G, n) - - -def _plain_bfs(G, source): - index = G._key_to_id[source] - A = G.get_property("offdiag") - n = A.nrows - v = Vector(bool, n, name="bfs_plain") - q = Vector(bool, n, name="q") - v[index] = True - q[index] = True - any_pair_bool = any_pair[bool] - for _i in range(1, n): - q(~v.S, replace) << any_pair_bool(q @ A) - if q.nvals == 0: - break - v(q.S) << True - return v diff --git a/graphblas_algorithms/algorithms/components/weakly_connected.py b/graphblas_algorithms/algorithms/components/weakly_connected.py index eb3dc75..99eba78 100644 --- a/graphblas_algorithms/algorithms/components/weakly_connected.py +++ b/graphblas_algorithms/algorithms/components/weakly_connected.py @@ -1,77 +1,8 @@ -from graphblas import Vector, binary, replace -from graphblas.semiring import any_pair - -from graphblas_algorithms.algorithms.exceptions import PointlessConcept +from .._bfs import _plain_bfs_bidirectional +from ..exceptions import PointlessConcept def is_weakly_connected(G): if len(G) == 0: raise PointlessConcept("Connectivity is undefined for the null graph.") - return _plain_bfs(G, next(iter(G))).nvals == len(G) - - -# TODO: benchmark this and the version commented out below -def _plain_bfs(G, source): - # Bi-directional BFS w/o symmetrizing the adjacency matrix - index = G._key_to_id[source] - A = G.get_property("offdiag") - # XXX: should we use `AT` if available? - n = A.nrows - v = Vector(bool, n, name="bfs_plain") - q_out = Vector(bool, n, name="q_out") - q_in = Vector(bool, n, name="q_in") - v[index] = True - q_in[index] = True - any_pair_bool = any_pair[bool] - is_out_empty = True - is_in_empty = False - for _i in range(1, n): - # Traverse out-edges from the most recent `q_in` and `q_out` - if is_out_empty: - q_out(~v.S) << any_pair_bool(q_in @ A) - else: - q_out << binary.any(q_out | q_in) - q_out(~v.S, replace) << any_pair_bool(q_out @ A) - is_out_empty = q_out.nvals == 0 - if not is_out_empty: - v(q_out.S) << True - elif is_in_empty: - break - # Traverse in-edges from the most recent `q_in` and `q_out` - if is_in_empty: - q_in(~v.S) << any_pair_bool(A @ q_out) - else: - q_in << binary.any(q_out | q_in) - q_in(~v.S, replace) << any_pair_bool(A @ q_in) - is_in_empty = q_in.nvals == 0 - if not is_in_empty: - v(q_in.S) << True - elif is_out_empty: - break - return v - - -""" -def _plain_bfs(G, source): - # Bi-directional BFS w/o symmetrizing the adjacency matrix - index = G._key_to_id[source] - A = G.get_property("offdiag") - n = A.nrows - v = Vector(bool, n, name="bfs_plain") - q = Vector(bool, n, name="q") - q2 = Vector(bool, n, name="q_2") - v[index] = True - q[index] = True - any_pair_bool = any_pair[bool] - for _i in range(1, n): - q2(~v.S, replace) << any_pair_bool(q @ A) - v(q2.S) << True - q(~v.S, replace) << any_pair_bool(A @ q) - if q.nvals == 0: - if q2.nvals == 0: - break - q, q2 = q2, q - elif q2.nvals != 0: - q << binary.any(q | q2) - return v -""" + return _plain_bfs_bidirectional(G, next(iter(G))).nvals == len(G) diff --git a/graphblas_algorithms/algorithms/core.py b/graphblas_algorithms/algorithms/core.py index 8133c71..a6ff26d 100644 --- a/graphblas_algorithms/algorithms/core.py +++ b/graphblas_algorithms/algorithms/core.py @@ -1,11 +1,12 @@ from graphblas import Matrix, monoid, replace, select, semiring -from graphblas_algorithms.classes.graph import Graph +from graphblas_algorithms import Graph __all__ = ["k_truss"] def k_truss(G: Graph, k) -> Graph: + # TODO: should we have an option to keep the output matrix the same size? # Ignore self-edges S = G.get_property("offdiag") @@ -32,6 +33,5 @@ def k_truss(G: Graph, k) -> Graph: Ktruss = C[indices, indices].new() # Convert back to networkx graph with correct node ids - keys = G.list_to_keys(indices) - key_to_id = dict(zip(keys, range(len(indices)))) + key_to_id = G.renumber_key_to_id(indices.tolist()) return Graph(Ktruss, key_to_id=key_to_id) diff --git a/graphblas_algorithms/algorithms/link_analysis/hits_alg.py b/graphblas_algorithms/algorithms/link_analysis/hits_alg.py index 515806e..662ac14 100644 --- a/graphblas_algorithms/algorithms/link_analysis/hits_alg.py +++ b/graphblas_algorithms/algorithms/link_analysis/hits_alg.py @@ -1,7 +1,7 @@ from graphblas import Vector -from graphblas_algorithms.algorithms._helpers import is_converged, normalize -from graphblas_algorithms.algorithms.exceptions import ConvergenceFailure +from .._helpers import is_converged, normalize +from ..exceptions import ConvergenceFailure __all__ = ["hits"] diff --git a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py index 518c09f..d665e98 100644 --- a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py +++ b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py @@ -3,8 +3,9 @@ from graphblas.semiring import plus_first, plus_times from graphblas_algorithms import Graph -from graphblas_algorithms.algorithms._helpers import is_converged -from graphblas_algorithms.algorithms.exceptions import ConvergenceFailure + +from .._helpers import is_converged +from ..exceptions import ConvergenceFailure __all__ = ["pagerank", "google_matrix"] diff --git a/graphblas_algorithms/algorithms/shortest_paths/unweighted.py b/graphblas_algorithms/algorithms/shortest_paths/unweighted.py index 3c8243f..5062e87 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/unweighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/unweighted.py @@ -1,6 +1,6 @@ -import numpy as np -from graphblas import Matrix, Vector, replace, unary -from graphblas.semiring import any_pair +from graphblas import Matrix + +from .._bfs import _bfs_level, _bfs_levels __all__ = [ "single_source_shortest_path_length", @@ -25,59 +25,3 @@ def all_pairs_shortest_path_length(G, cutoff=None, *, nodes=None, expand_output= rv[ids, :] = D return rv return D - - -def _bfs_level(G, source, cutoff, *, transpose=False): - index = G._key_to_id[source] - A = G.get_property("offdiag") - if transpose and G.is_directed(): - A = A.T # TODO: should we use "AT" instead? - n = A.nrows - v = Vector(int, n, name="bfs_unweighted") - q = Vector(bool, n, name="q") - v[index] = 0 - q[index] = True - any_pair_bool = any_pair[bool] - if cutoff is None or cutoff >= n: - cutoff = n # Everything - else: - cutoff += 1 # Inclusive - for i in range(1, cutoff): - q(~v.S, replace) << any_pair_bool(q @ A) - if q.nvals == 0: - break - v(q.S) << i - return v - - -def _bfs_levels(G, nodes, cutoff): - A = G.get_property("offdiag") - n = A.nrows - if nodes is None: - # TODO: `D = Vector.from_scalar(0, n, dtype).diag()` - D = Vector(int, n, name="bfs_unweighted_vector") - D << 0 - D = D.diag(name="bfs_unweighted") - else: - ids = G.list_to_ids(nodes) - D = Matrix.from_coo( - np.arange(len(ids), dtype=np.uint64), - ids, - 0, - int, - nrows=len(ids), - ncols=n, - name="bfs_unweighted", - ) - Q = unary.one[bool](D).new(name="Q") - any_pair_bool = any_pair[bool] - if cutoff is None or cutoff >= n: - cutoff = n # Everything - else: - cutoff += 1 # Inclusive - for i in range(1, cutoff): - Q(~D.S, replace) << any_pair_bool(Q @ A) - if Q.nvals == 0: - break - D(Q.S) << i - return D diff --git a/graphblas_algorithms/algorithms/shortest_paths/weighted.py b/graphblas_algorithms/algorithms/shortest_paths/weighted.py index 8e6efef..fddf672 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/weighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/weighted.py @@ -2,6 +2,7 @@ from graphblas import Matrix, Vector, binary, monoid, replace, select, unary from graphblas.semiring import any_pair, min_plus +from .._bfs import _bfs_level, _bfs_levels from ..exceptions import Unbounded __all__ = [ @@ -11,14 +12,16 @@ ] -def single_source_bellman_ford_path_length(G, source): +def single_source_bellman_ford_path_length(G, source, *, cutoff=None): # No need for `is_weighted=` keyword, b/c this is assumed to be weighted (I think) index = G._key_to_id[source] if G.get_property("is_iso"): # If the edges are iso-valued (and positive), then we can simply do level BFS is_negative, iso_value = G.get_properties("has_negative_edges+ iso_value") if not is_negative: - d = _bfs_level(G, source, dtype=iso_value.dtype) + if cutoff is not None: + cutoff = int(cutoff // iso_value) + d = _bfs_level(G, source, cutoff, dtype=iso_value.dtype) if iso_value != 1: d *= iso_value return d @@ -49,6 +52,8 @@ def single_source_bellman_ford_path_length(G, source): # `cur` is the current frontier of values that improved in the previous iteration. # This means that in this iteration we drop values from `cur` that are not better. cur << min_plus(cur @ A) + if cutoff is not None: + cur << select.valuele(cur, cutoff) # Mask is True where cur not in d or cur < d mask << one(cur) @@ -63,6 +68,8 @@ def single_source_bellman_ford_path_length(G, source): else: # Check for negative cycle when for loop completes without breaking cur << min_plus(cur @ A) + if cutoff is not None: + cur << select.valuele(cur, cutoff) mask << binary.lt(cur & d) if mask.reduce(monoid.lor): raise Unbounded("Negative cycle detected.") @@ -157,56 +164,6 @@ def bellman_ford_path_lengths(G, nodes=None, *, expand_output=False): return D -def _bfs_level(G, source, *, dtype=int): - if dtype == bool: - dtype = int - index = G._key_to_id[source] - A = G.get_property("offdiag") - n = A.nrows - v = Vector(dtype, n, name="bfs_level") - q = Vector(bool, n, name="q") - v[index] = 0 - q[index] = True - any_pair_bool = any_pair[bool] - for i in range(1, n): - q(~v.S, replace) << any_pair_bool(q @ A) - if q.nvals == 0: - break - v(q.S) << i - return v - - -def _bfs_levels(G, nodes=None, *, dtype=int): - if dtype == bool: - dtype = int - A = G.get_property("offdiag") - n = A.nrows - if nodes is None: - # TODO: `D = Vector.from_scalar(0, n, dtype).diag()` - D = Vector(dtype, n, name="bfs_levels_vector") - D << 0 - D = D.diag(name="bfs_levels") - else: - ids = G.list_to_ids(nodes) - D = Matrix.from_coo( - np.arange(len(ids), dtype=np.uint64), - ids, - 0, - dtype, - nrows=len(ids), - ncols=n, - name="bfs_levels", - ) - Q = unary.one[bool](D).new(name="Q") - any_pair_bool = any_pair[bool] - for i in range(1, n): - Q(~D.S, replace) << any_pair_bool(Q @ A) - if Q.nvals == 0: - break - D(Q.S) << i - return D - - def negative_edge_cycle(G): # TODO: use a heuristic to try to stop early if G.is_directed(): diff --git a/graphblas_algorithms/algorithms/triads.py b/graphblas_algorithms/algorithms/triads.py index 54702c7..e6ec2be 100644 --- a/graphblas_algorithms/algorithms/triads.py +++ b/graphblas_algorithms/algorithms/triads.py @@ -1,5 +1,4 @@ -from graphblas_algorithms.classes.digraph import DiGraph -from graphblas_algorithms.classes.graph import Graph +from graphblas_algorithms import DiGraph, Graph __all__ = ["is_triad"] diff --git a/graphblas_algorithms/classes/_utils.py b/graphblas_algorithms/classes/_utils.py index 92febc5..65ae010 100644 --- a/graphblas_algorithms/classes/_utils.py +++ b/graphblas_algorithms/classes/_utils.py @@ -250,3 +250,12 @@ def _cacheit(self, key, func, *args, **kwargs): if key not in self._cache: self._cache[key] = func(*args, **kwargs) return self._cache[key] + + +def renumber_key_to_id(self, indices): + """Create `key_to_id` for e.g. a subgraph with node ids from `indices`""" + id_to_key = self.id_to_key + return {id_to_key[index]: i for i, index in enumerate(indices)} + # Alternative (about the same performance) + # keys = self.list_to_keys(indices) + # return dict(zip(keys, range(len(indices)))) diff --git a/graphblas_algorithms/classes/digraph.py b/graphblas_algorithms/classes/digraph.py index 83e7356..bae66ae 100644 --- a/graphblas_algorithms/classes/digraph.py +++ b/graphblas_algorithms/classes/digraph.py @@ -553,6 +553,7 @@ def __init__(self, incoming_graph_data=None, *, key_to_id=None, **attr): vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set _cacheit = _utils._cacheit + renumber_key_to_id = _utils.renumber_key_to_id # NetworkX methods def to_directed_class(self): @@ -598,6 +599,16 @@ def is_multigraph(self): def is_directed(self): return True + def to_undirected(self, reciprocal=False, as_view=False, *, name=None): + if as_view: + raise NotImplementedError("`as_vew=True` is not implemented in `G.to_undirected`") + A = self._A + if reciprocal: + B = binary.any(A & A.T).new(name=name) + else: + B = binary.any(A | A.T).new(name=name) + return Graph(B, key_to_id=self._key_to_id) + class MultiDiGraph(DiGraph): def is_multigraph(self): diff --git a/graphblas_algorithms/classes/graph.py b/graphblas_algorithms/classes/graph.py index 03a2893..06f82be 100644 --- a/graphblas_algorithms/classes/graph.py +++ b/graphblas_algorithms/classes/graph.py @@ -401,6 +401,7 @@ def __init__(self, incoming_graph_data=None, *, key_to_id=None, **attr): vector_to_nodeset = _utils.vector_to_nodeset vector_to_set = _utils.vector_to_set _cacheit = _utils._cacheit + renumber_key_to_id = _utils.renumber_key_to_id # NetworkX methods def to_directed_class(self): diff --git a/graphblas_algorithms/generators/__init__.py b/graphblas_algorithms/generators/__init__.py new file mode 100644 index 0000000..65a6526 --- /dev/null +++ b/graphblas_algorithms/generators/__init__.py @@ -0,0 +1 @@ +from .ego import * diff --git a/graphblas_algorithms/generators/ego.py b/graphblas_algorithms/generators/ego.py new file mode 100644 index 0000000..26e9cf9 --- /dev/null +++ b/graphblas_algorithms/generators/ego.py @@ -0,0 +1,24 @@ +from ..algorithms.components.connected import _plain_bfs +from ..algorithms.shortest_paths.weighted import single_source_bellman_ford_path_length + +__all__ = ["ego_graph"] + + +def ego_graph(G, n, radius=1, center=True, undirected=False, is_weighted=False): + # TODO: should we have an option to keep the output matrix the same size? + if undirected and G.is_directed(): + # NOT COVERED + G2 = G.to_undirected() + else: + G2 = G + if is_weighted: + v = single_source_bellman_ford_path_length(G2, n, cutoff=radius) + else: + v = _plain_bfs(G2, n, cutoff=radius) + if not center: + del v[G._key_to_id[n]] + + indices, _ = v.to_coo(values=False) + A = G._A[indices, indices].new(name="ego") + key_to_id = G.renumber_key_to_id(indices.tolist()) + return type(G)(A, key_to_id=key_to_id) diff --git a/graphblas_algorithms/interface.py b/graphblas_algorithms/interface.py index 206d19c..94f02a6 100644 --- a/graphblas_algorithms/interface.py +++ b/graphblas_algorithms/interface.py @@ -45,6 +45,8 @@ class Dispatcher: descendants = nxapi.dag.descendants # Dominating is_dominating_set = nxapi.dominating.is_dominating_set + # Generators + ego_graph = nxapi.generators.ego.ego_graph # Isolate is_isolate = nxapi.isolate.is_isolate isolates = nxapi.isolate.isolates diff --git a/graphblas_algorithms/nxapi/__init__.py b/graphblas_algorithms/nxapi/__init__.py index 5ddc1fa..2d36017 100644 --- a/graphblas_algorithms/nxapi/__init__.py +++ b/graphblas_algorithms/nxapi/__init__.py @@ -7,6 +7,7 @@ from .cuts import * from .dag import * from .dominating import * +from .generators import * from .isolate import * from .link_analysis import * from .operators import * @@ -23,6 +24,7 @@ from . import cluster from . import community from . import components +from . import generators from . import link_analysis from . import operators from . import shortest_paths diff --git a/graphblas_algorithms/nxapi/generators/__init__.py b/graphblas_algorithms/nxapi/generators/__init__.py new file mode 100644 index 0000000..65a6526 --- /dev/null +++ b/graphblas_algorithms/nxapi/generators/__init__.py @@ -0,0 +1 @@ +from .ego import * diff --git a/graphblas_algorithms/nxapi/generators/ego.py b/graphblas_algorithms/nxapi/generators/ego.py new file mode 100644 index 0000000..e591cb3 --- /dev/null +++ b/graphblas_algorithms/nxapi/generators/ego.py @@ -0,0 +1,11 @@ +from graphblas_algorithms import generators +from graphblas_algorithms.classes.digraph import to_graph + +__all__ = ["ego_graph"] + + +def ego_graph(G, n, radius=1, center=True, undirected=False, distance=None): + G = to_graph(G, weight=distance) + return generators.ego_graph( + G, n, radius=radius, center=center, undirected=undirected, is_weighted=distance is not None + ) diff --git a/graphblas_algorithms/tests/test_match_nx.py b/graphblas_algorithms/tests/test_match_nx.py index c50896f..490d1d7 100644 --- a/graphblas_algorithms/tests/test_match_nx.py +++ b/graphblas_algorithms/tests/test_match_nx.py @@ -159,3 +159,35 @@ def test_dispatched_funcs_in_nxapi(nx_names_to_info, gb_names_to_info): print(" ", ":".join(path.rsplit(".", 1))) if failing: # pragma: no cover raise AssertionError + + +def test_print_dispatched_not_implemented(nx_names_to_info, gb_names_to_info): + """It may be informative to see the results from this to identify functions to implement. + + $ pytest -s -k test_print_dispatched_not_implemented + """ + not_implemented = nx_names_to_info.keys() - gb_names_to_info.keys() + fullnames = {next(iter(nx_names_to_info[name])).fullname for name in not_implemented} + print() + print("=================================================================================") + print("Functions dispatched in NetworkX that ARE NOT implemented in graphblas-algorithms") + print("---------------------------------------------------------------------------------") + for i, name in enumerate(sorted(fullnames)): + print(i, name) + print("=================================================================================") + + +def test_print_dispatched_implemented(nx_names_to_info, gb_names_to_info): + """It may be informative to see the results from this to identify implemented functions. + + $ pytest -s -k test_print_dispatched_implemented + """ + implemented = nx_names_to_info.keys() & gb_names_to_info.keys() + fullnames = {next(iter(nx_names_to_info[name])).fullname for name in implemented} + print() + print("=============================================================================") + print("Functions dispatched in NetworkX that ARE implemented in graphblas-algorithms") + print("-----------------------------------------------------------------------------") + for i, name in enumerate(sorted(fullnames)): + print(i, name) + print("=============================================================================") diff --git a/pyproject.toml b/pyproject.toml index df17600..1772aa2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ keywords = [ "math", ] classifiers = [ - "Development Status :: 3 - Alpha", + "Development Status :: 4 - Beta", "License :: OSI Approved :: Apache Software License", "Operating System :: MacOS :: MacOS X", "Operating System :: POSIX :: Linux", @@ -46,6 +46,7 @@ classifiers = [ "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3 :: Only", "Intended Audience :: Developers", "Intended Audience :: Other Audience", @@ -78,12 +79,8 @@ test = [ "setuptools", "tomli", ] -complete = [ - "pytest", - "networkx >=3.0", - "scipy >=1.8", - "setuptools", - "tomli", +all = [ + "graphblas-algorithms[test]", ] [tool.setuptools] @@ -104,10 +101,12 @@ packages = [ "graphblas_algorithms.algorithms.tests", "graphblas_algorithms.algorithms.traversal", "graphblas_algorithms.classes", + "graphblas_algorithms.generators", "graphblas_algorithms.nxapi", "graphblas_algorithms.nxapi.centrality", "graphblas_algorithms.nxapi.community", "graphblas_algorithms.nxapi.components", + "graphblas_algorithms.nxapi.generators", "graphblas_algorithms.nxapi.link_analysis", "graphblas_algorithms.nxapi.operators", "graphblas_algorithms.nxapi.shortest_paths", From 7dfd65b1ee8802d2fb2f545cd3af2e1f2cb15bb4 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Thu, 4 May 2023 12:39:40 -0500 Subject: [PATCH 10/24] Add `bellman_ford_path` (#64) * Add `bellman_ford_path` * Make sure README.md lists all algorithms --- README.md | 1 + graphblas_algorithms/algorithms/_bfs.py | 47 ++++++- .../algorithms/components/connected.py | 6 +- .../algorithms/components/weakly_connected.py | 4 +- .../algorithms/shortest_paths/weighted.py | 116 +++++++++++++++++- graphblas_algorithms/generators/ego.py | 4 +- graphblas_algorithms/interface.py | 1 + .../nxapi/shortest_paths/weighted.py | 10 ++ graphblas_algorithms/tests/test_match_nx.py | 19 +++ 9 files changed, 194 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 821cd95..adc236a 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,7 @@ dispatch pattern shown above. - Shortest Paths - all_pairs_bellman_ford_path_length - all_pairs_shortest_path_length + - bellman_ford_path - floyd_warshall - floyd_warshall_numpy - floyd_warshall_predecessor_and_distance diff --git a/graphblas_algorithms/algorithms/_bfs.py b/graphblas_algorithms/algorithms/_bfs.py index 31674ce..49ba735 100644 --- a/graphblas_algorithms/algorithms/_bfs.py +++ b/graphblas_algorithms/algorithms/_bfs.py @@ -1,7 +1,7 @@ """BFS routines used by other algorithms""" import numpy as np -from graphblas import Matrix, Vector, binary, replace, unary +from graphblas import Matrix, Vector, binary, indexunary, replace, semiring, unary from graphblas.semiring import any_pair @@ -11,8 +11,13 @@ def _get_cutoff(n, cutoff): return cutoff + 1 # Inclusive -def _plain_bfs(G, source, *, cutoff=None): - index = G._key_to_id[source] +def _bfs_plain(G, source=None, target=None, *, index=None, cutoff=None): + if source is not None: + index = G._key_to_id[source] + if target is not None: + dst_id = G._key_to_id[target] + else: + dst_id = None A = G.get_property("offdiag") n = A.nrows v = Vector(bool, n, name="bfs_plain") @@ -25,6 +30,8 @@ def _plain_bfs(G, source, *, cutoff=None): q(~v.S, replace) << any_pair_bool(q @ A) if q.nvals == 0: break + if dst_id is not None and dst_id in q: + break v(q.S) << True return v @@ -83,8 +90,38 @@ def _bfs_levels(G, nodes, cutoff=None, *, dtype=int): return D +def _bfs_parent(G, source, cutoff=None, *, target=None, transpose=False, dtype=int): + if dtype == bool: + dtype = int + index = G._key_to_id[source] + if target is not None: + dst_id = G._key_to_id[target] + else: + dst_id = None + A = G.get_property("offdiag") + if transpose and G.is_directed(): + A = A.T # TODO: should we use "AT" instead? + n = A.nrows + v = Vector(dtype, n, name="bfs_parent") + q = Vector(dtype, n, name="q") + v[index] = index + q[index] = index + min_first = semiring.min_first[v.dtype] + index = indexunary.index[v.dtype] + cutoff = _get_cutoff(n, cutoff) + for _i in range(1, cutoff): + q(~v.S, replace) << min_first(q @ A) + if q.nvals == 0: + break + v(q.S) << q + if dst_id is not None and dst_id in q: + break + q << index(q) + return v + + # TODO: benchmark this and the version commented out below -def _plain_bfs_bidirectional(G, source): +def _bfs_plain_bidirectional(G, source): # Bi-directional BFS w/o symmetrizing the adjacency matrix index = G._key_to_id[source] A = G.get_property("offdiag") @@ -125,7 +162,7 @@ def _plain_bfs_bidirectional(G, source): """ -def _plain_bfs_bidirectional(G, source): +def _bfs_plain_bidirectional(G, source): # Bi-directional BFS w/o symmetrizing the adjacency matrix index = G._key_to_id[source] A = G.get_property("offdiag") diff --git a/graphblas_algorithms/algorithms/components/connected.py b/graphblas_algorithms/algorithms/components/connected.py index fb2f678..3f19b86 100644 --- a/graphblas_algorithms/algorithms/components/connected.py +++ b/graphblas_algorithms/algorithms/components/connected.py @@ -1,12 +1,12 @@ -from .._bfs import _plain_bfs +from .._bfs import _bfs_plain from ..exceptions import PointlessConcept def is_connected(G): if len(G) == 0: raise PointlessConcept("Connectivity is undefined for the null graph.") - return _plain_bfs(G, next(iter(G))).nvals == len(G) + return _bfs_plain(G, next(iter(G))).nvals == len(G) def node_connected_component(G, n): - return _plain_bfs(G, n) + return _bfs_plain(G, n) diff --git a/graphblas_algorithms/algorithms/components/weakly_connected.py b/graphblas_algorithms/algorithms/components/weakly_connected.py index 99eba78..306d96e 100644 --- a/graphblas_algorithms/algorithms/components/weakly_connected.py +++ b/graphblas_algorithms/algorithms/components/weakly_connected.py @@ -1,8 +1,8 @@ -from .._bfs import _plain_bfs_bidirectional +from .._bfs import _bfs_plain_bidirectional from ..exceptions import PointlessConcept def is_weakly_connected(G): if len(G) == 0: raise PointlessConcept("Connectivity is undefined for the null graph.") - return _plain_bfs_bidirectional(G, next(iter(G))).nvals == len(G) + return _bfs_plain_bidirectional(G, next(iter(G))).nvals == len(G) diff --git a/graphblas_algorithms/algorithms/shortest_paths/weighted.py b/graphblas_algorithms/algorithms/shortest_paths/weighted.py index fddf672..d2a9f0e 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/weighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/weighted.py @@ -1,12 +1,13 @@ import numpy as np -from graphblas import Matrix, Vector, binary, monoid, replace, select, unary +from graphblas import Matrix, Vector, binary, indexunary, monoid, replace, select, unary from graphblas.semiring import any_pair, min_plus -from .._bfs import _bfs_level, _bfs_levels +from .._bfs import _bfs_level, _bfs_levels, _bfs_parent, _bfs_plain from ..exceptions import Unbounded __all__ = [ "single_source_bellman_ford_path_length", + "bellman_ford_path", "bellman_ford_path_lengths", "negative_edge_cycle", ] @@ -164,6 +165,117 @@ def bellman_ford_path_lengths(G, nodes=None, *, expand_output=False): return D +def _reconstruct_path_from_parents(G, parents, src, dst): + indices, values = parents.to_coo(sort=False) + d = dict(zip(indices.tolist(), values.tolist())) + if dst not in d: + return [] + cur = dst + path = [cur] + while cur != src: + cur = d[cur] + path.append(cur) + return G.list_to_keys(reversed(path)) + + +def bellman_ford_path(G, source, target): + src_id = G._key_to_id[source] + dst_id = G._key_to_id[target] + if G.get_property("is_iso"): + # If the edges are iso-valued (and positive), then we can simply do level BFS + is_negative = G.get_property("has_negative_edges+") + if not is_negative: + p = _bfs_parent(G, source, target=target) + return _reconstruct_path_from_parents(G, p, src_id, dst_id) + raise Unbounded("Negative cycle detected.") + A, is_negative, has_negative_diagonal = G.get_properties( + "offdiag has_negative_edges- has_negative_diagonal" + ) + if A.dtype == bool: + # Should we upcast e.g. INT8 to INT64 as well? + dtype = int + else: + dtype = A.dtype + cutoff = None + n = A.nrows + d = Vector(dtype, n, name="bellman_ford_path_length") + d[src_id] = 0 + p = Vector(int, n, name="bellman_ford_path_parent") + p[src_id] = src_id + + prev = d.dup(name="prev") + cur = Vector(dtype, n, name="cur") + indices = Vector(int, n, name="indices") + mask = Vector(bool, n, name="mask") + B = Matrix(dtype, n, n, name="B") + Indices = Matrix(int, n, n, name="Indices") + cols = prev.to_coo(values=False)[0] + one = unary.one[bool] + for _i in range(n - 1): + # This is a slightly modified Bellman-Ford algorithm. + # `cur` is the current frontier of values that improved in the previous iteration. + # This means that in this iteration we drop values from `cur` that are not better. + cur << min_plus(prev @ A) + if cutoff is not None: + cur << select.valuele(cur, cutoff) + + # Mask is True where cur not in d or cur < d + mask << one(cur) + mask(binary.second) << binary.lt(cur & d) + + # Drop values from `cur` that didn't improve + cur(mask.V, replace) << cur + if cur.nvals == 0: + break + # Update `d` with values that improved + d(cur.S) << cur + if not is_negative: + # Limit exploration if we have a target + cutoff = cur.get(dst_id, cutoff) + + # Now try to find the parents! + # This is also not standard. Typically, UDTs and UDFs are used to keep + # track of both the minimum element and the parent id at the same time. + # Only include rows and columns that were used this iteration. + rows = cols + cols = cur.to_coo(values=False)[0] + B.clear() + B[rows, cols] = A[rows, cols] + + # Reverse engineer to determine parent + B << binary.plus(prev & B) + B << binary.iseq(B & cur) + B << select.valuene(B, False) + Indices << indexunary.rowindex(B) + indices << Indices.reduce_columnwise(monoid.min) + p(indices.S) << indices + prev, cur = cur, prev + else: + # Check for negative cycle when for loop completes without breaking + cur << min_plus(prev @ A) + if cutoff is not None: + cur << select.valuele(cur, cutoff) + mask << binary.lt(cur & d) + if mask.get(dst_id): + raise Unbounded("Negative cycle detected.") + path = _reconstruct_path_from_parents(G, p, src_id, dst_id) + if has_negative_diagonal and path: + mask.clear() + mask[G.list_to_ids(path)] = True + diag = G.get_property("diag", mask=mask.S) + if diag.nvals > 0: + raise Unbounded("Negative cycle detected.") + mask << binary.first(mask & cur) # mask(cur.S, replace) << mask + if mask.nvals > 0: + # Is there a path from any visited node with negative self-loop to target? + # We could actually stop as soon as any from `path` is visited + indices, _ = mask.to_coo(values=False)[0] + q = _bfs_plain(G, target=target, index=indices, cutoff=_i) + if dst_id in q: + raise Unbounded("Negative cycle detected.") + return path + + def negative_edge_cycle(G): # TODO: use a heuristic to try to stop early if G.is_directed(): diff --git a/graphblas_algorithms/generators/ego.py b/graphblas_algorithms/generators/ego.py index 26e9cf9..4d95e0f 100644 --- a/graphblas_algorithms/generators/ego.py +++ b/graphblas_algorithms/generators/ego.py @@ -1,4 +1,4 @@ -from ..algorithms.components.connected import _plain_bfs +from ..algorithms.components.connected import _bfs_plain from ..algorithms.shortest_paths.weighted import single_source_bellman_ford_path_length __all__ = ["ego_graph"] @@ -14,7 +14,7 @@ def ego_graph(G, n, radius=1, center=True, undirected=False, is_weighted=False): if is_weighted: v = single_source_bellman_ford_path_length(G2, n, cutoff=radius) else: - v = _plain_bfs(G2, n, cutoff=radius) + v = _bfs_plain(G2, n, cutoff=radius) if not center: del v[G._key_to_id[n]] diff --git a/graphblas_algorithms/interface.py b/graphblas_algorithms/interface.py index 94f02a6..d0a8365 100644 --- a/graphblas_algorithms/interface.py +++ b/graphblas_algorithms/interface.py @@ -83,6 +83,7 @@ class Dispatcher: nxapi.shortest_paths.unweighted.single_target_shortest_path_length ) all_pairs_shortest_path_length = nxapi.shortest_paths.unweighted.all_pairs_shortest_path_length + bellman_ford_path = nxapi.shortest_paths.weighted.bellman_ford_path all_pairs_bellman_ford_path_length = ( nxapi.shortest_paths.weighted.all_pairs_bellman_ford_path_length ) diff --git a/graphblas_algorithms/nxapi/shortest_paths/weighted.py b/graphblas_algorithms/nxapi/shortest_paths/weighted.py index a44a18e..9916e45 100644 --- a/graphblas_algorithms/nxapi/shortest_paths/weighted.py +++ b/graphblas_algorithms/nxapi/shortest_paths/weighted.py @@ -6,6 +6,7 @@ __all__ = [ "all_pairs_bellman_ford_path_length", + "bellman_ford_path", "negative_edge_cycle", "single_source_bellman_ford_path_length", ] @@ -55,6 +56,15 @@ def single_source_bellman_ford_path_length(G, source, weight="weight"): return G.vector_to_nodemap(d) +def bellman_ford_path(G, source, target, weight="weight"): + # TODO: what if weight is a function? + G = to_graph(G, weight=weight) + try: + return algorithms.bellman_ford_path(G, source, target) + except KeyError as e: + raise NodeNotFound(*e.args) from e + + def negative_edge_cycle(G, weight="weight", heuristic=True): # TODO: what if weight is a function? # TODO: use a heuristic to try to stop early diff --git a/graphblas_algorithms/tests/test_match_nx.py b/graphblas_algorithms/tests/test_match_nx.py index 490d1d7..6250b1d 100644 --- a/graphblas_algorithms/tests/test_match_nx.py +++ b/graphblas_algorithms/tests/test_match_nx.py @@ -11,6 +11,7 @@ """ import sys from collections import namedtuple +from pathlib import Path import pytest @@ -191,3 +192,21 @@ def test_print_dispatched_implemented(nx_names_to_info, gb_names_to_info): for i, name in enumerate(sorted(fullnames)): print(i, name) print("=============================================================================") + + +def test_algorithms_in_readme(nx_names_to_info, gb_names_to_info): + """Ensure all algorithms are mentioned in README.md.""" + implemented = nx_names_to_info.keys() & gb_names_to_info.keys() + path = Path(__file__).parent.parent.parent / "README.md" + if not path.exists(): + return + with path.open("r") as f: + text = f.read() + missing = set() + for name in sorted(implemented): + if name not in text: + missing.add(name) + if missing: + msg = f"Algorithms missing in README.md: {', '.join(sorted(missing))}" + print(msg) + raise AssertionError(msg) From 759b9cd0ed1371cb931542000e4bb00bce4d97e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 May 2023 10:13:09 -0500 Subject: [PATCH 11/24] Bump pypa/gh-action-pypi-publish from 1.8.5 to 1.8.6 (#66) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.5 to 1.8.6. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.5...v1.8.6) --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index eda309c..7841b5b 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.5 + uses: pypa/gh-action-pypi-publish@v1.8.6 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} From d63d49d399e075a5e117c913ed45834e90d376e2 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 2 Jun 2023 09:52:51 -0500 Subject: [PATCH 12/24] A few more algorithms and automatically add algorithms to README (#67) * A few more algorithms and automatically add algorithms to README --- .github/workflows/lint.yml | 10 +- .pre-commit-config.yaml | 10 +- README.md | 225 ++++++++------ environment.yml | 2 + graphblas_algorithms/__init__.py | 3 +- graphblas_algorithms/algorithms/__init__.py | 3 + graphblas_algorithms/algorithms/_bfs.py | 29 +- graphblas_algorithms/algorithms/dag.py | 30 +- .../algorithms/efficiency_measures.py | 12 + .../algorithms/isomorphism/__init__.py | 1 + .../algorithms/isomorphism/isomorph.py | 56 ++++ .../algorithms/link_analysis/pagerank_alg.py | 2 - .../algorithms/lowest_common_ancestors.py | 21 ++ .../algorithms/operators/__init__.py | 1 + .../algorithms/operators/binary.py | 17 +- .../algorithms/operators/unary.py | 18 ++ .../algorithms/shortest_paths/generic.py | 38 +-- .../algorithms/shortest_paths/unweighted.py | 45 ++- .../algorithms/shortest_paths/weighted.py | 52 +++- .../traversal/breadth_first_search.py | 2 +- graphblas_algorithms/classes/_utils.py | 2 +- graphblas_algorithms/classes/digraph.py | 10 + graphblas_algorithms/interface.py | 281 +++++++++++------- graphblas_algorithms/linalg/__init__.py | 4 + .../linalg/bethehessianmatrix.py | 25 ++ graphblas_algorithms/linalg/graphmatrix.py | 19 ++ .../linalg/laplacianmatrix.py | 54 ++++ .../linalg/modularitymatrix.py | 37 +++ graphblas_algorithms/nxapi/__init__.py | 9 + .../nxapi/efficiency_measures.py | 9 + graphblas_algorithms/nxapi/exception.py | 4 + .../nxapi/isomorphism/__init__.py | 1 + .../nxapi/isomorphism/isomorph.py | 25 ++ graphblas_algorithms/nxapi/linalg/__init__.py | 5 + .../nxapi/linalg/bethehessianmatrix.py | 12 + .../nxapi/linalg/graphmatrix.py | 9 + .../nxapi/linalg/laplacianmatrix.py | 14 + .../nxapi/linalg/modularitymatrix.py | 20 ++ .../nxapi/lowest_common_ancestors.py | 11 + .../nxapi/operators/__init__.py | 1 + graphblas_algorithms/nxapi/operators/unary.py | 22 ++ .../nxapi/shortest_paths/dense.py | 4 +- .../nxapi/shortest_paths/weighted.py | 15 +- graphblas_algorithms/nxapi/tournament.py | 5 +- graphblas_algorithms/tests/test_match_nx.py | 11 +- pyproject.toml | 6 + scripts/maketree.py | 111 +++++++ 47 files changed, 1000 insertions(+), 303 deletions(-) create mode 100644 graphblas_algorithms/algorithms/efficiency_measures.py create mode 100644 graphblas_algorithms/algorithms/isomorphism/__init__.py create mode 100644 graphblas_algorithms/algorithms/isomorphism/isomorph.py create mode 100644 graphblas_algorithms/algorithms/lowest_common_ancestors.py create mode 100644 graphblas_algorithms/algorithms/operators/unary.py create mode 100644 graphblas_algorithms/linalg/__init__.py create mode 100644 graphblas_algorithms/linalg/bethehessianmatrix.py create mode 100644 graphblas_algorithms/linalg/graphmatrix.py create mode 100644 graphblas_algorithms/linalg/laplacianmatrix.py create mode 100644 graphblas_algorithms/linalg/modularitymatrix.py create mode 100644 graphblas_algorithms/nxapi/efficiency_measures.py create mode 100644 graphblas_algorithms/nxapi/isomorphism/__init__.py create mode 100644 graphblas_algorithms/nxapi/isomorphism/isomorph.py create mode 100644 graphblas_algorithms/nxapi/linalg/__init__.py create mode 100644 graphblas_algorithms/nxapi/linalg/bethehessianmatrix.py create mode 100644 graphblas_algorithms/nxapi/linalg/graphmatrix.py create mode 100644 graphblas_algorithms/nxapi/linalg/laplacianmatrix.py create mode 100644 graphblas_algorithms/nxapi/linalg/modularitymatrix.py create mode 100644 graphblas_algorithms/nxapi/lowest_common_ancestors.py create mode 100644 graphblas_algorithms/nxapi/operators/unary.py create mode 100755 scripts/maketree.py diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5ef2b10..81d9415 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,10 +1,12 @@ +# Rely on pre-commit.ci instead name: Lint via pre-commit on: - pull_request: - push: - branches-ignore: - - main + workflow_dispatch: + # pull_request: + # push: + # branches-ignore: + # - main permissions: contents: read diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 892b0c7..7b06df1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.12.2 + rev: v0.13 hooks: - id: validate-pyproject name: Validate pyproject.toml @@ -40,7 +40,7 @@ repos: hooks: - id: isort - repo: https://github.com/asottile/pyupgrade - rev: v3.3.2 + rev: v3.4.0 hooks: - id: pyupgrade args: [--py38-plus] @@ -55,7 +55,7 @@ repos: - id: black # - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.264 + rev: v0.0.269 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -66,7 +66,7 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.0.0 - - flake8-bugbear==23.3.23 + - flake8-bugbear==23.5.9 - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa rev: v1.4.0 @@ -81,7 +81,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.264 + rev: v0.0.269 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. diff --git a/README.md b/README.md index adc236a..ed66df3 100644 --- a/README.md +++ b/README.md @@ -112,93 +112,138 @@ The following NetworkX algorithms have been implemented by graphblas-algorithms and can be used following the dispatch pattern shown above. -- Boundary - - edge_boundary - - node_boundary -- Centrality - - degree_centrality - - eigenvector_centrality - - in_degree_centrality - - katz_centrality - - out_degree_centrality -- Cluster - - average_clustering - - clustering - - generalized_degree - - square_clustering - - transitivity - - triangles -- Community - - inter_community_edges - - intra_community_edges -- Components - - is_connected - - is_weakly_connected - - node_connected_component -- Core - - k_truss -- Cuts - - boundary_expansion - - conductance - - cut_size - - edge_expansion - - mixing_expansion - - node_expansion - - normalized_cut_size - - volume -- DAG - - ancestors - - descendants -- Dominating - - is_dominating_set -- Generators - - ego_graph -- Isolate - - is_isolate - - isolates - - number_of_isolates -- Link Analysis - - google_matrix - - hits - - pagerank -- Operators - - compose - - difference - - disjoint_union - - full_join - - intersection - - symmetric_difference - - union -- Reciprocity - - overall_reciprocity - - reciprocity -- Regular - - is_k_regular - - is_regular -- Shortest Paths - - all_pairs_bellman_ford_path_length - - all_pairs_shortest_path_length - - bellman_ford_path - - floyd_warshall - - floyd_warshall_numpy - - floyd_warshall_predecessor_and_distance - - has_path - - negative_edge_cycle - - single_source_bellman_ford_path_length - - single_source_shortest_path_length - - single_target_shortest_path_length -- Simple Paths - - is_simple_path -- S Metric - - s_metric -- Structural Holes - - mutual_weight -- Tournament - - is_tournament - - score_sequence - - tournament_matrix -- Traversal - - bfs_layers - - descendants_at_distance -- Triads - - is_triad +[//]: # (Begin auto-generated code) + +``` +graphblas_algorithms.nxapi +├── boundary +│ ├── edge_boundary +│ └── node_boundary +├── centrality +│ ├── degree_alg +│ │ ├── degree_centrality +│ │ ├── in_degree_centrality +│ │ └── out_degree_centrality +│ ├── eigenvector +│ │ └── eigenvector_centrality +│ └── katz +│ └── katz_centrality +├── cluster +│ ├── average_clustering +│ ├── clustering +│ ├── generalized_degree +│ ├── square_clustering +│ ├── transitivity +│ └── triangles +├── community +│ └── quality +│ ├── inter_community_edges +│ └── intra_community_edges +├── components +│ ├── connected +│ │ ├── is_connected +│ │ └── node_connected_component +│ └── weakly_connected +│ └── is_weakly_connected +├── core +│ └── k_truss +├── cuts +│ ├── boundary_expansion +│ ├── conductance +│ ├── cut_size +│ ├── edge_expansion +│ ├── mixing_expansion +│ ├── node_expansion +│ ├── normalized_cut_size +│ └── volume +├── dag +│ ├── ancestors +│ └── descendants +├── dominating +│ └── is_dominating_set +├── efficiency_measures +│ └── efficiency +├── generators +│ └── ego +│ └── ego_graph +├── isolate +│ ├── is_isolate +│ ├── isolates +│ └── number_of_isolates +├── isomorphism +│ └── isomorph +│ ├── fast_could_be_isomorphic +│ └── faster_could_be_isomorphic +├── linalg +│ ├── bethehessianmatrix +│ │ └── bethe_hessian_matrix +│ ├── graphmatrix +│ │ └── adjacency_matrix +│ ├── laplacianmatrix +│ │ ├── laplacian_matrix +│ │ └── normalized_laplacian_matrix +│ └── modularitymatrix +│ ├── directed_modularity_matrix +│ └── modularity_matrix +├── link_analysis +│ ├── hits_alg +│ │ └── hits +│ └── pagerank_alg +│ ├── google_matrix +│ └── pagerank +├── lowest_common_ancestors +│ └── lowest_common_ancestor +├── operators +│ ├── binary +│ │ ├── compose +│ │ ├── difference +│ │ ├── disjoint_union +│ │ ├── full_join +│ │ ├── intersection +│ │ ├── symmetric_difference +│ │ └── union +│ └── unary +│ ├── complement +│ └── reverse +├── reciprocity +│ ├── overall_reciprocity +│ └── reciprocity +├── regular +│ ├── is_k_regular +│ └── is_regular +├── shortest_paths +│ ├── dense +│ │ ├── floyd_warshall +│ │ ├── floyd_warshall_numpy +│ │ └── floyd_warshall_predecessor_and_distance +│ ├── generic +│ │ └── has_path +│ ├── unweighted +│ │ ├── all_pairs_shortest_path_length +│ │ ├── single_source_shortest_path_length +│ │ └── single_target_shortest_path_length +│ └── weighted +│ ├── all_pairs_bellman_ford_path_length +│ ├── bellman_ford_path +│ ├── bellman_ford_path_length +│ ├── negative_edge_cycle +│ └── single_source_bellman_ford_path_length +├── simple_paths +│ └── is_simple_path +├── smetric +│ └── s_metric +├── structuralholes +│ └── mutual_weight +├── tournament +│ ├── is_tournament +│ ├── score_sequence +│ └── tournament_matrix +├── traversal +│ └── breadth_first_search +│ ├── bfs_layers +│ └── descendants_at_distance +└── triads + └── is_triad +``` + +[//]: # (End auto-generated code) diff --git a/environment.yml b/environment.yml index 9342aa4..06142d1 100644 --- a/environment.yml +++ b/environment.yml @@ -42,6 +42,8 @@ dependencies: - pydot - pygraphviz - sympy + # For updating algorithm list in README + - rich # For linting - pre-commit # For testing diff --git a/graphblas_algorithms/__init__.py b/graphblas_algorithms/__init__.py index 09418f5..e86efa9 100644 --- a/graphblas_algorithms/__init__.py +++ b/graphblas_algorithms/__init__.py @@ -1,9 +1,10 @@ import importlib.metadata from .classes import * +from .generators import * +from .linalg import * from .algorithms import * # isort:skip -from .generators import * # isort:skip try: __version__ = importlib.metadata.version("graphblas-algorithms") diff --git a/graphblas_algorithms/algorithms/__init__.py b/graphblas_algorithms/algorithms/__init__.py index 37a47a3..be06324 100644 --- a/graphblas_algorithms/algorithms/__init__.py +++ b/graphblas_algorithms/algorithms/__init__.py @@ -8,8 +8,11 @@ from .cuts import * from .dag import * from .dominating import * +from .efficiency_measures import * from .isolate import * +from .isomorphism import * from .link_analysis import * +from .lowest_common_ancestors import * from .operators import * from .reciprocity import * from .regular import * diff --git a/graphblas_algorithms/algorithms/_bfs.py b/graphblas_algorithms/algorithms/_bfs.py index 49ba735..8189aae 100644 --- a/graphblas_algorithms/algorithms/_bfs.py +++ b/graphblas_algorithms/algorithms/_bfs.py @@ -11,16 +11,25 @@ def _get_cutoff(n, cutoff): return cutoff + 1 # Inclusive -def _bfs_plain(G, source=None, target=None, *, index=None, cutoff=None): +# Push-pull optimization is possible, but annoying to implement +def _bfs_plain( + G, source=None, target=None, *, index=None, cutoff=None, transpose=False, name="bfs_plain" +): if source is not None: + if source not in G._key_to_id: + raise KeyError(f"The node {source} is not in the graph") index = G._key_to_id[source] if target is not None: + if target not in G._key_to_id: + raise KeyError(f"The node {target} is not in the graph") dst_id = G._key_to_id[target] else: dst_id = None A = G.get_property("offdiag") + if transpose and G.is_directed(): + A = A.T # TODO: should we use "AT" instead? n = A.nrows - v = Vector(bool, n, name="bfs_plain") + v = Vector(bool, n, name=name) q = Vector(bool, n, name="q") v[index] = True q[index] = True @@ -30,16 +39,22 @@ def _bfs_plain(G, source=None, target=None, *, index=None, cutoff=None): q(~v.S, replace) << any_pair_bool(q @ A) if q.nvals == 0: break + v(q.S) << True if dst_id is not None and dst_id in q: break - v(q.S) << True return v -def _bfs_level(G, source, cutoff=None, *, transpose=False, dtype=int): +def _bfs_level(G, source, target=None, *, cutoff=None, transpose=False, dtype=int): if dtype == bool: dtype = int index = G._key_to_id[source] + if target is not None: + if target not in G._key_to_id: + raise KeyError(f"The node {target} is not in the graph") + dst_id = G._key_to_id[target] + else: + dst_id = None A = G.get_property("offdiag") if transpose and G.is_directed(): A = A.T # TODO: should we use "AT" instead? @@ -55,10 +70,12 @@ def _bfs_level(G, source, cutoff=None, *, transpose=False, dtype=int): if q.nvals == 0: break v(q.S) << i + if dst_id is not None and dst_id in q: + break return v -def _bfs_levels(G, nodes, cutoff=None, *, dtype=int): +def _bfs_levels(G, nodes, *, cutoff=None, dtype=int): if dtype == bool: dtype = int A = G.get_property("offdiag") @@ -90,7 +107,7 @@ def _bfs_levels(G, nodes, cutoff=None, *, dtype=int): return D -def _bfs_parent(G, source, cutoff=None, *, target=None, transpose=False, dtype=int): +def _bfs_parent(G, source, target=None, *, cutoff=None, transpose=False, dtype=int): if dtype == bool: dtype = int index = G._key_to_id[source] diff --git a/graphblas_algorithms/algorithms/dag.py b/graphblas_algorithms/algorithms/dag.py index c75dae1..63eb560 100644 --- a/graphblas_algorithms/algorithms/dag.py +++ b/graphblas_algorithms/algorithms/dag.py @@ -1,41 +1,17 @@ -from graphblas import Vector, replace -from graphblas.semiring import any_pair +from ._bfs import _bfs_plain __all__ = ["descendants", "ancestors"] -# Push-pull optimization is possible, but annoying to implement def descendants(G, source): - if source not in G._key_to_id: - raise KeyError(f"The node {source} is not in the graph") + rv = _bfs_plain(G, source, name="descendants") index = G._key_to_id[source] - A = G.get_property("offdiag") - q = Vector(bool, size=A.nrows, name="q") - q[index] = True - rv = q.dup(name="descendants") - any_pair_bool = any_pair[bool] - for _i in range(A.nrows): - q(~rv.S, replace) << any_pair_bool(q @ A) - if q.nvals == 0: - break - rv(q.S) << True del rv[index] return rv def ancestors(G, source): - if source not in G._key_to_id: - raise KeyError(f"The node {source} is not in the graph") + rv = _bfs_plain(G, source, transpose=True, name="ancestors") index = G._key_to_id[source] - A = G.get_property("offdiag") - q = Vector(bool, size=A.nrows, name="q") - q[index] = True - rv = q.dup(name="descendants") - any_pair_bool = any_pair[bool] - for _i in range(A.nrows): - q(~rv.S, replace) << any_pair_bool(A @ q) - if q.nvals == 0: - break - rv(q.S) << True del rv[index] return rv diff --git a/graphblas_algorithms/algorithms/efficiency_measures.py b/graphblas_algorithms/algorithms/efficiency_measures.py new file mode 100644 index 0000000..3d922ee --- /dev/null +++ b/graphblas_algorithms/algorithms/efficiency_measures.py @@ -0,0 +1,12 @@ +from .exceptions import NoPath +from .shortest_paths.unweighted import bidirectional_shortest_path_length + +__all__ = ["efficiency"] + + +def efficiency(G, u, v): + try: + eff = 1 / bidirectional_shortest_path_length(G, u, v) + except NoPath: + eff = 0 + return eff diff --git a/graphblas_algorithms/algorithms/isomorphism/__init__.py b/graphblas_algorithms/algorithms/isomorphism/__init__.py new file mode 100644 index 0000000..e701b70 --- /dev/null +++ b/graphblas_algorithms/algorithms/isomorphism/__init__.py @@ -0,0 +1 @@ +from .isomorph import * diff --git a/graphblas_algorithms/algorithms/isomorphism/isomorph.py b/graphblas_algorithms/algorithms/isomorphism/isomorph.py new file mode 100644 index 0000000..12e5af4 --- /dev/null +++ b/graphblas_algorithms/algorithms/isomorphism/isomorph.py @@ -0,0 +1,56 @@ +import numpy as np +from graphblas import binary + +from ..cluster import triangles + +__all__ = [ + "fast_could_be_isomorphic", + "faster_could_be_isomorphic", +] + + +def fast_could_be_isomorphic(G1, G2): + if len(G1) != len(G2): + return False + d1 = G1.get_property("total_degrees+" if G1.is_directed() else "degrees+") + d2 = G2.get_property("total_degrees+" if G2.is_directed() else "degrees+") + if d1.nvals != d2.nvals: + return False + t1 = triangles(G1) + t2 = triangles(G2) + if t1.nvals != t2.nvals: + return False + # Make ds and ts the same shape as numpy arrays so we can sort them lexicographically. + if t1.nvals != d1.nvals: + # Assign 0 to t1 where present in d1 but not t1 + t1(~t1.S) << binary.second(d1, 0) + if t2.nvals != d2.nvals: + # Assign 0 to t2 where present in d2 but not t2 + t2(~t2.S) << binary.second(d2, 0) + d1 = d1.to_coo(indices=False)[1] + d2 = d2.to_coo(indices=False)[1] + t1 = t1.to_coo(indices=False)[1] + t2 = t2.to_coo(indices=False)[1] + ind1 = np.lexsort((d1, t1)) + ind2 = np.lexsort((d2, t2)) + if not np.array_equal(d1[ind1], d2[ind2]): + return False + if not np.array_equal(t1[ind1], t2[ind2]): + return False + return True + + +def faster_could_be_isomorphic(G1, G2): + if len(G1) != len(G2): + return False + d1 = G1.get_property("total_degrees+" if G1.is_directed() else "degrees+") + d2 = G2.get_property("total_degrees+" if G2.is_directed() else "degrees+") + if d1.nvals != d2.nvals: + return False + d1 = d1.to_coo(indices=False)[1] + d2 = d2.to_coo(indices=False)[1] + d1.sort() + d2.sort() + if not np.array_equal(d1, d2): + return False + return True diff --git a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py index d665e98..7391dbe 100644 --- a/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py +++ b/graphblas_algorithms/algorithms/link_analysis/pagerank_alg.py @@ -1,4 +1,3 @@ -import numpy as np from graphblas import Matrix, Vector, binary, monoid from graphblas.semiring import plus_first, plus_times @@ -113,7 +112,6 @@ def google_matrix( A = G._A ids = G.list_to_ids(nodelist) if ids is not None: - ids = np.array(ids, np.uint64) A = A[ids, ids].new(float, name=name) else: A = A.dup(float, name=name) diff --git a/graphblas_algorithms/algorithms/lowest_common_ancestors.py b/graphblas_algorithms/algorithms/lowest_common_ancestors.py new file mode 100644 index 0000000..0dfac19 --- /dev/null +++ b/graphblas_algorithms/algorithms/lowest_common_ancestors.py @@ -0,0 +1,21 @@ +from graphblas import binary, replace +from graphblas.semiring import any_pair + +from ._bfs import _bfs_plain + +__all__ = ["lowest_common_ancestor"] + + +def lowest_common_ancestor(G, node1, node2, default=None): + common_ancestors = _bfs_plain(G, node1, name="common_ancestors", transpose=True) + other_ancestors = _bfs_plain(G, node2, name="other_ancestors", transpose=True) + common_ancestors << binary.pair(common_ancestors & other_ancestors) + if common_ancestors.nvals == 0: + return default + # Take one BFS step along predecessors. The lowest common ancestor is one we don't visit. + # An alternative strategy would be to walk along successors until there are no more. + other_ancestors(common_ancestors.S, replace) << any_pair[bool](G._A @ common_ancestors) + common_ancestors(~other_ancestors.S, replace) << common_ancestors + index = common_ancestors.to_coo(values=False)[0][0] + # XXX: should we return index or key? + return G.id_to_key[index] diff --git a/graphblas_algorithms/algorithms/operators/__init__.py b/graphblas_algorithms/algorithms/operators/__init__.py index 6e308b5..c2742b9 100644 --- a/graphblas_algorithms/algorithms/operators/__init__.py +++ b/graphblas_algorithms/algorithms/operators/__init__.py @@ -1 +1,2 @@ from .binary import * +from .unary import * diff --git a/graphblas_algorithms/algorithms/operators/binary.py b/graphblas_algorithms/algorithms/operators/binary.py index b2b19ca..11b5b19 100644 --- a/graphblas_algorithms/algorithms/operators/binary.py +++ b/graphblas_algorithms/algorithms/operators/binary.py @@ -1,4 +1,3 @@ -import numpy as np from graphblas import Matrix, binary, dtypes, unary from ..exceptions import GraphBlasAlgorithmException @@ -63,9 +62,9 @@ def intersection(G, H, *, name="intersection"): if G.is_multigraph(): raise NotImplementedError("Not yet implemented for multigraphs") keys = sorted(G._key_to_id.keys() & H._key_to_id.keys(), key=G._key_to_id.__getitem__) - ids = np.array(G.list_to_ids(keys), np.uint64) + ids = G.list_to_ids(keys) A = G._A[ids, ids].new() - ids = np.array(H.list_to_ids(keys), np.uint64) + ids = H.list_to_ids(keys) B = H._A[ids, ids].new(dtypes.unify(A.dtype, H._A.dtype), mask=A.S, name=name) B << unary.one(B) return type(G)(B, key_to_id=dict(zip(keys, range(len(keys))))) @@ -84,7 +83,7 @@ def difference(G, H, *, name="difference"): else: # Need to perform a permutation keys = sorted(G._key_to_id, key=G._key_to_id.__getitem__) - ids = np.array(H.list_to_ids(keys), np.uint64) + ids = H.list_to_ids(keys) B = H._A[ids, ids].new() C = unary.one(A).new(mask=~B.S, name=name) return type(G)(C, key_to_id=G._key_to_id) @@ -103,7 +102,7 @@ def symmetric_difference(G, H, *, name="symmetric_difference"): else: # Need to perform a permutation keys = sorted(G._key_to_id, key=G._key_to_id.__getitem__) - ids = np.array(H.list_to_ids(keys), np.uint64) + ids = H.list_to_ids(keys) B = H._A[ids, ids].new() Mask = binary.pair[bool](A & B).new(name="mask") C = binary.pair(A | B, left_default=True, right_default=True).new(mask=~Mask.S, name=name) @@ -121,7 +120,7 @@ def compose(G, H, *, name="compose"): if G._key_to_id != H._key_to_id: # Need to perform a permutation keys = sorted(G._key_to_id, key=G._key_to_id.__getitem__) - ids = np.array(H.list_to_ids(keys), np.uint64) + ids = H.list_to_ids(keys) B = B[ids, ids].new() C = binary.second(A | B).new(name=name) key_to_id = G._key_to_id @@ -135,11 +134,11 @@ def compose(G, H, *, name="compose"): name=name, ) C[: A.nrows, : A.ncols] = A - ids1 = np.array(G.list_to_ids(keys), np.uint64) - ids2 = np.array(H.list_to_ids(keys), np.uint64) + ids1 = G.list_to_ids(keys) + ids2 = H.list_to_ids(keys) C[ids1, ids1] = B[ids2, ids2] newkeys = sorted(H._key_to_id.keys() - G._key_to_id.keys(), key=H._key_to_id.__getitem__) - ids = np.array(H.list_to_ids(newkeys), np.uint64) + ids = H.list_to_ids(newkeys) C[A.nrows :, A.ncols :] = B[ids, ids] # Now make new `key_to_id` ids += A.nrows diff --git a/graphblas_algorithms/algorithms/operators/unary.py b/graphblas_algorithms/algorithms/operators/unary.py new file mode 100644 index 0000000..e7c46d6 --- /dev/null +++ b/graphblas_algorithms/algorithms/operators/unary.py @@ -0,0 +1,18 @@ +from graphblas import select + +from ..exceptions import GraphBlasAlgorithmException + +__all__ = ["complement", "reverse"] + + +def complement(G, *, name="complement"): + A = G._A + R = (~A.S).new(A.dtype, name=name) + R << select.offdiag(R) + return type(G)(R, key_to_id=G._key_to_id) + + +def reverse(G, copy=True): + if not G.is_directed(): + raise GraphBlasAlgorithmException("Cannot reverse an undirected graph.") + return G.reverse(copy=copy) diff --git a/graphblas_algorithms/algorithms/shortest_paths/generic.py b/graphblas_algorithms/algorithms/shortest_paths/generic.py index b92f7d6..ef86f89 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/generic.py +++ b/graphblas_algorithms/algorithms/shortest_paths/generic.py @@ -1,36 +1,12 @@ -from graphblas import Vector, replace -from graphblas.semiring import any_pair +from ..exceptions import NoPath +from .unweighted import bidirectional_shortest_path_length __all__ = ["has_path"] def has_path(G, source, target): - # Perform bidirectional BFS from source to target and target to source - src = G._key_to_id[source] - dst = G._key_to_id[target] - if src == dst: - return True - A = G.get_property("offdiag") - q_src = Vector(bool, size=A.nrows, name="q_src") - q_src[src] = True - seen_src = q_src.dup(name="seen_src") - q_dst = Vector(bool, size=A.nrows, name="q_dst") - q_dst[dst] = True - seen_dst = q_dst.dup(name="seen_dst", clear=True) - any_pair_bool = any_pair[bool] - for _i in range(A.nrows // 2): - q_src(~seen_src.S, replace) << any_pair_bool(q_src @ A) - if q_src.nvals == 0: - return False - if any_pair_bool(q_src @ q_dst): - return True - - seen_dst(q_dst.S) << True - q_dst(~seen_dst.S, replace) << any_pair_bool(A @ q_dst) - if q_dst.nvals == 0: - return False - if any_pair_bool(q_src @ q_dst): - return True - - seen_src(q_src.S) << True - return False + try: + bidirectional_shortest_path_length(G, source, target) + except NoPath: + return False + return True diff --git a/graphblas_algorithms/algorithms/shortest_paths/unweighted.py b/graphblas_algorithms/algorithms/shortest_paths/unweighted.py index 5062e87..ec87b65 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/unweighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/unweighted.py @@ -1,6 +1,8 @@ -from graphblas import Matrix +from graphblas import Matrix, Vector, replace +from graphblas.semiring import any_pair from .._bfs import _bfs_level, _bfs_levels +from ..exceptions import NoPath __all__ = [ "single_source_shortest_path_length", @@ -10,18 +12,53 @@ def single_source_shortest_path_length(G, source, cutoff=None): - return _bfs_level(G, source, cutoff) + return _bfs_level(G, source, cutoff=cutoff) def single_target_shortest_path_length(G, target, cutoff=None): - return _bfs_level(G, target, cutoff, transpose=True) + return _bfs_level(G, target, cutoff=cutoff, transpose=True) def all_pairs_shortest_path_length(G, cutoff=None, *, nodes=None, expand_output=False): - D = _bfs_levels(G, nodes, cutoff) + D = _bfs_levels(G, nodes, cutoff=cutoff) if nodes is not None and expand_output and D.ncols != D.nrows: ids = G.list_to_ids(nodes) rv = Matrix(D.dtype, D.ncols, D.ncols, name=D.name) rv[ids, :] = D return rv return D + + +def bidirectional_shortest_path_length(G, source, target): + # Perform bidirectional BFS from source to target and target to source + # TODO: have this raise NodeNotFound? + if source not in G or target not in G: + raise KeyError(f"Either source {source} or target {target} is not in G") # NodeNotFound + src = G._key_to_id[source] + dst = G._key_to_id[target] + if src == dst: + return 0 + A = G.get_property("offdiag") + q_src = Vector(bool, size=A.nrows, name="q_src") + q_src[src] = True + seen_src = q_src.dup(name="seen_src") + q_dst = Vector(bool, size=A.nrows, name="q_dst") + q_dst[dst] = True + seen_dst = q_dst.dup(name="seen_dst", clear=True) + any_pair_bool = any_pair[bool] + for i in range(1, A.nrows + 1, 2): + q_src(~seen_src.S, replace) << any_pair_bool(q_src @ A) + if q_src.nvals == 0: + raise NoPath(f"No path between {source} and {target}.") + if any_pair_bool(q_src @ q_dst): + return i + + seen_dst(q_dst.S) << True + q_dst(~seen_dst.S, replace) << any_pair_bool(A @ q_dst) + if q_dst.nvals == 0: + raise NoPath(f"No path between {source} and {target}.") + if any_pair_bool(q_src @ q_dst): + return i + 1 + + seen_src(q_src.S) << True + raise NoPath(f"No path between {source} and {target}.") diff --git a/graphblas_algorithms/algorithms/shortest_paths/weighted.py b/graphblas_algorithms/algorithms/shortest_paths/weighted.py index d2a9f0e..5afa0f4 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/weighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/weighted.py @@ -3,40 +3,55 @@ from graphblas.semiring import any_pair, min_plus from .._bfs import _bfs_level, _bfs_levels, _bfs_parent, _bfs_plain -from ..exceptions import Unbounded +from ..exceptions import NoPath, Unbounded __all__ = [ "single_source_bellman_ford_path_length", "bellman_ford_path", + "bellman_ford_path_length", "bellman_ford_path_lengths", "negative_edge_cycle", ] -def single_source_bellman_ford_path_length(G, source, *, cutoff=None): +def _bellman_ford_path_length(G, source, target=None, *, cutoff=None, name): # No need for `is_weighted=` keyword, b/c this is assumed to be weighted (I think) - index = G._key_to_id[source] + src_id = G._key_to_id[source] + if target is not None: + dst_id = G._key_to_id[target] + else: + dst_id = None + if G.get_property("is_iso"): # If the edges are iso-valued (and positive), then we can simply do level BFS is_negative, iso_value = G.get_properties("has_negative_edges+ iso_value") if not is_negative: if cutoff is not None: cutoff = int(cutoff // iso_value) - d = _bfs_level(G, source, cutoff, dtype=iso_value.dtype) + d = _bfs_level(G, source, target, cutoff=cutoff, dtype=iso_value.dtype) + if dst_id is not None: + d = d.get(dst_id) + if d is None: + raise NoPath(f"node {target} not reachable from {source}") if iso_value != 1: d *= iso_value return d # It's difficult to detect negative cycles with BFS - if G._A[index, index].get() is not None: + if G._A[src_id, src_id].get() is not None: raise Unbounded("Negative cycle detected.") - if not G.is_directed() and G._A[index, :].nvals > 0: + if not G.is_directed() and G._A[src_id, :].nvals > 0: # For undirected graphs, any negative edge is a cycle raise Unbounded("Negative cycle detected.") # Use `offdiag` instead of `A`, b/c self-loops don't contribute to the result, # and negative self-loops are easy negative cycles to avoid. # We check if we hit a self-loop negative cycle at the end. - A, has_negative_diagonal = G.get_properties("offdiag has_negative_diagonal") + if dst_id is None: + A, has_negative_diagonal = G.get_properties("offdiag has_negative_diagonal") + else: + A, is_negative, has_negative_diagonal = G.get_properties( + "offdiag has_negative_edges- has_negative_diagonal" + ) if A.dtype == bool: # Should we upcast e.g. INT8 to INT64 as well? dtype = int @@ -44,7 +59,7 @@ def single_source_bellman_ford_path_length(G, source, *, cutoff=None): dtype = A.dtype n = A.nrows d = Vector(dtype, n, name="single_source_bellman_ford_path_length") - d[index] = 0 + d[src_id] = 0 cur = d.dup(name="cur") mask = Vector(bool, n, name="mask") one = unary.one[bool] @@ -66,13 +81,16 @@ def single_source_bellman_ford_path_length(G, source, *, cutoff=None): break # Update `d` with values that improved d(cur.S) << cur + if dst_id is not None and not is_negative: + # Limit exploration if we have a target + cutoff = cur.get(dst_id, cutoff) else: # Check for negative cycle when for loop completes without breaking cur << min_plus(cur @ A) if cutoff is not None: cur << select.valuele(cur, cutoff) mask << binary.lt(cur & d) - if mask.reduce(monoid.lor): + if dst_id is None and mask.reduce(monoid.lor) or dst_id is not None and mask.get(dst_id): raise Unbounded("Negative cycle detected.") if has_negative_diagonal: # We removed diagonal entries above, so check if we visited one with a negative weight @@ -80,9 +98,23 @@ def single_source_bellman_ford_path_length(G, source, *, cutoff=None): cur << select.valuelt(diag, 0) if any_pair(d @ cur): raise Unbounded("Negative cycle detected.") + if dst_id is not None: + d = d.get(dst_id) + if d is None: + raise NoPath(f"node {target} not reachable from {source}") return d +def single_source_bellman_ford_path_length( + G, source, *, cutoff=None, name="single_source_bellman_ford_path_length" +): + return _bellman_ford_path_length(G, source, cutoff=cutoff, name=name) + + +def bellman_ford_path_length(G, source, target): + return _bellman_ford_path_length(G, source, target, name="bellman_ford_path_length") + + def bellman_ford_path_lengths(G, nodes=None, *, expand_output=False): """Extra parameter: expand_output @@ -185,7 +217,7 @@ def bellman_ford_path(G, source, target): # If the edges are iso-valued (and positive), then we can simply do level BFS is_negative = G.get_property("has_negative_edges+") if not is_negative: - p = _bfs_parent(G, source, target=target) + p = _bfs_parent(G, source, target) return _reconstruct_path_from_parents(G, p, src_id, dst_id) raise Unbounded("Negative cycle detected.") A, is_negative, has_negative_diagonal = G.get_properties( diff --git a/graphblas_algorithms/algorithms/traversal/breadth_first_search.py b/graphblas_algorithms/algorithms/traversal/breadth_first_search.py index e9be539..a761134 100644 --- a/graphblas_algorithms/algorithms/traversal/breadth_first_search.py +++ b/graphblas_algorithms/algorithms/traversal/breadth_first_search.py @@ -11,7 +11,7 @@ def bfs_layers(G, sources): if sources in G: sources = [sources] ids = G.list_to_ids(sources) - if not ids: + if ids is None or len(ids) == 0: return A = G.get_property("offdiag") n = A.nrows diff --git a/graphblas_algorithms/classes/_utils.py b/graphblas_algorithms/classes/_utils.py index 65ae010..d15a188 100644 --- a/graphblas_algorithms/classes/_utils.py +++ b/graphblas_algorithms/classes/_utils.py @@ -85,7 +85,7 @@ def list_to_ids(self, nodes): if nodes is None: return None key_to_id = self._key_to_id - return [key_to_id[key] for key in nodes] + return np.fromiter((key_to_id[key] for key in nodes), np.uint64) def list_to_keys(self, indices): diff --git a/graphblas_algorithms/classes/digraph.py b/graphblas_algorithms/classes/digraph.py index bae66ae..8da9c8a 100644 --- a/graphblas_algorithms/classes/digraph.py +++ b/graphblas_algorithms/classes/digraph.py @@ -1,4 +1,5 @@ from collections import defaultdict +from copy import deepcopy import graphblas as gb from graphblas import Matrix, binary, replace, select, unary @@ -609,6 +610,15 @@ def to_undirected(self, reciprocal=False, as_view=False, *, name=None): B = binary.any(A | A.T).new(name=name) return Graph(B, key_to_id=self._key_to_id) + def reverse(self, copy=True): + # We could even re-use many of the cached values + A = self._A.T # This probably mostly works, but does not yet support assignment + if copy: + A = A.new() + rv = type(self)(A, key_to_id=self._key_to_id) + rv.graph.update(deepcopy(self.graph)) + return rv + class MultiDiGraph(DiGraph): def is_multigraph(self): diff --git a/graphblas_algorithms/interface.py b/graphblas_algorithms/interface.py index d0a8365..a43b520 100644 --- a/graphblas_algorithms/interface.py +++ b/graphblas_algorithms/interface.py @@ -1,111 +1,174 @@ from . import nxapi ####### -# NOTE: Remember to update README.md when adding or removing algorithms from Dispatcher +# NOTE: Remember to run `python scripts/maketree.py` when adding or removing algorithms +# to automatically add it to README.md. You must still add algorithms below. ####### class Dispatcher: - # Boundary - edge_boundary = nxapi.boundary.edge_boundary - node_boundary = nxapi.boundary.node_boundary - # Centrality - degree_centrality = nxapi.centrality.degree_alg.degree_centrality - eigenvector_centrality = nxapi.centrality.eigenvector.eigenvector_centrality - in_degree_centrality = nxapi.centrality.degree_alg.in_degree_centrality - katz_centrality = nxapi.centrality.katz.katz_centrality - out_degree_centrality = nxapi.centrality.degree_alg.out_degree_centrality - # Cluster - average_clustering = nxapi.cluster.average_clustering - clustering = nxapi.cluster.clustering - generalized_degree = nxapi.cluster.generalized_degree - square_clustering = nxapi.cluster.square_clustering - transitivity = nxapi.cluster.transitivity - triangles = nxapi.cluster.triangles - # Community - inter_community_edges = nxapi.community.quality.inter_community_edges - intra_community_edges = nxapi.community.quality.intra_community_edges - # Components - is_connected = nxapi.components.connected.is_connected - node_connected_component = nxapi.components.connected.node_connected_component - is_weakly_connected = nxapi.components.weakly_connected.is_weakly_connected - # Core - k_truss = nxapi.core.k_truss - # Cuts - boundary_expansion = nxapi.cuts.boundary_expansion - conductance = nxapi.cuts.conductance - cut_size = nxapi.cuts.cut_size - edge_expansion = nxapi.cuts.edge_expansion - mixing_expansion = nxapi.cuts.mixing_expansion - node_expansion = nxapi.cuts.node_expansion - normalized_cut_size = nxapi.cuts.normalized_cut_size - volume = nxapi.cuts.volume - # DAG - ancestors = nxapi.dag.ancestors - descendants = nxapi.dag.descendants - # Dominating - is_dominating_set = nxapi.dominating.is_dominating_set - # Generators - ego_graph = nxapi.generators.ego.ego_graph - # Isolate - is_isolate = nxapi.isolate.is_isolate - isolates = nxapi.isolate.isolates - number_of_isolates = nxapi.isolate.number_of_isolates - # Link Analysis - hits = nxapi.link_analysis.hits_alg.hits - google_matrix = nxapi.link_analysis.pagerank_alg.google_matrix - pagerank = nxapi.link_analysis.pagerank_alg.pagerank - # Operators - compose = nxapi.operators.binary.compose - difference = nxapi.operators.binary.difference - disjoint_union = nxapi.operators.binary.disjoint_union - full_join = nxapi.operators.binary.full_join - intersection = nxapi.operators.binary.intersection - symmetric_difference = nxapi.operators.binary.symmetric_difference - union = nxapi.operators.binary.union - # Reciprocity + # Begin auto-generated code: dispatch + mod = nxapi.boundary + # ================== + edge_boundary = mod.edge_boundary + node_boundary = mod.node_boundary + + mod = nxapi.centrality + # ==================== + degree_centrality = mod.degree_alg.degree_centrality + in_degree_centrality = mod.degree_alg.in_degree_centrality + out_degree_centrality = mod.degree_alg.out_degree_centrality + eigenvector_centrality = mod.eigenvector.eigenvector_centrality + katz_centrality = mod.katz.katz_centrality + + mod = nxapi.cluster + # ================= + average_clustering = mod.average_clustering + clustering = mod.clustering + generalized_degree = mod.generalized_degree + square_clustering = mod.square_clustering + transitivity = mod.transitivity + triangles = mod.triangles + + mod = nxapi.community + # =================== + inter_community_edges = mod.quality.inter_community_edges + intra_community_edges = mod.quality.intra_community_edges + + mod = nxapi.components + # ==================== + is_connected = mod.connected.is_connected + node_connected_component = mod.connected.node_connected_component + is_weakly_connected = mod.weakly_connected.is_weakly_connected + + mod = nxapi.core + # ============== + k_truss = mod.k_truss + + mod = nxapi.cuts + # ============== + boundary_expansion = mod.boundary_expansion + conductance = mod.conductance + cut_size = mod.cut_size + edge_expansion = mod.edge_expansion + mixing_expansion = mod.mixing_expansion + node_expansion = mod.node_expansion + normalized_cut_size = mod.normalized_cut_size + volume = mod.volume + + mod = nxapi.dag + # ============= + ancestors = mod.ancestors + descendants = mod.descendants + + mod = nxapi.dominating + # ==================== + is_dominating_set = mod.is_dominating_set + + mod = nxapi.efficiency_measures + # ============================= + efficiency = mod.efficiency + + mod = nxapi.generators + # ==================== + ego_graph = mod.ego.ego_graph + + mod = nxapi.isolate + # ================= + is_isolate = mod.is_isolate + isolates = mod.isolates + number_of_isolates = mod.number_of_isolates + + mod = nxapi.isomorphism + # ===================== + fast_could_be_isomorphic = mod.isomorph.fast_could_be_isomorphic + faster_could_be_isomorphic = mod.isomorph.faster_could_be_isomorphic + + mod = nxapi.linalg + # ================ + bethe_hessian_matrix = mod.bethehessianmatrix.bethe_hessian_matrix + adjacency_matrix = mod.graphmatrix.adjacency_matrix + laplacian_matrix = mod.laplacianmatrix.laplacian_matrix + normalized_laplacian_matrix = mod.laplacianmatrix.normalized_laplacian_matrix + directed_modularity_matrix = mod.modularitymatrix.directed_modularity_matrix + modularity_matrix = mod.modularitymatrix.modularity_matrix + + mod = nxapi.link_analysis + # ======================= + hits = mod.hits_alg.hits + google_matrix = mod.pagerank_alg.google_matrix + pagerank = mod.pagerank_alg.pagerank + + mod = nxapi.lowest_common_ancestors + # ================================= + lowest_common_ancestor = mod.lowest_common_ancestor + + mod = nxapi.operators + # =================== + compose = mod.binary.compose + difference = mod.binary.difference + disjoint_union = mod.binary.disjoint_union + full_join = mod.binary.full_join + intersection = mod.binary.intersection + symmetric_difference = mod.binary.symmetric_difference + union = mod.binary.union + complement = mod.unary.complement + reverse = mod.unary.reverse + + mod = nxapi.reciprocity + # ===================== overall_reciprocity = nxapi.overall_reciprocity reciprocity = nxapi.reciprocity - # Regular - is_k_regular = nxapi.regular.is_k_regular - is_regular = nxapi.regular.is_regular - # Shortest Paths - floyd_warshall = nxapi.shortest_paths.dense.floyd_warshall - floyd_warshall_numpy = nxapi.shortest_paths.dense.floyd_warshall_numpy - floyd_warshall_predecessor_and_distance = ( - nxapi.shortest_paths.dense.floyd_warshall_predecessor_and_distance - ) - has_path = nxapi.shortest_paths.generic.has_path - single_source_shortest_path_length = ( - nxapi.shortest_paths.unweighted.single_source_shortest_path_length - ) - single_target_shortest_path_length = ( - nxapi.shortest_paths.unweighted.single_target_shortest_path_length - ) - all_pairs_shortest_path_length = nxapi.shortest_paths.unweighted.all_pairs_shortest_path_length - bellman_ford_path = nxapi.shortest_paths.weighted.bellman_ford_path - all_pairs_bellman_ford_path_length = ( - nxapi.shortest_paths.weighted.all_pairs_bellman_ford_path_length - ) - negative_edge_cycle = nxapi.shortest_paths.weighted.negative_edge_cycle - single_source_bellman_ford_path_length = ( - nxapi.shortest_paths.weighted.single_source_bellman_ford_path_length - ) - # Simple Paths - is_simple_path = nxapi.simple_paths.is_simple_path - # S Metric - s_metric = nxapi.smetric.s_metric - # Structural Holes - mutual_weight = nxapi.structuralholes.mutual_weight - # Tournament - is_tournament = nxapi.tournament.is_tournament - score_sequence = nxapi.tournament.score_sequence - tournament_matrix = nxapi.tournament.tournament_matrix - # Traversal - bfs_layers = nxapi.traversal.breadth_first_search.bfs_layers - descendants_at_distance = nxapi.traversal.breadth_first_search.descendants_at_distance - # Triads - is_triad = nxapi.triads.is_triad + + mod = nxapi.regular + # ================= + is_k_regular = mod.is_k_regular + is_regular = mod.is_regular + + mod = nxapi.shortest_paths + # ======================== + floyd_warshall = mod.dense.floyd_warshall + floyd_warshall_numpy = mod.dense.floyd_warshall_numpy + floyd_warshall_predecessor_and_distance = mod.dense.floyd_warshall_predecessor_and_distance + has_path = mod.generic.has_path + all_pairs_shortest_path_length = mod.unweighted.all_pairs_shortest_path_length + single_source_shortest_path_length = mod.unweighted.single_source_shortest_path_length + single_target_shortest_path_length = mod.unweighted.single_target_shortest_path_length + all_pairs_bellman_ford_path_length = mod.weighted.all_pairs_bellman_ford_path_length + bellman_ford_path = mod.weighted.bellman_ford_path + bellman_ford_path_length = mod.weighted.bellman_ford_path_length + negative_edge_cycle = mod.weighted.negative_edge_cycle + single_source_bellman_ford_path_length = mod.weighted.single_source_bellman_ford_path_length + + mod = nxapi.simple_paths + # ====================== + is_simple_path = mod.is_simple_path + + mod = nxapi.smetric + # ================= + s_metric = mod.s_metric + + mod = nxapi.structuralholes + # ========================= + mutual_weight = mod.mutual_weight + + mod = nxapi.tournament + # ==================== + is_tournament = mod.is_tournament + score_sequence = mod.score_sequence + tournament_matrix = mod.tournament_matrix + + mod = nxapi.traversal + # =================== + bfs_layers = mod.breadth_first_search.bfs_layers + descendants_at_distance = mod.breadth_first_search.descendants_at_distance + + mod = nxapi.triads + # ================ + is_triad = mod.is_triad + + del mod + # End auto-generated code: dispatch @staticmethod def convert_from_nx(graph, weight=None, *, name=None): @@ -125,14 +188,30 @@ def convert_from_nx(graph, weight=None, *, name=None): @staticmethod def convert_to_nx(obj, *, name=None): - from graphblas import Matrix + from graphblas import Matrix, io from .classes import Graph if isinstance(obj, Graph): obj = obj.to_networkx() elif isinstance(obj, Matrix): - obj = obj.to_dense(fill_value=False) + if name in { + "adjacency_matrix", + "bethe_hessian_matrix", + "laplacian_matrix", + "normalized_laplacian_matrix", + "tournament_matrix", + }: + obj = io.to_scipy_sparse(obj) + elif name in { + "directed_modularity_matrix", + "floyd_warshall_numpy", + "google_matrix", + "modularity_matrix", + }: + obj = obj.to_dense(fill_value=False) + else: # pragma: no cover + raise RuntimeError(f"Should {name} return a numpy or scipy.sparse array?") return obj @staticmethod diff --git a/graphblas_algorithms/linalg/__init__.py b/graphblas_algorithms/linalg/__init__.py new file mode 100644 index 0000000..5fb0b2b --- /dev/null +++ b/graphblas_algorithms/linalg/__init__.py @@ -0,0 +1,4 @@ +from .bethehessianmatrix import * +from .graphmatrix import * +from .laplacianmatrix import * +from .modularitymatrix import * diff --git a/graphblas_algorithms/linalg/bethehessianmatrix.py b/graphblas_algorithms/linalg/bethehessianmatrix.py new file mode 100644 index 0000000..edd000f --- /dev/null +++ b/graphblas_algorithms/linalg/bethehessianmatrix.py @@ -0,0 +1,25 @@ +from graphblas import Vector, binary + +__all__ = ["bethe_hessian_matrix"] + + +def bethe_hessian_matrix(G, r=None, nodelist=None, *, name="bethe_hessian_matrix"): + A = G._A + if nodelist is not None: + ids = G.list_to_ids(nodelist) + A = A[ids, ids].new() + d = A.reduce_rowwise().new(name="d") + else: + d = G.get_property("plus_rowwise+") + if r is None: + degrees = G.get_property("degrees+") + k = degrees.reduce().get(0) + k2 = (degrees @ degrees).get(0) + r = k2 / k - 1 + n = A.nrows + # result = (r**2 - 1) * I - r * A + D + ri = Vector.from_scalar(r**2 - 1.0, n, name="ri") + ri += d + rI = ri.diag(name=name) + rI(binary.plus) << binary.times(-r, A) # rI += -r * A + return rI diff --git a/graphblas_algorithms/linalg/graphmatrix.py b/graphblas_algorithms/linalg/graphmatrix.py new file mode 100644 index 0000000..0eff6ef --- /dev/null +++ b/graphblas_algorithms/linalg/graphmatrix.py @@ -0,0 +1,19 @@ +from graphblas import unary + +__all__ = ["adjacency_matrix"] + + +def adjacency_matrix(G, nodelist=None, dtype=None, is_weighted=False, *, name="adjacency_matrix"): + if dtype is None: + dtype = G._A.dtype + if G.is_multigraph(): + is_weighted = True # XXX + if nodelist is None: + if not is_weighted: + return unary.one[dtype](G._A).new(name=name) + return G._A.dup(dtype, name=name) + ids = G.list_to_ids(nodelist) + A = G._A[ids, ids].new(dtype, name=name) + if not is_weighted: + A << unary.one(A) + return A diff --git a/graphblas_algorithms/linalg/laplacianmatrix.py b/graphblas_algorithms/linalg/laplacianmatrix.py new file mode 100644 index 0000000..18ed65a --- /dev/null +++ b/graphblas_algorithms/linalg/laplacianmatrix.py @@ -0,0 +1,54 @@ +from graphblas import monoid, unary + +__all__ = [ + "laplacian_matrix", + "normalized_laplacian_matrix", +] + + +def _laplacian_helper(G, nodelist=None, is_weighted=False): + if G.is_multigraph(): + is_weighted = True # XXX + A = G._A + if nodelist is not None: + ids = G.list_to_ids(nodelist) + A = A[ids, ids].new() + if not is_weighted: + A << unary.one(A) + d = A.reduce_rowwise(monoid.plus).new() + elif is_weighted: + d = G.get_property("plus_rowwise+") + else: + d = G.get_property("degrees+") + A = unary.one(A).new() + return d, A + + +def laplacian_matrix(G, nodelist=None, is_weighted=False, *, name="laplacian_matrix"): + d, A = _laplacian_helper(G, nodelist, is_weighted) + D = d.diag(name="D") + return (D - A).new(name=name) + + +def normalized_laplacian_matrix( + G, nodelist=None, is_weighted=False, *, name="normalized_laplacian_matrix" +): + d, A = _laplacian_helper(G, nodelist, is_weighted) + d_invsqrt = unary.sqrt(d).new(name="d_invsqrt") + d_invsqrt << unary.minv(d_invsqrt) + + # XXX: what if `d` is 0 and `d_invsqrt` is infinity? (not tested) + # d_invsqrt(unary.isinf(d_invsqrt)) << 0 + + # Calculate: A_weighted = D_invsqrt @ A @ D_invsqrt + A_weighted = d_invsqrt.outer(d_invsqrt).new(mask=A.S, name=name) + A_weighted *= A + # Alt (no idea which implementation is better) + # D_invsqrt = d_invsqrt.diag(name="D_invsqrt") + # A_weighted = (D_invsqrt @ A).new(name=name) + # A_weighted @= D_invsqrt + + d_invsqrt << unary.one(d_invsqrt) + D = d_invsqrt.diag(name="D") + A_weighted << D - A_weighted + return A_weighted diff --git a/graphblas_algorithms/linalg/modularitymatrix.py b/graphblas_algorithms/linalg/modularitymatrix.py new file mode 100644 index 0000000..1efff65 --- /dev/null +++ b/graphblas_algorithms/linalg/modularitymatrix.py @@ -0,0 +1,37 @@ +from graphblas import monoid, unary + +from .laplacianmatrix import _laplacian_helper + +__all__ = ["modularity_matrix", "directed_modularity_matrix"] + + +def modularity_matrix(G, nodelist=None, is_weighted=False, *, name="modularity_matrix"): + k, A = _laplacian_helper(G, nodelist, is_weighted) + m = k.reduce().get(0) + X = k.outer(k).new(float, name=name) + X /= m + X << A - X + return X + + +def directed_modularity_matrix( + G, nodelist=None, is_weighted=False, *, name="directed_modularity_matrix" +): + A = G._A + if nodelist is not None: + ids = G.list_to_ids(nodelist) + A = A[ids, ids].new() + if not is_weighted: + A << unary.one(A) + k_out = A.reduce_rowwise(monoid.plus).new() + k_in = A.reduce_columnwise(monoid.plus).new() + elif is_weighted: + k_out, k_in = G.get_properties("plus_rowwise+ plus_columnwise+") + else: + A = unary.one(A).new() + k_out, k_in = G.get_properties("row_degrees+ column_degrees+") + m = k_out.reduce().get(0) + X = k_out.outer(k_in).new(float, name=name) + X /= m + X << A - X + return X diff --git a/graphblas_algorithms/nxapi/__init__.py b/graphblas_algorithms/nxapi/__init__.py index 2d36017..97d4249 100644 --- a/graphblas_algorithms/nxapi/__init__.py +++ b/graphblas_algorithms/nxapi/__init__.py @@ -7,9 +7,13 @@ from .cuts import * from .dag import * from .dominating import * +from .efficiency_measures import * from .generators import * from .isolate import * +from .isomorphism import fast_could_be_isomorphic, faster_could_be_isomorphic +from .linalg import * from .link_analysis import * +from .lowest_common_ancestors import * from .operators import * from .reciprocity import * from .regular import * @@ -19,13 +23,18 @@ from .structuralholes import * from .traversal import * from .triads import * +from .tournament import is_tournament from . import centrality from . import cluster from . import community from . import components +from . import efficiency_measures from . import generators +from . import isomorphism +from . import linalg from . import link_analysis +from . import lowest_common_ancestors from . import operators from . import shortest_paths from . import tournament diff --git a/graphblas_algorithms/nxapi/efficiency_measures.py b/graphblas_algorithms/nxapi/efficiency_measures.py new file mode 100644 index 0000000..06971a2 --- /dev/null +++ b/graphblas_algorithms/nxapi/efficiency_measures.py @@ -0,0 +1,9 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.classes.graph import to_undirected_graph +from graphblas_algorithms.utils import not_implemented_for + + +@not_implemented_for("directed") +def efficiency(G, u, v): + G = to_undirected_graph(G) + return algorithms.efficiency(G, u, v) diff --git a/graphblas_algorithms/nxapi/exception.py b/graphblas_algorithms/nxapi/exception.py index 2630384..0804bb1 100644 --- a/graphblas_algorithms/nxapi/exception.py +++ b/graphblas_algorithms/nxapi/exception.py @@ -5,6 +5,9 @@ class NetworkXError(Exception): pass + class NetworkXNoPath(Exception): + pass + class NetworkXPointlessConcept(Exception): pass @@ -20,6 +23,7 @@ class PowerIterationFailedConvergence(Exception): else: from networkx import ( NetworkXError, + NetworkXNoPath, NetworkXPointlessConcept, NetworkXUnbounded, NodeNotFound, diff --git a/graphblas_algorithms/nxapi/isomorphism/__init__.py b/graphblas_algorithms/nxapi/isomorphism/__init__.py new file mode 100644 index 0000000..e701b70 --- /dev/null +++ b/graphblas_algorithms/nxapi/isomorphism/__init__.py @@ -0,0 +1 @@ +from .isomorph import * diff --git a/graphblas_algorithms/nxapi/isomorphism/isomorph.py b/graphblas_algorithms/nxapi/isomorphism/isomorph.py new file mode 100644 index 0000000..1dedb64 --- /dev/null +++ b/graphblas_algorithms/nxapi/isomorphism/isomorph.py @@ -0,0 +1,25 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.classes.digraph import to_graph + +__all__ = [ + "fast_could_be_isomorphic", + "faster_could_be_isomorphic", +] + + +def fast_could_be_isomorphic(G1, G2): + G1 = to_graph(G1) + G2 = to_graph(G2) + return algorithms.fast_could_be_isomorphic(G1, G2) + + +fast_graph_could_be_isomorphic = fast_could_be_isomorphic + + +def faster_could_be_isomorphic(G1, G2): + G1 = to_graph(G1) + G2 = to_graph(G2) + return algorithms.faster_could_be_isomorphic(G1, G2) + + +faster_graph_could_be_isomorphic = faster_could_be_isomorphic diff --git a/graphblas_algorithms/nxapi/linalg/__init__.py b/graphblas_algorithms/nxapi/linalg/__init__.py new file mode 100644 index 0000000..aada0f4 --- /dev/null +++ b/graphblas_algorithms/nxapi/linalg/__init__.py @@ -0,0 +1,5 @@ +from . import bethehessianmatrix, graphmatrix, laplacianmatrix, modularitymatrix +from .bethehessianmatrix import * +from .graphmatrix import * +from .laplacianmatrix import * +from .modularitymatrix import * diff --git a/graphblas_algorithms/nxapi/linalg/bethehessianmatrix.py b/graphblas_algorithms/nxapi/linalg/bethehessianmatrix.py new file mode 100644 index 0000000..7fa30b4 --- /dev/null +++ b/graphblas_algorithms/nxapi/linalg/bethehessianmatrix.py @@ -0,0 +1,12 @@ +from graphblas_algorithms import linalg +from graphblas_algorithms.classes.graph import to_undirected_graph +from graphblas_algorithms.utils import not_implemented_for + +__all__ = ["bethe_hessian_matrix"] + + +@not_implemented_for("directed") +@not_implemented_for("multigraph") +def bethe_hessian_matrix(G, r=None, nodelist=None): + G = to_undirected_graph(G) + return linalg.bethe_hessian_matrix(G, r=r, nodelist=nodelist) diff --git a/graphblas_algorithms/nxapi/linalg/graphmatrix.py b/graphblas_algorithms/nxapi/linalg/graphmatrix.py new file mode 100644 index 0000000..0b3e7d9 --- /dev/null +++ b/graphblas_algorithms/nxapi/linalg/graphmatrix.py @@ -0,0 +1,9 @@ +from graphblas_algorithms import linalg +from graphblas_algorithms.classes.digraph import to_graph + +__all__ = ["adjacency_matrix"] + + +def adjacency_matrix(G, nodelist=None, dtype=None, weight="weight"): + G = to_graph(G, weight=weight, dtype=dtype) + return linalg.adjacency_matrix(G, nodelist, dtype, is_weighted=weight is not None) diff --git a/graphblas_algorithms/nxapi/linalg/laplacianmatrix.py b/graphblas_algorithms/nxapi/linalg/laplacianmatrix.py new file mode 100644 index 0000000..752ca1e --- /dev/null +++ b/graphblas_algorithms/nxapi/linalg/laplacianmatrix.py @@ -0,0 +1,14 @@ +from graphblas_algorithms import linalg +from graphblas_algorithms.classes.digraph import to_graph + +__all__ = ["laplacian_matrix", "normalized_laplacian_matrix"] + + +def laplacian_matrix(G, nodelist=None, weight="weight"): + G = to_graph(G, weight=weight) + return linalg.laplacian_matrix(G, nodelist, is_weighted=weight is not None) + + +def normalized_laplacian_matrix(G, nodelist=None, weight="weight"): + G = to_graph(G, weight=weight) + return linalg.normalized_laplacian_matrix(G, nodelist, is_weighted=weight is not None) diff --git a/graphblas_algorithms/nxapi/linalg/modularitymatrix.py b/graphblas_algorithms/nxapi/linalg/modularitymatrix.py new file mode 100644 index 0000000..76e160f --- /dev/null +++ b/graphblas_algorithms/nxapi/linalg/modularitymatrix.py @@ -0,0 +1,20 @@ +from graphblas_algorithms import linalg +from graphblas_algorithms.classes.digraph import to_directed_graph +from graphblas_algorithms.classes.graph import to_undirected_graph +from graphblas_algorithms.utils import not_implemented_for + +__all__ = ["modularity_matrix", "directed_modularity_matrix"] + + +@not_implemented_for("directed") +@not_implemented_for("multigraph") +def modularity_matrix(G, nodelist=None, weight=None): + G = to_undirected_graph(G, weight=weight) + return linalg.modularity_matrix(G, nodelist, is_weighted=weight is not None) + + +@not_implemented_for("undirected") +@not_implemented_for("multigraph") +def directed_modularity_matrix(G, nodelist=None, weight=None): + G = to_directed_graph(G, weight=weight) + return linalg.directed_modularity_matrix(G, nodelist, is_weighted=weight is not None) diff --git a/graphblas_algorithms/nxapi/lowest_common_ancestors.py b/graphblas_algorithms/nxapi/lowest_common_ancestors.py new file mode 100644 index 0000000..f94e8c2 --- /dev/null +++ b/graphblas_algorithms/nxapi/lowest_common_ancestors.py @@ -0,0 +1,11 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.classes.digraph import to_directed_graph +from graphblas_algorithms.utils import not_implemented_for + +__all__ = ["lowest_common_ancestor"] + + +@not_implemented_for("undirected") +def lowest_common_ancestor(G, node1, node2, default=None): + G = to_directed_graph(G) + return algorithms.lowest_common_ancestor(G, node1, node2, default=default) diff --git a/graphblas_algorithms/nxapi/operators/__init__.py b/graphblas_algorithms/nxapi/operators/__init__.py index 6e308b5..c2742b9 100644 --- a/graphblas_algorithms/nxapi/operators/__init__.py +++ b/graphblas_algorithms/nxapi/operators/__init__.py @@ -1 +1,2 @@ from .binary import * +from .unary import * diff --git a/graphblas_algorithms/nxapi/operators/unary.py b/graphblas_algorithms/nxapi/operators/unary.py new file mode 100644 index 0000000..6633b3b --- /dev/null +++ b/graphblas_algorithms/nxapi/operators/unary.py @@ -0,0 +1,22 @@ +from graphblas_algorithms import algorithms +from graphblas_algorithms.classes.digraph import to_graph + +from ..exception import NetworkXError + +__all__ = [ + "complement", + "reverse", +] + + +def complement(G): + G = to_graph(G) + return algorithms.complement(G) + + +def reverse(G, copy=True): + G = to_graph(G) + try: + return algorithms.reverse(G, copy=copy) + except algorithms.exceptions.GraphBlasAlgorithmException as e: + raise NetworkXError(*e.args) from e diff --git a/graphblas_algorithms/nxapi/shortest_paths/dense.py b/graphblas_algorithms/nxapi/shortest_paths/dense.py index cc86eb7..82c2eed 100644 --- a/graphblas_algorithms/nxapi/shortest_paths/dense.py +++ b/graphblas_algorithms/nxapi/shortest_paths/dense.py @@ -1,5 +1,3 @@ -import numpy as np - from graphblas_algorithms import algorithms from graphblas_algorithms.classes.digraph import to_graph @@ -28,7 +26,7 @@ def floyd_warshall_numpy(G, nodelist=None, weight="weight"): if nodelist is not None: if not (len(nodelist) == len(G) == len(set(nodelist))): raise NetworkXError("nodelist must contain every node in G with no repeats.") - permutation = np.array(G.list_to_ids(nodelist), np.uint64) + permutation = G.list_to_ids(nodelist) else: permutation = None try: diff --git a/graphblas_algorithms/nxapi/shortest_paths/weighted.py b/graphblas_algorithms/nxapi/shortest_paths/weighted.py index 9916e45..b08dd85 100644 --- a/graphblas_algorithms/nxapi/shortest_paths/weighted.py +++ b/graphblas_algorithms/nxapi/shortest_paths/weighted.py @@ -1,12 +1,13 @@ -from graphblas_algorithms import algorithms +from graphblas_algorithms import algorithms, exceptions from graphblas_algorithms.classes.digraph import to_graph from .._utils import normalize_chunksize, partition -from ..exception import NetworkXUnbounded, NodeNotFound +from ..exception import NetworkXNoPath, NetworkXUnbounded, NodeNotFound __all__ = [ "all_pairs_bellman_ford_path_length", "bellman_ford_path", + "bellman_ford_path_length", "negative_edge_cycle", "single_source_bellman_ford_path_length", ] @@ -65,6 +66,16 @@ def bellman_ford_path(G, source, target, weight="weight"): raise NodeNotFound(*e.args) from e +def bellman_ford_path_length(G, source, target, weight="weight"): + G = to_graph(G, weight=weight) + try: + return algorithms.bellman_ford_path_length(G, source, target) + except KeyError as e: + raise NodeNotFound(*e.args) from e + except exceptions.NoPath as e: + raise NetworkXNoPath(*e.args) from e + + def negative_edge_cycle(G, weight="weight", heuristic=True): # TODO: what if weight is a function? # TODO: use a heuristic to try to stop early diff --git a/graphblas_algorithms/nxapi/tournament.py b/graphblas_algorithms/nxapi/tournament.py index d951ade..6c1bb1f 100644 --- a/graphblas_algorithms/nxapi/tournament.py +++ b/graphblas_algorithms/nxapi/tournament.py @@ -1,5 +1,3 @@ -from graphblas import io - from graphblas_algorithms import algorithms from graphblas_algorithms.classes.digraph import to_directed_graph from graphblas_algorithms.utils import not_implemented_for @@ -28,6 +26,5 @@ def score_sequence(G): @not_implemented_for("multigraph") def tournament_matrix(G): G = to_directed_graph(G) - T = algorithms.tournament_matrix(G) # TODO: can we return a different, more native object? - return io.to_scipy_sparse(T) + return algorithms.tournament_matrix(G) diff --git a/graphblas_algorithms/tests/test_match_nx.py b/graphblas_algorithms/tests/test_match_nx.py index 6250b1d..225c970 100644 --- a/graphblas_algorithms/tests/test_match_nx.py +++ b/graphblas_algorithms/tests/test_match_nx.py @@ -162,13 +162,20 @@ def test_dispatched_funcs_in_nxapi(nx_names_to_info, gb_names_to_info): raise AssertionError +def get_fullname(info): + fullname = info.fullname + if not fullname.endswith(f".{info.dispatchname}"): + fullname += f" ({info.dispatchname})" + return fullname + + def test_print_dispatched_not_implemented(nx_names_to_info, gb_names_to_info): """It may be informative to see the results from this to identify functions to implement. $ pytest -s -k test_print_dispatched_not_implemented """ not_implemented = nx_names_to_info.keys() - gb_names_to_info.keys() - fullnames = {next(iter(nx_names_to_info[name])).fullname for name in not_implemented} + fullnames = {get_fullname(next(iter(nx_names_to_info[name]))) for name in not_implemented} print() print("=================================================================================") print("Functions dispatched in NetworkX that ARE NOT implemented in graphblas-algorithms") @@ -184,7 +191,7 @@ def test_print_dispatched_implemented(nx_names_to_info, gb_names_to_info): $ pytest -s -k test_print_dispatched_implemented """ implemented = nx_names_to_info.keys() & gb_names_to_info.keys() - fullnames = {next(iter(nx_names_to_info[name])).fullname for name in implemented} + fullnames = {get_fullname(next(iter(nx_names_to_info[name]))) for name in implemented} print() print("=============================================================================") print("Functions dispatched in NetworkX that ARE implemented in graphblas-algorithms") diff --git a/pyproject.toml b/pyproject.toml index 1772aa2..36afd28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -95,6 +95,7 @@ packages = [ "graphblas_algorithms.algorithms.centrality", "graphblas_algorithms.algorithms.community", "graphblas_algorithms.algorithms.components", + "graphblas_algorithms.algorithms.isomorphism", "graphblas_algorithms.algorithms.link_analysis", "graphblas_algorithms.algorithms.operators", "graphblas_algorithms.algorithms.shortest_paths", @@ -102,11 +103,14 @@ packages = [ "graphblas_algorithms.algorithms.traversal", "graphblas_algorithms.classes", "graphblas_algorithms.generators", + "graphblas_algorithms.linalg", "graphblas_algorithms.nxapi", "graphblas_algorithms.nxapi.centrality", "graphblas_algorithms.nxapi.community", "graphblas_algorithms.nxapi.components", "graphblas_algorithms.nxapi.generators", + "graphblas_algorithms.nxapi.isomorphism", + "graphblas_algorithms.nxapi.linalg", "graphblas_algorithms.nxapi.link_analysis", "graphblas_algorithms.nxapi.operators", "graphblas_algorithms.nxapi.shortest_paths", @@ -231,6 +235,7 @@ ignore = [ "TID", # flake8-tidy-imports (Rely on isort and our own judgement) "TCH", # flake8-type-checking (Note: figure out type checking later) "ARG", # flake8-unused-arguments (Sometimes helpful, but too strict) + "TD", # flake8-todos (Maybe okay to add some of these) "ERA", # eradicate (We like code in comments!) "PD", # pandas-vet (Intended for scripts that use pandas, not libraries) ] @@ -238,6 +243,7 @@ ignore = [ [tool.ruff.per-file-ignores] "__init__.py" = ["F401"] # Allow unused imports (w/o defining `__all__`) "graphblas_algorithms/**/tests/*py" = ["S101", "T201", "D103", "D100"] # Allow assert, print, and no docstring +"graphblas_algorithms/interface.py" = ["PIE794"] # Allow us to use `mod = nxapi.` repeatedly "graphblas_algorithms/nxapi/exception.py" = ["F401"] # Allow unused imports (w/o defining `__all__`) "scripts/*.py" = ["INP001", "S101", "T201"] # Not a package, allow assert, allow print diff --git a/scripts/maketree.py b/scripts/maketree.py new file mode 100755 index 0000000..e4deed5 --- /dev/null +++ b/scripts/maketree.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +"""Run this script to auto-generate API when adding or removing nxapi functions. + +This updates API tree in README.md and dispatch functions in `graphblas_algorithms/interface.py`. + +""" +from io import StringIO +from pathlib import Path + +import rich +from graphblas.core.utils import _autogenerate_code +from rich.tree import Tree + +from graphblas_algorithms.tests import test_match_nx +from graphblas_algorithms.tests.test_match_nx import get_fullname + + +def get_fixture(attr): + return getattr(test_match_nx, attr).__wrapped__ + + +def trim(name): + for prefix in ["networkx.algorithms.", "networkx."]: + if name.startswith(prefix): + return name[len(prefix) :] + raise ValueError(f"{name!r} does not begin with a recognized prefix") + + +def get_names(): + nx_names_to_info = get_fixture("nx_names_to_info")(get_fixture("nx_info")()) + gb_names_to_info = get_fixture("gb_names_to_info")(get_fixture("gb_info")()) + implemented = nx_names_to_info.keys() & gb_names_to_info.keys() + return sorted(trim(get_fullname(next(iter(nx_names_to_info[name])))) for name in implemented) + + +# Dispatched functions that are only available from `nxapi` +SHORTPATH = { + "overall_reciprocity", + "reciprocity", +} + + +def main(print_to_console=True, update_readme=True, update_interface=True): + fullnames = get_names() + # Step 1: add to README.md + tree = Tree("graphblas_algorithms.nxapi") + subtrees = {} + + def addtree(path): + if path in subtrees: + rv = subtrees[path] + elif "." not in path: + rv = subtrees[path] = tree.add(path) + else: + subpath, last = path.rsplit(".", 1) + subtree = addtree(subpath) + rv = subtrees[path] = subtree.add(last) + return rv + + for fullname in fullnames: + addtree(fullname) + if print_to_console: + rich.print(tree) + if update_readme: + s = StringIO() + rich.print(tree, file=s) + s.seek(0) + text = s.read() + _autogenerate_code( + Path(__file__).parent.parent / "README.md", + f"\n```\n{text}```\n\n", + begin="[//]: # (Begin auto-generated code)", + end="[//]: # (End auto-generated code)", + callblack=False, + ) + # Step 2: add to interface.py + lines = [] + prev_mod = None + for fullname in fullnames: + mod, subpath = fullname.split(".", 1) + if mod != prev_mod: + if prev_mod is not None: + lines.append("") + prev_mod = mod + lines.append(f" mod = nxapi.{mod}") + lines.append(" # " + "=" * (len(mod) + 10)) + if " (" in subpath: + subpath, name = subpath.rsplit(" (", 1) + name = name.split(")")[0] + else: + name = subpath.rsplit(".", 1)[-1] + if name in SHORTPATH: + subpath = subpath.rsplit(".", 1)[-1] + lines.append(f" {name} = nxapi.{subpath}") + else: + lines.append(f" {name} = mod.{subpath}") + lines.append("") + lines.append(" del mod") + lines.append("") + text = "\n".join(lines) + if update_interface: + _autogenerate_code( + Path(__file__).parent.parent / "graphblas_algorithms" / "interface.py", + text, + specializer="dispatch", + ) + return tree + + +if __name__ == "__main__": + main() From 1f5ccb64e7ebb7275f4e9f015d1e2b66c94b494a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Jun 2023 13:52:27 -0500 Subject: [PATCH 13/24] [pre-commit.ci] pre-commit autoupdate (#69) * [pre-commit.ci] pre-commit autoupdate * Drop Python 3.8, which latest networkx dropped --- .github/workflows/test.yml | 2 +- .pre-commit-config.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e6aa40..6a1b79e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] steps: - name: Checkout uses: actions/checkout@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7b06df1..c0c02d4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: - id: black # - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.269 + rev: v0.0.270 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -81,7 +81,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.269 + rev: v0.0.270 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. From 3caced294b8eb2dba29e074b8940acd278dab8e6 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Fri, 25 Aug 2023 15:36:03 -0500 Subject: [PATCH 14/24] Update to work with new networkx dispatching (#68) --- .github/workflows/publish_pypi.yml | 2 +- .github/workflows/test.yml | 5 +- .pre-commit-config.yaml | 30 +++++---- graphblas_algorithms/interface.py | 69 ++++++++++++++++++--- graphblas_algorithms/tests/test_match_nx.py | 24 ++++++- pyproject.toml | 2 + run_nx_tests.sh | 7 ++- scripts/bench.py | 4 +- scripts/download_data.py | 2 +- 9 files changed, 113 insertions(+), 32 deletions(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 7841b5b..8ff188b 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.6 + uses: pypa/gh-action-pypi-publish@v1.8.10 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6a1b79e..103821c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: activate-environment: testing - name: Install dependencies run: | - conda install -c conda-forge python-graphblas scipy pandas pytest-cov pytest-randomly + conda install -c conda-forge python-graphblas scipy pandas pytest-cov pytest-randomly pytest-mpl # matplotlib lxml pygraphviz pydot sympy # Extra networkx deps we don't need yet pip install git+https://github.com/networkx/networkx.git@main --no-deps pip install -e . --no-deps @@ -39,7 +39,8 @@ jobs: python -c 'import sys, graphblas_algorithms; assert "networkx" not in sys.modules' coverage run --branch -m pytest --color=yes -v --check-structure coverage report - NETWORKX_GRAPH_CONVERT=graphblas pytest --color=yes --pyargs networkx --cov --cov-append + # NETWORKX_GRAPH_CONVERT=graphblas pytest --color=yes --pyargs networkx --cov --cov-append + ./run_nx_tests.sh --color=yes --cov --cov-append coverage report coverage xml - name: Coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c0c02d4..6b08ce0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ ci: # See: https://pre-commit.ci/#configuration autofix_prs: false - autoupdate_schedule: monthly + autoupdate_schedule: quarterly skip: [no-commit-to-branch] fail_fast: true default_language_version: @@ -17,21 +17,27 @@ repos: rev: v4.4.0 hooks: - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks - id: check-ast - id: check-toml - id: check-yaml - id: debug-statements - id: end-of-file-fixer + exclude_types: [svg] - id: mixed-line-ending - id: trailing-whitespace + - id: name-tests-test + args: ["--pytest-test-first"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.13 + rev: v0.14 hooks: - id: validate-pyproject name: Validate pyproject.toml # I don't yet trust ruff to do what autoflake does - repo: https://github.com/PyCQA/autoflake - rev: v2.1.1 + rev: v2.2.0 hooks: - id: autoflake args: [--in-place] @@ -40,7 +46,7 @@ repos: hooks: - id: isort - repo: https://github.com/asottile/pyupgrade - rev: v3.4.0 + rev: v3.10.1 hooks: - id: pyupgrade args: [--py38-plus] @@ -50,38 +56,38 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black # - id: black-jupyter - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.270 + rev: v0.0.285 hooks: - id: ruff args: [--fix-only, --show-fixes] - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 additional_dependencies: &flake8_dependencies # These versions need updated manually - - flake8==6.0.0 - - flake8-bugbear==23.5.9 + - flake8==6.1.0 + - flake8-bugbear==23.7.10 - flake8-simplify==0.20.0 - repo: https://github.com/asottile/yesqa - rev: v1.4.0 + rev: v1.5.0 hooks: - id: yesqa additional_dependencies: *flake8_dependencies - repo: https://github.com/codespell-project/codespell - rev: v2.2.4 + rev: v2.2.5 hooks: - id: codespell types_or: [python, rst, markdown] additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.270 + rev: v0.0.285 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. diff --git a/graphblas_algorithms/interface.py b/graphblas_algorithms/interface.py index a43b520..1d8c283 100644 --- a/graphblas_algorithms/interface.py +++ b/graphblas_algorithms/interface.py @@ -171,20 +171,71 @@ class Dispatcher: # End auto-generated code: dispatch @staticmethod - def convert_from_nx(graph, weight=None, *, name=None): + def convert_from_nx( + graph, + edge_attrs=None, + node_attrs=None, + preserve_edge_attrs=False, + preserve_node_attrs=False, + preserve_graph_attrs=False, + name=None, + graph_name=None, + *, + weight=None, # For nx.__version__ <= 3.1 + ): import networkx as nx from .classes import DiGraph, Graph, MultiDiGraph, MultiGraph + if preserve_edge_attrs: + if graph.is_multigraph(): + attrs = set().union( + *( + datadict + for nbrs in graph._adj.values() + for keydict in nbrs.values() + for datadict in keydict.values() + ) + ) + else: + attrs = set().union( + *(datadict for nbrs in graph._adj.values() for datadict in nbrs.values()) + ) + if len(attrs) == 1: + [attr] = attrs + edge_attrs = {attr: None} + elif attrs: + raise NotImplementedError("`preserve_edge_attrs=True` is not fully implemented") + if node_attrs: + raise NotImplementedError("non-None `node_attrs` is not yet implemented") + if preserve_node_attrs: + attrs = set().union(*(datadict for node, datadict in graph.nodes(data=True))) + if attrs: + raise NotImplementedError("`preserve_node_attrs=True` is not implemented") + if edge_attrs: + if len(edge_attrs) > 1: + raise NotImplementedError( + "Multiple edge attributes is not implemented (bad value for edge_attrs)" + ) + if weight is not None: + raise TypeError("edge_attrs and weight both given") + [[weight, default]] = edge_attrs.items() + if default is not None and default != 1: + raise NotImplementedError(f"edge default != 1 is not implemented; got {default}") + if isinstance(graph, nx.MultiDiGraph): - return MultiDiGraph.from_networkx(graph, weight=weight) - if isinstance(graph, nx.MultiGraph): - return MultiGraph.from_networkx(graph, weight=weight) - if isinstance(graph, nx.DiGraph): - return DiGraph.from_networkx(graph, weight=weight) - if isinstance(graph, nx.Graph): - return Graph.from_networkx(graph, weight=weight) - raise TypeError(f"Unsupported type of graph: {type(graph)}") + G = MultiDiGraph.from_networkx(graph, weight=weight) + elif isinstance(graph, nx.MultiGraph): + G = MultiGraph.from_networkx(graph, weight=weight) + elif isinstance(graph, nx.DiGraph): + G = DiGraph.from_networkx(graph, weight=weight) + elif isinstance(graph, nx.Graph): + G = Graph.from_networkx(graph, weight=weight) + else: + raise TypeError(f"Unsupported type of graph: {type(graph)}") + if preserve_graph_attrs: + G.graph.update(graph.graph) + return G @staticmethod def convert_to_nx(obj, *, name=None): diff --git a/graphblas_algorithms/tests/test_match_nx.py b/graphblas_algorithms/tests/test_match_nx.py index 225c970..1924ff7 100644 --- a/graphblas_algorithms/tests/test_match_nx.py +++ b/graphblas_algorithms/tests/test_match_nx.py @@ -22,13 +22,29 @@ "Matching networkx namespace requires networkx to be installed", allow_module_level=True ) else: - from networkx.classes import backends # noqa: F401 + try: + from networkx.utils import backends + + IS_NX_30_OR_31 = False + except ImportError: # pragma: no cover (import) + # This is the location in nx 3.1 + from networkx.classes import backends # noqa: F401 + + IS_NX_30_OR_31 = True def isdispatched(func): """Can this NetworkX function dispatch to other backends?""" + if IS_NX_30_OR_31: + return ( + callable(func) + and hasattr(func, "dispatchname") + and func.__module__.startswith("networkx") + ) return ( - callable(func) and hasattr(func, "dispatchname") and func.__module__.startswith("networkx") + callable(func) + and hasattr(func, "preserve_edge_attrs") + and func.__module__.startswith("networkx") ) @@ -37,7 +53,9 @@ def dispatchname(func): # Haha, there should be a better way to get this if not isdispatched(func): raise ValueError(f"Function is not dispatched in NetworkX: {func.__name__}") - return func.dispatchname + if IS_NX_30_OR_31: + return func.dispatchname + return func.name def fullname(func): diff --git a/pyproject.toml b/pyproject.toml index 36afd28..8fb2ffc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -214,12 +214,14 @@ ignore = [ "RET502", # Do not implicitly `return None` in function able to return non-`None` value "RET503", # Missing explicit `return` at the end of function able to return non-`None` value "RET504", # Unnecessary variable assignment before `return` statement + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` (Note: no annotations yet) "S110", # `try`-`except`-`pass` detected, consider logging the exception (Note: good advice, but we don't log) "S112", # `try`-`except`-`continue` detected, consider logging the exception (Note: good advice, but we don't log) "SIM102", # Use a single `if` statement instead of nested `if` statements (Note: often necessary) "SIM105", # Use contextlib.suppress(...) instead of try-except-pass (Note: try-except-pass is much faster) "SIM108", # Use ternary operator ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) "TRY003", # Avoid specifying long messages outside the exception class (Note: why?) + "FIX001", "FIX002", "FIX003", "FIX004", # flake8-fixme (like flake8-todos) # Ignored categories "C90", # mccabe (Too strict, but maybe we should make things less complex) diff --git a/run_nx_tests.sh b/run_nx_tests.sh index 08a5582..740ab26 100755 --- a/run_nx_tests.sh +++ b/run_nx_tests.sh @@ -1,3 +1,6 @@ #!/bin/bash -NETWORKX_GRAPH_CONVERT=graphblas pytest --pyargs networkx "$@" -# NETWORKX_GRAPH_CONVERT=graphblas pytest --pyargs networkx --cov --cov-report term-missing "$@" +NETWORKX_GRAPH_CONVERT=graphblas \ +NETWORKX_TEST_BACKEND=graphblas \ +NETWORKX_FALLBACK_TO_NX=True \ + pytest --pyargs networkx "$@" +# pytest --pyargs networkx --cov --cov-report term-missing "$@" diff --git a/scripts/bench.py b/scripts/bench.py index ba61300..3b3f4dc 100755 --- a/scripts/bench.py +++ b/scripts/bench.py @@ -19,7 +19,7 @@ datapaths = [ Path(__file__).parent / ".." / "data", - Path("."), + Path(), ] @@ -37,7 +37,7 @@ def find_data(dataname): if dataname not in download_data.data_urls: raise FileNotFoundError(f"Unable to find data file for {dataname}") curpath = Path(download_data.main([dataname])[0]) - return curpath.resolve().relative_to(Path(".").resolve()) + return curpath.resolve().relative_to(Path().resolve()) def get_symmetry(file_or_mminfo): diff --git a/scripts/download_data.py b/scripts/download_data.py index 009ebf0..b01626c 100755 --- a/scripts/download_data.py +++ b/scripts/download_data.py @@ -47,7 +47,7 @@ def main(datanames, overwrite=False): for name in datanames: target = datapath / f"{name}.mtx" filenames.append(target) - relpath = target.resolve().relative_to(Path(".").resolve()) + relpath = target.resolve().relative_to(Path().resolve()) if not overwrite and target.exists(): print(f"{relpath} already exists; skipping", file=sys.stderr) continue From 4fccd7e66e078c48d963f2d59a95266c309df1ac Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Sun, 8 Oct 2023 22:26:08 -0500 Subject: [PATCH 15/24] Update to NetworkX 3.2 (#77) * Update to NetworkX 3.2 * Use mamba instead for faster environment creation * Drop Python 3.8 --- .github/workflows/test.yml | 26 +++++- .pre-commit-config.yaml | 38 +++++---- _nx_graphblas/__init__.py | 107 ++++++++++++++++++++++++ graphblas_algorithms/classes/digraph.py | 3 +- graphblas_algorithms/classes/graph.py | 1 + graphblas_algorithms/nxapi/smetric.py | 19 +++-- graphblas_algorithms/tests/test_core.py | 1 + pyproject.toml | 18 +++- 8 files changed, 181 insertions(+), 32 deletions(-) create mode 100644 _nx_graphblas/__init__.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 103821c..6758b93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,16 +21,34 @@ jobs: uses: actions/checkout@v3 with: fetch-depth: 0 - - name: Conda + - name: Setup mamba uses: conda-incubator/setup-miniconda@v2 + id: setup_mamba + continue-on-error: true + with: + miniforge-variant: Mambaforge + miniforge-version: latest + use-mamba: true + python-version: ${{ matrix.python-version }} + channels: conda-forge,${{ contains(matrix.python-version, 'pypy') && 'defaults' || 'nodefaults' }} + channel-priority: ${{ contains(matrix.python-version, 'pypy') && 'flexible' || 'strict' }} + activate-environment: graphblas + auto-activate-base: false + - name: Setup conda + uses: conda-incubator/setup-miniconda@v2 + id: setup_conda + if: steps.setup_mamba.outcome == 'failure' + continue-on-error: false with: auto-update-conda: true python-version: ${{ matrix.python-version }} - channels: conda-forge - activate-environment: testing + channels: conda-forge,${{ contains(matrix.python-version, 'pypy') && 'defaults' || 'nodefaults' }} + channel-priority: ${{ contains(matrix.python-version, 'pypy') && 'flexible' || 'strict' }} + activate-environment: graphblas + auto-activate-base: false - name: Install dependencies run: | - conda install -c conda-forge python-graphblas scipy pandas pytest-cov pytest-randomly pytest-mpl + $(command -v mamba || command -v conda) install python-graphblas scipy pandas pytest-cov pytest-randomly pytest-mpl # matplotlib lxml pygraphviz pydot sympy # Extra networkx deps we don't need yet pip install git+https://github.com/networkx/networkx.git@main --no-deps pip install -e . --no-deps diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b08ce0..474539b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,21 +5,23 @@ # To update: `pre-commit autoupdate` # - &flake8_dependencies below needs updated manually ci: - # See: https://pre-commit.ci/#configuration - autofix_prs: false - autoupdate_schedule: quarterly - skip: [no-commit-to-branch] + # See: https://pre-commit.ci/#configuration + autofix_prs: false + autoupdate_schedule: quarterly + autoupdate_commit_msg: "chore: update pre-commit hooks" + autofix_commit_msg: "style: pre-commit fixes" + skip: [no-commit-to-branch] fail_fast: true default_language_version: python: python3 repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-added-large-files - id: check-case-conflict - id: check-merge-conflict - - id: check-symlinks + # - id: check-symlinks - id: check-ast - id: check-toml - id: check-yaml @@ -37,7 +39,7 @@ repos: name: Validate pyproject.toml # I don't yet trust ruff to do what autoflake does - repo: https://github.com/PyCQA/autoflake - rev: v2.2.0 + rev: v2.2.1 hooks: - id: autoflake args: [--in-place] @@ -46,22 +48,22 @@ repos: hooks: - id: isort - repo: https://github.com/asottile/pyupgrade - rev: v3.10.1 + rev: v3.15.0 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py39-plus] - repo: https://github.com/MarcoGorelli/auto-walrus rev: v0.2.2 hooks: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black # - id: black-jupyter - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.285 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.292 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -72,22 +74,22 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.1.0 - - flake8-bugbear==23.7.10 - - flake8-simplify==0.20.0 + - flake8-bugbear==23.9.16 + - flake8-simplify==0.21.0 - repo: https://github.com/asottile/yesqa rev: v1.5.0 hooks: - id: yesqa additional_dependencies: *flake8_dependencies - repo: https://github.com/codespell-project/codespell - rev: v2.2.5 + rev: v2.2.6 hooks: - id: codespell types_or: [python, rst, markdown] additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.285 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.292 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. @@ -98,6 +100,6 @@ repos: - id: pyroma args: [-n, "10", .] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: no-commit-to-branch # no commit directly to main diff --git a/_nx_graphblas/__init__.py b/_nx_graphblas/__init__.py new file mode 100644 index 0000000..c3cc602 --- /dev/null +++ b/_nx_graphblas/__init__.py @@ -0,0 +1,107 @@ +def get_info(): + return { + "backend_name": "graphblas", + "project": "graphblas-algorithms", + "package": "graphblas_algorithms", + "url": "https://github.com/python-graphblas/graphblas-algorithms", + "short_summary": "Fast, OpenMP-enabled backend using GraphBLAS", + # "description": "TODO", + "functions": { + "adjacency_matrix": {}, + "all_pairs_bellman_ford_path_length": { + "extra_parameters": { + "chunksize": "Split the computation into chunks; " + 'may specify size as string or number of rows. Default "10 MiB"', + }, + }, + "all_pairs_shortest_path_length": { + "extra_parameters": { + "chunksize": "Split the computation into chunks; " + 'may specify size as string or number of rows. Default "10 MiB"', + }, + }, + "ancestors": {}, + "average_clustering": {}, + "bellman_ford_path": {}, + "bellman_ford_path_length": {}, + "bethe_hessian_matrix": {}, + "bfs_layers": {}, + "boundary_expansion": {}, + "clustering": {}, + "complement": {}, + "compose": {}, + "conductance": {}, + "cut_size": {}, + "degree_centrality": {}, + "descendants": {}, + "descendants_at_distance": {}, + "difference": {}, + "directed_modularity_matrix": {}, + "disjoint_union": {}, + "edge_boundary": {}, + "edge_expansion": {}, + "efficiency": {}, + "ego_graph": {}, + "eigenvector_centrality": {}, + "fast_could_be_isomorphic": {}, + "faster_could_be_isomorphic": {}, + "floyd_warshall": {}, + "floyd_warshall_numpy": {}, + "floyd_warshall_predecessor_and_distance": {}, + "full_join": {}, + "generalized_degree": {}, + "google_matrix": {}, + "has_path": {}, + "hits": {}, + "in_degree_centrality": {}, + "inter_community_edges": {}, + "intersection": {}, + "intra_community_edges": {}, + "is_connected": {}, + "is_dominating_set": {}, + "is_isolate": {}, + "is_k_regular": {}, + "isolates": {}, + "is_regular": {}, + "is_simple_path": {}, + "is_tournament": {}, + "is_triad": {}, + "is_weakly_connected": {}, + "katz_centrality": {}, + "k_truss": {}, + "laplacian_matrix": {}, + "lowest_common_ancestor": {}, + "mixing_expansion": {}, + "modularity_matrix": {}, + "mutual_weight": {}, + "negative_edge_cycle": {}, + "node_boundary": {}, + "node_connected_component": {}, + "node_expansion": {}, + "normalized_cut_size": {}, + "normalized_laplacian_matrix": {}, + "number_of_isolates": {}, + "out_degree_centrality": {}, + "overall_reciprocity": {}, + "pagerank": {}, + "reciprocity": {}, + "reverse": {}, + "score_sequence": {}, + "single_source_bellman_ford_path_length": {}, + "single_source_shortest_path_length": {}, + "single_target_shortest_path_length": {}, + "s_metric": {}, + "square_clustering": { + "extra_parameters": { + "chunksize": "Split the computation into chunks; " + 'may specify size as string or number of rows. Default "256 MiB"', + }, + }, + "symmetric_difference": {}, + "tournament_matrix": {}, + "transitivity": {}, + "triangles": {}, + "union": {}, + "volume": {}, + }, + } diff --git a/graphblas_algorithms/classes/digraph.py b/graphblas_algorithms/classes/digraph.py index 8da9c8a..1e9fe5f 100644 --- a/graphblas_algorithms/classes/digraph.py +++ b/graphblas_algorithms/classes/digraph.py @@ -442,6 +442,7 @@ def __missing__(self, key): class DiGraph(Graph): + __networkx_backend__ = "graphblas" __networkx_plugin__ = "graphblas" # "-" properties ignore self-edges, "+" properties include self-edges @@ -611,7 +612,7 @@ def to_undirected(self, reciprocal=False, as_view=False, *, name=None): return Graph(B, key_to_id=self._key_to_id) def reverse(self, copy=True): - # We could even re-use many of the cached values + # We could even reuse many of the cached values A = self._A.T # This probably mostly works, but does not yet support assignment if copy: A = A.new() diff --git a/graphblas_algorithms/classes/graph.py b/graphblas_algorithms/classes/graph.py index 06f82be..f3e2239 100644 --- a/graphblas_algorithms/classes/graph.py +++ b/graphblas_algorithms/classes/graph.py @@ -301,6 +301,7 @@ def __missing__(self, key): class Graph: + __networkx_backend__ = "graphblas" __networkx_plugin__ = "graphblas" # "-" properties ignore self-edges, "+" properties include self-edges diff --git a/graphblas_algorithms/nxapi/smetric.py b/graphblas_algorithms/nxapi/smetric.py index a363e1e..a1f60ab 100644 --- a/graphblas_algorithms/nxapi/smetric.py +++ b/graphblas_algorithms/nxapi/smetric.py @@ -1,13 +1,22 @@ +import warnings + from graphblas_algorithms import algorithms from graphblas_algorithms.classes.digraph import to_graph -from .exception import NetworkXError - __all__ = ["s_metric"] -def s_metric(G, normalized=True): - if normalized: - raise NetworkXError("Normalization not implemented") +def s_metric(G, **kwargs): + if kwargs: + if "normalized" in kwargs: + warnings.warn( + "\n\nThe `normalized` keyword is deprecated and will be removed\n" + "in the future. To silence this warning, remove `normalized`\n" + "when calling `s_metric`.\n\nThe value of `normalized` is ignored.", + DeprecationWarning, + stacklevel=2, + ) + else: + raise TypeError(f"s_metric got an unexpected keyword argument '{kwargs.popitem()[0]}'") G = to_graph(G) return algorithms.s_metric(G) diff --git a/graphblas_algorithms/tests/test_core.py b/graphblas_algorithms/tests/test_core.py index 5acd529..68dbeb7 100644 --- a/graphblas_algorithms/tests/test_core.py +++ b/graphblas_algorithms/tests/test_core.py @@ -27,6 +27,7 @@ def test_packages(): path = pathlib.Path(ga.__file__).parent pkgs = [f"graphblas_algorithms.{x}" for x in setuptools.find_packages(path)] pkgs.append("graphblas_algorithms") + pkgs.append("_nx_graphblas") pkgs.sort() pyproject = path.parent / "pyproject.toml" if not pyproject.exists(): diff --git a/pyproject.toml b/pyproject.toml index 8fb2ffc..78b07ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ name = "graphblas-algorithms" dynamic = ["version"] description = "Graph algorithms written in GraphBLAS and backend for NetworkX" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = {file = "LICENSE"} authors = [ {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, @@ -43,7 +43,6 @@ classifiers = [ "Operating System :: Microsoft :: Windows", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -65,6 +64,12 @@ dependencies = [ [project.entry-points."networkx.plugins"] graphblas = "graphblas_algorithms.interface:Dispatcher" +[project.entry-points."networkx.backends"] +graphblas = "graphblas_algorithms.interface:Dispatcher" + +[project.entry-points."networkx.backend_info"] +graphblas = "_nx_graphblas:get_info" + [project.urls] homepage = "https://github.com/python-graphblas/graphblas-algorithms" # documentation = "https://graphblas-algorithms.readthedocs.io" @@ -90,6 +95,7 @@ all = [ # $ find graphblas_algorithms/ -name __init__.py -print | sort | sed -e 's/\/__init__.py//g' -e 's/\//./g' # $ python -c 'import tomli ; [print(x) for x in sorted(tomli.load(open("pyproject.toml", "rb"))["tool"]["setuptools"]["packages"])]' packages = [ + "_nx_graphblas", "graphblas_algorithms", "graphblas_algorithms.algorithms", "graphblas_algorithms.algorithms.centrality", @@ -127,7 +133,7 @@ dirty_template = "{tag}+{ccount}.g{sha}.dirty" [tool.black] line-length = 100 -target-version = ["py38", "py39", "py310", "py311"] +target-version = ["py39", "py310", "py311"] [tool.isort] sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] @@ -143,6 +149,7 @@ skip = [ ] [tool.pytest.ini_options] +minversion = "6.0" testpaths = "graphblas_algorithms" xfail_strict = false markers = [ @@ -169,7 +176,10 @@ exclude_lines = [ [tool.ruff] # https://github.com/charliermarsh/ruff/ line-length = 100 -target-version = "py38" +target-version = "py39" +unfixable = [ + "F841" # unused-variable (Note: can leave useless expression) +] select = [ "ALL", ] From 6c89017b45c1b4b99ab7a0a764c50674bbbaafb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 9 Oct 2023 11:09:51 -0500 Subject: [PATCH 16/24] Bump actions/checkout from 3 to 4 (#74) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- .github/workflows/lint.yml | 2 +- .github/workflows/publish_pypi.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 81d9415..e094502 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: name: pre-commit-hooks runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: python-version: "3.10" diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 8ff188b..e48524f 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -14,7 +14,7 @@ jobs: shell: bash -l {0} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6758b93..0ff6ab9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: python-version: ["3.9", "3.10", "3.11"] steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup mamba From 86cca31c6d6b68256ef07ea4c8b06a0a19ba5159 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Mon, 9 Oct 2023 14:30:40 -0500 Subject: [PATCH 17/24] Update short summary for nx docs (#78) --- _nx_graphblas/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/_nx_graphblas/__init__.py b/_nx_graphblas/__init__.py index c3cc602..6ffa061 100644 --- a/_nx_graphblas/__init__.py +++ b/_nx_graphblas/__init__.py @@ -4,19 +4,19 @@ def get_info(): "project": "graphblas-algorithms", "package": "graphblas_algorithms", "url": "https://github.com/python-graphblas/graphblas-algorithms", - "short_summary": "Fast, OpenMP-enabled backend using GraphBLAS", + "short_summary": "OpenMP-enabled sparse linear algebra backend.", # "description": "TODO", "functions": { "adjacency_matrix": {}, "all_pairs_bellman_ford_path_length": { "extra_parameters": { - "chunksize": "Split the computation into chunks; " + "chunksize : int or str, optional": "Split the computation into chunks; " 'may specify size as string or number of rows. Default "10 MiB"', }, }, "all_pairs_shortest_path_length": { "extra_parameters": { - "chunksize": "Split the computation into chunks; " + "chunksize : int or str, optional": "Split the computation into chunks; " 'may specify size as string or number of rows. Default "10 MiB"', }, }, @@ -93,7 +93,7 @@ def get_info(): "s_metric": {}, "square_clustering": { "extra_parameters": { - "chunksize": "Split the computation into chunks; " + "chunksize : int or str, optional": "Split the computation into chunks; " 'may specify size as string or number of rows. Default "256 MiB"', }, }, From 43c1731213f29e954126b678ccb26967c3a67576 Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 18 Oct 2023 21:49:42 -0500 Subject: [PATCH 18/24] Support Python 3.12 and update pre-commit versions (#79) --- .github/workflows/publish_pypi.yml | 2 +- .github/workflows/test.yml | 3 ++- .pre-commit-config.yaml | 8 ++++---- .../algorithms/shortest_paths/weighted.py | 2 +- pyproject.toml | 5 +++-- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index e48524f..1970710 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.8" + python-version: "3.9" - name: Install build dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ff6ab9..e04e92f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,7 +48,8 @@ jobs: auto-activate-base: false - name: Install dependencies run: | - $(command -v mamba || command -v conda) install python-graphblas scipy pandas pytest-cov pytest-randomly pytest-mpl + $(command -v mamba || command -v conda) install python-suitesparse-graphblas scipy pandas donfig pyyaml numpy python-graphblas \ + pytest-cov pytest-randomly pytest-mpl # matplotlib lxml pygraphviz pydot sympy # Extra networkx deps we don't need yet pip install git+https://github.com/networkx/networkx.git@main --no-deps pip install -e . --no-deps diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 474539b..55021f2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -33,7 +33,7 @@ repos: - id: name-tests-test args: ["--pytest-test-first"] - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.14 + rev: v0.15 hooks: - id: validate-pyproject name: Validate pyproject.toml @@ -58,12 +58,12 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.10.0 hooks: - id: black # - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.292 + rev: v0.1.0 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -89,7 +89,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.292 + rev: v0.1.0 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. diff --git a/graphblas_algorithms/algorithms/shortest_paths/weighted.py b/graphblas_algorithms/algorithms/shortest_paths/weighted.py index 5afa0f4..0c2883c 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/weighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/weighted.py @@ -27,7 +27,7 @@ def _bellman_ford_path_length(G, source, target=None, *, cutoff=None, name): is_negative, iso_value = G.get_properties("has_negative_edges+ iso_value") if not is_negative: if cutoff is not None: - cutoff = int(cutoff // iso_value) + cutoff = int(cutoff // iso_value.get()) d = _bfs_level(G, source, target, cutoff=cutoff, dtype=iso_value.dtype) if dst_id is not None: d = d.get(dst_id) diff --git a/pyproject.toml b/pyproject.toml index 78b07ef..6ed6386 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ classifiers = [ "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "Intended Audience :: Developers", "Intended Audience :: Other Audience", @@ -80,7 +81,7 @@ changelog = "https://github.com/python-graphblas/graphblas-algorithms/releases" test = [ "pytest", "networkx >=3.0", - "scipy >=1.8", + "scipy >=1.9", "setuptools", "tomli", ] @@ -133,7 +134,7 @@ dirty_template = "{tag}+{ccount}.g{sha}.dirty" [tool.black] line-length = 100 -target-version = ["py39", "py310", "py311"] +target-version = ["py39", "py310", "py311", "py312"] [tool.isort] sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] From 10788face650cb292e62038e73ae9c2bc007912a Mon Sep 17 00:00:00 2001 From: Erik Welch Date: Wed, 13 Dec 2023 08:18:43 -0600 Subject: [PATCH 19/24] Drop Python 3.9, add 3.12; update pre-commit (#84) * Drop Python 3.9, add 3.12; update pre-commit * Use latest networkx release, not dev version * Add .codecov.yml --- .codecov.yml | 14 ++++++++ .github/workflows/publish_pypi.yml | 2 +- .github/workflows/test.yml | 7 ++-- .pre-commit-config.yaml | 12 +++---- .../algorithms/operators/binary.py | 4 +-- .../algorithms/shortest_paths/weighted.py | 2 +- graphblas_algorithms/classes/_utils.py | 23 +++++++------ graphblas_algorithms/interface.py | 32 +++++++++---------- graphblas_algorithms/nxapi/boundary.py | 6 +++- pyproject.toml | 8 ++--- scripts/scipy_impl.py | 2 +- 11 files changed, 67 insertions(+), 45 deletions(-) create mode 100644 .codecov.yml diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..4fd4800 --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,14 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + changes: false +comment: + layout: "header, diff" + behavior: default +github_checks: + annotations: false diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 1970710..d9889a1 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -20,7 +20,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.9" + python-version: "3.10" - name: Install build dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e04e92f..8be0379 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: fail-fast: true matrix: os: ["ubuntu-latest", "macos-latest", "windows-latest"] - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.10", "3.11", "3.12"] steps: - name: Checkout uses: actions/checkout@v4 @@ -49,9 +49,10 @@ jobs: - name: Install dependencies run: | $(command -v mamba || command -v conda) install python-suitesparse-graphblas scipy pandas donfig pyyaml numpy python-graphblas \ - pytest-cov pytest-randomly pytest-mpl + pytest-cov pytest-randomly pytest-mpl networkx # matplotlib lxml pygraphviz pydot sympy # Extra networkx deps we don't need yet - pip install git+https://github.com/networkx/networkx.git@main --no-deps + # Sometimes we prefer to use the latest release of NetworkX or the latest development from github + # pip install git+https://github.com/networkx/networkx.git@main --no-deps pip install -e . --no-deps - name: PyTest run: | diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 55021f2..c9e708b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,26 +44,26 @@ repos: - id: autoflake args: [--in-place] - repo: https://github.com/pycqa/isort - rev: 5.12.0 + rev: 5.13.1 hooks: - id: isort - repo: https://github.com/asottile/pyupgrade rev: v3.15.0 hooks: - id: pyupgrade - args: [--py39-plus] + args: [--py310-plus] - repo: https://github.com/MarcoGorelli/auto-walrus rev: v0.2.2 hooks: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.10.0 + rev: 23.12.0 hooks: - id: black # - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.0 + rev: v0.1.7 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -74,7 +74,7 @@ repos: additional_dependencies: &flake8_dependencies # These versions need updated manually - flake8==6.1.0 - - flake8-bugbear==23.9.16 + - flake8-bugbear==23.12.2 - flake8-simplify==0.21.0 - repo: https://github.com/asottile/yesqa rev: v1.5.0 @@ -89,7 +89,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.0 + rev: v0.1.7 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. diff --git a/graphblas_algorithms/algorithms/operators/binary.py b/graphblas_algorithms/algorithms/operators/binary.py index 11b5b19..4c14a11 100644 --- a/graphblas_algorithms/algorithms/operators/binary.py +++ b/graphblas_algorithms/algorithms/operators/binary.py @@ -67,7 +67,7 @@ def intersection(G, H, *, name="intersection"): ids = H.list_to_ids(keys) B = H._A[ids, ids].new(dtypes.unify(A.dtype, H._A.dtype), mask=A.S, name=name) B << unary.one(B) - return type(G)(B, key_to_id=dict(zip(keys, range(len(keys))))) + return type(G)(B, key_to_id=dict(zip(keys, range(len(keys)), strict=True))) def difference(G, H, *, name="difference"): @@ -142,7 +142,7 @@ def compose(G, H, *, name="compose"): C[A.nrows :, A.ncols :] = B[ids, ids] # Now make new `key_to_id` ids += A.nrows - key_to_id = dict(zip(newkeys, ids.tolist())) + key_to_id = dict(zip(newkeys, ids.tolist(), strict=True)) key_to_id.update(G._key_to_id) return type(G)(C, key_to_id=key_to_id) diff --git a/graphblas_algorithms/algorithms/shortest_paths/weighted.py b/graphblas_algorithms/algorithms/shortest_paths/weighted.py index 0c2883c..a83a060 100644 --- a/graphblas_algorithms/algorithms/shortest_paths/weighted.py +++ b/graphblas_algorithms/algorithms/shortest_paths/weighted.py @@ -199,7 +199,7 @@ def bellman_ford_path_lengths(G, nodes=None, *, expand_output=False): def _reconstruct_path_from_parents(G, parents, src, dst): indices, values = parents.to_coo(sort=False) - d = dict(zip(indices.tolist(), values.tolist())) + d = dict(zip(indices.tolist(), values.tolist(), strict=True)) if dst not in d: return [] cur = dst diff --git a/graphblas_algorithms/classes/_utils.py b/graphblas_algorithms/classes/_utils.py index d15a188..ecf66d9 100644 --- a/graphblas_algorithms/classes/_utils.py +++ b/graphblas_algorithms/classes/_utils.py @@ -61,7 +61,7 @@ def dict_to_vector(self, d, *, size=None, dtype=None, name=None): if size is None: size = len(self) key_to_id = self._key_to_id - indices, values = zip(*((key_to_id[key], val) for key, val in d.items())) + indices, values = zip(*((key_to_id[key], val) for key, val in d.items()), strict=True) return Vector.from_coo(indices, values, size=size, dtype=dtype, name=name) @@ -116,7 +116,7 @@ def vector_to_dict(self, v, *, mask=None, fill_value=None): elif fill_value is not None and v.nvals < v.size: v(mask=~v.S) << fill_value id_to_key = self.id_to_key - return {id_to_key[index]: value for index, value in zip(*v.to_coo(sort=False))} + return {id_to_key[index]: value for index, value in zip(*v.to_coo(sort=False), strict=True)} def vector_to_list(self, v, *, values_are_keys=False): @@ -198,26 +198,29 @@ def matrix_to_dicts(self, A, *, use_row_index=False, use_column_index=False, val id_to_key = self.id_to_key if values_are_keys: values = [id_to_key[val] for val in values] - it = zip(rows, np.lib.stride_tricks.sliding_window_view(indptr, 2).tolist()) + it = zip(rows, np.lib.stride_tricks.sliding_window_view(indptr, 2).tolist(), strict=True) if use_row_index and use_column_index: return { - row: dict(zip(col_indices[start:stop], values[start:stop])) for row, (start, stop) in it + row: dict(zip(col_indices[start:stop], values[start:stop], strict=True)) + for row, (start, stop) in it } if use_row_index: return { row: { - id_to_key[col]: val for col, val in zip(col_indices[start:stop], values[start:stop]) + id_to_key[col]: val + for col, val in zip(col_indices[start:stop], values[start:stop], strict=True) } for row, (start, stop) in it } if use_column_index: return { - id_to_key[row]: dict(zip(col_indices[start:stop], values[start:stop])) + id_to_key[row]: dict(zip(col_indices[start:stop], values[start:stop], strict=True)) for row, (start, stop) in it } return { id_to_key[row]: { - id_to_key[col]: val for col, val in zip(col_indices[start:stop], values[start:stop]) + id_to_key[col]: val + for col, val in zip(col_indices[start:stop], values[start:stop], strict=True) } for row, (start, stop) in it } @@ -239,9 +242,9 @@ def to_networkx(self, edge_attribute="weight"): rows = (id_to_key[row] for row in rows.tolist()) cols = (id_to_key[col] for col in cols.tolist()) if edge_attribute is None: - G.add_edges_from(zip(rows, cols)) + G.add_edges_from(zip(rows, cols, strict=True)) else: - G.add_weighted_edges_from(zip(rows, cols, vals), weight=edge_attribute) + G.add_weighted_edges_from(zip(rows, cols, vals, strict=True), weight=edge_attribute) # What else should we copy over? return G @@ -258,4 +261,4 @@ def renumber_key_to_id(self, indices): return {id_to_key[index]: i for i, index in enumerate(indices)} # Alternative (about the same performance) # keys = self.list_to_keys(indices) - # return dict(zip(keys, range(len(indices)))) + # return dict(zip(keys, range(len(indices)), strict=True)) diff --git a/graphblas_algorithms/interface.py b/graphblas_algorithms/interface.py index 1d8c283..c718371 100644 --- a/graphblas_algorithms/interface.py +++ b/graphblas_algorithms/interface.py @@ -281,31 +281,31 @@ def key(testpath): return (testname, frozenset({filename})) # Reasons to skip tests - multi_attributed = "unable to handle multi-attributed graphs" + # multi_attributed = "unable to handle multi-attributed graphs" multidigraph = "unable to handle MultiDiGraph" multigraph = "unable to handle MultiGraph" # Which tests to skip skip = { - key("test_mst.py:TestBoruvka.test_attributes"): multi_attributed, - key("test_mst.py:TestBoruvka.test_weight_attribute"): multi_attributed, + # key("test_mst.py:TestBoruvka.test_attributes"): multi_attributed, + # key("test_mst.py:TestBoruvka.test_weight_attribute"): multi_attributed, key("test_dense.py:TestFloyd.test_zero_weight"): multidigraph, key("test_dense_numpy.py:test_zero_weight"): multidigraph, key("test_weighted.py:TestBellmanFordAndGoldbergRadzik.test_multigraph"): multigraph, - key("test_binary.py:test_compose_multigraph"): multigraph, - key("test_binary.py:test_difference_multigraph_attributes"): multigraph, - key("test_binary.py:test_disjoint_union_multigraph"): multigraph, - key("test_binary.py:test_full_join_multigraph"): multigraph, - key("test_binary.py:test_intersection_multigraph_attributes"): multigraph, - key( - "test_binary.py:test_intersection_multigraph_attributes_node_set_different" - ): multigraph, - key("test_binary.py:test_symmetric_difference_multigraph"): multigraph, - key("test_binary.py:test_union_attributes"): multi_attributed, + # key("test_binary.py:test_compose_multigraph"): multigraph, + # key("test_binary.py:test_difference_multigraph_attributes"): multigraph, + # key("test_binary.py:test_disjoint_union_multigraph"): multigraph, + # key("test_binary.py:test_full_join_multigraph"): multigraph, + # key("test_binary.py:test_intersection_multigraph_attributes"): multigraph, + # key( + # "test_binary.py:test_intersection_multigraph_attributes_node_set_different" + # ): multigraph, + # key("test_binary.py:test_symmetric_difference_multigraph"): multigraph, + # key("test_binary.py:test_union_attributes"): multi_attributed, # TODO: move failing assertion from `test_union_and_compose` - key("test_binary.py:test_union_and_compose"): multi_attributed, - key("test_binary.py:test_union_multigraph"): multigraph, - key("test_vf2pp.py:test_custom_multigraph4_different_labels"): multigraph, + # key("test_binary.py:test_union_and_compose"): multi_attributed, + # key("test_binary.py:test_union_multigraph"): multigraph, + # key("test_vf2pp.py:test_custom_multigraph4_different_labels"): multigraph, } for item in items: kset = set(item.keywords) diff --git a/graphblas_algorithms/nxapi/boundary.py b/graphblas_algorithms/nxapi/boundary.py index 8907f09..662cfe4 100644 --- a/graphblas_algorithms/nxapi/boundary.py +++ b/graphblas_algorithms/nxapi/boundary.py @@ -29,15 +29,19 @@ def edge_boundary(G, nbunch1, nbunch2=None, data=False, keys=False, default=None (id_to_key[col] for col in cols), # Unsure about this; data argument may mean *all* edge attributes ({weight: val} for val in vals), + strict=True, ) else: it = zip( (id_to_key[row] for row in rows), (id_to_key[col] for col in cols), + strict=True, ) if is_multigraph: # Edge weights indicate number of times to repeat edges - it = itertools.chain.from_iterable(itertools.starmap(itertools.repeat, zip(it, vals))) + it = itertools.chain.from_iterable( + itertools.starmap(itertools.repeat, zip(it, vals, strict=True)) + ) return it diff --git a/pyproject.toml b/pyproject.toml index 6ed6386..b1625c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ name = "graphblas-algorithms" dynamic = ["version"] description = "Graph algorithms written in GraphBLAS and backend for NetworkX" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = {file = "LICENSE"} authors = [ {name = "Erik Welch", email = "erik.n.welch@gmail.com"}, @@ -43,7 +43,6 @@ classifiers = [ "Operating System :: Microsoft :: Windows", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -134,7 +133,7 @@ dirty_template = "{tag}+{ccount}.g{sha}.dirty" [tool.black] line-length = 100 -target-version = ["py39", "py310", "py311", "py312"] +target-version = ["py310", "py311", "py312"] [tool.isort] sections = ["FUTURE", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] @@ -177,7 +176,7 @@ exclude_lines = [ [tool.ruff] # https://github.com/charliermarsh/ruff/ line-length = 100 -target-version = "py39" +target-version = "py310" unfixable = [ "F841" # unused-variable (Note: can leave useless expression) ] @@ -205,6 +204,7 @@ ignore = [ # "SIM401", # Use dict.get ... instead of if-else-block (Note: if-else better for coverage and sometimes clearer) # "TRY004", # Prefer `TypeError` exception for invalid type (Note: good advice, but not worth the nuisance) # "TRY200", # Use `raise from` to specify exception cause (Note: sometimes okay to raise original exception) + "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` (Note: using `|` seems to be slower) # Intentionally ignored "COM812", # Trailing comma missing diff --git a/scripts/scipy_impl.py b/scripts/scipy_impl.py index 06244ea..35815a6 100644 --- a/scripts/scipy_impl.py +++ b/scripts/scipy_impl.py @@ -50,7 +50,7 @@ def pagerank( err = np.absolute(x - xlast).sum() if err < N * tol: return x - # return dict(zip(nodelist, map(float, x))) + # return dict(zip(nodelist, map(float, x), strict=True)) raise nx.PowerIterationFailedConvergence(max_iter) From 38a41d403117810ec3bea238a999826b6cbfe382 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 14 Dec 2023 05:41:34 -0600 Subject: [PATCH 20/24] Bump actions/setup-python from 4 to 5 (#83) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v4...v5) --- updated-dependencies: - dependency-name: actions/setup-python dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/lint.yml | 2 +- .github/workflows/publish_pypi.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e094502..97bb856 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: "3.10" - uses: pre-commit/action@v3.0.0 diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index d9889a1..70ad3a7 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -18,7 +18,7 @@ jobs: with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install build dependencies From 3f02d4c11893775f1aa9eb988336de8df35e0840 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 05:36:25 -0600 Subject: [PATCH 21/24] chore: update pre-commit hooks (#88) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pycqa/isort: 5.13.1 → 5.13.2](https://github.com/pycqa/isort/compare/5.13.1...5.13.2) - [github.com/psf/black: 23.12.0 → 23.12.1](https://github.com/psf/black/compare/23.12.0...23.12.1) - [github.com/astral-sh/ruff-pre-commit: v0.1.7 → v0.1.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.7...v0.1.9) - [github.com/astral-sh/ruff-pre-commit: v0.1.7 → v0.1.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.7...v0.1.9) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c9e708b..e4525c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,7 +44,7 @@ repos: - id: autoflake args: [--in-place] - repo: https://github.com/pycqa/isort - rev: 5.13.1 + rev: 5.13.2 hooks: - id: isort - repo: https://github.com/asottile/pyupgrade @@ -58,12 +58,12 @@ repos: - id: auto-walrus args: [--line-length, "100"] - repo: https://github.com/psf/black - rev: 23.12.0 + rev: 23.12.1 hooks: - id: black # - id: black-jupyter - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.7 + rev: v0.1.9 hooks: - id: ruff args: [--fix-only, --show-fixes] @@ -89,7 +89,7 @@ repos: additional_dependencies: [tomli] files: ^(graphblas_algorithms|docs)/ - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.7 + rev: v0.1.9 hooks: - id: ruff # `pyroma` may help keep our package standards up to date if best practices change. From fec54b667350378937f837acf7e6f6b3e4d813f6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 05:56:58 -0600 Subject: [PATCH 22/24] Bump actions/upload-artifact from 3 to 4 (#87) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 70ad3a7..64ca620 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -27,7 +27,7 @@ jobs: python -m pip install build twine - name: Build wheel and sdist run: python -m build --sdist --wheel - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: releases path: dist From dcaef80baf8fda3c07046e0c7f1aa8753f805c05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 06:38:59 -0600 Subject: [PATCH 23/24] Bump conda-incubator/setup-miniconda from 2 to 3 (#81) Bumps [conda-incubator/setup-miniconda](https://github.com/conda-incubator/setup-miniconda) from 2 to 3. - [Release notes](https://github.com/conda-incubator/setup-miniconda/releases) - [Changelog](https://github.com/conda-incubator/setup-miniconda/blob/main/CHANGELOG.md) - [Commits](https://github.com/conda-incubator/setup-miniconda/compare/v2...v3) --- updated-dependencies: - dependency-name: conda-incubator/setup-miniconda dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8be0379..47ca7bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: with: fetch-depth: 0 - name: Setup mamba - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 id: setup_mamba continue-on-error: true with: @@ -35,7 +35,7 @@ jobs: activate-environment: graphblas auto-activate-base: false - name: Setup conda - uses: conda-incubator/setup-miniconda@v2 + uses: conda-incubator/setup-miniconda@v3 id: setup_conda if: steps.setup_mamba.outcome == 'failure' continue-on-error: false From 35dbc90e808c6bf51b63d51d8a63f59238c02975 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Jan 2024 07:22:35 -0600 Subject: [PATCH 24/24] Bump pypa/gh-action-pypi-publish from 1.8.10 to 1.8.11 (#80) Bumps [pypa/gh-action-pypi-publish](https://github.com/pypa/gh-action-pypi-publish) from 1.8.10 to 1.8.11. - [Release notes](https://github.com/pypa/gh-action-pypi-publish/releases) - [Commits](https://github.com/pypa/gh-action-pypi-publish/compare/v1.8.10...v1.8.11) --- updated-dependencies: - dependency-name: pypa/gh-action-pypi-publish dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish_pypi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish_pypi.yml b/.github/workflows/publish_pypi.yml index 64ca620..f848ad6 100644 --- a/.github/workflows/publish_pypi.yml +++ b/.github/workflows/publish_pypi.yml @@ -35,7 +35,7 @@ jobs: - name: Check with twine run: python -m twine check --strict dist/* - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.10 + uses: pypa/gh-action-pypi-publish@v1.8.11 with: user: __token__ password: ${{ secrets.PYPI_TOKEN }}