From 5454d65d1d420f4783eda6ff40efee0de7b1d41c Mon Sep 17 00:00:00 2001 From: Ian Date: Sat, 1 Aug 2026 12:54:57 +0200 Subject: [PATCH 1/2] ci: gate publishing on tags, add a test workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit publish.yml fired on every push to main as well as on tags, with no test job in front of it — so any commit bumping the version published it. crates.io versions can be yanked but never replaced. Now tags only, behind fmt/clippy/tests, a tag-vs-Cargo.toml version check and a cargo package dry run. The publish step sits in a `crates-io` environment so a required reviewer can be added for a manual confirm. ci.yml runs the core with --no-default-features (fmt, clippy, tests in debug and release), the kNN feature separately so an hnsw breakage doesn't take the core red, and cargo-semver-checks. Also gitignore /data, which holds downloaded h5ad fixtures. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 62 +++++++++++++++++++++++++++++++++++ .github/workflows/publish.yml | 54 ++++++++++++++++++++++++------ .gitignore | 1 + 3 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9eef049 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + # the clustering core, without the optional hnsw stack + core: + name: Core (no default features) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + + - name: Format + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --no-default-features --all-targets -- -D warnings + + - name: Test (debug) + run: cargo test --no-default-features + # release too: the optimality and property tests crawl in debug + - name: Test (release) + run: cargo test --no-default-features --release + + # separate so an hnsw breakage doesn't take the core red + knn: + name: With kNN feature + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - name: Clippy + run: cargo clippy --all-features --all-targets -- -D warnings + - name: Test + run: cargo test --all-features + + semver: + name: Semver + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: obi1kenobi/cargo-semver-checks-action@v2 + with: + feature-group: only-explicit-features + features: knn diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d12b1f1..0afcd09 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,21 +1,55 @@ name: Publish to crates.io +# tags only — this used to fire on every push to main on: push: - branches: - - main tags: - 'v*' +env: + CARGO_TERM_COLOR: always + jobs: - publish: + # publishing can't be undone, so re-check here rather than trust an earlier CI run + verify: + name: Verify runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - uses: actions-rs/toolchain@v1 - with: - toolchain: stable - override: true - - uses: katyo/publish-crates@v2 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable with: - registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + + - name: Format + run: cargo fmt --check + - name: Clippy + run: cargo clippy --no-default-features --all-targets -- -D warnings + - name: Test + run: cargo test --no-default-features --release + + - name: Tag matches Cargo.toml version + run: | + tag="${GITHUB_REF_NAME#v}" + manifest=$(cargo metadata --no-deps --format-version 1 \ + | python3 -c 'import json,sys; print(json.load(sys.stdin)["packages"][0]["version"])') + if [ "$tag" != "$manifest" ]; then + echo "tag v$tag does not match Cargo.toml version $manifest" >&2 + exit 1 + fi + + - name: Package + run: cargo package --no-default-features + + publish: + name: Publish + needs: [verify] + runs-on: ubuntu-latest + # add a required reviewer to this environment for a manual confirm step + environment: crates-io + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Publish + run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.gitignore b/.gitignore index 3310346..80f81ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/data .idea .idea/ .fleet \ No newline at end of file From 74a36b1cd0bcd779c5c7915f0dfd407588f62d19 Mon Sep 17 00:00:00 2001 From: Ian Date: Sat, 1 Aug 2026 12:55:11 +0200 Subject: [PATCH 2/2] feat!: rewrite the Leiden core for 0.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.6.1 produced wrong, irreproducible clusterings, quadratically slowly. Six distinct bugs, each now with a regression test: - aggregate() halved the degree sum, because a self-loop added its weight once instead of twice to strength — the null model was off above level 0 - the RB null term divided by 2m instead of 4m, so resolution meant 2x what it does in scanpy/leidenalg - candidates were iterated out of a HashSet, so seed: Some(42) still drifted - the "only singletons may merge" guard in refinement hung off the wrong statement, losing the internal-connectivity guarantee - diff_move was O(n) per call rather than O(degree) - n_iterations and tolerance were dead config fields Rewritten: Partition keeps every aggregate incrementally in O(1) per move; NeighborWeights replaces the per-call HashSet and get_group_members allocation; Objective is a trait over RB and CPM; local moving is queue-based remove-then-insert; refinement matches libleidenalg's semantics with the randomised selection from the paper. Storage went to u32 ids and f32 weights (16 bytes per undirected edge), plus a zero-copy from_csr_parts that takes a connectivity matrix without a copy. 20k nodes: 44.1s -> 18ms. 1M: 1.75s. 8M: 20.1s. 1.33x faster than igraph native on synthetic graphs, 2.5-3x on pbmc3k. Modularity is within noise of leidenalg (-0.09% mean single-seed over 160 committed fixtures) and on pbmc3k matches the author cell types as well as scanpy does (ARI 0.8599 vs 0.8609). Verification is five independent layers: invariants, known answers, differential fixtures generated from leidenalg and committed, exhaustive brute-force optimality on small graphs, and randomised property tests. 90 tests. Drops the petgraph dependency and puts the kNN stack behind a default-on feature so the core builds where hnsw does not. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 316 +++- Cargo.toml | 26 +- README.md | 134 +- benches/leiden.rs | 144 ++ docs/testing-infrastructure.md | 260 ++++ examples/evaluate.rs | 186 +++ examples/memory.rs | 223 +++ examples/phases.rs | 189 +++ examples/real_data.rs | 237 +++ examples/scaling.rs | 106 ++ src/clustering.rs | 137 ++ src/community_search/leiden/local_move.rs | 255 +++ src/community_search/leiden/mod.rs | 540 ++++++- src/community_search/leiden/objective.rs | 368 +++++ src/community_search/leiden/optimizer.rs | 1372 ----------------- src/community_search/leiden/parallel.rs | 368 ----- src/community_search/leiden/partition.rs | 683 ++++++++ src/community_search/leiden/partition/mod.rs | 195 --- .../leiden/partition/modularity.rs | 268 ---- src/community_search/leiden/partition/rb.rs | 431 ------ src/community_search/leiden/refine.rs | 277 ++++ src/community_search/louvain.rs | 164 -- src/community_search/mod.rs | 3 +- src/error.rs | 118 ++ src/lib.rs | 10 +- src/moving/merging.rs | 194 --- src/moving/mod.rs | 3 - src/moving/standard.rs | 197 --- src/network/csr_network.rs | 1273 +++++++++------ src/network/grouping.rs | 3 +- src/network/mod.rs | 385 +---- src/testdata.rs | 127 ++ tests/common/mod.rs | 194 +++ tests/fixtures/leidenalg_reference.json | 1 + tests/invariants.rs | 242 +++ tests/known_answers.rs | 198 +++ tests/optimality.rs | 227 +++ tests/properties.rs | 359 +++++ tests/reference.rs | 386 +++++ tools/export_h5ad.py | 148 ++ tools/gen_eval_graphs.py | 123 ++ tools/gen_fixtures.py | 143 ++ 42 files changed, 7065 insertions(+), 4148 deletions(-) create mode 100644 benches/leiden.rs create mode 100644 docs/testing-infrastructure.md create mode 100644 examples/evaluate.rs create mode 100644 examples/memory.rs create mode 100644 examples/phases.rs create mode 100644 examples/real_data.rs create mode 100644 examples/scaling.rs create mode 100644 src/clustering.rs create mode 100644 src/community_search/leiden/local_move.rs create mode 100644 src/community_search/leiden/objective.rs delete mode 100644 src/community_search/leiden/optimizer.rs delete mode 100644 src/community_search/leiden/parallel.rs create mode 100644 src/community_search/leiden/partition.rs delete mode 100644 src/community_search/leiden/partition/mod.rs delete mode 100644 src/community_search/leiden/partition/modularity.rs delete mode 100644 src/community_search/leiden/partition/rb.rs create mode 100644 src/community_search/leiden/refine.rs delete mode 100644 src/community_search/louvain.rs create mode 100644 src/error.rs delete mode 100644 src/moving/merging.rs delete mode 100644 src/moving/mod.rs delete mode 100644 src/moving/standard.rs create mode 100644 src/testdata.rs create mode 100644 tests/common/mod.rs create mode 100644 tests/fixtures/leidenalg_reference.json create mode 100644 tests/invariants.rs create mode 100644 tests/known_answers.rs create mode 100644 tests/optimality.rs create mode 100644 tests/properties.rs create mode 100644 tests/reference.rs create mode 100644 tools/export_h5ad.py create mode 100644 tools/gen_eval_graphs.py create mode 100644 tools/gen_fixtures.py diff --git a/Cargo.lock b/Cargo.lock index 507d9ca..e6eae2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anndists" version = "0.1.3" @@ -148,6 +154,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytemuck" version = "1.22.0" @@ -166,6 +178,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cfg-if" version = "0.1.10" @@ -178,6 +196,58 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmov" version = "0.3.1" @@ -210,6 +280,42 @@ dependencies = [ "winapi", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -333,18 +439,36 @@ dependencies = [ "typenum", ] -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generator" version = "0.8.4" @@ -389,7 +513,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash", - "rayon", ] [[package]] @@ -437,7 +560,17 @@ checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", "hashbrown", - "rayon", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", ] [[package]] @@ -446,6 +579,21 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jiff" version = "0.2.15" @@ -470,6 +618,17 @@ dependencies = [ "syn", ] +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if 1.0.0", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "kiddo" version = "5.2.2" @@ -716,6 +875,12 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "ordered-float" version = "5.0.0" @@ -779,19 +944,6 @@ dependencies = [ "proc-macro-hack", ] -[[package]] -name = "petgraph" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54acf3a685220b533e437e264e4d932cfbdc4cc7ec0cd232ed73c08d03b8a7ca" -dependencies = [ - "fixedbitset", - "hashbrown", - "indexmap", - "rayon", - "serde", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -804,6 +956,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.11.0" @@ -957,6 +1137,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "safe_arch" version = "0.7.4" @@ -1001,6 +1187,18 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.143" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1035,18 +1233,19 @@ dependencies = [ [[package]] name = "single-clustering" -version = "0.6.1" +version = "0.7.0" dependencies = [ "anyhow", + "criterion", "hnsw_rs", "kiddo", "nalgebra-sparse", "ndarray", "num-traits", - "petgraph", "rand", "rand_chacha", "rayon", + "serde_json", "single-utilities", ] @@ -1059,6 +1258,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.0" @@ -1126,6 +1331,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tracing" version = "0.1.41" @@ -1226,6 +1441,61 @@ dependencies = [ "wit-bindgen-rt", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if 1.0.0", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wide" version = "0.7.32" diff --git a/Cargo.toml b/Cargo.toml index 116278d..a14e921 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "single-clustering" -version = "0.6.1" +version = "0.7.0" edition = "2024" authors = ["Ian F. Diks"] homepage = "https://singlerust.com" @@ -9,16 +9,30 @@ license-file = "LICENSE.md" readme = "README.md" description = "A high-performance network clustering library implementing community detection algorithms like Louvain and Leiden. Features efficient graph representation, abstract grouping systems, and K-NN graph creation from high-dimensional data. Provides parallel computation support via Rayon for handling large networks." +[features] +default = ["knn"] +# k-nearest-neighbour graph construction from high-dimensional data. Split out so the +# clustering core can be built and tested without the HNSW/kd-tree stack, which does not +# compile on every target. +knn = ["dep:hnsw_rs", "dep:kiddo", "dep:ndarray"] [dependencies] anyhow = "1.0.100" -kiddo = {version = "5.2.2" } nalgebra-sparse = "0.10.0" -ndarray = {version = "0.16.1" , features = ["rayon"]} num-traits = "0.2.19" -petgraph = { version = "0.8.2", features = ["rayon"] } rayon = "1.10.0" single-utilities = "0.8.6" rand = "0.9.0" -rand_chacha = {version = "0.9.0"} -hnsw_rs = {version = "0.3.2", features = ["simdeez_f"]} +rand_chacha = { version = "0.9.0" } + +kiddo = { version = "5.2.2", optional = true } +ndarray = { version = "0.16.1", features = ["rayon"], optional = true } +hnsw_rs = { version = "0.3.2", features = ["simdeez_f"], optional = true } + +[dev-dependencies] +serde_json = "1.0" +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "leiden" +harness = false diff --git a/README.md b/README.md index 1236bd4..c0ddc23 100644 --- a/README.md +++ b/README.md @@ -1,75 +1,129 @@ # single-clustering -⚠️ **Development Status**: This library is currently under heavy development and should **not be considered production ready**. APIs may change significantly between versions. +⚠️ **Development Status**: This library is under active development. APIs may change between +versions; see the changelog for 0.7.0, which is a breaking rewrite of the Leiden core. -A Rust library for community detection and graph clustering algorithms with a focus on performance and flexibility. +A Rust library for community detection and graph clustering, focused on being correct, +reproducible, and fast enough for single-cell-scale graphs. ## Features -- **Efficient Network Representation**: CSR (Compressed Sparse Row) format for optimal memory usage and performance -- **Community Detection Algorithms**: - - **Leiden Algorithm**: State-of-the-art method with guaranteed well-connected communities - - **Louvain Method**: Classic modularity optimization (work-in-progress) -- **Quality Functions**: Multiple partition quality metrics - - Modularity optimization - - Reichardt-Bornholdt (RB) configuration model with tunable resolution -- **Flexible Architecture**: Generic trait-based design supporting different network types and grouping strategies -- **Performance Optimized**: Caching strategies and efficient data structures for large networks +- **Efficient network representation**: CSR (compressed sparse row) storage with the + igraph/leidenalg weight conventions, including correct self-loop handling so that a + partition's quality is invariant under aggregation +- **Leiden algorithm** with the refinement phase, so communities are internally connected +- **Quality functions**: Reichardt–Bornholdt configuration model (modularity at + `resolution = 1.0`, matching `scanpy`) and CPM, which has no resolution limit +- **Reproducible**: a fixed seed gives bit-for-bit identical results +- **Linear scaling**: 100k nodes / 840k edges in ~110 ms single-threaded +- **Compact**: 16 bytes per undirected edge, and a zero-copy path for connectivity matrices + you already hold +- **k-NN graph construction** from high-dimensional data (optional `knn` feature) ## Usage ```rust use single_clustering::network::CSRNetwork; -use single_clustering::community_search::leiden::{LeidenOptimizer, LeidenConfig}; -use single_clustering::community_search::leiden::partition::ModularityPartition; +use single_clustering::community_search::leiden::{leiden, modularity, LeidenConfig, ObjectiveKind}; -// Create a CSR network from your data -let network = CSRNetwork::new(edges, weights, node_count); +# fn main() -> single_clustering::Result<()> { +// Build a graph from an edge list. Each undirected edge is given once. +let edges = [(0, 1, 1.0), (1, 2, 1.0), (0, 2, 1.0), (3, 4, 1.0), (4, 5, 1.0), (3, 5, 1.0)]; +let graph = CSRNetwork::from_edges(6, &edges)?; -// Configure the Leiden algorithm let config = LeidenConfig { - max_iterations: 100, - tolerance: 1e-6, + objective: ObjectiveKind::Rb { resolution: 1.0 }, seed: Some(42), ..Default::default() }; -// Initialize the optimizer -let mut optimizer = LeidenOptimizer::new(config); +let clustering = leiden(&graph, &config)?; -// Find communities using modularity optimization -let partition: ModularityPartition = optimizer.find_partition(network)?; - -// Access results -for node in 0..partition.node_count() { - println!("Node {} is in community {}", node, partition.membership(node)); +println!("{} communities", clustering.n_clusters()); +for (node, &label) in clustering.labels().iter().enumerate() { + println!("node {node} is in community {label}"); } -println!("Modularity: {:.4}", partition.quality()); +println!("modularity: {:.4}", modularity(&graph, clustering.labels(), 1.0)); +# Ok(()) +# } ``` -## Installation +### Large graphs + +`CSRNetwork::from_edges` needs the caller's edge list resident alongside the graph. When the +data is already a connectivity matrix — as it is coming out of a k-NN step — +`from_csr_parts` takes ownership of the CSR buffers instead, so nothing proportional to the +edge count is copied: + +```rust,no_run +use single_clustering::network::CSRNetwork; +# fn main() -> single_clustering::Result<()> { +# let (node_ptrs, neighbors, weights) = (vec![0usize, 1, 2], vec![1u32, 0], vec![1.0f32, 1.0]); +// node_ptrs: row offsets; neighbors: column indices; weights: values. +// Must be the full symmetric adjacency. Rows need not be sorted. +let graph = CSRNetwork::from_csr_parts(node_ptrs, neighbors, weights, None)?; +println!("{:.1} GB", graph.memory_bytes() as f64 / 1e9); +# Ok(()) +# } +``` + +Measured peak memory, clustering included: + +| nodes | edges | via `from_edges` | via `from_csr_parts` | +|---|---|---|---| +| 4M | 28M | 2.0 GB | 1.4 GB | +| 8M | 57M | 3.7 GB | 2.6 GB | + +The `from_edges` figures include the caller's edge list, which `from_csr_parts` never needs. -Add this to your `Cargo.toml`: +Adjacency is stored as `u32` ids and `f32` weights (16 bytes per undirected edge); all +arithmetic is `f64`. That caps graphs at ~4.29 billion nodes and means quality values agree +across aggregation levels to `f32` precision rather than exactly — a tradeoff that measurably +costs nothing in cluster quality, and none at all when the input was `f32` to begin with, as +k-NN connectivities normally are. + +### Choosing a resolution + +`ObjectiveKind::Rb { resolution }` means the same thing it does in `scanpy` and `leidenalg`: +higher values give more, smaller communities, and `1.0` is standard modularity. If you are +porting parameters from a Python pipeline, they carry over directly. + +`ObjectiveKind::Cpm { resolution }` measures communities in node weight rather than degree and +has no resolution limit, which makes it better behaved when sweeping resolution on large +graphs. + +## Installation ```toml [dependencies] -single-clustering = "0.6.0" +single-clustering = "0.7" +``` + +The k-NN graph construction is behind the default-on `knn` feature. To build just the +clustering core — useful in CI, or on targets where the HNSW stack does not compile: + +```toml +single-clustering = { version = "0.7", default-features = false } ``` -## Current Status +## Current status -- ✅ **Leiden Algorithm**: Core implementation with modularity and RB quality functions -- ✅ **CSR Network Representation**: Efficient storage for large graphs -- ✅ **Quality Functions**: Modularity and Reichardt-Bornholdt implementations -- 🚧 **Louvain Algorithm**: Basic implementation (work-in-progress) -- 🚧 **Documentation**: API documentation and examples (ongoing) -- ❌ **Benchmarks**: Performance testing suite (planned) -- ❌ **Python Bindings**: PyO3 integration (planned) +- ✅ **Leiden algorithm**: local moving, refinement, and aggregation +- ✅ **CSR network representation** +- ✅ **Quality functions**: RB configuration model and CPM +- ✅ **Reproducibility**: deterministic under a fixed seed +- 🚧 **Louvain**: available as `LeidenConfig { refine: false, .. }`; no separate entry point +- 🚧 **Benchmarks**: `cargo run --release --example scaling` +- ❌ **Parallel local moving**: planned, deliberately deferred until the sequential path is + measured +- ❌ **DBSCAN / HDBSCAN / spatial-aware clustering**: planned +- ❌ **Python bindings**: PyO3 integration (planned) ## Contributing -This project is in active development. Contributions, bug reports, and feature requests are welcome! +This project is in active development. Contributions, bug reports, and feature requests are +welcome! ## License -This crate is licensed under the BSD 3-Clause License. \ No newline at end of file +This crate is licensed under the BSD 3-Clause License. diff --git a/benches/leiden.rs b/benches/leiden.rs new file mode 100644 index 0000000..19e26f3 --- /dev/null +++ b/benches/leiden.rs @@ -0,0 +1,144 @@ +//! Benchmarks over synthetic kNN-like graphs, the shape single-cell pipelines produce. +//! +//! ```text +//! cargo bench --no-default-features --bench leiden +//! ``` + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; +use single_clustering::community_search::leiden::{LeidenConfig, ObjectiveKind, leiden}; +use single_clustering::network::CSRNetwork; +use std::collections::{HashMap, HashSet}; +use std::hint::black_box; + +/// Points drawn around well-separated centres, each joined to its `k` nearest neighbours. +/// Neighbour search is grid-bucketed so building the graph does not dominate setup. +fn knn_graph(n: usize, blocks: usize, k: usize, seed: u64) -> CSRNetwork { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let per = n / blocks; + let centers: Vec<(f64, f64)> = (0..blocks) + .map(|b| { + let a = 2.0 * std::f64::consts::PI * (b as f64) / (blocks as f64); + (60.0 * a.cos(), 60.0 * a.sin()) + }) + .collect(); + let pts: Vec<(f64, f64)> = (0..n) + .map(|i| { + let c = centers[(i / per).min(blocks - 1)]; + ( + c.0 + rng.random::() * 16.0 - 8.0, + c.1 + rng.random::() * 16.0 - 8.0, + ) + }) + .collect(); + + let cell = 4.0; + let mut grid: HashMap<(i64, i64), Vec> = HashMap::new(); + for (i, p) in pts.iter().enumerate() { + grid.entry(((p.0 / cell) as i64, (p.1 / cell) as i64)) + .or_default() + .push(i); + } + + let mut edges = HashSet::new(); + let mut cand: Vec = Vec::new(); + for i in 0..n { + let (gx, gy) = ((pts[i].0 / cell) as i64, (pts[i].1 / cell) as i64); + cand.clear(); + for dx in -2..=2 { + for dy in -2..=2 { + if let Some(bucket) = grid.get(&(gx + dx, gy + dy)) { + cand.extend(bucket.iter().copied().filter(|&j| j != i)); + } + } + } + cand.sort_unstable_by(|&a, &b| { + let da = (pts[i].0 - pts[a].0).powi(2) + (pts[i].1 - pts[a].1).powi(2); + let db = (pts[i].0 - pts[b].0).powi(2) + (pts[i].1 - pts[b].1).powi(2); + da.partial_cmp(&db).unwrap() + }); + for &j in cand.iter().take(k) { + edges.insert(if i < j { (i, j) } else { (j, i) }); + } + } + + let edges: Vec<(usize, usize, f64)> = edges.into_iter().map(|(a, b)| (a, b, 1.0)).collect(); + CSRNetwork::from_edges(n, &edges).unwrap() +} + +fn bench_scaling(c: &mut Criterion) { + let mut group = c.benchmark_group("leiden/knn15"); + group.sample_size(10); + + for &n in &[1_000usize, 10_000, 100_000] { + let graph = knn_graph(n, 10, 15, 5); + group.throughput(Throughput::Elements(n as u64)); + group.bench_with_input(BenchmarkId::from_parameter(n), &graph, |b, graph| { + b.iter(|| black_box(leiden(graph, &LeidenConfig::default()).unwrap())); + }); + } + group.finish(); +} + +fn bench_resolution(c: &mut Criterion) { + let graph = knn_graph(20_000, 10, 15, 5); + let mut group = c.benchmark_group("leiden/resolution"); + group.sample_size(10); + + for &r in &[0.25f64, 1.0, 4.0] { + group.bench_with_input(BenchmarkId::from_parameter(r), &r, |b, &r| { + b.iter(|| black_box(leiden(&graph, &LeidenConfig::with_resolution(r)).unwrap())); + }); + } + group.finish(); +} + +fn bench_objective(c: &mut Criterion) { + let graph = knn_graph(20_000, 10, 15, 5); + let mut group = c.benchmark_group("leiden/objective"); + group.sample_size(10); + + for (name, objective) in [ + ("rb", ObjectiveKind::Rb { resolution: 1.0 }), + ("cpm", ObjectiveKind::Cpm { resolution: 0.05 }), + ] { + group.bench_function(name, |b| { + let config = LeidenConfig { + objective, + ..Default::default() + }; + b.iter(|| black_box(leiden(&graph, &config).unwrap())); + }); + } + group.finish(); +} + +fn bench_graph_construction(c: &mut Criterion) { + let graph = knn_graph(100_000, 10, 15, 5); + let edges: Vec<(usize, usize, f64)> = (0..graph.node_count()) + .flat_map(|v| { + graph + .neighbors(v) + .filter(move |&(u, _)| v <= u) + .map(move |(u, w)| (v, u, w)) + }) + .collect(); + + let mut group = c.benchmark_group("graph"); + group.sample_size(10); + group.throughput(Throughput::Elements(edges.len() as u64)); + group.bench_function("from_edges/100k", |b| { + b.iter(|| black_box(CSRNetwork::from_edges(100_000, &edges).unwrap())); + }); + group.finish(); +} + +criterion_group!( + benches, + bench_scaling, + bench_resolution, + bench_objective, + bench_graph_construction +); +criterion_main!(benches); diff --git a/docs/testing-infrastructure.md b/docs/testing-infrastructure.md new file mode 100644 index 0000000..c393325 --- /dev/null +++ b/docs/testing-infrastructure.md @@ -0,0 +1,260 @@ +# SingleRust testing & benchmarking infrastructure + +Org-level design notes. Scoped to what one maintainer can run and keep running, with no +institutional compute and no recurring engineering budget. + +> Lives in `single-clustering` for now; belongs in a `SingleRust/.github` org repo once the +> reusable workflows exist. + +## Constraints that shape everything + +- **Compute is not the scarce resource.** GitHub Actions is free and unlimited for public + repos on Linux runners. *Maintainer attention* is the budget. +- Therefore: every CI test must be **deterministic, fast, and diagnostic on failure**. A flaky + test is worse than no test — there is nobody else to triage it, and you learn to ignore red. +- Anything slow, large, or hardware-sensitive moves **out of CI** and onto rented ephemeral + compute, on a schedule. + +--- + +## P0 — the publish gate (live hazard) + +`.github/workflows/publish.yml`: + +```yaml +on: + push: + branches: [main] # ← these are OR, not AND + tags: ['v*'] +``` + +Every push to `main` triggers a crates.io publish, with **no test job gating it**. crates.io +has 0.6.1 (7.8k downloads); `Cargo.toml` says 0.7.0. Pushing the Leiden rewrite publishes it — +untested and unrecallable (crates.io versions yank, never replace). + +**Fix:** trigger on `tags: ['v*']` only, add `needs: [test]`, replace the EOL +`actions/checkout@v2` and archived `actions-rs/toolchain`. Blocks committing 0.7.0. + +--- + +## Test tiers + +| Tier | Content | Where | Cadence | Cost | +|---|---|---|---|---| +| 0 | Committed fixtures, <1 MB | CI | every push | seconds | +| 1 | Seeded synthetic, ≤1M nodes | CI | nightly | minutes | +| 2 | **Real data, committed exports** | CI | every push | ~80 ms | +| 3 | 50–75M cells, perf + peak RSS | rented instance | weekly / on tag | €, not free | + +### Tier 2 — real data belongs in CI + +Commit the **export**, not the `.h5ad`. Measured for pbmc3k: + +``` +indptr.bin 21 KB indices.bin 260 KB data.bin 260 KB +reference.json 75 KB (scanpy labels × 8 resolutions + author cell types) + 616 KB raw → 350 KB in git +``` + +No Python, no scanpy, no download in CI. pbmc68k_reduced is ~6 MB by the same math — still +committable, worth adding as a second size point. + +Promote `examples/real_data.rs` → `tests/real_data.rs`. Assertions that hold with margin: + +| Assertion | Measured | Note | +|---|---|---| +| cluster count within ±1 of scanpy, every resolution | 3/3 … 50/49 | | +| **modularity ≥ scanpy − 0.005**, every resolution | ±0.0015 | tightest, implementation-independent | +| ARI vs author cell types ≥ 0.85 | 0.8599 | the biology gate | +| ARI vs scanpy ≥ 0.95, **res ≤ 0.75 only** | 0.985–1.000 | | +| symmetry passes, no self-loops, seed → identical labels | ✓ | | + +Deliberately **not** gated: ARI vs scanpy at fine resolution (0.65 at res=1.5). That is genuine +near-degeneracy, not a defect — gating it buys a flaky test. + +--- + +## The oracle: committed differential fixtures + +The pattern that earned its keep. It caught the two Leiden bugs nothing else reached +(`n_iterations` continuation semantics, and the level-loop early break) — neither was +reachable by invariant or property tests. + +**Python lives in `tools/`, runs on a laptop, and never runs in CI.** 208 KB of committed JSON +is a permanent oracle with zero ongoing cost and no dependency rot. + +Generalizes org-wide — every crate has a Python counterpart that *is* ground truth: + +| Crate | Oracle | +|---|---| +| single-clustering | leidenalg / igraph / scanpy | +| anndata-rs | anndata (currently done the expensive way: 4 versions installed per CI run) | +| Anndata-Memory | anndata semantics | + +Convention: `tools/gen_fixtures.py` + `uv` + **committed lockfile** + `just regen-fixtures` +that regenerates and shows the diff. The lockfile is what makes this reproducible in two years. + +--- + +## Shared infrastructure + +1. **`SingleRust/.github` org repo** with reusable workflows. Each crate's CI becomes ~6 lines + calling `rust-ci.yml@main`. Fix once, not four times. +2. **`singlerust-testkit`** dev-dependency crate: + - ARI / NMI / clustering metrics — *already* duplicated between `tests/common/mod.rs` and + `examples/real_data.rs`; that spreads + - fixture loading + schema + - deterministic synthetic generators (SBM, LFR, synthetic AnnData with known structure) + - cached dataset fetcher keyed by content hash + +--- + +## Benchmark platform (ephemeral OVH) + +``` +weekly cron / workflow_dispatch / tag + ├─ provision (ubuntu-latest, free) create instance; cloud-init carries a JIT + │ runner token; tag purpose=bench, run-id=… + ├─ benchmark (self-hosted, run-${{ github.run_id }}) + └─ teardown (ubuntu-latest, if: always()) +``` + +Label the runner with the **run id** so concurrent runs cannot steal each other's instance. +Provisioning is the easy part. Two things decide whether this works. + +### 1. Leak protection — three independent layers + +`if: always()` alone is not enough; a cancelled workflow or crashed runner skips it. A leaked +instance bills indefinitely. + +1. `if: always()` teardown — the normal path +2. **Self-destruct on the box**: cloud-init `systemd-run --on-active=3h`. Must **delete**, not + `shutdown` — OVH bills a stopped Public Cloud instance. Needs a narrowly-scoped API key + that can only delete `purpose=bench` resources. +3. **Reaper**: daily cron on a free runner, deletes any `purpose=bench` instance older than + 6h. ~20 lines; catches everything the other two miss. **Treat as mandatory.** + +Plus a hard budget alert on the account. + +### 2. Trustworthy numbers + +Shared-tenancy variance will exceed the regressions being hunted. + +- **Pin the machine exactly** — flavor, region, *image by ID* (never "latest Ubuntu"), kernel. + Record all of it. If any field differs from the baseline, the tooling **refuses the + comparison** rather than silently reporting a regression. +- **Ship a calibration microbenchmark in every run** — a fixed CPU-bound kernel and a fixed + pointer-chase for memory latency. Report `benchmark / calibration`, not raw seconds. This is + what separates "my code got slower" from "I landed on a degraded host"; without it, + month-over-month cloud benchmarking is close to unusable. +- **≥5 reps, median + MAD.** If MAD is too wide, **discard the run** rather than publish it. + +### Machine notes + +- Check whether **OVH Metal Instances** (bare metal, hourly billing) exist in-region — no noisy + neighbours, so fewer reps and a much tighter calibration ratio. Classic Rise/Advance + dedicated is cheaper per unit but monthly-committed, which breaks the ephemeral model. +- **Sizing at 75M cells**: graph ~35 GB (12.3 undirected edges/cell measured on real 15-NN), + plus ~1.5 GB partition state plus aggregation levels. 64 GB tight, **128 GB comfortable**. +- Keep **kNN construction a separate benchmark** — 75M × 50 dims is 15 GB of input before the + HNSW index; mixing it in stops measuring clustering. +- **Staging**: derived CSR graphs in OVH Object Storage, same region (egress free), versioned + by content hash. Never re-derive from `.h5ad` per run — that benchmarks scanpy preprocessing. +- Order of magnitude: weekly 2-hour run on a memory-optimised flavour ≈ €10–20/month. Confirm + against current pricing. + +### Platform shape + +`workflow_dispatch` inputs (dataset, flavor, crate ref, resolutions) are effectively the +run-submission API today. A platform generalizes that surface — queue, parameterise, and +compare runs — but the artifact contract below is what it should be built around, not a +database. + +--- + +## Results schema + +singlerust.com already exists and already hosts benchmarks, so the transport is decided — the +runner should emit whatever that site ingests. What matters here is the **field set**, which is +transport-independent and is the part that is expensive to add retroactively: a run recorded +without `calibration` or without full `machine` provenance cannot be placed in a longitudinal +series later. + +Below is the minimum field set, expressed as JSON for concreteness. Map it onto the existing +format rather than adopting it wholesale. + +```json +{ + "schema_version": 1, + "timestamp": "2026-08-01T12:00:00Z", + "git_sha": "…", + "crate_version": "0.7.0", + "machine": { + "provider": "ovh", "flavor": "…", "region": "…", + "image_id": "…", "cpu_model": "…", "kernel": "…" + }, + "calibration": { "cpu_ns": 0, "mem_latency_ns": 0 }, + "results": [ + { + "name": "leiden/pbmc3k", "n_cells": 2638, "n_edges": 32507, + "reps": 5, "median_s": 0.0085, "mad_s": 0.0002, "peak_rss_bytes": 0, + "quality": { "modularity": 0.729, "ari_vs_truth": 0.86 } + } + ] +} +``` + +Whatever the transport, keep a copy of each run as a plain artifact next to the code — a run +should stay readable if the site is rewritten, and it makes `git bisect`-style perf archaeology +possible without querying anything. + +--- + +## Per-crate, by risk + +**Anndata-Memory — highest risk in the org.** 11 `unsafe` blocks, concurrency, and a `1.0.7` +version implying a stability promise. Needs `cargo miri test`, and `loom` if there are +hand-rolled synchronisation primitives. Two things look wrong now: it sits on branch +`feat-anndata-sprs-upgrade` while its CI triggers on `master` — **CI is likely not running on +the active branch at all**. + +**anndata-rs** — keep the Python version matrix, but first+last of the range on PRs, full +matrix nightly. + +**single-clustering** — suite is in good shape (87 tests, 5 integration binaries). Add +`cargo-semver-checks`; it is published and just took a breaking rewrite. + +--- + +## Deliberately skipped + +| Not doing | Why | +|---|---| +| Benchmark dashboard / server | committed JSON + static site covers it | +| Coverage gates | vanity metric solo; `cargo-mutants` quarterly says far more | +| Self-hosted persistent runners | leak risk and maintenance for no gain | +| Property tests everywhere | only where the invariant is crisp enough to be diagnostic | + +--- + +## Sequencing + +1. **Fix `publish.yml`** — blocks committing 0.7.0 +2. **Commit pbmc3k export + `tests/real_data.rs`** — 350 KB, real biology gate on every push +3. Org `.github` repo with reusable `rust-ci.yml`; point all four crates at it +4. Fix Anndata-Memory's CI branch trigger; add `miri` +5. Extract `singlerust-testkit` (once there is a second consumer) +6. Benchmark platform: reaper first, then provision/teardown, then calibration, then the runs + +## First sweep + +All published numbers get regenerated, so there is no history to preserve and no migration — +the field set is a greenfield decision, and the first run should be **one clean sweep**: all +crates, all datasets, one machine shape, one session. Every number then comparable to every +other, which is the strongest baseline the ephemeral-instance model can produce. Calibration +still matters for everything after it. + +Note the numbers currently on singlerust.com are 0.6.1 — quadratic local moving, and a +resolution parameter off by 2×, so that axis does not mean what 0.7.0's does. They measure +code that clustered incorrectly. Worth pulling or marking independently of when the sweep +lands; the crate is public with ~7.8k downloads. diff --git a/examples/evaluate.rs b/examples/evaluate.rs new file mode 100644 index 0000000..1750e71 --- /dev/null +++ b/examples/evaluate.rs @@ -0,0 +1,186 @@ +//! Head-to-head evaluation against `leidenalg` on LFR benchmarks and large kNN graphs. +//! +//! Reads graphs and reference results produced by the generator in `tools/`, runs our +//! algorithm on the same inputs, and reports NMI against ground truth, modularity, and wall +//! time for both. +//! +//! ```text +//! cargo run --release --no-default-features --example evaluate -- +//! ``` + +use single_clustering::community_search::leiden::{LeidenConfig, leiden, modularity}; +use single_clustering::network::CSRNetwork; +use std::time::Instant; + +fn read_edges(path: &std::path::Path) -> (usize, Vec<(usize, usize, f64)>) { + let text = std::fs::read_to_string(path).unwrap(); + let mut edges = Vec::new(); + let mut max_node = 0usize; + for line in text.lines() { + let mut it = line.split_whitespace(); + let a: usize = it.next().unwrap().parse().unwrap(); + let b: usize = it.next().unwrap().parse().unwrap(); + let w: f64 = it.next().map_or(1.0, |v| v.parse().unwrap()); + max_node = max_node.max(a).max(b); + edges.push((a, b, w)); + } + (max_node + 1, edges) +} + +fn read_labels(path: &std::path::Path) -> Option> { + std::fs::read_to_string(path) + .ok() + .map(|t| t.lines().map(|l| l.trim().parse().unwrap()).collect()) +} + +fn nmi(a: &[usize], b: &[usize]) -> f64 { + let n = a.len() as f64; + if n == 0.0 { + return 1.0; + } + let ka = a.iter().max().map_or(0, |m| m + 1); + let kb = b.iter().max().map_or(0, |m| m + 1); + let mut joint = std::collections::HashMap::new(); + let (mut pa, mut pb) = (vec![0.0f64; ka], vec![0.0f64; kb]); + for i in 0..a.len() { + *joint.entry((a[i], b[i])).or_insert(0.0) += 1.0; + pa[a[i]] += 1.0; + pb[b[i]] += 1.0; + } + let mut mi = 0.0; + for (&(i, j), &c) in &joint { + let p = c / n; + mi += p * (p / ((pa[i] / n) * (pb[j] / n))).ln(); + } + let h = |p: &[f64]| -> f64 { + -p.iter() + .filter(|&&x| x > 0.0) + .map(|&x| (x / n) * (x / n).ln()) + .sum::() + }; + let (ha, hb) = (h(&pa), h(&pb)); + if ha + hb == 0.0 { + 1.0 + } else { + 2.0 * mi / (ha + hb) + } +} + +fn main() { + let dir = std::path::PathBuf::from( + std::env::args() + .nth(1) + .expect("usage: evaluate "), + ); + let doc: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("results.json")).unwrap()).unwrap(); + + // Optional: igraph's native results, for the strongest available baseline. + let igraph: std::collections::HashMap = + std::fs::read_to_string(dir.join("igraph_results.json")) + .ok() + .and_then(|t| serde_json::from_str::(&t).ok()) + .map(|v| { + v.as_array() + .unwrap() + .iter() + .map(|r| (r["name"].as_str().unwrap().to_string(), r.clone())) + .collect() + }) + .unwrap_or_default(); + + println!( + "{:<22} {:>7} | {:>8} {:>9} | {:>8} {:>9} | {:>8} {:>9}", + "case", "nodes", "ourQ", "ours(ms)", "igQ", "igraph(ms)", "laQ", "la(ms)" + ); + println!("{}", "-".repeat(96)); + let mut q_vs_igraph = Vec::new(); + let mut speed_vs_igraph = Vec::new(); + + for case in doc["cases"].as_array().unwrap() { + let name = case["name"].as_str().unwrap(); + let (n, edges) = read_edges(&dir.join(format!("{name}.edges"))); + let truth = read_labels(&dir.join(format!("{name}.truth"))); + let resolution = case["resolution"].as_f64().unwrap(); + + let graph = CSRNetwork::from_edges(n, &edges).unwrap(); + let n_iterations = std::env::var("EVAL_ITERS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(2); + let config = LeidenConfig { + n_iterations, + ..LeidenConfig::with_resolution(resolution) + }; + + // Best of N, matching how the igraph/leidenalg timings were taken. + let repeats = if n <= 60_000 { 3 } else { 1 }; + let mut ours_secs = f64::INFINITY; + let mut clustering = leiden(&graph, &config).unwrap(); + for _ in 0..repeats { + let start = Instant::now(); + clustering = leiden(&graph, &config).unwrap(); + ours_secs = ours_secs.min(start.elapsed().as_secs_f64()); + } + + let reference: Vec = case["reference_membership"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap() as usize) + .collect(); + + let our_q = modularity(&graph, clustering.labels(), resolution); + let ref_q = modularity(&graph, &reference, resolution); + let (our_nmi, ref_nmi) = match &truth { + Some(t) => (nmi(clustering.labels(), t), nmi(&reference, t)), + None => (f64::NAN, f64::NAN), + }; + + let ig = igraph.get(name); + let (ig_q, ig_s) = match ig { + Some(r) => ( + r["igleiden"]["q"].as_f64().unwrap(), + r["igleiden"]["s"].as_f64().unwrap(), + ), + None => (f64::NAN, f64::NAN), + }; + let la_s = ig + .map(|r| r["leidenalg"]["s"].as_f64().unwrap()) + .unwrap_or(case["reference_seconds"].as_f64().unwrap()); + + if ig_q.is_finite() { + q_vs_igraph.push((our_q - ig_q) / ig_q.abs().max(1e-12)); + speed_vs_igraph.push(ig_s / ours_secs); + } + + println!( + "{:<22} {:>7} | {:>8.4} {:>9.1} | {:>8.4} {:>9.1} | {:>8.4} {:>9.1}", + name, + n, + our_q, + ours_secs * 1000.0, + ig_q, + ig_s * 1000.0, + ref_q, + la_s * 1000.0, + ); + let _ = (our_nmi, ref_nmi); + } + + if !q_vs_igraph.is_empty() { + let mean_q = q_vs_igraph.iter().sum::() / q_vs_igraph.len() as f64; + let wins = q_vs_igraph.iter().filter(|&&d| d > 1e-9).count(); + let losses = q_vs_igraph.iter().filter(|&&d| d < -1e-9).count(); + let geo_speed = (speed_vs_igraph.iter().map(|s| s.ln()).sum::() + / speed_vs_igraph.len() as f64) + .exp(); + println!("{}", "-".repeat(96)); + println!( + "vs igraph native: modularity {:+.4}% mean ({wins} better, {losses} worse of {}), \ + speed {geo_speed:.2}x (geometric mean)", + 100.0 * mean_q, + q_vs_igraph.len() + ); + } +} diff --git a/examples/memory.rs b/examples/memory.rs new file mode 100644 index 0000000..89e3396 --- /dev/null +++ b/examples/memory.rs @@ -0,0 +1,223 @@ +//! Measures peak memory and time per node/edge, so cost at sizes too large to run here can be +//! extrapolated. +//! +//! ```text +//! cargo run --release --no-default-features --example memory -- 2000000 +//! ``` + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; +use single_clustering::community_search::leiden::{LeidenConfig, leiden}; +use single_clustering::network::CSRNetwork; +use std::time::Instant; + +/// Peak resident set size in bytes. +#[cfg(target_os = "macos")] +fn peak_rss() -> u64 { + // getrusage(RUSAGE_SELF).ru_maxrss, in bytes on macOS + unsafe { + let mut usage: libc_rusage = std::mem::zeroed(); + rusage(0, &mut usage); + usage.ru_maxrss as u64 + } +} + +#[cfg(target_os = "macos")] +#[repr(C)] +#[derive(Default)] +struct libc_rusage { + ru_utime: [i64; 2], + ru_stime: [i64; 2], + ru_maxrss: i64, + rest: [i64; 14], +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + #[link_name = "getrusage"] + fn rusage(who: i32, usage: *mut libc_rusage) -> i32; +} + +#[cfg(not(target_os = "macos"))] +fn peak_rss() -> u64 { + std::fs::read_to_string("/proc/self/status") + .ok() + .and_then(|s| { + s.lines() + .find(|l| l.starts_with("VmHWM:")) + .and_then(|l| l.split_whitespace().nth(1)?.parse::().ok()) + .map(|kb| kb * 1024) + }) + .unwrap_or(0) +} + +/// A ring-lattice-with-noise graph: cheap to build at scale and structurally similar to a kNN +/// graph (bounded degree, local connectivity, a few long-range edges). +fn synthetic_knn(n: usize, k: usize, seed: u64) -> Vec<(usize, usize, f64)> { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let mut edges = Vec::with_capacity(n * k / 2 + n); + let block = 2_000usize; // community size + for i in 0..n { + for d in 1..=(k / 2) { + let j = i + d; + if j < n && (i / block == j / block) { + edges.push((i, j, 1.0f64)); + } + } + // one cross-block edge per node, keeping communities detectable + if rng.random::() < 0.1 { + let j = rng.random_range(0..n); + if j != i { + edges.push((i.min(j), i.max(j), 1.0f64)); + } + } + } + edges +} + +/// Builds the same graph as `synthetic_knn`, but emits symmetric CSR arrays directly — the +/// shape a kNN connectivity matrix already has. No edge list is ever materialised. +fn synthetic_knn_csr(n: usize, k: usize, seed: u64) -> (Vec, Vec, Vec) { + // two streaming passes, count then fill, so no edge list is ever resident; the seeded + // RNG makes both passes emit the same edges + let emit = |mut f: Box| { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let block = 2_000usize; + for i in 0..n { + for d in 1..=(k / 2) { + let j = i + d; + if j < n && (i / block == j / block) { + f(i, j, 1.0); + } + } + if rng.random::() < 0.1 { + let j = rng.random_range(0..n); + if j != i { + f(i.min(j), i.max(j), 1.0); + } + } + } + }; + + let mut counts = vec![0usize; n]; + emit(Box::new(|a, b, _| { + counts[a] += 1; + if a != b { + counts[b] += 1; + } + })); + + let mut node_ptrs = vec![0usize; n + 1]; + for v in 0..n { + node_ptrs[v + 1] = node_ptrs[v] + counts[v]; + } + drop(counts); + + let slots = node_ptrs[n]; + let mut neighbors = vec![0u32; slots]; + let mut weights = vec![0.0f32; slots]; + let mut cursor = node_ptrs.clone(); + emit(Box::new(|a, b, w| { + neighbors[cursor[a]] = b as u32; + weights[cursor[a]] = w; + cursor[a] += 1; + if a != b { + neighbors[cursor[b]] = a as u32; + weights[cursor[b]] = w; + cursor[b] += 1; + } + })); + drop(cursor); + (node_ptrs, neighbors, weights) +} + +fn gb(bytes: u64) -> f64 { + bytes as f64 / (1024.0 * 1024.0 * 1024.0) +} + +fn main() { + let n: usize = std::env::args() + .nth(1) + .and_then(|v| v.parse().ok()) + .unwrap_or(1_000_000); + let k = 15; + + // Zero-copy path: hand over CSR buffers we already hold. + if std::env::var("CSR_PATH").is_ok() { + let t = Instant::now(); + let (node_ptrs, neighbors, weights) = synthetic_knn_csr(n, k, 7); + let input_bytes = (node_ptrs.len() * 8 + neighbors.len() * 4 + weights.len() * 4) as u64; + println!( + "built symmetric CSR ({:.2} GB) in {:.2?}", + gb(input_bytes), + t.elapsed() + ); + let t = Instant::now(); + let graph = CSRNetwork::from_csr_parts(node_ptrs, neighbors, weights, None).unwrap(); + println!( + "from_csr_parts: {:.2?}, peak RSS {:.2} GB, graph itself {:.2} GB", + t.elapsed(), + gb(peak_rss()), + gb(graph.memory_bytes() as u64) + ); + let t = Instant::now(); + let c = leiden(&graph, &LeidenConfig::default()).unwrap(); + println!( + "leiden: {:.2?}, {} clusters, peak RSS {:.2} GB", + t.elapsed(), + c.n_clusters(), + gb(peak_rss()) + ); + return; + } + + let base = peak_rss(); + let t0 = Instant::now(); + let edges = synthetic_knn(n, k, 7); + let edge_list_bytes = (edges.len() * std::mem::size_of::<(usize, usize, f64)>()) as u64; + println!( + "built {} edges for {n} nodes in {:.2?} (edge list itself: {:.2} GB)", + edges.len(), + t0.elapsed(), + gb(edge_list_bytes) + ); + + let t1 = Instant::now(); + let graph = CSRNetwork::from_edges(n, &edges).unwrap(); + let after_build = peak_rss(); + println!( + "CSRNetwork::from_edges: {:.2?}, peak RSS {:.2} GB, graph itself {:.2} GB", + t1.elapsed(), + gb(after_build), + gb(graph.memory_bytes() as u64) + ); + + drop(edges); + + let t2 = Instant::now(); + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + let after_cluster = peak_rss(); + let cluster_time = t2.elapsed(); + + println!( + "leiden: {:.2?}, {} clusters, peak RSS {:.2} GB", + cluster_time, + clustering.n_clusters(), + gb(after_cluster) + ); + + let m = graph.edge_count(); + println!(); + println!("--- per-element costs (for extrapolation) ---"); + println!("nodes {n}, undirected edges {m}"); + println!( + "peak bytes/node {:.1}, peak bytes/edge {:.1}", + (after_cluster - base) as f64 / n as f64, + (after_cluster - base) as f64 / m as f64 + ); + println!( + "clustering time: {:.1} ns/edge, {:.1} ns/node", + cluster_time.as_nanos() as f64 / m as f64, + cluster_time.as_nanos() as f64 / n as f64 + ); +} diff --git a/examples/phases.rs b/examples/phases.rs new file mode 100644 index 0000000..7dc5f9a --- /dev/null +++ b/examples/phases.rs @@ -0,0 +1,189 @@ +//! Splits one Leiden pass into its three phases and times each, per aggregation level. +//! +//! ```text +//! cargo run --release --no-default-features --example phases -- 1000000 +//! ``` +//! +//! Replicates the driver in `leiden::run_once` using the public API, so the phase split is +//! measured on exactly the work the real driver does. Answers "what is worth parallelising". + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; +use single_clustering::community_search::leiden::objective::{Cpm, Objective, Rb}; +use single_clustering::community_search::leiden::{ + LeidenConfig, MoveScratch, ObjectiveKind, Partition, local_move, refine, +}; +use single_clustering::network::CSRNetwork; +use std::time::{Duration, Instant}; + +/// Same generator as `examples/scaling.rs`: blobs joined to their k nearest neighbours. +fn knn_graph(n: usize, blocks: usize, k: usize, seed: u64) -> Vec<(usize, usize, f64)> { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let n_per = n / blocks; + let centers: Vec<(f64, f64)> = (0..blocks) + .map(|b| { + let a = 2.0 * std::f64::consts::PI * (b as f64) / (blocks as f64); + (40.0 * a.cos(), 40.0 * a.sin()) + }) + .collect(); + let pts: Vec<(f64, f64)> = (0..n) + .map(|i| { + let c = centers[(i / n_per).min(blocks - 1)]; + ( + c.0 + rng.random::() * 12.0 - 6.0, + c.1 + rng.random::() * 12.0 - 6.0, + ) + }) + .collect(); + + let cell = 3.0; + let mut grid: std::collections::HashMap<(i64, i64), Vec> = + std::collections::HashMap::new(); + for (i, p) in pts.iter().enumerate() { + grid.entry(((p.0 / cell) as i64, (p.1 / cell) as i64)) + .or_default() + .push(i); + } + + let mut edges = std::collections::HashSet::new(); + let mut cand = Vec::new(); + for i in 0..n { + let (gx, gy) = ((pts[i].0 / cell) as i64, (pts[i].1 / cell) as i64); + cand.clear(); + for dx in -1..=1 { + for dy in -1..=1 { + if let Some(bucket) = grid.get(&(gx + dx, gy + dy)) { + cand.extend(bucket.iter().copied().filter(|&j| j != i)); + } + } + } + let d = |j: &usize| { + let (dx, dy) = (pts[*j].0 - pts[i].0, pts[*j].1 - pts[i].1); + dx * dx + dy * dy + }; + if cand.len() > k { + cand.select_nth_unstable_by(k, |a, b| d(a).partial_cmp(&d(b)).unwrap()); + cand.truncate(k); + } + for &j in &cand { + edges.insert(if i < j { (i, j) } else { (j, i) }); + } + } + edges.into_iter().map(|(a, b)| (a, b, 1.0)).collect() +} + +fn pct(d: Duration, total: Duration) -> f64 { + 100.0 * d.as_secs_f64() / total.as_secs_f64().max(1e-12) +} + +fn main() { + let n: usize = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(1_000_000); + + println!("generating {n}-node 15-NN graph..."); + let t = Instant::now(); + let edges = knn_graph(n, 12, 15, 7); + let graph = CSRNetwork::from_edges(n, &edges).unwrap(); + drop(edges); + println!( + " {} nodes, {} edges, {:.2} MB, built in {:.2?}\n", + graph.node_count(), + graph.edge_count(), + graph.memory_bytes() as f64 / 1e6, + t.elapsed() + ); + + let config = LeidenConfig::default(); + let objective: Box = match config.objective { + ObjectiveKind::Rb { resolution } => Box::new(Rb::new(resolution)), + ObjectiveKind::Cpm { resolution } => Box::new(Cpm::new(resolution)), + }; + let mut rng = ChaCha8Rng::seed_from_u64(config.seed.unwrap_or(42)); + + let mut level_graph = graph.clone(); + let initial: Vec = (0..n).collect(); + let mut partition = Partition::from_membership(&level_graph, &initial); + let mut scratch = MoveScratch::with_capacity(n, partition.slots().max(n) + 1); + + let (mut t_move, mut t_refine, mut t_agg) = (Duration::ZERO, Duration::ZERO, Duration::ZERO); + + println!( + "{:>5} {:>10} {:>10} | {:>10} {:>10} {:>10}", + "lvl", "nodes", "edges", "move", "refine", "aggregate" + ); + println!("{}", "-".repeat(64)); + + let whole = Instant::now(); + for level in 0..config.max_levels { + let nodes_before = level_graph.node_count(); + + let a = Instant::now(); + local_move( + &level_graph, + &mut partition, + objective.as_ref(), + &mut scratch, + &mut rng, + config.max_community_weight, + ); + let d_move = a.elapsed(); + + let a = Instant::now(); + let mut collapse_by = refine( + &level_graph, + partition.membership_raw(), + objective.as_ref(), + &mut scratch, + &mut rng, + config.max_community_weight, + config.refine_randomness, + ); + let d_refine = a.elapsed(); + + let a = Instant::now(); + let grouping = collapse_by.renumber_into_grouping(); + let coarse_graph = level_graph.aggregate(&grouping); + let d_agg = a.elapsed(); + + t_move += d_move; + t_refine += d_refine; + t_agg += d_agg; + + println!( + "{level:>5} {:>10} {:>10} | {:>10.2?} {:>10.2?} {:>10.2?}", + nodes_before, + level_graph.edge_count(), + d_move, + d_refine, + d_agg + ); + + if coarse_graph.node_count() >= nodes_before { + break; + } + + let mut coarse_membership = vec![0usize; coarse_graph.node_count()]; + for v in 0..nodes_before { + coarse_membership[collapse_by.membership(v)] = partition.membership(v); + } + level_graph = coarse_graph; + partition = Partition::from_membership(&level_graph, &coarse_membership); + } + let total = whole.elapsed(); + + println!("{}", "-".repeat(64)); + println!( + "{:>27} | {:>10.2?} {:>10.2?} {:>10.2?}", + "total", t_move, t_refine, t_agg + ); + println!( + "{:>27} | {:>9.1}% {:>9.1}% {:>9.1}%", + "share", + pct(t_move, total), + pct(t_refine, total), + pct(t_agg, total) + ); + println!("\none pass: {total:.2?}"); +} diff --git a/examples/real_data.rs b/examples/real_data.rs new file mode 100644 index 0000000..aa072f3 --- /dev/null +++ b/examples/real_data.rs @@ -0,0 +1,237 @@ +//! Validates against a real single-cell dataset exported by `tools/export_h5ad.py`. +//! +//! ```text +//! uv run tools/export_h5ad.py --dataset pbmc3k --out /tmp/pbmc3k +//! cargo run --release --no-default-features --example real_data -- /tmp/pbmc3k +//! ``` +//! +//! Clusters the *same* connectivity matrix scanpy did, so any difference is the algorithm, +//! not graph construction. Reports: +//! +//! * cluster count vs resolution, ours against scanpy's +//! * agreement with scanpy's partition (ARI / NMI) +//! * agreement with the author cell-type annotation — the only number here that's about +//! biology rather than matching another implementation +//! * modularity and wall time + +use single_clustering::community_search::leiden::{LeidenConfig, leiden, modularity}; +use single_clustering::network::CSRNetwork; +use std::path::Path; +use std::time::Instant; + +fn read_u64(path: &Path) -> Vec { + std::fs::read(path) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) + .chunks_exact(8) + .map(|c| u64::from_le_bytes(c.try_into().unwrap()) as usize) + .collect() +} + +fn read_u32(path: &Path) -> Vec { + std::fs::read(path) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) + .chunks_exact(4) + .map(|c| u32::from_le_bytes(c.try_into().unwrap())) + .collect() +} + +fn read_f32(path: &Path) -> Vec { + std::fs::read(path) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())) + .chunks_exact(4) + .map(|c| f32::from_le_bytes(c.try_into().unwrap())) + .collect() +} + +/// Adjusted Rand Index: agreement between two partitions, corrected for chance. +/// +/// The standard measure for comparing clusterings against a reference labelling. 1.0 is exact +/// agreement, 0.0 is what random labelling would score. +fn adjusted_rand_index(a: &[usize], b: &[usize]) -> f64 { + let n = a.len() as f64; + if n == 0.0 { + return 1.0; + } + let ka = a.iter().max().map_or(0, |m| m + 1); + let kb = b.iter().max().map_or(0, |m| m + 1); + let mut joint = std::collections::HashMap::new(); + let (mut sa, mut sb) = (vec![0.0f64; ka], vec![0.0f64; kb]); + for i in 0..a.len() { + *joint.entry((a[i], b[i])).or_insert(0.0f64) += 1.0; + sa[a[i]] += 1.0; + sb[b[i]] += 1.0; + } + let comb2 = |x: f64| x * (x - 1.0) / 2.0; + let sum_ij: f64 = joint.values().map(|&c| comb2(c)).sum(); + let sum_a: f64 = sa.iter().map(|&c| comb2(c)).sum(); + let sum_b: f64 = sb.iter().map(|&c| comb2(c)).sum(); + let total = comb2(n); + let expected = sum_a * sum_b / total; + let max = 0.5 * (sum_a + sum_b); + if (max - expected).abs() < 1e-12 { + return 1.0; + } + (sum_ij - expected) / (max - expected) +} + +fn nmi(a: &[usize], b: &[usize]) -> f64 { + let n = a.len() as f64; + if n == 0.0 { + return 1.0; + } + let ka = a.iter().max().map_or(0, |m| m + 1); + let kb = b.iter().max().map_or(0, |m| m + 1); + let mut joint = std::collections::HashMap::new(); + let (mut pa, mut pb) = (vec![0.0f64; ka], vec![0.0f64; kb]); + for i in 0..a.len() { + *joint.entry((a[i], b[i])).or_insert(0.0f64) += 1.0; + pa[a[i]] += 1.0; + pb[b[i]] += 1.0; + } + let mut mi = 0.0; + for (&(i, j), &c) in &joint { + let p = c / n; + mi += p * (p / ((pa[i] / n) * (pb[j] / n))).ln(); + } + let h = |p: &[f64]| -> f64 { + -p.iter() + .filter(|&&x| x > 0.0) + .map(|&x| (x / n) * (x / n).ln()) + .sum::() + }; + let (ha, hb) = (h(&pa), h(&pb)); + if ha + hb == 0.0 { + 1.0 + } else { + 2.0 * mi / (ha + hb) + } +} + +fn main() { + let dir = std::path::PathBuf::from( + std::env::args() + .nth(1) + .expect("usage: real_data "), + ); + + let meta: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("meta.json")).unwrap()).unwrap(); + let reference: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(dir.join("reference.json")).unwrap()) + .unwrap(); + + let indptr = read_u64(&dir.join("indptr.bin")); + let indices = read_u32(&dir.join("indices.bin")); + let data = read_f32(&dir.join("data.bin")); + + println!( + "dataset {} — {} cells, {} directed entries, {}-NN, scanpy {}", + meta["dataset"].as_str().unwrap(), + meta["n_cells"], + meta["n_entries"], + meta["n_neighbors"], + meta["scanpy"].as_str().unwrap() + ); + + // The zero-copy path: exactly what a pipeline would hand over. + let build = Instant::now(); + let graph = CSRNetwork::from_csr_parts(indptr, indices, data, None) + .expect("scanpy connectivities should be a valid symmetric graph"); + let build_time = build.elapsed(); + + println!( + "graph built in {:.2?}: {} nodes, {} undirected edges, {:.1} MB, self-loops: {}", + build_time, + graph.node_count(), + graph.edge_count(), + graph.memory_bytes() as f64 / 1e6, + graph.has_self_loops() + ); + graph + .validate_symmetry() + .expect("exhaustive symmetry check should pass on scanpy output"); + println!("exhaustive symmetry check: OK"); + + let annotation: Option> = reference.get("annotation").map(|a| { + a["labels"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_i64().unwrap().max(0) as usize) + .collect() + }); + if let Some(a) = reference.get("annotation") { + println!( + "author annotation '{}': {} cell types", + a["name"].as_str().unwrap(), + a["categories"].as_array().unwrap().len() + ); + } + + println!(); + println!( + "{:>6} | {:>7} {:>7} | {:>7} {:>7} | {:>8} {:>8} | {:>9} {:>9}", + "res", "ours_k", "scanpy_k", "ARI", "NMI", "ourQ", "scanpyQ", "ours(ms)", "scanpy(ms)" + ); + println!("{}", "-".repeat(96)); + + // Keep the original key strings: Rust renders 1.0f64 as "1" while Python wrote "1.0", so + // reconstructing the key from the parsed float would miss. + let mut resolutions: Vec<(f64, String)> = reference["resolutions"] + .as_object() + .unwrap() + .keys() + .map(|k| (k.parse().unwrap(), k.clone())) + .collect(); + resolutions.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + + let mut best_ari_to_truth = 0.0f64; + let mut best_ari_res = 0.0f64; + let mut scanpy_best_ari = 0.0f64; + + for (res, key) in resolutions { + let entry = &reference["resolutions"][&key]; + let scanpy_labels: Vec = entry["labels"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap() as usize) + .collect(); + + let config = LeidenConfig::with_resolution(res); + let t = Instant::now(); + let clustering = leiden(&graph, &config).unwrap(); + let elapsed = t.elapsed(); + + let ours = clustering.labels(); + println!( + "{:>6} | {:>7} {:>7} | {:>7.4} {:>7.4} | {:>8.4} {:>8.4} | {:>9.1} {:>9.1}", + res, + clustering.n_clusters(), + entry["n_clusters"].as_u64().unwrap(), + adjusted_rand_index(ours, &scanpy_labels), + nmi(ours, &scanpy_labels), + modularity(&graph, ours, res), + modularity(&graph, &scanpy_labels, res), + elapsed.as_secs_f64() * 1000.0, + entry["seconds"].as_f64().unwrap() * 1000.0, + ); + + if let Some(truth) = &annotation { + let ari = adjusted_rand_index(ours, truth); + if ari > best_ari_to_truth { + best_ari_to_truth = ari; + best_ari_res = res; + } + scanpy_best_ari = scanpy_best_ari.max(adjusted_rand_index(&scanpy_labels, truth)); + } + } + + if annotation.is_some() { + println!(); + println!( + "best agreement with author cell types: ours ARI {best_ari_to_truth:.4} \ + (at resolution {best_ari_res}), scanpy ARI {scanpy_best_ari:.4}" + ); + } +} diff --git a/examples/scaling.rs b/examples/scaling.rs new file mode 100644 index 0000000..79abc3b --- /dev/null +++ b/examples/scaling.rs @@ -0,0 +1,106 @@ +//! Times Leiden on synthetic kNN-like graphs of increasing size. +//! +//! ```text +//! cargo run --release --no-default-features --example scaling +//! ``` +//! +//! Expect more communities than blobs — a uniform blob wired up by 15-NN is a sparse mesh, +//! not one dense community, and splitting it really does score higher than the planted +//! labelling. That's the objective working, not over-splitting. + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; +use single_clustering::community_search::leiden::{LeidenConfig, leiden, modularity}; +use single_clustering::network::CSRNetwork; +use std::time::Instant; + +/// Points drawn around `blocks` well-separated centres, each joined to its `k` nearest +/// neighbours — the shape of a single-cell neighbourhood graph. +fn knn_graph( + n_per: usize, + blocks: usize, + k: usize, + seed: u64, +) -> (Vec<(usize, usize, f64)>, usize) { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let n = n_per * blocks; + let centers: Vec<(f64, f64)> = (0..blocks) + .map(|b| { + let a = 2.0 * std::f64::consts::PI * (b as f64) / (blocks as f64); + (40.0 * a.cos(), 40.0 * a.sin()) + }) + .collect(); + let pts: Vec<(f64, f64)> = (0..n) + .map(|i| { + let c = centers[i / n_per]; + ( + c.0 + rng.random::() * 12.0 - 6.0, + c.1 + rng.random::() * 12.0 - 6.0, + ) + }) + .collect(); + + // Grid-bucketed nearest neighbours, so graph construction does not dominate the timing. + let cell = 3.0; + let mut grid: std::collections::HashMap<(i64, i64), Vec> = + std::collections::HashMap::new(); + for (i, p) in pts.iter().enumerate() { + grid.entry(((p.0 / cell) as i64, (p.1 / cell) as i64)) + .or_default() + .push(i); + } + + let mut edges = std::collections::HashSet::new(); + let mut cand = Vec::new(); + for i in 0..n { + let (gx, gy) = ((pts[i].0 / cell) as i64, (pts[i].1 / cell) as i64); + cand.clear(); + for dx in -2..=2 { + for dy in -2..=2 { + if let Some(bucket) = grid.get(&(gx + dx, gy + dy)) { + cand.extend(bucket.iter().copied().filter(|&j| j != i)); + } + } + } + cand.sort_unstable_by(|&a, &b| { + let da = (pts[i].0 - pts[a].0).powi(2) + (pts[i].1 - pts[a].1).powi(2); + let db = (pts[i].0 - pts[b].0).powi(2) + (pts[i].1 - pts[b].1).powi(2); + da.partial_cmp(&db).unwrap() + }); + for &j in cand.iter().take(k) { + edges.insert(if i < j { (i, j) } else { (j, i) }); + } + } + + (edges.into_iter().map(|(a, b)| (a, b, 1.0)).collect(), n) +} + +fn main() { + println!( + "{:>8} {:>9} {:>10} {:>7} {:>8}", + "nodes", "edges", "time", "comms", "Q" + ); + for &(n_per, blocks) in &[ + (250usize, 4usize), + (500, 8), + (1000, 10), + (2000, 10), + (10_000, 10), + ] { + let (edges, n) = knn_graph(n_per, blocks, 15, 5); + let graph = CSRNetwork::from_edges(n, &edges).unwrap(); + + let start = Instant::now(); + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + let elapsed = start.elapsed(); + + println!( + "{:>8} {:>9} {:>10.2?} {:>7} {:>8.4}", + n, + edges.len(), + elapsed, + clustering.n_clusters(), + modularity(&graph, clustering.labels(), 1.0) + ); + } +} diff --git a/src/clustering.rs b/src/clustering.rs new file mode 100644 index 0000000..419d2fa --- /dev/null +++ b/src/clustering.rs @@ -0,0 +1,137 @@ +//! The result type shared by every clustering algorithm in this crate. + +/// Label assigned to points that an algorithm considers noise. +/// +/// Leiden never produces it — every node lands somewhere — but density-based methods will, so +/// it's here from the start rather than as a breaking change later. +pub const NOISE: usize = usize::MAX; + +/// An assignment of items to clusters. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Clustering { + labels: Vec, + n_clusters: usize, +} + +impl Clustering { + /// Builds a clustering from labels, renumbering them to be consecutive from 0. + /// + /// [`NOISE`] labels are preserved as-is and excluded from the cluster count. + pub fn from_labels(labels: Vec) -> Self { + let mut remap = std::collections::HashMap::new(); + let mut next = 0usize; + let mut out = Vec::with_capacity(labels.len()); + // node order, so numbering is deterministic + for &l in &labels { + if l == NOISE { + out.push(NOISE); + continue; + } + let id = *remap.entry(l).or_insert_with(|| { + let id = next; + next += 1; + id + }); + out.push(id); + } + Self { + labels: out, + n_clusters: next, + } + } + + /// Builds a clustering from labels already known to be consecutive from 0. + pub(crate) fn from_normalized(labels: Vec, n_clusters: usize) -> Self { + Self { labels, n_clusters } + } + + /// Cluster label of each item, in input order. + #[inline] + pub fn labels(&self) -> &[usize] { + &self.labels + } + + /// Number of clusters, excluding noise. + #[inline] + pub fn n_clusters(&self) -> usize { + self.n_clusters + } + + /// Number of items. + #[inline] + pub fn len(&self) -> usize { + self.labels.len() + } + + /// Whether there are no items. + #[inline] + pub fn is_empty(&self) -> bool { + self.labels.is_empty() + } + + /// Number of items in each cluster, indexed by cluster id. + pub fn cluster_sizes(&self) -> Vec { + let mut sizes = vec![0usize; self.n_clusters]; + for &l in &self.labels { + if l != NOISE { + sizes[l] += 1; + } + } + sizes + } + + /// The items in each cluster, indexed by cluster id. + pub fn clusters(&self) -> Vec> { + let mut out = vec![Vec::new(); self.n_clusters]; + for (item, &l) in self.labels.iter().enumerate() { + if l != NOISE { + out[l].push(item); + } + } + out + } + + /// Items labelled as noise. + pub fn noise(&self) -> Vec { + self.labels + .iter() + .enumerate() + .filter(|&(_, &l)| l == NOISE) + .map(|(i, _)| i) + .collect() + } + + /// Consumes the clustering and returns the raw labels. + pub fn into_labels(self) -> Vec { + self.labels + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_labels_renumbers_in_first_seen_order() { + let c = Clustering::from_labels(vec![7, 7, 3, 9, 3]); + assert_eq!(c.labels(), &[0, 0, 1, 2, 1]); + assert_eq!(c.n_clusters(), 3); + assert_eq!(c.cluster_sizes(), vec![2, 2, 1]); + } + + #[test] + fn noise_is_preserved_and_excluded() { + let c = Clustering::from_labels(vec![5, NOISE, 5, NOISE]); + assert_eq!(c.n_clusters(), 1); + assert_eq!(c.labels(), &[0, NOISE, 0, NOISE]); + assert_eq!(c.noise(), vec![1, 3]); + assert_eq!(c.cluster_sizes(), vec![2]); + } + + #[test] + fn empty_is_empty() { + let c = Clustering::from_labels(vec![]); + assert!(c.is_empty()); + assert_eq!(c.n_clusters(), 0); + } +} diff --git a/src/community_search/leiden/local_move.rs b/src/community_search/leiden/local_move.rs new file mode 100644 index 0000000..cf27017 --- /dev/null +++ b/src/community_search/leiden/local_move.rs @@ -0,0 +1,255 @@ +//! Local moving: the inner loop of Leiden. +//! +//! Take a node out of its community, put it wherever it scores best, repeat until nothing +//! wants to move. Nodes come off a queue seeded with a shuffled order; a node that moves +//! re-queues its neighbours, so only the affected part gets revisited. +//! +//! O(degree) per node, and nothing allocates per node or per candidate. + +use rand_chacha::ChaCha8Rng; + +use crate::community_search::leiden::objective::{InsertContext, Objective}; +#[allow(unused_imports)] +use crate::community_search::leiden::partition::NeighborWeights; +use crate::community_search::leiden::partition::{MoveScratch, Partition}; +use crate::network::CSRNetwork; + +/// Improvement floor, as a fraction of total edge weight. +/// +/// Relative, not absolute: otherwise scaling every weight by a constant could change the +/// clustering, even though the objective itself is scale-invariant. Low enough to sit under +/// any real gain, high enough that float dust doesn't cause no-op move cycles. +pub(crate) const MIN_GAIN_RELATIVE: f64 = 1e-12; + +/// The absolute improvement threshold for a given graph. +#[inline] +pub(crate) fn min_gain(partition: &Partition) -> f64 { + MIN_GAIN_RELATIVE * partition.total_weight().abs().max(1.0) +} + +/// Move cap, as a multiple of node count. Only a safety valve — this terminates on its own, +/// since every accepted move strictly increases a bounded objective. +const MAX_MOVES_PER_NODE: usize = 100; + +/// Moves nodes between communities until no single move improves the objective. +/// +/// Returns the total quality improvement. `max_community_weight` caps a community's summed +/// node weight; node weights add up when communities collapse, so it means the same thing at +/// every level. +pub fn local_move( + graph: &CSRNetwork, + partition: &mut Partition, + objective: &dyn Objective, + scratch: &mut MoveScratch, + rng: &mut ChaCha8Rng, + max_community_weight: Option, +) -> f64 { + let n = graph.node_count(); + if n == 0 { + return 0.0; + } + + scratch.seed_queue(n, rng); + let threshold = min_gain(partition); + let mut total_gain = 0.0; + let mut moves = 0usize; + let move_budget = n.saturating_mul(MAX_MOVES_PER_NODE); + + while let Some(v) = scratch.pop() { + let ctx = InsertContext::for_node(graph, v); + // hoisted out of the neighbour loop, ~15x fewer calls + scratch.weights.ensure_capacity(partition.slots() + 1); + scratch + .weights + .collect(graph, partition.membership_raw(), v); + + let old = partition.membership(v); + let weight_to_old = scratch.weights.weight_to(old); + partition.remove_node(v, graph, weight_to_old); + + // staying put is just another candidate — keeps the empty case uniform + let base_gain = objective.delta_insert(partition, &ctx, old, weight_to_old); + let mut best = old; + let mut best_gain = base_gain; + + let fits = |partition: &Partition, c: usize| match max_community_weight { + Some(limit) => partition.weight(c) + ctx.node_weight <= limit, + None => true, + }; + + for i in 0..scratch.weights.touched().len() { + let c = scratch.weights.touched()[i]; + if c == old || !fits(partition, c) { + continue; + } + let gain = objective.delta_insert(partition, &ctx, c, scratch.weights.weight_to(c)); + if gain > best_gain || (gain == best_gain && c < best) { + best = c; + best_gain = gain; + } + } + + // one per node, claimed below, so no two get the same + let empty = partition.empty_community(); + if empty != old && fits(partition, empty) { + let gain = objective.delta_insert(partition, &ctx, empty, 0.0); + if gain > best_gain || (gain == best_gain && empty < best) { + best = empty; + best_gain = gain; + } + } + + let accept = best != old && best_gain - base_gain > threshold && moves < move_budget; + let target = if accept { best } else { old }; + partition.insert_node(v, target, graph, scratch.weights.weight_to(target)); + + if !accept { + continue; + } + + total_gain += best_gain - base_gain; + moves += 1; + + // only neighbours outside the new community can have gone unstable + for (u, _) in graph.neighbors(v) { + if u != v && !scratch.is_queued(u) && partition.membership(u) != target { + scratch.push(u); + } + } + } + + total_gain +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::community_search::leiden::objective::{Rb, modularity}; + use rand::SeedableRng; + + fn two_cliques() -> CSRNetwork { + let mut edges = Vec::new(); + for b in 0..2usize { + for i in 0..5 { + for j in (i + 1)..5 { + edges.push((b * 5 + i, b * 5 + j, 1.0)); + } + } + } + edges.push((0, 5, 1.0)); + CSRNetwork::from_edges(10, &edges).unwrap() + } + + fn run(graph: &CSRNetwork, seed: u64) -> (Partition, f64) { + let mut p = Partition::singleton(graph); + let mut scratch = MoveScratch::with_capacity(graph.node_count(), p.slots()); + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let gain = local_move(graph, &mut p, &Rb::new(1.0), &mut scratch, &mut rng, None); + (p, gain) + } + + #[test] + fn separates_two_cliques() { + let g = two_cliques(); + let (mut p, _) = run(&g, 42); + p.renumber(); + let labels = p.membership_vec(); + assert_eq!(p.community_count(), 2, "labels: {labels:?}"); + for i in 1..5 { + assert_eq!(labels[i], labels[0]); + assert_eq!(labels[5 + i], labels[5]); + } + assert_ne!(labels[0], labels[5]); + } + + #[test] + fn reported_gain_matches_the_objective() { + let g = two_cliques(); + let obj = Rb::new(1.0); + let mut p = Partition::singleton(&g); + let before = obj.quality(&p); + let mut scratch = MoveScratch::with_capacity(g.node_count(), p.slots()); + let mut rng = ChaCha8Rng::seed_from_u64(7); + let gain = local_move(&g, &mut p, &obj, &mut scratch, &mut rng, None); + let after = obj.quality(&p); + assert!( + (after - before - gain).abs() < 1e-9, + "reported {gain}, actual {}", + after - before + ); + } + + #[test] + fn aggregates_stay_exact_after_local_moving() { + let g = two_cliques(); + let (p, _) = run(&g, 3); + p.verify_against(&g).unwrap(); + } + + #[test] + fn quality_never_decreases() { + let g = two_cliques(); + let obj = Rb::new(1.0); + for seed in 0..25 { + let mut p = Partition::singleton(&g); + let before = obj.quality(&p); + let mut scratch = MoveScratch::with_capacity(g.node_count(), p.slots()); + let mut rng = ChaCha8Rng::seed_from_u64(seed); + local_move(&g, &mut p, &obj, &mut scratch, &mut rng, None); + assert!(obj.quality(&p) >= before - 1e-12, "seed {seed}"); + } + } + + #[test] + fn is_deterministic_for_a_fixed_seed() { + let g = two_cliques(); + let (mut first, gain) = run(&g, 99); + first.renumber(); + for _ in 0..10 { + let (mut again, g2) = run(&g, 99); + again.renumber(); + assert_eq!(first.membership_vec(), again.membership_vec()); + assert_eq!(gain, g2); + } + } + + #[test] + fn respects_max_community_weight() { + let g = two_cliques(); + let mut p = Partition::singleton(&g); + let mut scratch = MoveScratch::with_capacity(g.node_count(), p.slots()); + let mut rng = ChaCha8Rng::seed_from_u64(1); + local_move(&g, &mut p, &Rb::new(1.0), &mut scratch, &mut rng, Some(3.0)); + for c in 0..p.slots() { + assert!( + p.weight(c) <= 3.0, + "community {c} has weight {}", + p.weight(c) + ); + } + } + + #[test] + fn handles_degenerate_graphs() { + for g in [ + CSRNetwork::from_edges(0, &[] as &[(usize, usize, f64)]).unwrap(), + CSRNetwork::from_edges(1, &[] as &[(usize, usize, f64)]).unwrap(), + CSRNetwork::from_edges(4, &[] as &[(usize, usize, f64)]).unwrap(), + CSRNetwork::from_edges(2, &[(0, 0, 1.0), (1, 1, 1.0)]).unwrap(), + ] { + let (p, _) = run(&g, 5); + p.verify_against(&g).unwrap(); + } + } + + #[test] + fn improves_karate_modularity_over_singletons() { + let g = crate::testdata::karate(); + let (mut p, _) = run(&g, 42); + p.renumber(); + let q = modularity(&g, &p.membership_vec(), 1.0); + // one pass from singletons gives Q = 0.31..0.40; 0.4198 needs the aggregation levels + assert!(q > 0.30, "karate modularity after one pass: {q}"); + assert!((3..=12).contains(&p.community_count())); + } +} diff --git a/src/community_search/leiden/mod.rs b/src/community_search/leiden/mod.rs index 0b089dc..0fe9508 100644 --- a/src/community_search/leiden/mod.rs +++ b/src/community_search/leiden/mod.rs @@ -1,78 +1,496 @@ -//! Leiden algorithm implementation for community detection in networks. +//! The Leiden algorithm for community detection. //! -//! The Leiden algorithm is an improvement over the Louvain algorithm that guarantees -//! well-connected communities. It uses a refinement phase to ensure high-quality -//! partitions by preventing poorly connected communities. IMPORTANT: This code is currently work-in-progress and neither production ready nor optimize to the fullest! +//! ``` +//! use single_clustering::network::CSRNetwork; +//! use single_clustering::community_search::leiden::{leiden, LeidenConfig, ObjectiveKind}; +//! +//! # fn main() -> single_clustering::Result<()> { +//! let graph = CSRNetwork::from_edges(4, &[(0, 1, 1.0), (1, 2, 1.0), (2, 3, 1.0)])?; +//! let config = LeidenConfig { +//! objective: ObjectiveKind::Rb { resolution: 1.0 }, +//! seed: Some(42), +//! ..Default::default() +//! }; +//! let clustering = leiden(&graph, &config)?; +//! println!("{} communities", clustering.n_clusters()); +//! # Ok(()) +//! # } +//! ``` +//! +//! # How it works +//! +//! Each level: [`local_move()`] settles nodes into communities, [`refine()`] re-derives a +//! finer partition inside each one, and [`CSRNetwork::aggregate`] collapses the **refined** +//! communities into super-nodes. The next level starts from the coarse partition the working +//! communities induce, so the same partition keeps improving at coarser granularity until +//! collapsing stops shrinking the graph. +//! +//! Collapsing the refined partition instead of the working one is the whole difference from +//! Louvain — it's why badly connected communities don't get locked in. +pub mod local_move; +pub mod objective; pub mod partition; -mod optimizer; -pub use optimizer::LeidenOptimizer; -mod parallel; - -/// Strategy for selecting communities to consider during optimization. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum ConsiderComms { - /// Consider all non-empty communities. - AllComms = 1, - /// Consider communities of neighboring nodes only. - AllNeighComms = 2, - /// Randomly select one community. - RandComm = 3, - /// Randomly select a neighbor's community. - RandNeighComm = 4, -} +pub mod refine; -/// Optimization routine for node movement during community detection. -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum OptimiseRoutine { - /// Move individual nodes to different communities. - MoveNodes = 10, - /// Merge entire nodes/communities together. - MergeNodes = 11, -} +pub use local_move::local_move; +pub use objective::{Cpm, Objective, ObjectiveKind, Rb, modularity}; +pub use partition::{MoveScratch, NeighborWeights, Partition}; +pub use refine::refine; -/// Configuration parameters for the Leiden algorithm. -/// -/// Controls the behavior of community detection including iteration limits, -/// convergence criteria, randomization, and optimization strategies. -#[derive(Debug, Clone)] +use rand::{RngCore, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +use crate::clustering::Clustering; +use crate::error::{ClusteringError, Result}; +use crate::network::CSRNetwork; + +/// Configuration for [`leiden`]. +#[derive(Debug, Clone, PartialEq)] pub struct LeidenConfig { - /// Maximum number of iterations before termination. - pub max_iterations: usize, - /// Convergence tolerance for quality improvement. - pub tolerance: f64, - /// Random seed for reproducible results. None for random seed. + /// Quality function to optimize, carrying the resolution parameter. + /// + /// [`ObjectiveKind::Rb`] at `resolution = 1.0` is modularity, matching `scanpy`'s default. + pub objective: ObjectiveKind, + /// Seed for the random node orderings. `Some(_)` makes runs bit-for-bit reproducible; + /// `None` draws from the OS. pub seed: Option, - /// Maximum allowed community size. None for unlimited. - pub max_community_size: Option, - /// Whether to perform partition refinement for better connectivity. - pub refine_partition: bool, - /// Whether to consider moving nodes to empty communities. - pub consider_empty_community: bool, - /// Strategy for selecting communities during main optimization. - pub consider_comms: ConsiderComms, - /// Strategy for selecting communities during refinement. - pub refine_consider_comms: ConsiderComms, - /// Optimization routine for main phase. - pub optimise_routine: OptimiseRoutine, - /// Optimization routine for refinement phase. - pub refine_routine: OptimiseRoutine, + /// Number of passes, each continuing from the previous result. + /// + /// Matches `leidenalg` — passes keep improving the same partition rather than restarting + /// from singletons, which is how it escapes local optima a single pass falls into. Stops + /// early once a pass finds nothing. + pub n_iterations: usize, + /// Cap on aggregation levels within one pass. Reaching it is unusual — a pass normally + /// stops earlier, when collapsing no longer shrinks the graph. + pub max_levels: usize, + /// Minimum improvement for a pass to count as progress, relative to total edge weight. + /// + /// Relative, so the stopping rule doesn't depend on what units the weights are in. + /// + /// Between passes only, never between levels — a level finding nothing says nothing about + /// the coarser level above it, which is exactly where aggregation earns its keep. + pub tolerance: f64, + /// Whether to run the refinement phase. Disabling it turns the algorithm into Louvain. + pub refine: bool, + /// Randomness of community selection during refinement, as in the Leiden paper. + /// + /// Higher explores more. `0.0` is a deterministic argmax, which gets stuck in local optima + /// no number of passes can escape. Deterministic for a fixed [`seed`](Self::seed) either + /// way. + pub refine_randomness: f64, + /// How many consecutive passes may find nothing before stopping. + /// + /// Refinement is randomised, so one fruitless pass isn't proof of convergence. `1` gives + /// `leidenalg`'s stop-on-first-stall behaviour. + pub patience: usize, + /// Optional cap on a community's total node weight. Node weights add up on aggregation, so + /// this means the same thing at every level — a limit on original nodes. + pub max_community_weight: Option, } impl Default for LeidenConfig { fn default() -> Self { Self { - max_iterations: 100, - tolerance: 1e-6, + objective: ObjectiveKind::default(), seed: Some(42), - max_community_size: None, - refine_partition: true, - consider_empty_community: true, - consider_comms: ConsiderComms::AllNeighComms, - refine_consider_comms: ConsiderComms::AllNeighComms, - optimise_routine: OptimiseRoutine::MoveNodes, - refine_routine: OptimiseRoutine::MergeNodes, + n_iterations: 2, + max_levels: 64, + tolerance: 1e-9, + refine: true, + refine_randomness: 0.01, + patience: 2, + max_community_weight: None, + } + } +} + +impl LeidenConfig { + /// Convenience constructor for RB (modularity-style) clustering at a given resolution. + pub fn with_resolution(resolution: f64) -> Self { + Self { + objective: ObjectiveKind::Rb { resolution }, + ..Default::default() + } + } + + fn validate(&self) -> Result<()> { + let r = self.objective.resolution(); + if !r.is_finite() || r < 0.0 { + return Err(ClusteringError::InvalidConfig(format!( + "resolution must be finite and non-negative, got {r}" + ))); + } + if self.n_iterations == 0 { + return Err(ClusteringError::InvalidConfig( + "n_iterations must be at least 1".into(), + )); + } + if self.max_levels == 0 { + return Err(ClusteringError::InvalidConfig( + "max_levels must be at least 1".into(), + )); + } + if self.patience == 0 { + return Err(ClusteringError::InvalidConfig( + "patience must be at least 1".into(), + )); + } + if !self.refine_randomness.is_finite() || self.refine_randomness < 0.0 { + return Err(ClusteringError::InvalidConfig(format!( + "refine_randomness must be finite and non-negative, got {}", + self.refine_randomness + ))); + } + if let Some(w) = self.max_community_weight + && (!w.is_finite() || w <= 0.0) + { + return Err(ClusteringError::InvalidConfig(format!( + "max_community_weight must be finite and positive, got {w}" + ))); + } + Ok(()) + } +} + +fn build_objective(kind: ObjectiveKind) -> Box { + match kind { + ObjectiveKind::Rb { resolution } => Box::new(Rb::new(resolution)), + ObjectiveKind::Cpm { resolution } => Box::new(Cpm::new(resolution)), + } +} + +/// Detects communities in `graph`. +/// +/// Runs the algorithm [`LeidenConfig::n_iterations`] times, each pass continuing from the +/// previous result, and stops early once a pass finds no improvement. With a fixed +/// [`seed`](LeidenConfig::seed) the result is deterministic. +pub fn leiden(graph: &CSRNetwork, config: &LeidenConfig) -> Result { + config.validate()?; + + let objective = build_objective(config.objective); + let mut rng = match config.seed { + Some(seed) => ChaCha8Rng::seed_from_u64(seed), + None => ChaCha8Rng::from_os_rng(), + }; + + let n = graph.node_count(); + if n == 0 { + return Ok(Clustering::from_normalized(Vec::new(), 0)); + } + + // singletons; each pass picks up the last one's result + let mut labels: Vec = (0..n).collect(); + let mut n_clusters = n; + let mut stagnant = 0usize; + for _ in 0..config.n_iterations { + let (next, clusters, improvement) = + run_once(graph, objective.as_ref(), &mut rng, config, &labels); + labels = next; + n_clusters = clusters; + let scale = graph.total_weight().abs().max(1.0); + if improvement <= config.tolerance * scale { + stagnant += 1; + // refinement is randomised, so the next pass may see moves this one couldn't + if stagnant >= config.patience { + break; + } + } else { + stagnant = 0; + } + } + + Ok(Clustering::from_normalized(labels, n_clusters)) +} + +/// One pass: local move, refine, aggregate, repeat until the graph stops shrinking. +/// +/// Starts from `initial`, not singletons — that's what makes successive passes build on each +/// other. Returns consecutive labels over the original nodes, the community count, and the +/// total improvement. +/// +/// The improvement is just the sum of per-level local-moving gains, which is exact: +/// aggregation doesn't change quality, and the coarse partition is the same partition. +fn run_once( + graph: &CSRNetwork, + objective: &dyn Objective, + rng: &mut impl RngCore, + config: &LeidenConfig, + initial: &[usize], +) -> (Vec, usize, f64) { + let n = graph.node_count(); + let mut level_rng = ChaCha8Rng::seed_from_u64(rng.next_u64()); + + let mut level_graph = graph.clone(); + let mut partition = Partition::from_membership(&level_graph, initial); + // one set for the whole pass; per-level realloc is ~1.3 GB of churn at 75M nodes + let mut scratch = MoveScratch::with_capacity(n, partition.slots().max(n) + 1); + let mut total_improvement = 0.0; + + // where each original node lives now + let mut node_at_level: Vec = (0..n).collect(); + + for _ in 0..config.max_levels { + let improvement = local_move( + &level_graph, + &mut partition, + objective, + &mut scratch, + &mut level_rng, + config.max_community_weight, + ); + total_improvement += improvement; + + // refining before the collapse is what makes this Leiden + let mut collapse_by = if config.refine { + refine( + &level_graph, + partition.membership_raw(), + objective, + &mut scratch, + &mut level_rng, + config.max_community_weight, + config.refine_randomness, + ) + } else { + partition.clone() + }; + + let grouping = collapse_by.renumber_into_grouping(); + let coarse_graph = level_graph.aggregate(&grouping); + + // no shrink, nothing left to gain + if coarse_graph.node_count() >= level_graph.node_count() { + break; } + + // several coarse nodes can share a working community — that's what lets the next + // level pull them apart again + let mut coarse_membership = vec![0usize; coarse_graph.node_count()]; + for v in 0..level_graph.node_count() { + coarse_membership[collapse_by.membership(v)] = partition.membership(v); + } + + for slot in node_at_level.iter_mut() { + *slot = collapse_by.membership(*slot); + } + + level_graph = coarse_graph; + partition = Partition::from_membership(&level_graph, &coarse_membership); } + + partition.renumber(); + let labels: Vec = node_at_level + .iter() + .map(|&node| partition.membership(node)) + .collect(); + let n_clusters = partition.community_count(); + (labels, n_clusters, total_improvement) } +#[cfg(test)] +mod tests { + use super::*; + use crate::testdata::{disconnected_community_count, karate, nmi, sbm}; + + #[test] + fn finds_the_karate_club_optimum() { + let g = karate(); + let c = leiden(&g, &LeidenConfig::default()).unwrap(); + let q = modularity(&g, c.labels(), 1.0); + assert_eq!(c.n_clusters(), 4, "sizes: {:?}", c.cluster_sizes()); + assert!( + (q - 0.4198).abs() < 1e-3, + "expected the known optimum 0.4198, got {q}" + ); + } + + #[test] + fn recovers_a_planted_partition() { + let (edges, truth) = sbm(60, 6, 0.25, 0.01, 11); + let g = CSRNetwork::from_edges(360, &edges).unwrap(); + let c = leiden(&g, &LeidenConfig::default()).unwrap(); + let score = nmi(c.labels(), &truth); + assert!(score > 0.95, "NMI {score}, {} clusters", c.n_clusters()); + assert_eq!(c.n_clusters(), 6); + } + + #[test] + fn is_deterministic_for_a_fixed_seed() { + let (edges, _) = sbm(40, 5, 0.3, 0.02, 4); + let g = CSRNetwork::from_edges(200, &edges).unwrap(); + let first = leiden(&g, &LeidenConfig::default()).unwrap(); + for _ in 0..10 { + let again = leiden(&g, &LeidenConfig::default()).unwrap(); + assert_eq!(first, again); + } + } + + #[test] + fn different_seeds_are_allowed_to_differ_but_stay_good() { + let (edges, truth) = sbm(40, 5, 0.3, 0.02, 4); + let g = CSRNetwork::from_edges(200, &edges).unwrap(); + for seed in 0..8 { + let c = leiden( + &g, + &LeidenConfig { + seed: Some(seed), + ..Default::default() + }, + ) + .unwrap(); + assert!(nmi(c.labels(), &truth) > 0.9, "seed {seed}"); + } + } + + #[test] + fn communities_are_internally_connected() { + // what refinement is for + let (edges, _) = sbm(40, 5, 0.25, 0.03, 8); + let g = CSRNetwork::from_edges(200, &edges).unwrap(); + for seed in 0..8 { + let c = leiden( + &g, + &LeidenConfig { + seed: Some(seed), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + disconnected_community_count(&g, c.labels()), + 0, + "seed {seed} produced a disconnected community" + ); + } + } + + #[test] + fn resolution_controls_granularity() { + let (edges, _) = sbm(40, 5, 0.3, 0.02, 4); + let g = CSRNetwork::from_edges(200, &edges).unwrap(); + let mut counts = Vec::new(); + for r in [0.25, 1.0, 4.0, 16.0] { + let c = leiden(&g, &LeidenConfig::with_resolution(r)).unwrap(); + counts.push(c.n_clusters()); + } + assert!( + counts.windows(2).all(|w| w[0] <= w[1]), + "cluster count should be non-decreasing in resolution, got {counts:?}" + ); + assert!( + counts[0] < counts[3], + "resolution had no effect: {counts:?}" + ); + } + + #[test] + fn cpm_also_recovers_the_planted_partition() { + let (edges, truth) = sbm(40, 5, 0.3, 0.02, 4); + let g = CSRNetwork::from_edges(200, &edges).unwrap(); + let c = leiden( + &g, + &LeidenConfig { + objective: ObjectiveKind::Cpm { resolution: 0.1 }, + ..Default::default() + }, + ) + .unwrap(); + assert!(nmi(c.labels(), &truth) > 0.9, "{} clusters", c.n_clusters()); + } + + #[test] + fn respects_max_community_weight() { + let (edges, _) = sbm(40, 5, 0.3, 0.02, 4); + let g = CSRNetwork::from_edges(200, &edges).unwrap(); + let c = leiden( + &g, + &LeidenConfig { + max_community_weight: Some(25.0), + ..Default::default() + }, + ) + .unwrap(); + for (id, size) in c.cluster_sizes().iter().enumerate() { + assert!(*size <= 25, "cluster {id} has {size} nodes"); + } + } + + #[test] + fn handles_degenerate_graphs() { + let cases: Vec<(CSRNetwork, usize)> = vec![ + ( + CSRNetwork::from_edges(0, &[] as &[(usize, usize, f64)]).unwrap(), + 0, + ), + ( + CSRNetwork::from_edges(1, &[] as &[(usize, usize, f64)]).unwrap(), + 1, + ), + ( + CSRNetwork::from_edges(5, &[] as &[(usize, usize, f64)]).unwrap(), + 5, + ), + (CSRNetwork::from_edges(2, &[(0, 1, 1.0)]).unwrap(), 1), + ]; + for (g, expected) in cases { + let c = leiden(&g, &LeidenConfig::default()).unwrap(); + assert_eq!(c.len(), g.node_count()); + assert_eq!(c.n_clusters(), expected, "graph: {g}"); + } + } + + #[test] + fn disconnected_components_are_never_merged() { + // two components with no edge between them must not share a community + let mut edges = Vec::new(); + for b in 0..2usize { + for i in 0..5 { + for j in (i + 1)..5 { + edges.push((b * 5 + i, b * 5 + j, 1.0)); + } + } + } + let g = CSRNetwork::from_edges(10, &edges).unwrap(); + let c = leiden(&g, &LeidenConfig::default()).unwrap(); + assert_eq!(c.n_clusters(), 2); + assert_ne!(c.labels()[0], c.labels()[5]); + } + + #[test] + fn rejects_invalid_config() { + let g = karate(); + for bad in [ + LeidenConfig { + n_iterations: 0, + ..Default::default() + }, + LeidenConfig { + max_levels: 0, + ..Default::default() + }, + LeidenConfig::with_resolution(-1.0), + LeidenConfig { + max_community_weight: Some(0.0), + ..Default::default() + }, + ] { + assert!(leiden(&g, &bad).is_err(), "{bad:?} should be rejected"); + } + } + + #[test] + fn louvain_mode_still_works() { + let g = karate(); + let c = leiden( + &g, + &LeidenConfig { + refine: false, + ..Default::default() + }, + ) + .unwrap(); + let q = modularity(&g, c.labels(), 1.0); + assert!(q > 0.41, "Louvain mode reached only {q}"); + } +} diff --git a/src/community_search/leiden/objective.rs b/src/community_search/leiden/objective.rs new file mode 100644 index 0000000..8b17f8a --- /dev/null +++ b/src/community_search/leiden/objective.rs @@ -0,0 +1,368 @@ +//! Quality functions. +//! +//! An [`Objective`] scores a [`Partition`], and more usefully scores *inserting* a removed +//! node into a candidate community. Both read only the aggregates the partition already +//! maintains, so neither touches the graph. +//! +//! # Scaling +//! +//! Everything here is `Σ_c [ internal_c − null_c ]` with each internal edge counted once. For +//! [`Rb`] the null term is `γ·K_c²/(4m)`. That `4m` is load-bearing — with `2m` you get `Rb` +//! at resolution `2γ`, which is why this crate used to return about twice as many communities +//! as `leidenalg` at the same nominal resolution. [`modularity`] reports a value directly +//! comparable with `scanpy`/`leidenalg`. + +use crate::community_search::leiden::partition::Partition; +use crate::network::CSRNetwork; + +/// The node-level quantities an objective needs to score an insertion. +/// +/// Built once per node by the local-moving loop and reused across every candidate community. +#[derive(Debug, Clone, Copy)] +pub struct InsertContext { + /// Sum of the node's incident edge weights, self-loop counted twice. + pub node_strength: f64, + /// The node's own weight ("size"), summed across members after aggregation. + pub node_weight: f64, + /// Weight of the node's self-loop, or `0.0`. + pub self_loop: f64, +} + +impl InsertContext { + /// Reads the quantities for `node` out of `graph`. + #[inline] + pub fn for_node(graph: &CSRNetwork, node: usize) -> Self { + Self { + node_strength: graph.strength(node), + node_weight: graph.node_weight(node), + self_loop: graph.self_loop_weight(node), + } + } +} + +/// A quality function to maximize. +pub trait Objective: Send + Sync { + /// Change in quality from inserting a currently-removed node into `community`. + /// + /// `weight_to_comm` excludes the node's self-loop; that gets added here, since it becomes + /// internal to whichever community the node lands in. + /// + /// `community` may name an unallocated slot, which reads as empty. + fn delta_insert( + &self, + partition: &Partition, + ctx: &InsertContext, + community: usize, + weight_to_comm: f64, + ) -> f64; + + /// Total quality of the partition, in the same units as [`delta_insert`](Self::delta_insert). + fn quality(&self, partition: &Partition) -> f64; + + /// The resolution parameter. Higher values favour smaller communities. + fn resolution(&self) -> f64; +} + +/// Reichardt–Bornholdt configuration-model objective. +/// +/// `Q = Σ_c [ internal_c − γ · K_c² / (4m) ]`, with `K_c` the summed node strength of `c` and +/// `m` the total edge weight. At `γ = 1` this is modularity, matching `leidenalg`'s +/// `RBConfigurationVertexPartition` and so `scanpy`'s default. +#[derive(Debug, Clone, Copy)] +pub struct Rb { + resolution: f64, +} + +impl Rb { + /// Creates an RB objective with the given resolution. + pub fn new(resolution: f64) -> Self { + Self { resolution } + } +} + +impl Default for Rb { + fn default() -> Self { + Self::new(1.0) + } +} + +impl Objective for Rb { + #[inline] + fn delta_insert( + &self, + partition: &Partition, + ctx: &InsertContext, + community: usize, + weight_to_comm: f64, + ) -> f64 { + let four_m = 4.0 * partition.total_weight(); + if four_m == 0.0 { + return 0.0; + } + let k_c = partition.strength(community); + let k = ctx.node_strength; + // d/dK of γ·K²/(4m) discretised: ((K+k)² − K²) = k·(2K + k) + (weight_to_comm + ctx.self_loop) - self.resolution * k * (2.0 * k_c + k) / four_m + } + + fn quality(&self, partition: &Partition) -> f64 { + let four_m = 4.0 * partition.total_weight(); + if four_m == 0.0 { + return 0.0; + } + (0..partition.slots()) + .map(|c| { + let k_c = partition.strength(c); + partition.internal(c) - self.resolution * k_c * k_c / four_m + }) + .sum() + } + + #[inline] + fn resolution(&self) -> f64 { + self.resolution + } +} + +/// Constant Potts Model objective. +/// +/// `Q = Σ_c [ internal_c − γ · n_c(n_c − 1) / 2 ]`, where `n_c` is the summed node weight of +/// community `c` — that is, the internal weight minus `γ` times the number of node *pairs* +/// inside the community. Unlike RB, CPM has no resolution limit: `γ` is directly the internal +/// edge density below which a community will not form, so it behaves predictably as you turn +/// the knob on large graphs. +/// +/// The pair count matches `leidenalg`'s `CPMVertexPartition`, whose reported quality is +/// exactly twice this one's. Because node weights are summed on aggregation, `n_c` counts +/// original nodes at every level, so the objective is unchanged by collapsing. +#[derive(Debug, Clone, Copy)] +pub struct Cpm { + resolution: f64, +} + +impl Cpm { + /// Creates a CPM objective with the given resolution. + pub fn new(resolution: f64) -> Self { + Self { resolution } + } +} + +impl Default for Cpm { + fn default() -> Self { + Self::new(0.05) + } +} + +impl Objective for Cpm { + #[inline] + fn delta_insert( + &self, + partition: &Partition, + ctx: &InsertContext, + community: usize, + weight_to_comm: f64, + ) -> f64 { + let n_c = partition.weight(community); + let a = ctx.node_weight; + // ((n+a)(n+a-1) - n(n-1)) / 2 = a(2n + a - 1) / 2 + (weight_to_comm + ctx.self_loop) - self.resolution * a * (2.0 * n_c + a - 1.0) / 2.0 + } + + fn quality(&self, partition: &Partition) -> f64 { + (0..partition.slots()) + .map(|c| { + let n_c = partition.weight(c); + partition.internal(c) - self.resolution * n_c * (n_c - 1.0) / 2.0 + }) + .sum() + } + + #[inline] + fn resolution(&self) -> f64 { + self.resolution + } +} + +/// Which quality function to optimize. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ObjectiveKind { + /// Reichardt–Bornholdt configuration model. `resolution = 1.0` is modularity. + Rb { + /// Resolution parameter; higher values yield smaller communities. + resolution: f64, + }, + /// Constant Potts Model, free of the resolution limit. + Cpm { + /// Resolution parameter; the internal density threshold for forming a community. + resolution: f64, + }, +} + +impl Default for ObjectiveKind { + fn default() -> Self { + Self::Rb { resolution: 1.0 } + } +} + +impl ObjectiveKind { + /// The resolution parameter carried by this objective. + pub fn resolution(&self) -> f64 { + match *self { + Self::Rb { resolution } | Self::Cpm { resolution } => resolution, + } + } +} + +/// Standard Newman–Girvan modularity of a labelling, computed directly from the graph. +/// +/// Deliberately independent of [`Partition`] and [`Objective`]: it is what users should report +/// and compare against `scanpy`, and it doubles as an outside check on the optimizer's own +/// arithmetic. At `resolution = 1.0` this is textbook modularity in `[-1, 1]`. +pub fn modularity(graph: &CSRNetwork, labels: &[usize], resolution: f64) -> f64 { + let m = graph.total_weight(); + if m == 0.0 { + return 0.0; + } + let n_comms = labels.iter().copied().max().map_or(0, |c| c + 1); + let mut internal = vec![0.0f64; n_comms]; + let mut strength = vec![0.0f64; n_comms]; + + for v in 0..graph.node_count() { + let c = labels[v]; + strength[c] += graph.strength(v); + for (u, w) in graph.neighbors(v) { + if labels[u] == c && (u == v || v < u) { + internal[c] += w; + } + } + } + + let two_m = 2.0 * m; + (0..n_comms) + .map(|c| 2.0 * internal[c] / two_m - resolution * (strength[c] / two_m).powi(2)) + .sum() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testdata::{karate, karate_optimum}; + + #[test] + fn rb_at_resolution_one_is_modularity() { + let g = karate(); + let labels = karate_optimum(); + let p = Partition::from_membership(&g, &labels); + + let q_scaled = Rb::new(1.0).quality(&p); + let q_standard = modularity(&g, &labels, 1.0); + + // Rb::quality is m * Q — the scaling the old code got wrong by 2x + assert!( + (q_scaled / g.total_weight() - q_standard).abs() < 1e-12, + "Rb::quality/m = {}, modularity = {q_standard}", + q_scaled / g.total_weight() + ); + assert!( + (q_standard - 0.4198).abs() < 1e-3, + "karate optimum should score 0.4198, got {q_standard}" + ); + } + + #[test] + fn rb_resolution_scales_the_null_term_not_the_objective() { + let g = karate(); + let labels = karate_optimum(); + let p = Partition::from_membership(&g, &labels); + // Rb(gamma) == textbook Q(gamma), not Q(2*gamma) like the old code + for gamma in [0.5, 1.0, 2.0, 4.0] { + let got = Rb::new(gamma).quality(&p) / g.total_weight(); + let want = modularity(&g, &labels, gamma); + assert!((got - want).abs() < 1e-12, "gamma={gamma}: {got} vs {want}"); + } + } + + /// The invariant that catches any drift between the delta and the objective. + fn delta_matches_quality_change(obj: &O, graph: &CSRNetwork, labels: &[usize]) { + use crate::community_search::leiden::partition::NeighborWeights; + let mut p = Partition::from_membership(graph, labels); + let mut acc = NeighborWeights::with_capacity(p.slots()); + let n_comms = p.slots(); + + for v in 0..graph.node_count() { + for target in 0..n_comms { + let before = obj.quality(&p); + let ctx = InsertContext::for_node(graph, v); + + acc.collect(graph, p.membership_raw(), v); + let own = p.membership(v); + p.remove_node(v, graph, acc.weight_to(own)); + let predicted = obj.delta_insert(&p, &ctx, target, acc.weight_to(target)); + p.insert_node(v, target, graph, acc.weight_to(target)); + let after = obj.quality(&p); + + let removed_gain = { + // undo + acc.collect(graph, p.membership_raw(), v); + p.remove_node(v, graph, acc.weight_to(target)); + let g = obj.delta_insert(&p, &ctx, own, acc.weight_to(own)); + p.insert_node(v, own, graph, acc.weight_to(own)); + g + }; + + let actual = after - before; + let expected = predicted - removed_gain; + assert!( + (expected - actual).abs() < 1e-9, + "node {v} -> comm {target}: delta {expected} != actual {actual}" + ); + } + } + } + + #[test] + fn rb_delta_equals_quality_change() { + let g = karate(); + delta_matches_quality_change(&Rb::new(1.0), &g, &karate_optimum()); + delta_matches_quality_change(&Rb::new(0.37), &g, &karate_optimum()); + } + + #[test] + fn cpm_delta_equals_quality_change() { + let g = karate(); + delta_matches_quality_change(&Cpm::new(0.1), &g, &karate_optimum()); + } + + #[test] + fn quality_is_invariant_under_aggregation() { + // what the old aggregate() broke: collapsing must not change the score + let g = karate(); + let labels = karate_optimum(); + let mut p = Partition::from_membership(&g, &labels); + + let grouping = p.renumber_into_grouping(); + let coarse_graph = g.aggregate(&grouping); + let coarse_labels: Vec = (0..coarse_graph.node_count()).collect(); + let coarse_p = Partition::from_membership(&coarse_graph, &coarse_labels); + + for gamma in [0.5, 1.0, 2.0] { + let fine = Rb::new(gamma).quality(&p); + let coarse = Rb::new(gamma).quality(&coarse_p); + assert!( + (fine - coarse).abs() < 1e-9, + "gamma={gamma}: fine {fine} != coarse {coarse}" + ); + } + let fine = Cpm::new(0.1).quality(&p); + let coarse = Cpm::new(0.1).quality(&coarse_p); + assert!((fine - coarse).abs() < 1e-9, "cpm: {fine} != {coarse}"); + } + + #[test] + fn empty_graph_scores_zero() { + let g = CSRNetwork::from_edges(5, &[] as &[(usize, usize, f64)]).unwrap(); + let p = Partition::singleton(&g); + assert_eq!(Rb::new(1.0).quality(&p), 0.0); + assert_eq!(modularity(&g, &[0, 1, 2, 3, 4], 1.0), 0.0); + } +} diff --git a/src/community_search/leiden/optimizer.rs b/src/community_search/leiden/optimizer.rs deleted file mode 100644 index fc57924..0000000 --- a/src/community_search/leiden/optimizer.rs +++ /dev/null @@ -1,1372 +0,0 @@ -//! Leiden algorithm optimizer implementation. -//! -//! Contains the core optimization logic for the Leiden community detection algorithm, -//! including node movement, community merging, and partition refinement strategies. - -use std::{collections::VecDeque, time::Instant}; - -use anyhow::Ok; -use num_traits::Float; -use rand::{Rng, SeedableRng, seq::SliceRandom}; -use rand_chacha::ChaCha8Rng; -use single_utilities::traits::FloatOpsTS; - -use crate::{ - community_search::leiden::{parallel::{ConflictFreeBatcher, ParallelEvaluator}, partition::VertexPartition, ConsiderComms, LeidenConfig}, - network::{grouping::NetworkGrouping, CSRNetwork}, -}; - -/// Result of evaluating a potential community move for a node. -/// -/// Contains the target community and the quality improvement that would result -/// from moving the node to that community. -#[derive(Debug, Clone)] -pub struct CommunityMoveResult { - /// The community to move the node to. - pub community: usize, - /// The quality improvement gained by this move. - pub improvement: N, -} - -impl PartialEq for CommunityMoveResult { - fn eq(&self, other: &Self) -> bool { - self.improvement == other.improvement - } -} - -impl PartialOrd for CommunityMoveResult { - fn partial_cmp(&self, other: &Self) -> Option { - self.improvement.partial_cmp(&other.improvement) - } -} - -/// Core optimizer for the Leiden community detection algorithm. -/// -/// Handles the iterative optimization process including node movement, community merging, -/// partition refinement, and hierarchical aggregation to find high-quality community structures. -pub struct LeidenOptimizer { - config: LeidenConfig, - rng: ChaCha8Rng, -} - -impl LeidenOptimizer { - /// Creates a new Leiden optimizer with the specified configuration. - /// - /// Initializes the random number generator using the seed from the config, - /// or a random seed if none is provided. - pub fn new(config: LeidenConfig) -> Self { - let rng = match config.seed { - Some(n) => ChaCha8Rng::seed_from_u64(n), - None => ChaCha8Rng::from_os_rng(), - }; - - Self { config, rng } - } - - /// Merges nodes into communities by evaluating all possible moves. - /// - /// Unlike `move_nodes`, this method considers merging entire nodes/communities - /// rather than individual node movements, which can be more efficient for - /// certain network structures. - fn merge_nodes( - &mut self, - partitions: &mut [P], - layer_weights: &[N], - is_membership_fixed: &[bool], - consider_comms: ConsiderComms, - renumber_fixed_nodes: bool, - max_comm_size: Option, - ) -> anyhow::Result - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - let nb_layers = partitions.len(); - if nb_layers == 0 { - return Ok(N::from(-1.0).unwrap()); - } - - let n = partitions[0].node_count(); - - for partition in partitions.iter() { - if partition.node_count() != n { - return Err(anyhow::anyhow!("Unequal node size in partitions detected!")); - } - } - - let mut fixed_nodes = Vec::new(); - let mut fixed_membership = vec![0; n]; - if renumber_fixed_nodes { - for v in 0..n { - if is_membership_fixed[v] { - fixed_nodes.push(v); - fixed_membership[v] = partitions[0].membership(v); - } - } - } - - let mut total_improvement = N::zero(); - - let mut vertex_order: Vec = (0..n).filter(|&v| !is_membership_fixed[v]).collect(); - - vertex_order.shuffle(&mut self.rng); - - let mut comm_added = vec![false; partitions[0].community_count()]; - let mut comms = Vec::new(); - - for v in vertex_order { - let v_comm = partitions[0].membership(v); - - for &comm in &comms { - if comm < comm_added.len() { - comm_added[comm] = false; - } - } - - comms.clear(); - if partitions[0].cnodes(v_comm) == 1 { - self.collect_candidate_communities( - v, - partitions, - consider_comms, - &mut comms, - &mut comm_added, - ); - } - - let mut max_comm = v_comm; - let mut max_improv = if let Some(max_size) = max_comm_size { - if max_size < partitions[0].csize(v_comm) { - N::from(f64::NEG_INFINITY).unwrap() - } else { - N::zero() - } - } else { - N::zero() - }; - - let v_size = N::one(); - - for &comm in &comms { - if let Some(max_size) = max_comm_size { - let comm_size = N::from(partitions[0].csize(comm)).unwrap(); - if N::from(max_size).unwrap() < comm_size + v_size { - continue; - } - } - - let mut possible_improvement = N::zero(); - for layer in 0..nb_layers { - let diff = partitions[layer].diff_move(v, comm); - possible_improvement += layer_weights[layer] * diff; - } - - if possible_improvement >= max_improv { - max_comm = comm; - max_improv = possible_improvement; - } - } - - if max_comm != v_comm { - total_improvement += max_improv; - - for partition in partitions.iter_mut() { - // reflect changes to all partitions - partition.move_node(v, max_comm); - } - } - } - - partitions[0].renumber_communities(); - if renumber_fixed_nodes { - partitions[0].renumber_communities_fixed(&fixed_nodes, &fixed_membership); - } - - let membership = partitions[0].membership_vector(); - for partition in partitions.iter_mut().skip(1) { - partition.set_membership(&membership); - } - Ok(total_improvement) - } - - /// Finds the best community for a node to move to. - /// - /// Evaluates the quality improvement for moving a node to each candidate - /// community and returns the community and improvement of the best move. - fn find_best_community_move( - &self, - v: usize, - v_comm: usize, - comms: &[usize], - partitions: &mut [P], - layer_weights: &[N], - max_comm_size: Option, - ) -> anyhow::Result<(usize, N)> - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - let mut max_comm = v_comm; - let time = Instant::now(); - // println!("Finding best community move: {:?}", time.elapsed()); - - // Pre-compute these values once instead of in the loop - let v_comm_size = partitions[0].csize(v_comm); - let epsilon_threshold = N::from(10.0).unwrap() * ::epsilon(); - - let mut max_improv = if let Some(max_size) = max_comm_size { - if max_size < v_comm_size { - ::neg_infinity() - } else { - epsilon_threshold - } - } else { - epsilon_threshold - }; - - const V_SIZE: usize = 1; - - if comms.is_empty() { - return Ok((max_comm, max_improv)); - } - - // println!("Prefiltering valid comms {:?}", time.elapsed()); - let valid_comms: Vec = if let Some(max_size) = max_comm_size { - comms - .iter() - .copied() - .filter(|&comm| partitions[0].csize(comm) + V_SIZE <= max_size) - .collect() - } else { - comms.to_vec() - }; - // println!("Filtered valid comms: {:?}", time.elapsed()); - - if valid_comms.is_empty() { - return Ok((max_comm, max_improv)); - } - - // Optimized single-layer case - if partitions.len() == 1 && layer_weights[0] == N::one() { - // println!("checking valid comms: {:?}", time.elapsed()); - - let partition = &mut partitions[0]; - - for &comm in &valid_comms { - let t = Instant::now(); - let possible_improv = partition.diff_move(v, comm); - // println!("Executed diff move, took: {:?}", t.elapsed()); - - if possible_improv > max_improv { - max_comm = comm; - max_improv = possible_improv; - } - } - } else { - // Multi-layer case - for &comm in &valid_comms { - let mut possible_improv = N::zero(); - - for layer_idx in 0..partitions.len() { - let layer_improv = partitions[layer_idx].diff_move(v, comm); - possible_improv += layer_weights[layer_idx] * layer_improv; - - // Early termination optimization - if possible_improv + epsilon_threshold < max_improv { - let remaining_positive = layer_weights[layer_idx + 1..] - .iter() - .all(|&w| w >= N::zero()); - - if remaining_positive && layer_improv <= N::zero() { - break; - } - } - } - - if possible_improv > max_improv { - max_comm = comm; - max_improv = possible_improv; - } - } - } - - Ok((max_comm, max_improv)) - } - - /// Collects candidate communities that a node can potentially move to. - /// - /// Uses the specified `ConsiderComms` strategy to determine which communities - /// should be evaluated as potential destinations for the node. - fn collect_candidate_communities( - &mut self, - v: usize, - partitions: &[P], - consider_comms: ConsiderComms, - comms: &mut Vec, - comm_added: &mut [bool], - ) where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - match consider_comms { - ConsiderComms::AllComms => { - // Consider all non-empty communities across all layers - for comm in 0..partitions[0].community_count() { - for partition in partitions { - if partition.cnodes(comm) > 0 - && comm < comm_added.len() - && !comm_added[comm] - { - comms.push(comm); - comm_added[comm] = true; - break; - } - } - } - } - ConsiderComms::AllNeighComms => { - // Consider all neighbor communities across all layers - for partition in partitions { - let neigh_comms = partition.get_neigh_comms(v, None); - for comm in neigh_comms { - if comm < comm_added.len() && !comm_added[comm] { - comms.push(comm); - comm_added[comm] = true; - } - } - } - } - ConsiderComms::RandComm => { - // Pick a random community from a random node - if let Some(partition) = partitions.first() { - let random_node = self.rng.random_range(0..partition.node_count()); - let rand_comm = partition.membership(random_node); - comms.push(rand_comm); - if rand_comm < comm_added.len() { - comm_added[rand_comm] = true; - } - } - } - ConsiderComms::RandNeighComm => { - // Pick a random neighbor's community from a random layer - if !partitions.is_empty() { - let rand_layer = self.rng.random_range(0..partitions.len()); - let neighbors: Vec<_> = partitions[rand_layer] - .network() - .neighbors(v) - .map(|(neighbor, _)| neighbor) - .collect(); - - if !neighbors.is_empty() { - let random_neighbor = neighbors[self.rng.random_range(0..neighbors.len())]; - let rand_comm = partitions[0].membership(random_neighbor); - comms.push(rand_comm); - if rand_comm < comm_added.len() { - comm_added[rand_comm] = true; - } - } - } - } - } - } - - fn mark_neighbors_unstable( - &self, - v: usize, - new_comm: usize, - partition: &P, - is_node_stable: &mut [bool], - is_membership_fixed: &[bool], - vertex_order: &mut VecDeque, - ) where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - for (neighbor, _) in partition.network().neighbors(v) { - // If neighbor was stable, not in new community, and not fixed - if is_node_stable[neighbor] - && partition.membership(neighbor) != new_comm - && !is_membership_fixed[neighbor] - { - vertex_order.push_back(neighbor); - is_node_stable[neighbor] = false; - } - } - } - - /// Moves nodes between communities to optimize partition quality. - /// - /// Core optimization routine that iteratively moves nodes to better communities - /// until no further improvement can be made. Uses a queue-based approach to - /// ensure all potentially beneficial moves are evaluated. - fn move_nodes( - &mut self, - partitions: &mut [P], - layer_weights: &[N], - is_membership_fixed: &[bool], - consider_comms: ConsiderComms, - consider_empty_community: bool, - renumber_fixed_nodes: bool, - max_comm_size: Option, - ) -> anyhow::Result - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - let time = Instant::now(); - // println!("MOVE_NODES | Starting | time: {:?}", time.elapsed()); - if partitions.is_empty() { - return Ok(N::from(-1.0).unwrap()); - } - - let nb_layers = partitions.len(); - let n = partitions[0].node_count(); - - for partition in partitions.iter() { - if partition.node_count() != n { - return Err(anyhow::anyhow!( - "Number of nodes are not equal for all graphs." - )); - } - } - - let mut fixed_nodes = Vec::new(); - let mut fixed_membership = vec![0; n]; - if renumber_fixed_nodes { - for v in 0..n { - if is_membership_fixed[v] { - fixed_nodes.push(v); - fixed_membership[v] = partitions[0].membership(v); - } - } - } - - let mut total_improv = N::zero(); - - let mut is_node_stable = is_membership_fixed.to_vec(); - - let mut nodes: Vec = (0..n).filter(|&v| !is_membership_fixed[v]).collect(); - nodes.shuffle(&mut self.rng); - let mut vertex_order: VecDeque = nodes.into(); - - let mut comm_added = vec![false; partitions[0].community_count()]; - let mut comms = Vec::new(); - - // println!( - // "MOVE_NODES | Basic setup finished... | time: {:?}", - // time.elapsed() - // ); - let mut i: i32 = 0; - while let Some(v) = vertex_order.pop_front() { - // println!( - // "MOVE_NODES | Starting while loop | time: {:?} | iteration: {:?}, left: {:?}", - // time.elapsed(), - // i, - // vertex_order.len() - // ); - let v_comm = partitions[0].membership(v); - for &comm in &comms { - if comm < comm_added.len() { - comm_added[comm] = false; - } - } - comms.clear(); - - // println!( - // "MOVE_NODES | Basic setup done | time: {:?} | iteration: {:?}", - // time.elapsed(), - // i - // ); - - self.collect_candidate_communities( - v, - partitions, - consider_comms, - &mut comms, - &mut comm_added, - ); - - // println!( - // "MOVE_NODES | Found all candidates | time: {:?} | iteration: {:?}", - // time.elapsed(), - // i - // ); - - if consider_empty_community && partitions[0].cnodes(v_comm) > 1 { - // println!( - // "MOVE_NODES | Considering empty move | time: {:?} | iteration: {:?}", - // time.elapsed(), - // i - // ); - let n_comms_before = partitions[0].community_count(); - let empty_comm = partitions[0].get_empty_community(); - comms.push(empty_comm); - - if partitions[0].community_count() > n_comms_before { - for layer in 1..nb_layers { - partitions[layer].add_empty_community(); - } - comm_added.resize(partitions[0].community_count(), false); - if empty_comm < comm_added.len() { - comm_added[empty_comm] = true; - } - } - } - - // println!( - // "MOVE_NODES | Finsing best community move | time: {:?} | iteration: {:?}", - // time.elapsed(), - // i - // ); - - let (max_comm, max_improv) = self.find_best_community_move( - v, - v_comm, - &comms, - partitions, - layer_weights, - max_comm_size, - )?; - - // println!( - // "MOVE_NODES | Found best community move | time: {:?} | iteration: {:?}", - // time.elapsed(), - // i - // ); - - is_node_stable[v] = true; - - if max_comm != v_comm && max_improv > N::zero() { - total_improv += max_improv; - - for partition in partitions.iter_mut() { - partition.move_node(v, max_comm); - } - // println!( - // "MOVE_NODES | Marking neighbors as unstable | time: {:?} | iteration: {:?}", - // time.elapsed(), - // i - // ); - self.mark_neighbors_unstable( - v, - max_comm, - &partitions[0], - &mut is_node_stable, - is_membership_fixed, - &mut vertex_order, - ); - } - i += 1; - } - - partitions[0].renumber_communities(); - if renumber_fixed_nodes { - partitions[0].renumber_communities_fixed(&fixed_nodes, &fixed_membership); - } - - let membership = partitions[0].membership_vector(); - for partition in partitions.iter_mut().skip(1) { - partition.set_membership(&membership); - } - - Ok(total_improv) - } - - - fn move_nodes_parallel( - &mut self, - partitions: &mut [P], - layer_weights: &[N], - is_membership_fixed: &[bool], - consider_comms: ConsiderComms, - consider_empty_community: bool, - max_comm_size: Option - ) -> anyhow::Result - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition { - let n = partitions[0].node_count(); - let network = partitions[0].network().clone(); - - let mut total_improv = N::zero(); - let mut is_node_stable = is_membership_fixed.to_vec(); - - let mut nodes: Vec = (0..n) - .filter(|&v| !is_membership_fixed[v]) - .collect(); - nodes.shuffle(&mut self.rng); - - let mut pending_nodes: VecDeque = nodes.into(); - let batcher = ConflictFreeBatcher::new(10_000); - - while !pending_nodes.is_empty() { - let current_nodes: Vec = pending_nodes.drain(..).collect(); - let batches = batcher.create_batches(¤t_nodes, &network, &is_node_stable); - - for batch in batches { - let proposed_moves = ParallelEvaluator::evaluate_batch(&batch, partitions, layer_weights, consider_comms, consider_empty_community, max_comm_size); - - for proposed in proposed_moves { - if proposed.is_beneficial() { - total_improv += proposed.improvement; - - for partition in partitions.iter_mut() { - partition.move_node(proposed.node, proposed.to_comm); - } - - is_node_stable[proposed.node] = true; - - for (neighbor, _) in network.neighbors(proposed.node) { - if is_node_stable[neighbor] && partitions[0].membership(neighbor) != proposed.to_comm && !is_membership_fixed[neighbor] { - pending_nodes.push_back(neighbor); - is_node_stable[neighbor] = false; - } - } - } - } - } - } - - partitions[0].renumber_communities(); - let membership = partitions[0].membership_vector(); - for partition in partitions.iter_mut().skip(1) { - partition.set_membership(&membership); - } - - Ok(total_improv) - } - - fn move_nodes_constrained( - &mut self, - partitions: &mut [P], - layer_weights: &[N], - consider_comms: ConsiderComms, - constrained_partition: &P, - max_comm_size: Option, - ) -> anyhow::Result - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - if partitions.is_empty() { - return Ok(N::from(-1.0).unwrap()); - } - - let nb_layers = partitions.len(); - let n = partitions[0].node_count(); - - for partition in partitions.iter() { - if partition.node_count() != n { - return Err(anyhow::anyhow!( - "Number of nodes are not equal for all graphs." - )); - } - } - - let mut total_improv = N::zero(); - let mut is_node_stable = vec![false; n]; - let mut nb_moves = 0; - - let mut nodes: Vec = (0..n).collect(); - nodes.shuffle(&mut self.rng); - let mut vertex_order: VecDeque = nodes.into(); - - // Get constrained communities structure - let constrained_comms = constrained_partition.get_communities(); - - let mut comm_added = vec![false; partitions[0].community_count()]; - let mut comms = Vec::new(); - - while let Some(v) = vertex_order.pop_front() { - for &comm in &comms { - if comm < comm_added.len() { - comm_added[comm] = false; - } - } - - comms.clear(); - - let v_comm = partitions[0].membership(v); - - self.collect_constrained_candidate_communities( - v, - partitions, - constrained_partition, - &constrained_comms, - consider_comms, - &mut comms, - &mut comm_added, - ); - - let (max_comm, max_improv) = self.find_best_community_move_constrained( - v, - v_comm, - &comms, - partitions, - layer_weights, - max_comm_size, - )?; - - is_node_stable[v] = true; - - if max_comm != v_comm { - total_improv += max_improv; - - for partition in partitions.iter_mut() { - partition.move_node(v, max_comm); - } - - self.mark_constrained_neighbors_unstable( - v, - max_comm, - &partitions[0], - constrained_partition, - &mut is_node_stable, - &mut vertex_order, - ); - nb_moves += 1; - } - } - - partitions[0].renumber_communities(); - let membership = partitions[0].membership_vector(); - for partition in partitions.iter_mut().skip(1) { - partition.set_membership(&membership); - } - - Ok(total_improv) - } - - fn collect_constrained_candidate_communities( - &mut self, - v: usize, - partitions: &[P], - constrained_partition: &P, - constrained_comms: &[Vec], - consider_comms: ConsiderComms, - comms: &mut Vec, - comm_added: &mut [bool], - ) where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - match consider_comms { - ConsiderComms::AllComms => { - let v_constrained_comm = constrained_partition.membership(v); - if v_constrained_comm < constrained_comms.len() { - for &u in &constrained_comms[v_constrained_comm] { - let u_comm = partitions[0].membership(u); - if u_comm < comm_added.len() && !comm_added[u_comm] { - comms.push(u_comm); - comm_added[u_comm] = true; - } - } - } - } - ConsiderComms::AllNeighComms => { - for partition in partitions { - let constrained_membership = constrained_partition.membership_vector(); - let neigh_comms = partition.get_neigh_comms(v, Some(&constrained_membership)); - for comm in neigh_comms { - if comm < comm_added.len() && !comm_added[comm] { - comms.push(comm); - comm_added[comm] = true; - } - } - } - } - ConsiderComms::RandComm => { - let v_constrained_comm = constrained_partition.membership(v); - if v_constrained_comm < constrained_comms.len() - && !constrained_comms[v_constrained_comm].is_empty() - { - let random_idx = self - .rng - .random_range(0..constrained_comms[v_constrained_comm].len()); - let random_node = constrained_comms[v_constrained_comm][random_idx]; - let rand_comm = partitions[0].membership(random_node); - comms.push(rand_comm); - if rand_comm < comm_added.len() { - comm_added[rand_comm] = true; - } - } - } - ConsiderComms::RandNeighComm => { - let mut all_neigh_comms_incl_dupes = Vec::new(); - for partition in partitions { - let constrained_membership = constrained_partition.membership_vector(); - let neigh_comms = partition.get_neigh_comms(v, Some(&constrained_membership)); - all_neigh_comms_incl_dupes.extend(neigh_comms); - } - - if !all_neigh_comms_incl_dupes.is_empty() { - let random_idx = self.rng.random_range(0..all_neigh_comms_incl_dupes.len()); - let rand_comm = all_neigh_comms_incl_dupes[random_idx]; - comms.push(rand_comm); - if rand_comm < comm_added.len() { - comm_added[rand_comm] = true; - } - } - } - } - } - - fn find_best_community_move_constrained( - &self, - v: usize, - v_comm: usize, - comms: &[usize], - partitions: &mut [P], - layer_weights: &[N], - max_comm_size: Option, - ) -> anyhow::Result<(usize, N)> - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - let mut max_comm = v_comm; - let mut max_improv = if let Some(max_size) = max_comm_size { - if max_size < partitions[0].csize(v_comm) { - ::neg_infinity() - } else { - N::from(10.0).unwrap() * ::epsilon() - } - } else { - N::from(10.0).unwrap() * ::epsilon() - }; - - let v_size = 1; - - for &comm in comms { - if let Some(max_size) = max_comm_size { - if max_size < partitions[0].csize(comm) + v_size { - continue; - } - } - - let mut possible_improvement = N::zero(); - for (layer, partition) in partitions.iter_mut().enumerate() { - let layer_improv = partition.diff_move(v, comm); - possible_improvement += layer_weights[layer] * layer_improv; - } - - if possible_improvement > max_improv { - max_comm = comm; - max_improv = possible_improvement; - } - } - - Ok((max_comm, max_improv)) - } - - fn mark_constrained_neighbors_unstable( - &self, - v: usize, - new_comm: usize, - partition: &P, - constrained_partition: &P, - is_node_stable: &mut [bool], - vertex_order: &mut VecDeque, - ) where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - let v_constrained_comm = constrained_partition.membership(v); - for (neighbor, _) in partition.network().neighbors(v) { - if is_node_stable[neighbor] - && partition.membership(neighbor) != new_comm - && constrained_partition.membership(neighbor) == v_constrained_comm - { - vertex_order.push_back(neighbor); - is_node_stable[neighbor] = false; - } - } - } - - fn merge_nodes_constrained( - &mut self, - partitions: &mut [P], - layer_weights: &[N], - consider_comms: ConsiderComms, - constrained_partition: &P, - max_comm_size: Option, - ) -> anyhow::Result - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - let nb_layers = partitions.len(); - if nb_layers == 0 { - return Ok(N::from(-1.0).unwrap()); - } - - let n = partitions[0].node_count(); - - // Check all partitions have same number of nodes - for partition in partitions.iter() { - if partition.node_count() != n { - return Err(anyhow::anyhow!( - "Number of nodes are not equal for all graphs." - )); - } - } - - let mut total_improv = N::zero(); - - // Establish vertex order and shuffle it - let mut vertex_order: Vec = (0..n).collect(); - vertex_order.shuffle(&mut self.rng); - - // Get constrained communities structure - let constrained_comms = constrained_partition.get_communities(); - - let mut comm_added = vec![false; partitions[0].community_count()]; - let mut comms = Vec::new(); - - for v in vertex_order { - let v_comm = partitions[0].membership(v); - - if partitions[0].cnodes(v_comm) == 1 { - for &comm in &comms { - if comm < comm_added.len() { - comm_added[comm] = false; - } - } - } - - comms.clear(); - - self.collect_constrained_candidate_communities( - v, - partitions, - constrained_partition, - &constrained_comms, - consider_comms, - &mut comms, - &mut comm_added, - ); - - let mut max_comm = v_comm; - let mut max_improv = if let Some(max_size) = max_comm_size { - if max_size < partitions[0].csize(v_comm) { - ::neg_infinity() - } else { - N::zero() - } - } else { - N::zero() - }; - - let v_size = 1; - - for &comm in &comms { - if let Some(max_size) = max_comm_size { - if max_size < partitions[0].csize(comm) + v_size { - continue; - } - } - - let mut possible_improv = N::zero(); - - for (layer, partition) in partitions.iter_mut().enumerate() { - let layer_imrpov = partition.diff_move(v, comm); - possible_improv += layer_weights[layer] * layer_imrpov; - } - - if possible_improv >= max_improv { - max_comm = comm; - max_improv = possible_improv; - } - } - - if max_comm != v_comm { - total_improv += max_improv; - - for partition in partitions.iter_mut() { - partition.move_node(v, max_comm); - } - } - } - - partitions[0].renumber_communities(); - let membership = partitions[0].membership_vector(); - for partition in partitions.iter_mut().skip(1) { - partition.set_membership(&membership); - } - - Ok(total_improv) - } - - fn refine_and_collapse( - &mut self, - collapsed_partitions: &[P], - layer_weights: &[N], - aggregate_node_per_individual_node: &mut [usize], - original_n: usize, - ) -> anyhow::Result> - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - let nb_layers = collapsed_partitions.len(); - - let mut sub_collapsed_partitions: Vec

= Vec::with_capacity(nb_layers); - - for partition in collapsed_partitions { - let network = partition.network().clone(); - let sub_partition = partition.create_like(network); - sub_collapsed_partitions.push(sub_partition); - } - - match self.config.refine_routine { - super::OptimiseRoutine::MoveNodes => { - self.move_nodes_constrained( - &mut sub_collapsed_partitions, - layer_weights, - self.config.refine_consider_comms, - &collapsed_partitions[0], - self.config.max_community_size, - )?; - } - super::OptimiseRoutine::MergeNodes => { - self.merge_nodes_constrained( - &mut sub_collapsed_partitions, - layer_weights, - self.config.refine_consider_comms, - &collapsed_partitions[0], - self.config.max_community_size, - )?; - } - } - - for v in 0..original_n { - if v < aggregate_node_per_individual_node.len() { - let aggregate_node = aggregate_node_per_individual_node[v]; - if aggregate_node < sub_collapsed_partitions[0].node_count() { - aggregate_node_per_individual_node[v] = - sub_collapsed_partitions[0].membership(aggregate_node); - } - } - } - - let mut new_collapsed_partitions = Vec::with_capacity(nb_layers); - - for layer in 0..nb_layers { - let collapsed_network = collapsed_partitions[layer] - .network() - .aggregate(sub_collapsed_partitions[layer].grouping()); - let refined_membership = sub_collapsed_partitions[layer].membership_vector(); - let mut new_membership = vec![0; collapsed_network.node_count()]; - - for v in 0..collapsed_partitions[layer].node_count() { - let refined_comm = refined_membership[v]; - let original_comm = collapsed_partitions[layer].membership(v); - if refined_comm < new_membership.len() { - new_membership[refined_comm] = original_comm; - } - } - - let new_partition = collapsed_partitions[layer] - .create_like_with_membership(collapsed_network, &new_membership); - new_collapsed_partitions.push(new_partition); - } - - Ok(new_collapsed_partitions) - } - - fn simple_collapse(&self, collapsed_partitions: &[P]) -> anyhow::Result> - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - let mut new_collapsed_partitions = Vec::new(); - - for partition in collapsed_partitions { - let collapsed_network = partition.network().aggregate(partition.grouping()); - let new_partition = partition.create_like(collapsed_network); - new_collapsed_partitions.push(new_partition); - } - - Ok(new_collapsed_partitions) - } - - fn from_coarse_partition_with_refinement( - &self, - fine_partition: &mut P, - coarse_partition: &P, - aggregate_node_per_individual_node: &[usize], - ) where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - for node in 0..fine_partition.node_count() { - if node < aggregate_node_per_individual_node.len() { - let aggregate_node = aggregate_node_per_individual_node[node]; - if aggregate_node < coarse_partition.node_count() { - let new_community = coarse_partition.membership(aggregate_node); - fine_partition.move_node(node, new_community); - } - } - } - } - - fn from_coarse_partition_simple(&self, fine_partition: &mut P, coarse_partition: &P) - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - for node in 0..fine_partition.node_count() { - if node < coarse_partition.community_count() { - let new_membership = coarse_partition.membership(node); - fine_partition.move_node(node, new_membership); - } - } - } - - fn should_aggregate_further( - &self, - new_partition: &P, - old_partition: &P, - is_collapsed_membership_fixed: &[bool], - ) -> bool - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - if is_collapsed_membership_fixed.iter().all(|&fixed| fixed) { - return false; - } - - new_partition.node_count() < old_partition.node_count() - && old_partition.node_count() > old_partition.community_count() - } - - /// Optimizes multiple partitions simultaneously across different layers. - /// - /// Performs the complete Leiden optimization including iterative node movement, - /// community aggregation, and refinement until convergence or max iterations. - /// - /// # Arguments - /// * `partitions` - Array of partitions to optimize (one per layer) - /// * `layer_weights` - Relative importance weights for each layer - /// * `is_membership_fixed` - Nodes that cannot change communities - /// - /// # Returns - /// Total quality improvement achieved during optimization - pub fn optimize_partition( - &mut self, - partitions: &mut [P], - layer_weights: &[N], - is_membership_fixed: &[bool], - ) -> anyhow::Result - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - println!("Optimizing partition! Start..."); - if partitions.is_empty() { - return Err(anyhow::anyhow!("No partitions provided")); - } - - let nb_layers = partitions.len(); - let n = partitions[0].node_count(); - - // Validate all partitions have same number of nodes - for partition in partitions.iter() { - if partition.node_count() != n { - return Err(anyhow::anyhow!( - "Number of nodes are not equal for all graphs" - )); - } - } - - // Store fixed node information - let mut fixed_nodes = Vec::new(); - let mut fixed_membership = vec![0; n]; - for v in 0..n { - if is_membership_fixed[v] { - fixed_nodes.push(v); - fixed_membership[v] = partitions[0].membership(v); - } - } - - // Initialize collapsed structures - start with original partitions - let mut collapsed_partitions: Vec

= partitions.to_vec(); - let mut is_collapsed_membership_fixed = is_membership_fixed.to_vec(); - let mut aggregate_node_per_individual_node: Vec = (0..n).collect(); - let mut is_first_iteration = true; - - let mut total_improvement = N::zero(); - let mut aggregate_further = true; - let mut i = 0; - let mut time = Instant::now(); - while aggregate_further { - println!("Starting iteration {:?}, time: {:?}", i, time.elapsed()); - let improvement = match self.config.optimise_routine { - super::OptimiseRoutine::MoveNodes => self.move_nodes_parallel( - &mut collapsed_partitions, - layer_weights, - &is_collapsed_membership_fixed, - self.config.consider_comms, - self.config.consider_empty_community, - self.config.max_community_size, - )?, - super::OptimiseRoutine::MergeNodes => self.merge_nodes( - &mut collapsed_partitions, - layer_weights, - &is_collapsed_membership_fixed, - self.config.consider_comms, - false, // renumber_fixed_nodes - self.config.max_community_size, - )?, - }; - println!("Finished moving nodes {:?}, time: {:?}", i, time.elapsed()); - - total_improvement += improvement; - - // TODO inspect here! - if is_first_iteration - && aggregate_node_per_individual_node.len() == n - && aggregate_node_per_individual_node - .iter() - .enumerate() - .all(|(i, &v)| i == v) - { - // First iteration, no aggregation - direct copy - for (orig, collapsed) in partitions.iter_mut().zip(collapsed_partitions.iter()) { - let membership = collapsed.membership_vector(); - orig.set_membership(&membership); - } - } else { - // Use coarse partition mapping - for (layer, partition) in partitions.iter_mut().enumerate() { - if self.config.refine_partition { - self.from_coarse_partition_with_refinement( - partition, - &collapsed_partitions[layer], - &aggregate_node_per_individual_node, - ); - } else { - self.from_coarse_partition_simple(partition, &collapsed_partitions[layer]); - } - } - } - - println!("Refining partition {:?}, time: {:?}", i, time.elapsed()); - let new_collapsed_partitions = if self.config.refine_partition { - self.refine_and_collapse( - &collapsed_partitions, - layer_weights, - &mut aggregate_node_per_individual_node, - n, - )? - } else { - self.simple_collapse(&collapsed_partitions)? - }; - - is_collapsed_membership_fixed = vec![false; new_collapsed_partitions[0].node_count()]; - for v in 0..n { - if is_membership_fixed[v] - && aggregate_node_per_individual_node[v] < is_collapsed_membership_fixed.len() - { - is_collapsed_membership_fixed[aggregate_node_per_individual_node[v]] = true; - } - } - - aggregate_further = self.should_aggregate_further( - &new_collapsed_partitions[0], - &collapsed_partitions[0], - &is_collapsed_membership_fixed, - ); - - collapsed_partitions = new_collapsed_partitions; - is_first_iteration = false; - i += 1; - } - - partitions[0].renumber_communities(); - if !fixed_nodes.is_empty() { - partitions[0].renumber_communities_fixed(&fixed_nodes, &fixed_membership); - } - - let membership = partitions[0].membership_vector(); - for partition in partitions.iter_mut().skip(1) { - partition.set_membership(&membership); - } - - Ok(total_improvement) - } - - /// Optimizes a single partition using Leiden algorithm. - /// - /// Convenience method for optimizing a single-layer network partition. - /// Internally calls `optimize_partition` with appropriate single-layer parameters. - /// - /// # Arguments - /// * `partition` - The partition to optimize - /// * `is_membership_fixed` - Optional array of nodes that cannot change communities - /// - /// # Returns - /// Total quality improvement achieved during optimization - pub fn optimize_single_partition( - &mut self, - partition: &mut P, - is_membership_fixed: Option<&[bool]>, - ) -> anyhow::Result - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - let mut partitions = vec![partition.clone()]; - let layer_weights = vec![N::one()]; - let fixed = is_membership_fixed - .map(|f| f.to_vec()) - .unwrap_or_else(|| vec![false; partition.node_count()]); - - let improvement = self.optimize_partition(&mut partitions, &layer_weights, &fixed)?; - - *partition = partitions.into_iter().next().unwrap(); - - Ok(improvement) - } - - /// Finds the optimal community partition for a network from scratch. - /// - /// Creates a singleton partition (each node in its own community) and then - /// optimizes it using the Leiden algorithm to discover community structure. - /// - /// # Arguments - /// * `network` - The network to partition - /// - /// # Returns - /// Optimized partition with detected communities - pub fn find_partition(&mut self, network: CSRNetwork) -> anyhow::Result

- where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, - P: VertexPartition, - { - let mut partition = P::create_partition(network); - - self.optimize_single_partition(&mut partition, None)?; - Ok(partition) - } -} diff --git a/src/community_search/leiden/parallel.rs b/src/community_search/leiden/parallel.rs deleted file mode 100644 index 3e9e21b..0000000 --- a/src/community_search/leiden/parallel.rs +++ /dev/null @@ -1,368 +0,0 @@ -use std::{ - collections::{HashSet, VecDeque}, string::ParseError, thread::current -}; - -use num_traits::Float; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; -use single_utilities::traits::FloatOpsTS; - -use crate::{ - community_search::leiden::{ - ConsiderComms, - partition::{self, VertexPartition}, - }, - network::{CSRNetwork, grouping::NetworkGrouping}, -}; - -#[derive(Debug, Clone, Copy)] -pub struct ProposedMove { - pub node: usize, - pub from_comm: usize, - pub to_comm: usize, - pub improvement: N, -} - -impl ProposedMove { - #[inline] - pub fn new(node: usize, from_comm: usize, to_comm: usize, improvement: N) -> Self { - Self { - node, - from_comm, - to_comm, - improvement, - } - } - - #[inline] - pub fn is_beneficial(&self) -> bool { - self.from_comm != self.to_comm && self.improvement > N::zero() - } -} - -#[derive(Debug, Clone)] -pub struct ParallelConfig { - pub max_batch_size: usize, - pub max_iterations: usize, -} - -impl Default for ParallelConfig { - fn default() -> Self { - Self { - max_batch_size: 10_000, - max_iterations: 1_000, - } - } -} - -pub struct ConflictFreeBatcher { - max_batch_size: usize, -} - -impl ConflictFreeBatcher { - pub fn new(max_batch_size: usize) -> Self { - Self { max_batch_size } - } - - pub fn create_batches( - &self, - nodes: &[usize], - network: &CSRNetwork, - skip_stable: &[bool], - ) -> Vec> - where - N: FloatOpsTS + 'static, - { - let mut batches = Vec::new(); - let mut remaining: Vec = - nodes.iter().copied().filter(|&n| !skip_stable[n]).collect(); - - while !remaining.is_empty() { - let (batch, leftover) = self.extract_batch(&remaining, network); - - if batch.is_empty() { - break; - } - - batches.push(batch); - remaining = leftover; - } - batches - } - - fn extract_batch( - &self, - candidates: &[usize], - network: &CSRNetwork, - ) -> (Vec, Vec) - where - N: FloatOpsTS + 'static, - { - let mut batch = Vec::new(); - let mut leftover = Vec::new(); - let mut locked = vec![false; network.node_count()]; - - for &node in candidates { - if self.has_conflict(node, network, &locked) { - leftover.push(node); - } else { - batch.push(node); - self.mark_locked(node, network, &mut locked); - if batch.len() >= self.max_batch_size { - leftover.extend_from_slice( - &candidates[candidates.iter().position(|&n| n == node).unwrap() + 1..], - ); - break; - } - } - } - (batch, leftover) - } - - #[inline] - fn has_conflict(&self, node: usize, network: &CSRNetwork, locked: &[bool]) -> bool - where - N: FloatOpsTS + 'static, - { - if locked[node] { - return true; - } - - for (neighbor, _) in network.neighbors(node) { - if locked[neighbor] { - return true; - } - } - false - } - - #[inline] - fn mark_locked(&self, node: usize, network: &CSRNetwork, locked: &mut [bool]) - where - N: FloatOpsTS + 'static, - { - locked[node] = true; - for (neighbor, _) in network.neighbors(node) { - locked[neighbor] = true; - } - } -} - -pub struct ParallelEvaluator; - -impl ParallelEvaluator { - pub fn evaluate_batch( - batch: &[usize], - partitions: &[P], - layer_weights: &[N], - consider_comms: ConsiderComms, - consider_empty: bool, - max_comm_size: Option, - ) -> Vec> - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - batch - .par_iter() - .filter_map(|&node| { - Self::evaluate_node( - node, - partitions, - layer_weights, - consider_comms, - consider_empty, - max_comm_size, - ) - }) - .collect() - } - - fn evaluate_node( - node: usize, - partitions: &[P], - layer_weights: &[N], - consider_comms: ConsiderComms, - consider_empty: bool, - max_comm_size: Option, - ) -> Option> - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - let current_comm = partitions[0].membership(node); - - let candidates = Self::get_candidates(node, partitions, consider_comms); - - let (best_comm, best_improv) = Self::find_best( - node, - current_comm, - &candidates, - partitions, - layer_weights, - consider_empty, - max_comm_size, - )?; - - if best_comm != current_comm && best_improv > N::zero() { - Some(ProposedMove { - node, - from_comm: current_comm, - to_comm: best_comm, - improvement: best_improv, - }) - } else { - None - } - } - - fn get_candidates(node: usize, partitions: &[P], stategy: ConsiderComms) -> Vec - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - let mut comms = HashSet::new(); - match stategy { - ConsiderComms::AllComms => { - for comm in 0..partitions[0].community_count() { - if partitions[0].cnodes(comm) > 0 { - comms.insert(comm); - } - } - } - ConsiderComms::AllNeighComms => { - for partition in partitions { - for (neighbor, _) in partition.network().neighbors(node) { - comms.insert(partition.membership(neighbor)); - } - } - } - ConsiderComms::RandComm | ConsiderComms::RandNeighComm => { - for partition in partitions { - for (neighbor, _) in partition.network().neighbors(node) { - comms.insert(partition.membership(neighbor)); - } - } - } - } - - comms.into_iter().collect() - } - - fn find_best( - node: usize, - current_comm: usize, - candidates: &[usize], - partitions: &[P], - layer_weights: &[N], - consider_empty: bool, - max_comm_size: Option, - ) -> Option<(usize, N)> - where - N: FloatOpsTS + 'static, - G: NetworkGrouping, - P: VertexPartition, - { - let epsilon = N::from(10.0).unwrap() * ::epsilon(); - let mut best_comm = current_comm; - let mut best_improv = epsilon; - - let mut to_check = candidates.to_vec(); - if consider_empty && partitions[0].cnodes(current_comm) > 1 { - to_check.push(partitions[0].community_count()); - } - - for &comm in &to_check { - if let Some(max_size) = max_comm_size { - if comm < partitions[0].community_count() - && partitions[0].csize(comm) + 1 > max_size - { - continue; - } - } - - let mut total_improv = N::zero(); - for (idx, partitions) in partitions.iter().enumerate() { - let layer_improv = partitions.diff_move_readonly(node, comm); - total_improv += layer_weights[idx] * layer_improv; - } - - if total_improv > best_improv { - best_comm = comm; - best_improv = total_improv; - } - } - - Some((best_comm, best_improv)) - } -} - -pub struct MoveApplicator; - -impl MoveApplicator { - pub fn apply_moves( - moves: Vec>, - partitions: &mut [P], - is_stable: &mut [bool], - is_fixed: &[bool], - pending: &mut VecDeque, - ) -> N - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone, - P: VertexPartition, - { - - let mut total_improv = N::zero(); - - if moves.is_empty() { - return N::zero(); - } - - for m in moves { - Self::ensure_community_exists(m.to_comm, partitions); - - for partition in partitions.iter_mut() { - partition.move_node(m.node, m.to_comm); - } - - total_improv += m.improvement; - is_stable[m.node] = true; - - let network = partitions[0].network(); - Self::mark_neighbors_unstable(m.node, network, is_stable, is_fixed, pending); - } - total_improv - } - - fn ensure_community_exists(comm: usize, partitions: &mut [P]) - where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone, - P: VertexPartition { - for partition in partitions.iter_mut() { - while partition.community_count() <= comm { - partition.add_empty_community(); - } - } - } - - fn mark_neighbors_unstable( - node: usize, - network: &CSRNetwork, - is_stable: &mut [bool], - is_fixed: &[bool], - pending: &mut VecDeque - ) - where - N: FloatOpsTS + 'static { - for (neighbor, _) in network.neighbors(node) { - if !is_fixed[neighbor] && is_stable[neighbor] { - is_stable[neighbor] = false; - pending.push_back(neighbor); - } - } - } -} diff --git a/src/community_search/leiden/partition.rs b/src/community_search/leiden/partition.rs new file mode 100644 index 0000000..3dd14e8 --- /dev/null +++ b/src/community_search/leiden/partition.rs @@ -0,0 +1,683 @@ +//! Partition state. +//! +//! [`Partition`] holds every aggregate the quality functions need — sizes, node weights, +//! strengths, internal edge weights — updated in O(1) per move. Nothing here walks the graph +//! or allocates per call, which is what keeps local moving at O(degree) rather than O(n). +//! +//! A move is [`remove_node`](Partition::remove_node) then +//! [`insert_node`](Partition::insert_node), not one operation. The node sits in no community +//! in between, so scoring every candidate — its old community, an empty one, anything else — +//! is the same calculation. + +use rand::seq::SliceRandom; +use rand_chacha::ChaCha8Rng; + +use crate::network::CSRNetwork; +use crate::network::grouping::{NetworkGrouping, VectorGrouping}; + +/// Membership value for a node that has been removed from its community. +/// +/// Visible in [`Partition::membership_raw`] while a move is in progress; never in a +/// partition handed back to a caller. +pub const UNASSIGNED: u32 = u32::MAX; + +/// A partition of a graph's nodes into communities, with incrementally maintained aggregates. +#[derive(Debug, Clone)] +pub struct Partition { + /// Community of each node, or [`UNASSIGNED`] while a node is removed. + /// + /// `u32`, not `usize`: this is the hottest random access in the algorithm — every + /// neighbour of every visited node reads it — so halving the array halves that cache + /// footprint. Ids are bounded by the node count, which `CSRNetwork` caps at `u32::MAX`. + membership: Vec, + /// Number of nodes in each community. + size: Vec, + /// Sum of node weights in each community. This is what CPM measures. + weight: Vec, + /// Sum of node strengths in each community. This is `K_c` in the RB null model. + strength: Vec, + /// Internal edge weight of each community: edges with both endpoints inside, each + /// undirected edge counted once, self-loops counted once at full weight. + internal: Vec, + /// Community slots that currently hold no nodes, available for reuse. + free: Vec, + /// Total edge weight, each edge once. Kept here so quality functions don't need the graph, + /// and so it stays consistent across levels. + total_weight: f64, + /// Sum of all node weights. + total_node_weight: f64, +} + +impl Partition { + /// Creates a partition where every node is alone in its own community. + pub fn singleton(graph: &CSRNetwork) -> Self { + let n = graph.node_count(); + let mut p = Self { + membership: (0..n as u32).collect(), + size: vec![1; n], + weight: (0..n).map(|v| graph.node_weight(v)).collect(), + strength: (0..n).map(|v| graph.strength(v)).collect(), + // only internal edge is its own self-loop + internal: (0..n).map(|v| graph.self_loop_weight(v)).collect(), + free: Vec::new(), + total_weight: graph.total_weight(), + total_node_weight: graph.total_node_weight(), + }; + // no nodes, no communities + p.free.clear(); + p + } + + /// Creates a partition from an explicit membership vector. + /// + /// Ids need not be consecutive — empty slots below the maximum are recorded as free and + /// reused before anything new is allocated. + pub fn from_membership(graph: &CSRNetwork, membership: &[usize]) -> Self { + debug_assert_eq!(membership.len(), graph.node_count()); + let slots = membership.iter().copied().max().map_or(0, |m| m + 1); + + let mut p = Self { + membership: membership.iter().map(|&c| c as u32).collect(), + size: vec![0; slots], + weight: vec![0.0; slots], + strength: vec![0.0; slots], + internal: vec![0.0; slots], + free: Vec::new(), + total_weight: graph.total_weight(), + total_node_weight: graph.total_node_weight(), + }; + + for (v, &c) in membership.iter().enumerate() { + p.size[c] += 1; + p.weight[c] += graph.node_weight(v); + p.strength[c] += graph.strength(v); + } + for (v, &c) in membership.iter().enumerate() { + for (u, w) in graph.neighbors(v) { + if membership[u] != c { + continue; + } + // each edge once: loops here, regular ones from the lower id + if u == v || v < u { + p.internal[c] += w; + } + } + } + for c in (0..slots).rev() { + if p.size[c] == 0 { + p.free.push(c); + } + } + p + } + + /// Number of community slots, including empty ones. + #[inline] + pub fn slots(&self) -> usize { + self.size.len() + } + + /// Number of non-empty communities. + #[inline] + pub fn community_count(&self) -> usize { + self.size.len() - self.free.len() + } + + /// Number of nodes in the partition. + #[inline] + pub fn node_count(&self) -> usize { + self.membership.len() + } + + /// Community of a node. Panics in debug builds if the node is currently removed. + #[inline] + pub fn membership(&self, node: usize) -> usize { + debug_assert_ne!(self.membership[node], UNASSIGNED, "node {node} is removed"); + self.membership[node] as usize + } + + /// The raw membership slice, which may contain [`UNASSIGNED`] mid-move. + #[inline] + pub fn membership_raw(&self) -> &[u32] { + &self.membership + } + + /// The membership as `usize` values, allocated fresh. + pub fn membership_vec(&self) -> Vec { + self.membership.iter().map(|&c| c as usize).collect() + } + + /// Number of nodes in a community. + #[inline] + pub fn size(&self, community: usize) -> usize { + self.size.get(community).copied().unwrap_or(0) as usize + } + + /// Sum of node weights in a community — `n_c` in CPM. + #[inline] + pub fn weight(&self, community: usize) -> f64 { + self.weight.get(community).copied().unwrap_or(0.0) + } + + /// Sum of node strengths in a community — `K_c` in the RB null model. + #[inline] + pub fn strength(&self, community: usize) -> f64 { + self.strength.get(community).copied().unwrap_or(0.0) + } + + /// Internal edge weight of a community, each edge counted once. + #[inline] + pub fn internal(&self, community: usize) -> f64 { + self.internal.get(community).copied().unwrap_or(0.0) + } + + /// Total edge weight of the underlying graph, each edge counted once. + #[inline] + pub fn total_weight(&self) -> f64 { + self.total_weight + } + + /// Sum of all node weights. + #[inline] + pub fn total_node_weight(&self) -> f64 { + self.total_node_weight + } + + /// Returns a community id that is currently empty, without allocating. + /// + /// Reuses a free slot if there is one, otherwise the next index, which + /// [`insert_node`](Self::insert_node) allocates on demand. Each caller takes exactly one + /// and fills it immediately, so two nodes can't get the same empty community. + #[inline] + pub fn empty_community(&self) -> usize { + self.free.last().copied().unwrap_or(self.size.len()) + } + + /// Removes a node from its community, updating all aggregates in O(1). + /// + /// `weight_to_own` is the total weight of edges from `node` to other members of its + /// community, **excluding** the node's self-loop — exactly what + /// [`NeighborWeights`] accumulates. Returns the community the node was in. + pub fn remove_node(&mut self, node: usize, graph: &CSRNetwork, weight_to_own: f64) -> usize { + let c = self.membership[node] as usize; + debug_assert_ne!( + self.membership[node], UNASSIGNED, + "node {node} was already removed" + ); + + self.size[c] -= 1; + self.weight[c] -= graph.node_weight(node); + self.strength[c] -= graph.strength(node); + // its edges in, plus its self-loop, stop being internal + self.internal[c] -= weight_to_own + graph.self_loop_weight(node); + + if self.size[c] == 0 { + // clamp drift so empty is exactly empty + self.weight[c] = 0.0; + self.strength[c] = 0.0; + self.internal[c] = 0.0; + self.free.push(c); + } + + self.membership[node] = UNASSIGNED; + c + } + + /// Inserts a removed node into a community, updating all aggregates in O(1). + /// + /// `community` may be [`empty_community`](Self::empty_community), in which case a new + /// slot is allocated. `weight_to_comm` excludes the node's self-loop. + pub fn insert_node( + &mut self, + node: usize, + community: usize, + graph: &CSRNetwork, + weight_to_comm: f64, + ) { + debug_assert_eq!( + self.membership[node], UNASSIGNED, + "node {node} is not removed" + ); + debug_assert!(community <= self.size.len(), "community id skips a slot"); + + if community == self.size.len() { + self.size.push(0); + self.weight.push(0.0); + self.strength.push(0.0); + self.internal.push(0.0); + } else if self.size[community] == 0 { + // reusing a free slot + if let Some(pos) = self.free.iter().rposition(|&c| c == community) { + self.free.swap_remove(pos); + } + } + + self.size[community] += 1; + self.weight[community] += graph.node_weight(node); + self.strength[community] += graph.strength(node); + self.internal[community] += weight_to_comm + graph.self_loop_weight(node); + self.membership[node] = community as u32; + } + + /// Renumbers communities consecutively from 0, keeping their relative order. + /// + /// Ascending order, so the output never depends on iteration order upstream. + pub fn renumber(&mut self) { + let slots = self.size.len(); + let mut new_id = vec![usize::MAX; slots]; + let mut next = 0; + for (id, size) in new_id.iter_mut().zip(self.size.iter()) { + if *size > 0 { + *id = next; + next += 1; + } + } + + for (c, &target) in new_id.iter().enumerate() { + if target == usize::MAX || target == c { + continue; + } + self.size[target] = self.size[c]; + self.weight[target] = self.weight[c]; + self.strength[target] = self.strength[c]; + self.internal[target] = self.internal[c]; + } + self.size.truncate(next); + self.weight.truncate(next); + self.strength.truncate(next); + self.internal.truncate(next); + self.free.clear(); + + for m in self.membership.iter_mut() { + if *m != UNASSIGNED { + *m = new_id[*m as usize] as u32; + } + } + } + + /// Renumbers this partition and returns its membership as a [`VectorGrouping`]. + /// + /// `&mut self` on purpose: the grouping's ids and this partition's must be the same + /// numbers, since callers use `membership(v)` to index the graph + /// [`CSRNetwork::aggregate`] produces. Renumbering a copy would let them drift silently. + pub fn renumber_into_grouping(&mut self) -> VectorGrouping { + self.renumber(); + VectorGrouping::from_assignments(&self.membership_vec()) + } + + /// Recomputes every aggregate from scratch and checks it against the maintained value. + /// + /// Used by tests to prove the incremental updates never drift. + pub fn verify_against(&self, graph: &CSRNetwork) -> std::result::Result<(), String> { + let rebuilt = Self::from_membership(graph, &self.membership_vec()); + for c in 0..self.size.len() { + if self.size[c] as usize != rebuilt.size(c) { + return Err(format!( + "community {c}: size {} != {}", + self.size[c], + rebuilt.size(c) + )); + } + for (name, got, want) in [ + ("weight", self.weight[c], rebuilt.weight(c)), + ("strength", self.strength[c], rebuilt.strength(c)), + ("internal", self.internal[c], rebuilt.internal(c)), + ] { + if (got - want).abs() > 1e-9 * want.abs().max(1.0) { + return Err(format!("community {c}: {name} {got} != {want}")); + } + } + } + Ok(()) + } +} + +/// Accumulates edge weight from one node to each neighbouring community. +/// +/// Replaces the per-call `HashSet` and `get_group_members()` allocation that made the old +/// implementation O(n) per candidate. One pass over a node's neighbours gives every candidate +/// and its weight, so scoring all of them is O(degree), not O(n · degree). +/// +/// Candidates come back in first-touch order, i.e. ascending neighbour id — deterministic, +/// unlike hash-set iteration. +#[derive(Debug, Clone, Copy, Default)] +struct Slot { + /// Generation this slot was last written in, avoiding an O(slots) clear between nodes. + stamp: u64, + /// Accumulated edge weight to this community, valid only when `stamp == generation`. + weight: f64, +} + +#[derive(Debug, Clone, Default)] +pub struct NeighborWeights { + /// Stamp and weight packed together — the inner loop touches both for the same community, + /// and at scale this array is way past cache, so adjacency turns two random misses per + /// neighbour into one. + /// + /// Tried and reverted: a linear-scan path for low-degree nodes. 19% worse at 100k (the + /// indexed array still fits in cache there) and only break-even at 3M, since the scan + /// trades a miss for a branch-mispredicting loop. + slots: Vec, + generation: u64, + touched: Vec, +} + +impl NeighborWeights { + /// Creates an accumulator sized for `slots` communities. + pub fn with_capacity(slots: usize) -> Self { + Self { + slots: vec![Slot::default(); slots], + generation: 0, + touched: Vec::new(), + } + } + + /// Grows the accumulator so community ids up to `slots - 1` are addressable. + /// + /// Call this once per node, before [`collect`](Self::collect) - not once per neighbour. + #[inline] + pub fn ensure_capacity(&mut self, slots: usize) { + if self.slots.len() < slots { + self.slots.resize(slots, Slot::default()); + } + } + + /// Starts a new accumulation round. + /// + /// A generation counter is what makes clearing free. `u64` won't wrap in any realistic + /// run, but the reset keeps that from being a silent correctness cliff. + #[inline] + fn begin(&mut self) { + self.touched.clear(); + match self.generation.checked_add(1) { + Some(next) => self.generation = next, + None => { + for slot in &mut self.slots { + slot.stamp = 0; + } + self.generation = 1; + } + } + } + + /// Accumulates `w` into `community`, recording it as touched the first time. + #[inline] + fn add(&mut self, community: usize, w: f64) { + // callers use `ensure_capacity`; grow anyway so forgetting is slow, not wrong + if community >= self.slots.len() { + self.slots.resize(community + 1, Slot::default()); + } + let generation = self.generation; + let slot = &mut self.slots[community]; + if slot.stamp != generation { + slot.stamp = generation; + slot.weight = w; + self.touched.push(community); + } else { + slot.weight += w; + } + } + + /// Accumulates the weight from `node` to each community containing one of its neighbours. + /// + /// Self-loops are excluded, matching the convention of + /// [`Partition::remove_node`]/[`Partition::insert_node`], which add the self-loop + /// themselves. Returns the touched community ids. + pub fn collect(&mut self, graph: &CSRNetwork, membership: &[u32], node: usize) -> &[usize] { + self.begin(); + + for (u, w) in graph.neighbors(node) { + if u == node { + continue; // self-loop handled by the partition + } + let c = membership[u]; + if c == UNASSIGNED { + continue; // neighbour is mid-move; treated as belonging to nothing + } + self.add(c as usize, w); + } + &self.touched + } + + /// Like [`collect`](Self::collect), but only counts neighbours that share the node's + /// group in `constraint`. + /// + /// What keeps refinement inside the communities local moving found — a node can only + /// merge with refined communities inside its own working community. + pub fn collect_constrained( + &mut self, + graph: &CSRNetwork, + membership: &[u32], + node: usize, + constraint: &[u32], + ) -> &[usize] { + self.begin(); + let own = constraint[node]; + + for (u, w) in graph.neighbors(node) { + if u == node || constraint[u] != own { + continue; + } + let c = membership[u]; + if c == UNASSIGNED { + continue; + } + self.add(c as usize, w); + } + &self.touched + } + + /// Weight from the last collected node to `community`, or `0.0` if untouched. + #[inline] + pub fn weight_to(&self, community: usize) -> f64 { + match self.slots.get(community) { + Some(slot) if slot.stamp == self.generation => slot.weight, + _ => 0.0, + } + } + + /// The communities touched by the last [`collect`](Self::collect). + #[inline] + pub fn touched(&self) -> &[usize] { + &self.touched + } +} + +/// Reusable working buffers for local moving and refinement. +/// +/// Both phases want a shuffled order, a work queue, and a queued-flag array, all sized to the +/// node count. Per-call allocation is ~1.3 GB of churn per level at 75M nodes, so the driver +/// owns one and hands it down. +#[derive(Debug, Clone, Default)] +pub struct MoveScratch { + /// Per-node accumulator of edge weight to each neighbouring community. + pub weights: NeighborWeights, + order: Vec, + queue: std::collections::VecDeque, + queued: Vec, +} + +impl MoveScratch { + /// Creates scratch sized for `n` nodes and `slots` communities. + pub fn with_capacity(n: usize, slots: usize) -> Self { + Self { + weights: NeighborWeights::with_capacity(slots), + order: Vec::with_capacity(n), + queue: std::collections::VecDeque::with_capacity(n), + queued: Vec::with_capacity(n), + } + } + + /// Fills the queue with all `n` nodes in a freshly shuffled order and marks them queued. + pub(crate) fn seed_queue(&mut self, n: usize, rng: &mut ChaCha8Rng) { + self.order.clear(); + self.order.extend(0..n); + self.order.shuffle(rng); + + self.queue.clear(); + self.queue.extend(self.order.iter().copied()); + + self.queued.clear(); + self.queued.resize(n, true); + } + + /// Produces a freshly shuffled node order without touching the queue. + pub(crate) fn shuffled_order(&mut self, n: usize, rng: &mut ChaCha8Rng) -> &[usize] { + self.order.clear(); + self.order.extend(0..n); + self.order.shuffle(rng); + &self.order + } + + #[inline] + pub(crate) fn pop(&mut self) -> Option { + let v = self.queue.pop_front()?; + self.queued[v] = false; + Some(v) + } + + #[inline] + pub(crate) fn push(&mut self, v: usize) { + if !self.queued[v] { + self.queued[v] = true; + self.queue.push_back(v); + } + } + + #[inline] + pub(crate) fn is_queued(&self, v: usize) -> bool { + self.queued[v] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn two_triangles() -> CSRNetwork { + // 0-1-2 triangle, 3-4-5 triangle, bridge 2-3 + CSRNetwork::from_edges( + 6, + &[ + (0, 1, 1.0), + (1, 2, 1.0), + (0, 2, 1.0), + (3, 4, 1.0), + (4, 5, 1.0), + (3, 5, 1.0), + (2, 3, 1.0), + ], + ) + .unwrap() + } + + #[test] + fn from_membership_computes_aggregates() { + let g = two_triangles(); + let p = Partition::from_membership(&g, &[0, 0, 0, 1, 1, 1]); + assert_eq!(p.size(0), 3); + assert_eq!(p.internal(0), 3.0, "three triangle edges"); + assert_eq!(p.internal(1), 3.0); + assert_eq!(p.strength(0), 2.0 + 2.0 + 3.0, "node 2 has the bridge"); + assert_eq!(p.community_count(), 2); + } + + #[test] + fn remove_then_insert_round_trips() { + let g = two_triangles(); + let mut p = Partition::from_membership(&g, &[0, 0, 0, 1, 1, 1]); + let before = p.clone(); + + let mut acc = NeighborWeights::with_capacity(p.slots()); + acc.collect(&g, p.membership_raw(), 2); + let w_own = acc.weight_to(0); + assert_eq!(w_own, 2.0, "node 2 links to 0 and 1"); + + let old = p.remove_node(2, &g, w_own); + assert_eq!(old, 0); + assert_eq!(p.size(0), 2); + assert_eq!(p.internal(0), 1.0, "only edge 0-1 remains internal"); + + p.insert_node(2, old, &g, w_own); + assert_eq!(p.membership(2), 0); + for c in 0..p.slots() { + assert_eq!(p.size(c), before.size(c)); + assert!((p.internal(c) - before.internal(c)).abs() < 1e-12); + assert!((p.strength(c) - before.strength(c)).abs() < 1e-12); + } + p.verify_against(&g).unwrap(); + } + + #[test] + fn moving_a_node_keeps_aggregates_exact() { + let g = two_triangles(); + let mut p = Partition::from_membership(&g, &[0, 0, 0, 1, 1, 1]); + let mut acc = NeighborWeights::with_capacity(p.slots()); + + // walk node 2 across to the other community and back + for target in [1usize, 0] { + acc.collect(&g, p.membership_raw(), 2); + let own = p.membership(2); + let w_own = acc.weight_to(own); + p.remove_node(2, &g, w_own); + let w_new = acc.weight_to(target); + p.insert_node(2, target, &g, w_new); + p.verify_against(&g).unwrap(); + } + assert_eq!(p.membership(2), 0); + } + + #[test] + fn emptied_community_is_reused_not_leaked() { + let g = two_triangles(); + let mut p = Partition::from_membership(&g, &[0, 1, 1, 1, 1, 1]); + let mut acc = NeighborWeights::with_capacity(p.slots()); + + acc.collect(&g, p.membership_raw(), 0); + let old = p.remove_node(0, &g, acc.weight_to(0)); + assert_eq!(old, 0); + assert_eq!(p.community_count(), 1); + // the emptied slot is the one offered back as "an empty community" + assert_eq!(p.empty_community(), 0); + + p.insert_node(0, 1, &g, acc.weight_to(1)); + assert_eq!(p.community_count(), 1); + assert_eq!(p.empty_community(), 0, "slot 0 stays free"); + p.verify_against(&g).unwrap(); + } + + #[test] + fn self_loops_are_excluded_from_the_accumulator() { + let g = CSRNetwork::from_edges(2, &[(0, 1, 1.0), (0, 0, 5.0)]).unwrap(); + let p = Partition::singleton(&g); + let mut acc = NeighborWeights::with_capacity(p.slots()); + acc.collect(&g, p.membership_raw(), 0); + assert_eq!(acc.weight_to(0), 0.0, "own self-loop must not appear"); + assert_eq!(acc.weight_to(1), 1.0); + // but the partition does account for it + assert_eq!(p.internal(0), 5.0); + } + + #[test] + fn candidate_order_is_deterministic() { + let g = two_triangles(); + let p = Partition::from_membership(&g, &[3, 1, 2, 0, 1, 2]); + let mut acc = NeighborWeights::with_capacity(p.slots()); + let first: Vec = acc.collect(&g, p.membership_raw(), 2).to_vec(); + for _ in 0..20 { + let again: Vec = acc.collect(&g, p.membership_raw(), 2).to_vec(); + assert_eq!(first, again); + } + } + + #[test] + fn renumber_compacts_ids_in_ascending_order() { + let g = two_triangles(); + let mut p = Partition::from_membership(&g, &[5, 5, 5, 2, 2, 2]); + assert_eq!(p.community_count(), 2); + p.renumber(); + assert_eq!(p.slots(), 2); + assert_eq!(p.membership_raw(), &[1, 1, 1, 0, 0, 0]); + p.verify_against(&g).unwrap(); + } +} diff --git a/src/community_search/leiden/partition/mod.rs b/src/community_search/leiden/partition/mod.rs deleted file mode 100644 index 5bbe4cc..0000000 --- a/src/community_search/leiden/partition/mod.rs +++ /dev/null @@ -1,195 +0,0 @@ -//! Vertex partition trait and implementations for community detection. -//! -//! Defines the core `VertexPartition` trait that abstracts different quality functions -//! (modularity, RB configuration) for community detection algorithms. - -use std::collections::HashSet; - -use single_utilities::traits::FloatOpsTS; - -use crate::network::{grouping::NetworkGrouping, CSRNetwork}; - -mod modularity; -pub use modularity::ModularityPartition; -mod rb; -pub use rb::RBConfigurationPartition; - -/// Core trait for vertex partitions in community detection algorithms. -/// -/// Abstracts different quality functions (modularity, RB configuration, etc.) while -/// providing common operations for node movement, community management, and quality evaluation. -/// Implementations must be thread-safe and cloneable for parallel optimization. -pub trait VertexPartition: Send + Sync + Clone -where - N: FloatOpsTS + 'static, - G: NetworkGrouping, -{ - /// Creates a new partition with singleton communities (each node in its own community). - fn create_partition(network: CSRNetwork) -> Self; - - /// Creates a partition with the specified community membership. - fn create_with_membership(network: CSRNetwork, membership: &[usize]) -> Self; - - /// Creates a partition of the same type for a different network. - fn create_like(&self, network: CSRNetwork) -> Self; - - /// Creates a partition of the same type with specified membership. - fn create_like_with_membership(&self, network: CSRNetwork, membership: &[usize]) -> Self; - - /// Calculates the current partition quality (modularity, RB configuration, etc.). - fn quality(&self) -> N; - - /// Calculates the quality change from moving a node to a new community. - /// - /// Returns the improvement (positive) or degradation (negative) in quality. - fn diff_move(&mut self, node: usize, new_community: usize) -> N; - - /// Returns a reference to the underlying network. - fn network(&self) -> &CSRNetwork; - - /// Returns a reference to the community grouping structure. - fn grouping(&self) -> &G; - - /// Returns a mutable reference to the community grouping structure. - fn grouping_mut(&mut self) -> &mut G; - - /// Moves a node to a new community. - fn move_node(&mut self, node: usize, new_community: usize) { - self.grouping_mut().set_group(node, new_community); - } - - /// Returns the community membership of a node. - fn membership(&self, node: usize) -> usize { - self.grouping().get_group(node) - } - - /// Returns the membership vector for all nodes. - fn membership_vector(&self) -> Vec { - (0..self.node_count()).map(|i| self.membership(i)).collect() - } - - /// Sets the community membership for all nodes. - fn set_membership(&mut self, membership: &[usize]) { - for (node, &comm) in membership.iter().enumerate() { - self.grouping_mut().set_group(node, comm); - } - } - - /// Returns the total number of nodes in the network. - fn node_count(&self) -> usize { - self.network().node_count() - } - - /// Returns the total number of communities in the partition. - fn community_count(&self) -> usize { - self.grouping().group_count() - } - - /// Renumbers communities to be consecutive starting from 0. - fn renumber_communities(&mut self) { - self.grouping_mut().normalize_groups(); - } - - /// Renumbers communities while preserving fixed node memberships. - fn renumber_communities_fixed(&mut self, fixed_nodes: &[usize], fixed_membership: &[usize]) { - // First renumber normally - self.renumber_communities(); - - // Then restore fixed nodes to their original communities - for (i, &node) in fixed_nodes.iter().enumerate() { - self.move_node(node, fixed_membership[i]); - } - } - - /// Returns the next available empty community ID. - fn get_empty_community(&mut self) -> usize { - - self.community_count() - } - - /// Adds a new empty community (handled implicitly by moving nodes). - fn add_empty_community(&mut self) { - - // This is handled implicitly when we move a node to a new community number - } - - /// Updates partition from a coarser partition using node aggregation mapping. - fn from_coarse_partition>(&mut self, coarse_partition: &P, aggregate_mapping: &[usize]) { - for node in 0..self.node_count() { - let aggregate_node = aggregate_mapping[node]; - let new_comm = coarse_partition.membership(aggregate_node); - self.move_node(node, new_comm); - } - } - - /// Checks if a node is well-connected within its community. - /// - /// Returns true if more than half of the node's connections are internal to the community. - fn is_well_connected(&self, node: usize, community: usize) -> bool { - let internal_connections = self.count_internal_connections(node, community); - let total_connections = self.count_total_connections(node); - - if total_connections == 0 { - return true; - } - - N::from(internal_connections).unwrap() / N::from(total_connections).unwrap() - > N::from(0.5).unwrap() - } - - /// Counts internal connections within a community for a node. - fn count_internal_connections(&self, node: usize, community: usize) -> usize { - self.network() - .neighbors(node) - .filter(|(neighbor, _)| self.membership(*neighbor) == community) - .count() - } - - /// Counts total connections for a node. - fn count_total_connections(&self, node: usize) -> usize { - self.network().neighbors(node).count() - } - - /// Returns all communities as vectors of their member nodes. - fn get_communities(&self) -> Vec> { - self.grouping().get_group_members() - } - - /// Returns the size (number of nodes) of a community. - fn csize(&self, community: usize) -> usize { - if community < self.community_count() { - self.grouping().get_group_members()[community].len() - } else { - 0 - } - } - - /// Alias for `csize()` - returns the number of nodes in a community. - fn cnodes(&self, community: usize) -> usize { - self.csize(community) - } - - /// Gets neighboring communities for a node, optionally constrained by membership. - /// - /// Returns communities that contain at least one neighbor of the given node. - /// If constrained membership is provided, only considers neighbors in the same constraint group. - fn get_neigh_comms(&self, node: usize, constrained_membership: Option<&[usize]>) -> Vec { - let mut comms = HashSet::new(); - - for (neighbor, _) in self.network().neighbors(node) { - // If we have constraints, check them - if let Some(constraints) = constrained_membership { - if constraints[node] != constraints[neighbor] { - continue; - } - } - comms.insert(self.membership(neighbor)); - } - - comms.into_iter().collect() - } - - fn diff_move_readonly(&self, node: usize, new_community: usize) -> N; - -} - diff --git a/src/community_search/leiden/partition/modularity.rs b/src/community_search/leiden/partition/modularity.rs deleted file mode 100644 index 2e6324b..0000000 --- a/src/community_search/leiden/partition/modularity.rs +++ /dev/null @@ -1,268 +0,0 @@ -//! Modularity-based vertex partition implementation. -//! -//! Implements the modularity quality function for community detection, which measures -//! the density of connections within communities compared to a random null model. - -use single_utilities::traits::FloatOpsTS; - -use crate::{ - community_search::leiden::partition::VertexPartition, - network::{grouping::NetworkGrouping, CSRNetwork}, -}; - -/// Modularity-based vertex partition for community detection. -/// -/// Uses the modularity quality function to evaluate community structures by comparing -/// the density of internal connections against a null model based on node degrees. -#[derive(Clone)] -pub struct ModularityPartition -where - N: FloatOpsTS + 'static, - G: NetworkGrouping, -{ - network: CSRNetwork, - grouping: G, - total_weight: N, -} - -impl ModularityPartition -where - N: FloatOpsTS + 'static, - G: NetworkGrouping, -{ - /// Creates a new modularity partition with the given network and grouping. - pub fn new(network: CSRNetwork, grouping: G) -> Self { - let tot_weight = network.total_weight(); - Self { - network, - grouping, - total_weight: tot_weight, - } - } - - /// Creates a new partition with singleton communities (each node in its own community). - pub fn new_singleton(network: CSRNetwork) -> Self { - let grouping = G::create_isolated(network.node_count()); - Self::new(network, grouping) - } - - /// Consumes the partition and returns the underlying grouping structure. - pub fn into_grouping(self) -> G { - self.grouping - } - - /// Calculates the total weight of edges from a node to a specific community. - fn weight_to_comm(&self, node: usize, community: usize) -> N { - let mut weight = N::zero(); - for (neighbor, edge_weight) in self.network.neighbors(node) { - if self.grouping.get_group(neighbor) == community { - weight += edge_weight; - } - } - weight - } - - fn weight_from_comm(&self, node: usize, community: usize) -> N { - // For undirected graphs, this is the same as weight_to_comm - self.weight_to_comm(node, community) - } - - /// Calculates the total degree (strength) of all nodes in a community. - fn total_weight_from_comm(&self, community: usize) -> N { - if community >= self.grouping.group_count() { - return N::zero(); - } - - let members = &self.grouping.get_group_members()[community]; - let mut total_weight = N::zero(); - - for &node in members { - total_weight += self.network.strength(node); - } - total_weight - } - - fn total_weight_to_comm(&self, community: usize) -> N { - // For undirected graphs, this is the same as total_weight_from_comm - self.total_weight_from_comm(community) - } - - /// Calculates the total weight of edges within a community (internal edges). - fn total_weight_in_comm(&self, community: usize) -> N { - if community >= self.grouping.group_count() { - return N::zero(); - } - - let members = &self.grouping.get_group_members()[community]; - let mut total_weight = N::zero(); - - for &node in members { - for (neighbor, weight) in self.network.neighbors(node) { - if self.grouping.get_group(neighbor) == community { - if node == neighbor { - // Self-loop: count full weight - total_weight += weight; - } else if node < neighbor { - // Regular edge: count once to avoid double counting - total_weight += weight; - } - } - } - } - total_weight - } - - /// Returns the weight of self-loops for a node (if any). - fn node_self_weight(&self, node: usize) -> N { - // Look for self-loop - for (neighbor, weight) in self.network.neighbors(node) { - if neighbor == node { - return weight; - } - } - N::zero() - } - - /// Returns the strength (total degree) of a node. - fn node_strength(&self, node: usize) -> N { - self.network.strength(node) - } - - pub fn diff_move_readonly(&self, node: usize, new_community: usize) -> N { - let old_comm = self.grouping.get_group(node); - if new_community == old_comm { - return N::zero(); - } - - if self.total_weight == N::zero() { - return N::zero(); - } - - let two_m = N::from(2.0).unwrap() * self.total_weight; - - let w_to_old = self.weight_to_comm(node, old_comm); - let w_to_new = if new_community < self.grouping.group_count() { - self.weight_to_comm(node, new_community) - } else { - N::zero() - }; - - - let k_i = self.network.strength(node); - - let k_old = self.total_weight_from_comm(old_comm); - let k_new = if new_community < self.grouping.group_count() { - self.total_weight_from_comm(new_community) - } else { - N::zero() - }; - - - let delta_edges = w_to_new - w_to_old; - let delta_expected = (k_new * k_i - (k_old - k_i) * k_i) / two_m; - - (delta_edges - delta_expected) / self.total_weight - } -} - -impl VertexPartition for ModularityPartition -where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, -{ - fn create_partition(network: CSRNetwork) -> Self { - let node_count = network.node_count(); - Self::new(network, G::create_isolated(node_count)) - } - - fn create_with_membership(network: CSRNetwork, membership: &[usize]) -> Self { - Self::new(network, G::from_assignments(membership)) - } - - fn quality(&self) -> N { - let total_weight = self.total_weight; // changed here - if total_weight == N::zero() { - return N::zero(); - } - - // For undirected graphs: m = 2 * total_weight - let m = N::from(2.0).unwrap() * total_weight; - let mut modularity = N::zero(); - - for community in 0..self.community_count() { - let w = self.total_weight_in_comm(community); - let w_out = self.total_weight_from_comm(community); - let w_in = w_out; - - // Following C++ formula for undirected graphs: - // mod += w - w_out*w_in/(4.0*total_weight) - let null_model = (w_out * w_in) / (N::from(4.0).unwrap() * total_weight); - modularity += w - null_model; - } - - let q = N::from(2.0).unwrap() * modularity; - q / m - } - - fn diff_move(&mut self, node: usize, new_community: usize) -> N { - let old_community = self.grouping.get_group(node); - if new_community == old_community { - return N::zero(); - } - - let total_weight = N::from(2.0).unwrap() * self.total_weight; - if total_weight == N::zero() { - return N::zero(); - } - - let w_to_old = self.weight_to_comm(node, old_community); - let w_from_old = w_to_old; - let w_to_new = self.weight_to_comm(node, new_community); - let w_from_new = w_to_new; - - let k_out = self.node_strength(node); - let k_in = k_out; - let self_weight = self.node_self_weight(node); - - let K_out_old = self.total_weight_from_comm(old_community); - let K_in_old = K_out_old; - let k_new = self.total_weight_from_comm(new_community); - let K_out_new = k_new + k_out; - let K_in_new = k_new + k_in; - - let diff_old = (w_to_old - k_out*K_in_old/total_weight) + - (w_from_old - k_in*K_out_old/total_weight); - - let diff_new = (w_to_new + self_weight - k_out*K_in_new/total_weight) + - (w_from_new + self_weight - k_in*K_out_new/total_weight); - - let diff = diff_new - diff_old; - - let m = total_weight; - diff / m - } - - fn network(&self) -> &CSRNetwork { - &self.network - } - - fn grouping(&self) -> &G { - &self.grouping - } - - fn grouping_mut(&mut self) -> &mut G { - &mut self.grouping - } - - fn create_like(&self, network: CSRNetwork) -> Self { - Self::create_partition(network) - } - - fn create_like_with_membership(&self, network: CSRNetwork, membership: &[usize]) -> Self { - Self::create_with_membership(network, membership) - } - - fn diff_move_readonly(&self, node: usize, new_community: usize) -> N { - self.diff_move_readonly(node, new_community) - } -} diff --git a/src/community_search/leiden/partition/rb.rs b/src/community_search/leiden/partition/rb.rs deleted file mode 100644 index d359e00..0000000 --- a/src/community_search/leiden/partition/rb.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! Reichardt-Bornholdt (RB) configuration model partition implementation. -//! -//! Implements the RB quality function which generalizes modularity with a tunable -//! resolution parameter, allowing detection of communities at different scales. - -use single_utilities::traits::FloatOpsTS; - -use crate::{ - community_search::leiden::partition::VertexPartition, neighborhood, network::{grouping::NetworkGrouping, CSRNetwork} -}; - -/// Reichardt-Bornholdt configuration model partition for multi-resolution community detection. -/// -/// Uses the RB quality function: Q = Σ(w_in - γ·k_c²/(2m)) where γ is the resolution parameter. -/// Higher resolution detects smaller communities, lower resolution finds larger communities. -/// Performance-optimized with caching for community strengths and internal weights. -#[derive(Clone)] -pub struct RBConfigurationPartition -where - N: FloatOpsTS + 'static, - G: NetworkGrouping, -{ - network: CSRNetwork, - grouping: G, - /// Resolution parameter γ - controls community size preference - resolution: N, - total_weight: N, - /// Pre-computed 2*total_weight for efficiency - two_m: N, - - /// Cached node strengths (degrees) - never changes - node_strengths: Vec, - - /// Cached community strengths - updated when communities change - community_strengths: Vec, - community_strengths_dirty: bool, - - /// Cached community internal weights - community_internal_weights: Vec, - community_internal_weights_dirty: bool, -} - -impl RBConfigurationPartition -where - N: FloatOpsTS + 'static, - G: NetworkGrouping, -{ - /// Creates a new RB partition with specified network, grouping, and resolution parameter. - /// - /// The resolution parameter γ controls community size: higher values favor smaller communities, - /// lower values favor larger communities. γ=1 approximates standard modularity. - pub fn new(network: CSRNetwork, grouping: G, resolution: N) -> Self { - let tot_weight = network.total_weight(); - let two_m = N::from(2.0).unwrap() * tot_weight; - let node_count = network.node_count(); - - // Pre-compute node strengths since they never change - let node_strengths: Vec = (0..node_count) - .map(|node| network.strength(node)) // Use the cached strength from CSRNetwork - .collect(); - - let mut partition = Self { - network, - grouping, - resolution, - total_weight: tot_weight, - two_m, - node_strengths, - community_strengths: Vec::new(), - community_strengths_dirty: true, - community_internal_weights: Vec::new(), - community_internal_weights_dirty: true, - }; - - // Initialize caches - partition.update_community_caches(); - partition - } - - /// Creates a new partition with singleton communities and specified resolution. - pub fn new_singleton(network: CSRNetwork, resolution: N) -> Self { - let grouping = G::create_isolated(network.node_count()); - Self::new(network, grouping, resolution) - } - - /// Consumes the partition and returns the underlying grouping structure. - pub fn into_grouping(self) -> G { - self.grouping - } - - /// Updates community caches when needed - optimized for performance. - /// - /// Recalculates community strengths and internal weights only when marked dirty. - /// Uses efficient resize and zero-out strategies to minimize allocations. - #[inline] - fn update_community_caches(&mut self) { - let community_count = self.grouping.group_count(); - - if self.community_strengths_dirty { - // Resize and zero out the community strengths vector - if self.community_strengths.len() != community_count { - self.community_strengths.resize(community_count, N::zero()); - } else { - // Fast zero-out for existing vector - for strength in &mut self.community_strengths { - *strength = N::zero(); - } - } - - // Calculate total strengths for each community - for node in 0..self.network.node_count() { - let community = self.grouping.get_group(node); - self.community_strengths[community] += self.node_strengths[node]; - } - self.community_strengths_dirty = false; - } - - if self.community_internal_weights_dirty { - // Resize and zero out - if self.community_internal_weights.len() != community_count { - self.community_internal_weights.resize(community_count, N::zero()); - } else { - for weight in &mut self.community_internal_weights { - *weight = N::zero(); - } - } - - // Calculate internal weights for each community - for node in 0..self.network.node_count() { - let node_community = self.grouping.get_group(node); - for (neighbor, weight) in self.network.neighbors(node) { - if self.grouping.get_group(neighbor) == node_community { - if node == neighbor { - // Self-loop: count full weight - self.community_internal_weights[node_community] += weight; - } else if node < neighbor { - // Regular edge: count once to avoid double counting - self.community_internal_weights[node_community] += weight; - } - } - } - } - self.community_internal_weights_dirty = false; - } - } - - /// Marks caches as dirty when community structure changes. - #[inline] - fn invalidate_caches(&mut self) { - self.community_strengths_dirty = true; - self.community_internal_weights_dirty = true; - } - - /// Gets cached community strength (fast lookup). - #[inline] - fn get_community_strength(&self, community: usize) -> N { - if community < self.community_strengths.len() { - self.community_strengths[community] - } else { - N::zero() - } - } - - /// Gets the self-loop weight of a node (optimized). - #[inline] - fn node_self_weight(&self, node: usize) -> N { - // Use the optimized method from CSRNetwork - self.network.self_loop_weight(node) - } - - /// Calculate the strength (degree) of a node (cached lookup) - #[inline] - fn node_strength(&self, node: usize) -> N { - self.node_strengths[node] - } - - pub fn diff_move_readonly(&self, node: usize, new_community: usize) -> N { - let old_comm = self.grouping.get_group(node); - if new_community == old_comm { - return N::zero(); - } - - if self.two_m == N::zero() { - return N::zero(); - } - - let k_i = self.node_strengths[node]; - let self_weight = self.node_self_weight(node); - - let w_to_old = self.weight_to_comm(node, old_comm); - let w_to_new = if new_community < self.grouping.group_count() { - self.weight_to_comm(node, new_community) - } else { - N::zero() - }; - - let k_old = self.compute_total_weight_from_comm_uncached(old_comm); - let k_new = if new_community < self.grouping.group_count() { - self.compute_total_weight_from_comm_uncached(new_community) - } else { - N::zero() - }; - - let delta_w_in = (w_to_new + self_weight) - w_to_old; - - let delta_k_squared = N::from(2.0).unwrap() * k_i * (k_new - k_old + k_i); - let delta_null_model = self.resolution * delta_k_squared / self.two_m; - - delta_w_in - delta_null_model - } - - #[inline] - fn weight_to_comm(&self, node: usize, community: usize) -> N { - let mut weight = N::zero(); - for (neighbor, edge_weight) in self.network.neighbors(node) { - if self.grouping.get_group(neighbor) == community { - weight += edge_weight; - } - } - weight - } - -} - -impl VertexPartition for RBConfigurationPartition -where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, -{ - fn create_partition(network: CSRNetwork) -> Self { - let node_count = network.node_count(); - Self::new(network, G::create_isolated(node_count), N::one()) - } - - fn create_with_membership(network: CSRNetwork, membership: &[usize]) -> Self { - Self::new(network, G::from_assignments(membership), N::one()) - } - - /// Calculate the RB quality function - fn quality(&self) -> N { - let mut quality_sum = N::zero(); - - if self.two_m == N::zero() { - return N::zero(); - } - - for c in 0..self.community_count() { - let w_in = self.compute_total_weight_in_comm_uncached(c); - let k_c = self.compute_total_weight_from_comm_uncached(c); - - // RB quality: w_in - γ * k_c² / (2m) - quality_sum += w_in - self.resolution * k_c * k_c / self.two_m; - } - - quality_sum - } - - /// Fixed diff_move calculation - this was the main issue - fn diff_move(&mut self, node: usize, new_community: usize) -> N { - self.diff_move_cached(node, new_community) - } - - - - fn network(&self) -> &CSRNetwork { - &self.network - } - - fn grouping(&self) -> &G { - &self.grouping - } - - fn grouping_mut(&mut self) -> &mut G { - &mut self.grouping - } - - #[inline] - fn move_node(&mut self, node: usize, new_community: usize) { - if self.grouping.get_group(node) != new_community { - self.grouping.set_group(node, new_community); - self.invalidate_caches(); - } - } - - fn create_like(&self, network: CSRNetwork) -> Self { - Self::with_resolution(network, self.resolution) - } - - fn create_like_with_membership(&self, network: CSRNetwork, membership: &[usize]) -> Self { - Self::with_membership_and_resolution(network, membership, self.resolution) - } - - fn diff_move_readonly(&self, node: usize, new_community: usize) -> N { - self.diff_move_readonly(node, new_community) - } - - fn add_empty_community(&mut self) { - self.community_strengths.push(N::zero()); - self.community_internal_weights.push(N::zero()); - } -} - -// Helper methods for computing without cache (for const methods) -impl RBConfigurationPartition -where - N: FloatOpsTS + 'static, - G: NetworkGrouping, -{ - /// Optimized diff_move that uses cached values for performance. - /// - /// Calculates the quality change from moving a node to a new community using - /// cached community strengths to avoid expensive recomputations. - pub fn diff_move_cached(&mut self, node: usize, new_community: usize) -> N { - let old_comm = self.grouping.get_group(node); - if new_community == old_comm { - return N::zero(); - } - - if self.two_m == N::zero() { - return N::zero(); - } - - // Ensure community strengths are up to date - self.update_community_caches(); - - let k_i = self.node_strengths[node]; - let self_weight = self.node_self_weight(node); - - // Calculate weights to old and new communities - let w_to_old = self.weight_to_comm(node, old_comm); - let w_to_new = self.weight_to_comm(node, new_community); - - // Use cached community strengths - let k_old = self.get_community_strength(old_comm); - let k_new = self.get_community_strength(new_community); - - let delta_w_in = (w_to_new + self_weight) - w_to_old; - - // Optimized delta k² calculation - let delta_k_squared = N::from(2.0).unwrap() * k_i * (k_new - k_old + k_i); - let delta_null_model = self.resolution * delta_k_squared / self.two_m; - - delta_w_in - delta_null_model - } - - fn compute_total_weight_from_comm_uncached(&self, community: usize) -> N { - if community >= self.grouping.group_count() { - return N::zero(); - } - - let members = &self.grouping.get_group_members()[community]; - let mut total_weight = N::zero(); - - for &node in members { - total_weight += self.node_strengths[node]; - } - total_weight - } - - fn compute_total_weight_in_comm_uncached(&self, community: usize) -> N { - if community >= self.grouping.group_count() { - return N::zero(); - } - - let members = &self.grouping.get_group_members()[community]; - let mut total_weight = N::zero(); - - for &node in members { - for (neighbor, weight) in self.network.neighbors(node) { - if self.grouping.get_group(neighbor) == community { - if node == neighbor { - total_weight += weight; - } else if node < neighbor { - total_weight += weight; - } - } - } - } - total_weight - } -} - -// Convenience constructor functions -impl RBConfigurationPartition -where - N: FloatOpsTS + 'static, - G: NetworkGrouping + Clone + Default, -{ - /// Creates RB partition with specified resolution parameter. - pub fn with_resolution(network: CSRNetwork, resolution: N) -> Self { - let node_count = network.node_count(); - Self::new(network, G::create_isolated(node_count), resolution) - } - - /// Creates RB partition with specified membership and resolution. - pub fn with_membership_and_resolution( - network: CSRNetwork, - membership: &[usize], - resolution: N, - ) -> Self { - Self::new(network, G::from_assignments(membership), resolution) - } - - /// Gets the current resolution parameter. - pub fn resolution(&self) -> N { - self.resolution - } - - /// Sets a new resolution parameter. - pub fn set_resolution(&mut self, resolution: N) { - self.resolution = resolution; - } - - /// Gets the membership of a node. - #[inline] - pub fn membership(&self, node: usize) -> usize { - self.grouping.get_group(node) - } - - /// Gets the community count. - #[inline] - pub fn community_count(&self) -> usize { - self.grouping.group_count() - } - - /// Gets the node count. - #[inline] - pub fn node_count(&self) -> usize { - self.network.node_count() - } -} \ No newline at end of file diff --git a/src/community_search/leiden/refine.rs b/src/community_search/leiden/refine.rs new file mode 100644 index 0000000..56bce7e --- /dev/null +++ b/src/community_search/leiden/refine.rs @@ -0,0 +1,277 @@ +//! Refinement — what makes this Leiden and not Louvain. +//! +//! Re-derives a finer partition inside each community local moving found, from singletons, +//! merging only within a community. Aggregation then collapses the *refined* communities, so +//! Louvain's badly-connected (sometimes disconnected) communities don't get locked in. +//! +//! Two rules, both of which the previous implementation got wrong: +//! +//! 1. Only singletons may merge — once absorbed, a node is never moved again. Otherwise +//! refined communities stop being bottom-up unions and the guarantee is gone. +//! 2. Moves stay inside the constraining community. +//! +//! Candidate selection is randomised (Leiden paper, Algorithm 3): draw with probability +//! proportional to `exp(gain / randomness)`. Greedy argmax gets stuck somewhere repeated +//! passes can't escape; this is how it explores past that. `randomness = 0.0` is greedy. + +use rand::Rng; +use rand_chacha::ChaCha8Rng; + +use crate::community_search::leiden::objective::{InsertContext, Objective}; +use crate::community_search::leiden::partition::{MoveScratch, Partition}; +use crate::network::CSRNetwork; + +/// Builds a refined partition inside the communities of `constraint`. +/// +/// Every refined community sits inside exactly one community of `constraint`. One pass over a +/// shuffled order is enough — only singletons merge and each node is seen once, so it can't +/// cycle. That's why zero-gain merges are safe to accept here; they help escape plateaus. +pub fn refine( + graph: &CSRNetwork, + constraint: &[u32], + objective: &dyn Objective, + scratch: &mut MoveScratch, + rng: &mut ChaCha8Rng, + max_community_weight: Option, + randomness: f64, +) -> Partition { + let n = graph.node_count(); + let mut refined = Partition::singleton(graph); + if n == 0 { + return refined; + } + + // relative to a typical edge, so rescaling weights doesn't change behaviour + let mean_edge_weight = if graph.edge_count() > 0 { + graph.total_weight() / graph.edge_count() as f64 + } else { + 1.0 + }; + let randomness = randomness * mean_edge_weight; + + // snapshot — the loop body needs `scratch` mutably + let order: Vec = scratch.shuffled_order(n, rng).to_vec(); + let mut candidates: Vec<(usize, f64)> = Vec::new(); + let mut exp_buf: Vec = Vec::new(); + + for v in order { + let own = refined.membership(v); + // rule 1: singletons only + if refined.size(own) != 1 { + continue; + } + + let ctx = InsertContext::for_node(graph, v); + // rule 2: stay inside the constraint + scratch.weights.ensure_capacity(refined.slots() + 1); + scratch + .weights + .collect_constrained(graph, refined.membership_raw(), v, constraint); + + let weight_to_own = scratch.weights.weight_to(own); + refined.remove_node(v, graph, weight_to_own); + + let base_gain = objective.delta_insert(&refined, &ctx, own, weight_to_own); + + // candidates that don't lose quality + candidates.clear(); + let mut best_rel = 0.0f64; + for i in 0..scratch.weights.touched().len() { + let c = scratch.weights.touched()[i]; + if c == own { + continue; + } + if let Some(limit) = max_community_weight + && refined.weight(c) + ctx.node_weight > limit + { + continue; + } + let rel = + objective.delta_insert(&refined, &ctx, c, scratch.weights.weight_to(c)) - base_gain; + // `>=` takes ties, like libleidenalg + if rel >= 0.0 { + candidates.push((c, rel)); + best_rel = best_rel.max(rel); + } + } + + let target = select(&candidates, best_rel, randomness, rng, &mut exp_buf).unwrap_or(own); + refined.insert_node(v, target, graph, scratch.weights.weight_to(target)); + } + + refined +} + +/// Draws a candidate with probability proportional to `exp(gain / randomness)`. +/// +/// Subtracting `best_rel` before exponentiating keeps weights in `(0, 1]`, so a large +/// gain/randomness ratio can't overflow. `randomness <= 0` is a deterministic argmax, ties +/// going to the lowest id so traversal order never shows through. +fn select( + candidates: &[(usize, f64)], + best_rel: f64, + randomness: f64, + rng: &mut ChaCha8Rng, + weights: &mut Vec, +) -> Option { + if candidates.is_empty() { + return None; + } + if randomness <= 0.0 { + return candidates + .iter() + .filter(|&&(_, rel)| rel >= best_rel) + .map(|&(c, _)| c) + .min(); + } + + // one exp per candidate; computing it twice cost ~4% + weights.clear(); + weights.extend( + candidates + .iter() + .map(|&(_, rel)| ((rel - best_rel) / randomness).exp()), + ); + let total: f64 = weights.iter().sum(); + if !total.is_finite() || total <= 0.0 { + return candidates + .iter() + .filter(|&&(_, rel)| rel >= best_rel) + .map(|&(c, _)| c) + .min(); + } + + let mut threshold = rng.random::() * total; + for (&(c, _), &w) in candidates.iter().zip(weights.iter()) { + threshold -= w; + if threshold <= 0.0 { + return Some(c); + } + } + // float drift; take the last rather than lose the move + candidates.last().map(|&(c, _)| c) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::community_search::leiden::objective::Rb; + use rand::SeedableRng; + + fn barbell() -> CSRNetwork { + // two 5-cliques joined by a single edge + let mut edges = Vec::new(); + for b in 0..2usize { + for i in 0..5 { + for j in (i + 1)..5 { + edges.push((b * 5 + i, b * 5 + j, 1.0)); + } + } + } + edges.push((0, 5, 1.0)); + CSRNetwork::from_edges(10, &edges).unwrap() + } + + #[test] + fn refined_communities_never_cross_the_constraint() { + let g = barbell(); + let constraint: Vec = vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1]; + let mut scratch = MoveScratch::with_capacity(g.node_count(), g.node_count()); + let mut rng = ChaCha8Rng::seed_from_u64(11); + let refined = refine( + &g, + &constraint, + &Rb::new(1.0), + &mut scratch, + &mut rng, + None, + 0.0, + ); + + // every refined community must sit inside exactly one constraint community + let mut owner = std::collections::HashMap::new(); + for (v, &cv) in constraint.iter().enumerate() { + let r = refined.membership(v); + let entry = owner.entry(r).or_insert(cv); + assert_eq!(*entry, cv, "refined community {r} straddles the constraint"); + } + refined.verify_against(&g).unwrap(); + } + + #[test] + fn a_single_constraint_community_can_still_split() { + // one constraint community, should still split the barbell rather than merge it + let g = barbell(); + let constraint: Vec = vec![0; 10]; + let mut scratch = MoveScratch::with_capacity(g.node_count(), g.node_count()); + let mut rng = ChaCha8Rng::seed_from_u64(3); + let mut refined = refine( + &g, + &constraint, + &Rb::new(1.0), + &mut scratch, + &mut rng, + None, + 0.0, + ); + refined.renumber(); + assert!( + refined.community_count() >= 2, + "expected the barbell to split, got {} communities", + refined.community_count() + ); + } + + #[test] + fn refinement_is_deterministic() { + let g = barbell(); + let constraint: Vec = vec![0, 0, 0, 0, 0, 1, 1, 1, 1, 1]; + let mut first: Option> = None; + for _ in 0..10 { + let mut scratch = MoveScratch::with_capacity(g.node_count(), g.node_count()); + let mut rng = ChaCha8Rng::seed_from_u64(77); + let mut r = refine( + &g, + &constraint, + &Rb::new(1.0), + &mut scratch, + &mut rng, + None, + 0.0, + ); + r.renumber(); + let labels = r.membership_vec(); + match &first { + None => first = Some(labels), + Some(f) => assert_eq!(f, &labels), + } + } + } + + #[test] + fn singletons_only_rule_holds() { + // aggregates stay exact and nothing straddles the constraint + let (edges, truth) = crate::testdata::sbm(20, 4, 0.4, 0.02, 5); + let g = CSRNetwork::from_edges(80, &edges).unwrap(); + let mut scratch = MoveScratch::with_capacity(g.node_count(), g.node_count()); + let mut rng = ChaCha8Rng::seed_from_u64(9); + let truth32: Vec = truth.iter().map(|&c| c as u32).collect(); + let refined = refine( + &g, + &truth32, + &Rb::new(1.0), + &mut scratch, + &mut rng, + None, + 0.0, + ); + refined.verify_against(&g).unwrap(); + for (v, &tv) in truth.iter().enumerate() { + for (u, _) in g.neighbors(v) { + if refined.membership(u) == refined.membership(v) { + assert_eq!(truth[u], tv, "refined community crosses the constraint"); + } + } + } + } +} diff --git a/src/community_search/louvain.rs b/src/community_search/louvain.rs deleted file mode 100644 index 05a10b2..0000000 --- a/src/community_search/louvain.rs +++ /dev/null @@ -1,164 +0,0 @@ -use crate::moving::standard::StandardLocalMoving; -use crate::network::grouping::{NetworkGrouping, VectorGrouping}; -use crate::network::{Graph, Network}; -use rand::SeedableRng; -use rand_chacha::ChaCha20Rng; -use single_utilities::traits::FloatOpsTS; -use std::collections::HashSet; - -pub struct Louvain -where - T: FloatOpsTS, -{ - rng: ChaCha20Rng, - local_moving: StandardLocalMoving, - iterno: u64, -} - -impl Louvain -where - T: FloatOpsTS + 'static, -{ - pub fn new(resolution: T, seed: Option) -> Self { - let seed = seed.unwrap_or_default(); - println!( - "WARNING!!!!! This implementation extremely highly unfinished and will be moved to a separate package in the future!" - ); - Louvain { - rng: ChaCha20Rng::seed_from_u64(seed), - local_moving: StandardLocalMoving::new(resolution), - iterno: 0, - } - } - - pub fn iterate_one_level( - &mut self, - network: &Network, - clustering: &mut VectorGrouping, - ) -> bool { - self.local_moving - .iterate(network, clustering, &mut self.rng) - } - - pub fn iterate(&mut self, network: &Network, clustering: &mut VectorGrouping) -> bool { - let num_nodes = network.graph.node_count(); - println!( - "Running iteration, iteration: {:?}, num nodes: {:?}", - self.iterno, num_nodes - ); - self.iterno += 1; - let mut update = self - .local_moving - .iterate(network, clustering, &mut self.rng); - - if clustering.group_count() == network.nodes() { - return update; - } - - let reduced_network = network.create_reduced_network(clustering); - let mut reduced_clustering = VectorGrouping::create_isolated(reduced_network.nodes()); - update |= self.iterate(&reduced_network, &mut reduced_clustering); - clustering.merge(&reduced_clustering); - update - } - - pub fn build_network(n_nodes: usize, n_edges: usize, adjacency: I) -> Network - where - I: Iterator, - { - let mut graph = Graph::with_capacity(n_nodes, n_edges); - let mut node_indices = Vec::with_capacity(n_nodes); - - for _ in 0..n_nodes { - node_indices.push(graph.add_node(1.0)); - } - - let mut seen = vec![HashSet::::new(); n_nodes]; - let mut node_weights = vec![0.0; n_nodes]; - - for (i, j) in adjacency { - let (i, j) = if i < j { (i, j) } else { (j, i) }; - let i_ = i as usize; - let j_ = j as usize; - - if seen[i_].insert(j) { - graph.add_edge(node_indices[i_], node_indices[j_], 1.0); - node_weights[j_] += 1.0; - node_weights[i_] += 1.0; - } - } - - for &i in &node_indices { - *graph.node_weight_mut(i).unwrap() = node_weights[i.index()]; - } - - Network::new_from_graph(graph) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::network::Network; - use crate::network::grouping::{NetworkGrouping, VectorGrouping}; - use petgraph::graph::NodeIndex; - - fn create_test_network() -> Network { - let mut graph = Graph::new_undirected(); - - // Add 5 nodes - for _ in 0..5 { - graph.add_node(1.0); - } - - // Add edges to create two communities - graph.add_edge(NodeIndex::new(0), NodeIndex::new(1), 1.0); - graph.add_edge(NodeIndex::new(1), NodeIndex::new(2), 1.0); - graph.add_edge(NodeIndex::new(0), NodeIndex::new(2), 1.0); - graph.add_edge(NodeIndex::new(3), NodeIndex::new(4), 1.0); - - Network::new_from_graph(graph) - } - - #[test] - fn test_louvain_clustering() { - let network = create_test_network(); - let mut clustering = VectorGrouping::create_isolated(network.nodes()); - let mut louvain: Louvain = Louvain::new(1.0, Some(42)); - - assert!(louvain.iterate(&network, &mut clustering)); - - // Should identify two communities - assert!(clustering.group_count() == 2); - - // Nodes 0,1,2 should be in same cluster - let cluster1 = clustering.get_group(0); - assert_eq!(clustering.get_group(1), cluster1); - assert_eq!(clustering.get_group(2), cluster1); - - // Nodes 3,4 should be in different cluster - let cluster2 = clustering.get_group(3); - assert_eq!(clustering.get_group(4), cluster2); - assert_ne!(cluster1, cluster2); - } - - #[test] - fn test_build_network() { - let edges = vec![(0, 1), (1, 2), (2, 0), (3, 4)]; - let network = Louvain::::build_network(5, edges.len(), edges.into_iter()); - - assert_eq!(network.nodes(), 5); - assert_eq!(network.graph.edge_count(), 4); - - // Check node weights (should equal degree) - for i in 0..5 { - let weight = network.weight(i); - let expected = match i { - 0..=2 => 2.0, // Nodes in triangle - 3..=4 => 1.0, // Nodes in single edge - _ => unreachable!(), - }; - assert_eq!(weight, expected); - } - } -} diff --git a/src/community_search/mod.rs b/src/community_search/mod.rs index 688abb6..ef32d08 100644 --- a/src/community_search/mod.rs +++ b/src/community_search/mod.rs @@ -1,7 +1,6 @@ //! Community detection algorithms for network analysis. //! //! Provides implementations of state-of-the-art community detection algorithms -//! including Leiden and Louvain methods for identifying modular structures in networks. +//! for identifying modular structures in networks. pub mod leiden; -pub mod louvain; \ No newline at end of file diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..e741dbc --- /dev/null +++ b/src/error.rs @@ -0,0 +1,118 @@ +//! Error types for the clustering crate. + +use std::fmt; + +/// Errors produced when building networks or running clustering algorithms. +#[derive(Debug, Clone, PartialEq)] +pub enum ClusteringError { + /// An edge referenced a node index that does not exist in the graph. + NodeIndexOutOfRange { + /// The offending node index. + node: usize, + /// Number of nodes in the graph. + n_nodes: usize, + }, + /// An edge weight was NaN or infinite. + NonFiniteWeight { + /// Endpoints of the offending edge. + edge: (usize, usize), + }, + /// An edge weight was negative. The quality functions assume non-negative weights. + NegativeWeight { + /// Endpoints of the offending edge. + edge: (usize, usize), + /// The offending weight. + weight: f64, + }, + /// A node weight was NaN, infinite, or negative. + InvalidNodeWeight { + /// The offending node index. + node: usize, + /// The offending weight. + weight: f64, + }, + /// The supplied node-weight vector did not match the node count. + NodeWeightLengthMismatch { + /// Length of the supplied vector. + got: usize, + /// Expected length. + expected: usize, + }, + /// A membership vector did not match the node count. + MembershipLengthMismatch { + /// Length of the supplied vector. + got: usize, + /// Expected length. + expected: usize, + }, + /// A configuration value was outside its valid range. + InvalidConfig(String), + /// The graph has more nodes than the `u32` adjacency representation can address. + TooManyNodes { + /// The requested node count. + n_nodes: usize, + }, + /// The supplied CSR arrays were not a well-formed sparse matrix. + InvalidCsr(String), + /// The adjacency is not symmetric, so `Σ strength != 2 · total_weight`. + /// + /// Most often means an un-symmetrised k-NN graph was passed to + /// `CSRNetwork::from_csr_parts`: if `j` is among `i`'s nearest neighbours but `i` is not + /// among `j`'s, the matrix has an entry in one direction only. + AsymmetricGraph { + /// The observed sum of node strengths. + degree_sum: f64, + /// Twice the total edge weight, which it should equal. + expected: f64, + }, +} + +impl fmt::Display for ClusteringError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NodeIndexOutOfRange { node, n_nodes } => write!( + f, + "node index {node} is out of range for a graph with {n_nodes} nodes" + ), + Self::NonFiniteWeight { edge } => { + write!(f, "edge ({}, {}) has a non-finite weight", edge.0, edge.1) + } + Self::NegativeWeight { edge, weight } => write!( + f, + "edge ({}, {}) has negative weight {weight}; weights must be non-negative", + edge.0, edge.1 + ), + Self::InvalidNodeWeight { node, weight } => write!( + f, + "node {node} has invalid weight {weight}; must be finite and non-negative" + ), + Self::NodeWeightLengthMismatch { got, expected } => write!( + f, + "node weight vector has length {got}, expected {expected}" + ), + Self::MembershipLengthMismatch { got, expected } => { + write!(f, "membership vector has length {got}, expected {expected}") + } + Self::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"), + Self::TooManyNodes { n_nodes } => write!( + f, + "graph has {n_nodes} nodes, exceeding the {} addressable by u32 adjacency", + crate::network::MAX_NODES + ), + Self::InvalidCsr(msg) => write!(f, "malformed CSR input: {msg}"), + Self::AsymmetricGraph { + degree_sum, + expected, + } => write!( + f, + "adjacency is not symmetric: Σ strength = {degree_sum}, expected \ + 2 · total_weight = {expected}. A k-NN graph must be symmetrised before use." + ), + } + } +} + +impl std::error::Error for ClusteringError {} + +/// Convenience alias for results produced by this crate. +pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index f5ace4b..1a2c419 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,14 @@ +#![doc = include_str!("../README.md")] + +pub mod clustering; +pub mod error; +#[cfg(feature = "knn")] pub mod neighborhood; pub mod network; +#[cfg(test)] +pub(crate) mod testdata; pub mod community_search; -pub mod moving; +pub use clustering::{Clustering, NOISE}; +pub use error::{ClusteringError, Result}; diff --git a/src/moving/merging.rs b/src/moving/merging.rs deleted file mode 100644 index 4c7081c..0000000 --- a/src/moving/merging.rs +++ /dev/null @@ -1,194 +0,0 @@ -use crate::network::Network; -use rand::prelude::SliceRandom; -use rand::Rng; -use single_utilities::traits::{FloatOpsTS, ZeroVec}; -use crate::network::grouping::{NetworkGrouping, VectorGrouping}; - -pub struct LocalMerging -where - T: FloatOpsTS, -{ - resolution: T, - randomness: T, - cluster_weights: Vec, - non_singleton_clusters: Vec, - external_edge_weight_per_cluster: Vec, - edge_weight_per_cluster: Vec, - neighboring_clusters: Vec, - cum_transformed_qv_incr_per_cluster: Vec, - node_order: Vec, -} - -impl LocalMerging -where - T: FloatOpsTS + 'static, -{ - pub fn new(resolution: T, randomness: T) -> Self { - LocalMerging { - resolution, - randomness, - cluster_weights: Vec::new(), - non_singleton_clusters: Vec::new(), - external_edge_weight_per_cluster: Vec::new(), - edge_weight_per_cluster: Vec::new(), - neighboring_clusters: Vec::new(), - cum_transformed_qv_incr_per_cluster: Vec::new(), - node_order: Vec::new(), - } - } - - pub fn run(&mut self, network: &Network, rng: &mut R) -> VectorGrouping - where - R: Rng, - { - let mut clustering = VectorGrouping::create_isolated(network.nodes()); - - if network.nodes() == 1 { - return clustering; - } - - let mut update = false; - - let total_node_weight = network.get_total_node_weight(); - - // Initialize cluster weights - self.cluster_weights.clear(); - for i in 0..network.nodes() { - self.cluster_weights - .push(network.weight(i)); - } - - // Get total edge weight per node - self.external_edge_weight_per_cluster.clear(); - network.get_total_edge_weight_per_node(&mut self.external_edge_weight_per_cluster); - - // Generate random permutation of nodes - self.node_order.clear(); - self.node_order.extend(0..network.nodes()); - self.node_order.shuffle(rng); - - // Initialize arrays - self.non_singleton_clusters.zero_len(network.nodes()); - self.edge_weight_per_cluster.zero_len(network.nodes()); - self.neighboring_clusters.zero_len(network.nodes()); - - for i in 0..network.nodes() { - let j = self.node_order[i]; - - // Only nodes belonging to singleton clusters that are well connected - // can be moved to a different cluster - let thresh = self.cluster_weights[j] - * (total_node_weight - self.cluster_weights[j]) - * self.resolution; - let thresh = num_traits::Float::max((self.cluster_weights[j] * (total_node_weight - self.cluster_weights[j]) * self.resolution), T::from_f64(1e-10).unwrap()); - if !self.non_singleton_clusters[j] && self.external_edge_weight_per_cluster[j] >= thresh - { - // Remove current node from its cluster - self.cluster_weights[j] = T::zero(); - self.external_edge_weight_per_cluster[j] = T::zero(); - - // Identify neighboring clusters - self.neighboring_clusters[0] = j; - let mut num_neighboring_clusters = 1; - - for (neighbor, weight) in network.neighbors(j) { - let neighbor_cluster = clustering.get_group(neighbor); - if self.edge_weight_per_cluster[neighbor_cluster] == T::zero() { - self.neighboring_clusters[num_neighboring_clusters] = neighbor_cluster; - num_neighboring_clusters += 1; - } - self.edge_weight_per_cluster[neighbor_cluster] += - T::from_f64(weight.to_f64().unwrap()).unwrap(); - } - - // Calculate quality increments for each neighboring cluster - let mut best_cluster = j; - let mut max_qv_increment = T::zero(); - let mut total_transformed_qv_increment = T::zero(); - - self.cum_transformed_qv_incr_per_cluster.clear(); - - for k in 0..num_neighboring_clusters { - let l = self.neighboring_clusters[k]; - - let thresh = num_traits::Float::max((self.cluster_weights[l] * (total_node_weight - self.cluster_weights[l]) * self.resolution), T::from_f64(1e-10).unwrap()); - if self.external_edge_weight_per_cluster[l] >= thresh { - let node_weight = T::from_f64(network.weight(j).to_f64().unwrap()).unwrap(); - let qv_increment = self.edge_weight_per_cluster[l] - - node_weight * self.cluster_weights[l] * self.resolution; - - if qv_increment > max_qv_increment { - best_cluster = l; - max_qv_increment = qv_increment; - } - - if qv_increment >= T::zero() { - total_transformed_qv_increment += - (qv_increment / self.randomness).exp(); - } - } - - // Store cumulative value for this cluster - self.cum_transformed_qv_incr_per_cluster - .push(total_transformed_qv_increment); - - // Reset edge weight for this cluster - self.edge_weight_per_cluster[l] = T::zero(); - } - - // Determine which cluster to move to - let mut chosen_cluster = best_cluster; - - if total_transformed_qv_increment < ::infinity() { - let r = total_transformed_qv_increment * T::from_f64(rng.random::()).unwrap(); - let mut min_idx = -1isize; - let mut max_idx = num_neighboring_clusters as isize; - - while min_idx < max_idx - 1 { - let mid_idx = (min_idx + max_idx) / 2; - if mid_idx < 0 { - min_idx = mid_idx; - } else if (mid_idx as usize) - >= self.cum_transformed_qv_incr_per_cluster.len() - { - max_idx = mid_idx; - } else if self.cum_transformed_qv_incr_per_cluster[mid_idx as usize] >= r { - max_idx = mid_idx; - } else { - min_idx = mid_idx; - } - } - - if max_idx >= 0 && (max_idx as usize) < num_neighboring_clusters { - chosen_cluster = self.neighboring_clusters[max_idx as usize]; - } - } - - // Move node to its new cluster and update statistics - self.cluster_weights[chosen_cluster] += - network.weight(j); - - for (neighbor, weight) in network.neighbors(j) { - let edge_weight = weight; - if clustering.get_group(neighbor) == chosen_cluster { - self.external_edge_weight_per_cluster[chosen_cluster] = self.external_edge_weight_per_cluster[chosen_cluster] - edge_weight; - } else { - self.external_edge_weight_per_cluster[chosen_cluster] += edge_weight; - } - } - - if chosen_cluster != j { - clustering.set_group(j, chosen_cluster); - self.non_singleton_clusters[chosen_cluster] = true; - update = true; - } - } - } - - if update { - clustering.normalize_groups(); - } - - clustering - } -} diff --git a/src/moving/mod.rs b/src/moving/mod.rs deleted file mode 100644 index c863cda..0000000 --- a/src/moving/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub(crate) mod standard; - -pub(crate) mod merging; \ No newline at end of file diff --git a/src/moving/standard.rs b/src/moving/standard.rs deleted file mode 100644 index cb46263..0000000 --- a/src/moving/standard.rs +++ /dev/null @@ -1,197 +0,0 @@ -use crate::network::Network; -use crate::network::grouping::NetworkGrouping; -use rand::RngCore; -use rand::prelude::SliceRandom; -use single_utilities::traits::FloatOpsTS; - -#[derive(Debug)] -pub struct StandardLocalMoving -where - T: FloatOpsTS, -{ - resolution: T, - cluster_weights: Vec, - nodes_per_cluster: Vec, - unused_clusters: Vec, - node_order: Vec, - edge_weight_per_cluster: Vec, - neighboring_clusters: Vec, -} - -impl StandardLocalMoving -where - T: FloatOpsTS + 'static, -{ - pub fn new(resolution: T) -> Self { - StandardLocalMoving { - resolution, - cluster_weights: Vec::new(), - nodes_per_cluster: Vec::new(), - unused_clusters: Vec::new(), - node_order: Vec::new(), - edge_weight_per_cluster: Vec::new(), - neighboring_clusters: Vec::new(), - } - } - - fn ensure_capacity(&mut self, size: usize) { - self.cluster_weights.resize(size, T::zero()); - self.nodes_per_cluster.resize(size, 0); - self.unused_clusters.resize(size, 0); - self.node_order.resize(size, 0); - self.edge_weight_per_cluster.resize(size, T::zero()); - self.neighboring_clusters.resize(size, 0); - } - - fn calculate_modularity_gain( - &self, - node_weight: T, - k_i_in: T, - cluster_tot: T, - total_edge_weight_2m: T, - ) -> T { - // ΔQ = [Σin + ki,in]/2m - [(Σtot + ki)/2m]² - [Σin/2m - (Σtot/2m)² - (ki/2m)²] - // Simplifying: ΔQ = ki,in/2m - ki*Σtot/2m² - ki²/2m² - // Further: ΔQ = (ki,in - ki*Σtot/2m - ki²/2m) / 2m - - let two_m = total_edge_weight_2m; - let term1 = k_i_in / two_m; - let term2 = (node_weight * cluster_tot * self.resolution) / (T::from(2).unwrap() * two_m * two_m); - - term1 - term2 - } - - pub fn iterate( - &mut self, - network: &Network, - clustering: &mut C, - rng: &mut R, - ) -> bool - where - C: NetworkGrouping, - R: RngCore, - { - let node_count = network.nodes(); - if node_count == 0 { - return false; - } - - self.ensure_capacity(node_count); - - // Initialize cluster weights and counts - self.cluster_weights[..node_count].fill(T::zero()); - self.nodes_per_cluster[..node_count].fill(0); - self.edge_weight_per_cluster[..node_count].fill(T::zero()); - - // Calculate initial cluster weights - for i in 0..node_count { - let cluster = clustering.get_group(i); - self.cluster_weights[cluster] += network.weight(i); - self.nodes_per_cluster[cluster] += 1; - } - - // Find unused clusters - let mut num_unused_clusters = 0; - for i in (0..node_count).rev() { - if self.nodes_per_cluster[i] == 0 { - self.unused_clusters[num_unused_clusters] = i; - num_unused_clusters += 1; - } - } - - // Shuffle node order once per iteration - self.node_order.clear(); - self.node_order.extend(0..node_count); - self.node_order.shuffle(rng); - - //changed here - let total_edge_weight_2m = network.get_total_edge_weight(); - let mut global_update = false; - - // Keep iterating until no improvements are found - let mut local_improvement = true; - while local_improvement { - local_improvement = false; - - for &node in &self.node_order { - let current_cluster = clustering.get_group(node); - let node_weight = network.weight(node); - - // Remove node from current cluster - self.cluster_weights[current_cluster] -= node_weight; - self.nodes_per_cluster[current_cluster] -= 1; - - if self.nodes_per_cluster[current_cluster] == 0 { - self.unused_clusters[num_unused_clusters] = current_cluster; - num_unused_clusters += 1; - } - - // Clear edge weights from previous iteration - self.edge_weight_per_cluster[..node_count].fill(T::zero()); - - // Find neighboring clusters and calculate edge weights - self.neighboring_clusters[0] = if num_unused_clusters > 0 { - self.unused_clusters[num_unused_clusters - 1] - } else { - current_cluster // fallback - }; - let mut num_neighboring_clusters = 1; - - for (target, weight) in network.neighbors(node) { - let neighbor_cluster = clustering.get_group(target); - - if self.edge_weight_per_cluster[neighbor_cluster] == T::zero() && - neighbor_cluster != self.neighboring_clusters[0] { - self.neighboring_clusters[num_neighboring_clusters] = neighbor_cluster; - num_neighboring_clusters += 1; - } - self.edge_weight_per_cluster[neighbor_cluster] += weight; - } - - // Find best cluster - let mut best_cluster = current_cluster; - let mut max_quality_increment = self.calculate_modularity_gain( - node_weight, - self.edge_weight_per_cluster[current_cluster], - self.cluster_weights[current_cluster], - total_edge_weight_2m, - ); - - for &cluster in &self.neighboring_clusters[..num_neighboring_clusters] { - let quality_increment = self.calculate_modularity_gain( - node_weight, - self.edge_weight_per_cluster[cluster], - self.cluster_weights[cluster], - total_edge_weight_2m, - ); - - if quality_increment > max_quality_increment || - (quality_increment == max_quality_increment && cluster < best_cluster) { - best_cluster = cluster; - max_quality_increment = quality_increment; - } - } - - // Update cluster assignment - self.cluster_weights[best_cluster] += node_weight; - self.nodes_per_cluster[best_cluster] += 1; - - if best_cluster == self.unused_clusters[num_unused_clusters - 1] { - num_unused_clusters -= 1; - } - - if best_cluster != current_cluster { - clustering.set_group(node, best_cluster); - local_improvement = true; - global_update = true; - } - } - } - - if global_update { - clustering.normalize_groups(); - } - - global_update - } -} diff --git a/src/network/csr_network.rs b/src/network/csr_network.rs index f11575c..4ee8292 100644 --- a/src/network/csr_network.rs +++ b/src/network/csr_network.rs @@ -1,157 +1,406 @@ -//! # CSR Network Module +//! CSR storage for a weighted, undirected graph. //! -//! This module provides a Compressed Sparse Row (CSR) representation of networks/graphs -//! optimized for clustering algorithms. It supports weighted, undirected graphs with -//! efficient neighbor iteration and community detection operations. +//! ## Weight conventions +//! +//! igraph/leidenalg conventions. Every quality function here depends on them: +//! +//! - edge `{u, v}` of weight `w` → `w` to `strength(u)`, `w` to `strength(v)`, `w` to +//! [`total_weight`](CSRNetwork::total_weight) +//! - **self-loop** on `v` of weight `w` → **`2w`** to `strength(v)`, `w` to `total_weight` +//! +//! Which gives the invariant `Σ strength(v) == 2 · total_weight`. This matters because +//! [`aggregate`](CSRNetwork::aggregate) packs a community's whole internal weight into one +//! self-loop — get the degree contribution wrong and the null model silently dies above +//! level 0. +//! +//! ## Memory layout +//! +//! `u32` ids and `f32` weights, so 8 bytes per stored entry / 16 per undirected edge. Per-node +//! data and all arithmetic stay `f64` — weights are read at `f32`, never accumulated at it. +//! Node weights especially, since after aggregation they count original nodes and would lose +//! integer precision past 2^24. +//! +//! ~12 GB of adjacency at 75M nodes rather than ~24. [`from_csr_parts`](CSRNetwork::from_csr_parts) +//! takes buffers you already have without copying; [`from_edges`](CSRNetwork::from_edges) also +//! needs the caller's edge list resident, but builds in place rather than doubling. -use core::num; -use std::{collections::HashMap, sync::Arc}; +use std::sync::Arc; use nalgebra_sparse::CsrMatrix; -use rand::random; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use single_utilities::traits::FloatOpsTS; -use crate::network::grouping::{self, NetworkGrouping}; +use crate::error::{ClusteringError, Result}; +use crate::network::grouping::NetworkGrouping; + +/// Largest addressable node id. `u32` adjacency caps the graph at ~4.29 billion nodes. +pub const MAX_NODES: usize = u32::MAX as usize; -/// A compressed sparse row (CSR) representation of a weighted, undirected network. -/// -/// This structure provides efficient storage and access patterns for networks used in -/// clustering algorithms. Neighbors are sorted for each node to enable binary search -/// for edge weight lookups. -/// -/// # Type Parameters -/// * `N` - Node weight type (e.g., f32, f64) -/// * `E` - Edge weight type (e.g., f32, f64) #[derive(Debug, Clone)] -pub struct CSRNetworkData { +struct CSRNetworkData { + /// Offsets into `neighbors`/`weights`; length `n_nodes + 1`. Stays `usize`, since it + /// indexes an array with up to `2m` entries. node_ptrs: Vec, - neighbors: Vec, - weights: Vec, - - node_weights: Vec, - - degrees: Vec, - strengths: Vec, - total_weight: E, + /// Neighbour ids, strictly increasing within each node's slice. + neighbors: Vec, + /// Edge weights, parallel to `neighbors`. + weights: Vec, + /// Per-node weights (aggregate node size). Summed by [`CSRNetwork::aggregate`]. + node_weights: Vec, + /// Per-node sum of incident edge weights, self-loops counted twice. + strengths: Vec, + /// Sum of edge weights, each undirected edge counted once. + total_weight: f64, + /// Number of distinct undirected edges, self-loops included. edge_count: usize, + /// Whether any node carries a self-loop. + /// + /// Lets `self_loop_weight` skip its binary search on graphs without any — which the level-0 + /// graph, where most node visits happen, normally is. That search was ~4% of runtime. + any_self_loops: bool, } +/// A weighted, undirected graph in compressed-sparse-row form. +/// +/// Cloning is cheap: the underlying data is shared behind an [`Arc`]. See the module-level +/// documentation for the weight conventions and memory layout this type guarantees. #[derive(Debug, Clone)] -pub struct CSRNetwork { - data: Arc>, +pub struct CSRNetwork { + data: Arc, +} + +/// Iterator over `(neighbor, weight)` pairs for one node. +/// +/// Yields `usize`/`f64` regardless of the narrower storage types, so callers can index +/// directly and do arithmetic at full precision. +pub struct CSRNeighborIterator<'a> { + neighbors: std::slice::Iter<'a, u32>, + weights: std::slice::Iter<'a, f32>, } -impl CSRNetwork -where - N: FloatOpsTS + 'static, - E: FloatOpsTS + 'static, -{ - /// Creates a CSR network from a list of edges and node weights. +impl Iterator for CSRNeighborIterator<'_> { + type Item = (usize, f64); + + #[inline] + fn next(&mut self) -> Option { + let n = *self.neighbors.next()?; + let w = *self.weights.next()?; + Some((n as usize, w as f64)) + } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + self.neighbors.size_hint() + } +} + +impl ExactSizeIterator for CSRNeighborIterator<'_> {} + +impl CSRNetwork { + /// Builds a graph from an edge list, giving every node unit weight. /// - /// Constructs the compressed sparse row representation by building adjacency lists, - /// sorting neighbors, and computing node degrees and strengths. + /// Each undirected edge should be supplied **once**; `(u, v, w)` and `(v, u, w)` are two + /// parallel edges, not one. Parallel edges between the same pair are merged by summing + /// their weights. A `(v, v, w)` entry is a self-loop of weight `w`. + pub fn from_edges(n_nodes: usize, edges: &[(usize, usize, W)]) -> Result + where + W: FloatOpsTS + 'static, + { + Self::from_edges_with_node_weights(edges, vec![1.0; n_nodes]) + } + + /// Builds a graph from an edge list with explicit node weights. /// - /// # Arguments - /// * `edges` - List of (from, to, weight) tuples representing edges - /// * `node_weights` - Vector of weights for each node - pub fn from_edges(edges: &[(usize, usize, E)], node_weights: Vec) -> Self { - let num_nodes = node_weights.len(); - - let mut degrees = vec![0; num_nodes]; - for &(from, to, _) in edges { - degrees[from] += 1; + /// Node weight is a node's "size", and what CPM measures communities in. Usually all + /// `1.0` straight from data; it starts mattering after [`aggregate`](Self::aggregate), + /// where a super-node's weight is the sum of its members'. + /// + /// Allocates the adjacency arrays once and sorts in place, so peak memory is the graph + /// plus the caller's edge list, not double. For very large inputs + /// [`from_csr_parts`](Self::from_csr_parts) needs no edge list at all. + pub fn from_edges_with_node_weights( + edges: &[(usize, usize, W)], + node_weights: Vec, + ) -> Result + where + W: FloatOpsTS + 'static, + { + let n_nodes = node_weights.len(); + validate_node_weights(&node_weights)?; + if n_nodes > MAX_NODES { + return Err(ClusteringError::TooManyNodes { n_nodes }); + } + + // pass 1: validate and count slots per node + let mut counts = vec![0usize; n_nodes]; + for &(from, to, w) in edges { + check_edge(from, to, w.to_f64().unwrap_or(f64::NAN), n_nodes)?; + counts[from] += 1; if from != to { - degrees[to] += 1; + counts[to] += 1; } } - let total_degree: usize = degrees.iter().sum(); - let mut node_ptrs = Vec::with_capacity(num_nodes + 1); - let mut neighbors = Vec::with_capacity(total_degree); - let mut weights = Vec::with_capacity(total_degree); - let mut strengths = vec![E::zero(); num_nodes]; - - let mut adjacency_lists: Vec> = vec![Vec::new(); num_nodes]; - for &(from, to, weight) in edges { - adjacency_lists[from].push((to, weight)); + let mut node_ptrs = vec![0usize; n_nodes + 1]; + for v in 0..n_nodes { + node_ptrs[v + 1] = node_ptrs[v] + counts[v]; + } + let slots = node_ptrs[n_nodes]; + + // pass 2: scatter into place; `cursor` is the write position in each slice + let mut neighbors = vec![0u32; slots]; + let mut weights = vec![0.0f32; slots]; + let mut cursor = node_ptrs.clone(); + for &(from, to, w) in edges { + let w = w.to_f64().unwrap() as f32; + neighbors[cursor[from]] = to as u32; + weights[cursor[from]] = w; + cursor[from] += 1; if from != to { - adjacency_lists[to].push((from, weight)); + neighbors[cursor[to]] = from as u32; + weights[cursor[to]] = w; + cursor[to] += 1; } } + drop(cursor); + + Self::finish(node_ptrs, neighbors, weights, node_weights, true) + } - node_ptrs.push(0); - let mut total_weight = E::zero(); + /// Builds a graph directly from CSR arrays, taking ownership of them. + /// + /// The low-memory entry point: nothing proportional to the edge count is allocated, so a + /// connectivity matrix you already hold — scanpy's `connectivities`, say — becomes a graph + /// with no copy. + /// + /// Must be the **full symmetric** adjacency: if `j` is in row `i`, `i` must be in row `j` + /// with the same weight. Rows need not be sorted (they're sorted in place); duplicates + /// within a row are summed, and anything summing to zero is dropped. + /// + /// # Arguments + /// * `node_ptrs` - row offsets, length `n_nodes + 1`, non-decreasing, last equals + /// `neighbors.len()` + /// * `neighbors` - column indices + /// * `weights` - values, parallel to `neighbors` + /// * `node_weights` - per-node weights, or `None` for unit weights + pub fn from_csr_parts( + node_ptrs: Vec, + neighbors: Vec, + weights: Vec, + node_weights: Option>, + ) -> Result { + if node_ptrs.is_empty() { + return Err(ClusteringError::InvalidCsr( + "node_ptrs must have at least one element".into(), + )); + } + let n_nodes = node_ptrs.len() - 1; + if n_nodes > MAX_NODES { + return Err(ClusteringError::TooManyNodes { n_nodes }); + } + if neighbors.len() != weights.len() { + return Err(ClusteringError::InvalidCsr(format!( + "neighbors has {} entries but weights has {}", + neighbors.len(), + weights.len() + ))); + } + if node_ptrs[n_nodes] != neighbors.len() { + return Err(ClusteringError::InvalidCsr(format!( + "node_ptrs ends at {} but there are {} entries", + node_ptrs[n_nodes], + neighbors.len() + ))); + } + for v in 0..n_nodes { + if node_ptrs[v] > node_ptrs[v + 1] { + return Err(ClusteringError::InvalidCsr(format!( + "node_ptrs is not non-decreasing at {v}" + ))); + } + } + for (&u, &w) in neighbors.iter().zip(weights.iter()) { + if u as usize >= n_nodes { + return Err(ClusteringError::NodeIndexOutOfRange { + node: u as usize, + n_nodes, + }); + } + if !w.is_finite() { + return Err(ClusteringError::NonFiniteWeight { + edge: (0, u as usize), + }); + } + if w < 0.0 { + return Err(ClusteringError::NegativeWeight { + edge: (0, u as usize), + weight: w as f64, + }); + } + } - for (node, adj_list) in adjacency_lists.into_iter().enumerate() { - let mut sorted_adj = adj_list; - sorted_adj.sort_by_key(|&(neighbor, _)| neighbor); + let node_weights = match node_weights { + Some(w) => { + if w.len() != n_nodes { + return Err(ClusteringError::NodeWeightLengthMismatch { + got: w.len(), + expected: n_nodes, + }); + } + validate_node_weights(&w)?; + w + } + None => vec![1.0; n_nodes], + }; - for (neighbor, weight) in sorted_adj { - neighbors.push(neighbor); - weights.push(weight); - strengths[node] += weight; + Self::finish(node_ptrs, neighbors, weights, node_weights, false) + } - if node <= neighbor { - total_weight += weight; + /// Sorts each row, merges duplicates, compacts, and derives the cached aggregates. + /// + /// `may_have_gaps` says whether compaction can shrink the arrays. Sorting borrows one + /// scratch buffer sized to the largest row, so the temporary is O(max degree), not O(m). + fn finish( + mut node_ptrs: Vec, + mut neighbors: Vec, + mut weights: Vec, + node_weights: Vec, + may_have_gaps: bool, + ) -> Result { + let n_nodes = node_ptrs.len() - 1; + let max_degree = (0..n_nodes) + .map(|v| node_ptrs[v + 1] - node_ptrs[v]) + .max() + .unwrap_or(0); + let mut scratch: Vec<(u32, f32)> = Vec::with_capacity(max_degree); + + let mut strengths = vec![0.0f64; n_nodes]; + let mut total_weight = 0.0f64; + let mut edge_count = 0usize; + let mut write = 0usize; + let mut any_self_loops = false; + + for v in 0..n_nodes { + let (lo, hi) = (node_ptrs[v], node_ptrs[v + 1]); + node_ptrs[v] = write; + + scratch.clear(); + scratch.extend( + neighbors[lo..hi] + .iter() + .copied() + .zip(weights[lo..hi].iter().copied()), + ); + scratch.sort_unstable_by_key(|&(u, _)| u); + + let mut i = 0; + while i < scratch.len() { + let u = scratch[i].0; + let mut w = 0.0f64; + while i < scratch.len() && scratch[i].0 == u { + w += scratch[i].1 as f64; + i += 1; + } + // an explicit zero isn't an edge — keeps degree/edge_count honest + if w == 0.0 { + continue; + } + // `write <= lo` always, so this never clobbers unread input + debug_assert!(write <= lo + (i - 1)); + neighbors[write] = u; + weights[write] = w as f32; + write += 1; + + // self-loops: twice toward strength, once toward total weight + let stored = w as f32 as f64; + if u as usize == v { + any_self_loops = true; + strengths[v] += 2.0 * stored; + } else { + strengths[v] += stored; + } + if v <= u as usize { + total_weight += stored; + edge_count += 1; } } - node_ptrs.push(neighbors.len()); + } + node_ptrs[n_nodes] = write; + + if may_have_gaps || write < neighbors.len() { + neighbors.truncate(write); + weights.truncate(write); + neighbors.shrink_to_fit(); + weights.shrink_to_fit(); } - let csr = CSRNetworkData { + let data = CSRNetworkData { node_ptrs, neighbors, weights, node_weights, - degrees, strengths, total_weight, - edge_count: edges.len(), + edge_count, + any_self_loops, }; + check_degree_sum(&data)?; - Self { - data: Arc::new(csr), - } + Ok(Self { + data: Arc::new(data), + }) } - /// Creates a CSR network from a CSR matrix and node weights. + /// Builds a graph from a sparse adjacency matrix, giving every node unit weight. /// - /// Converts a nalgebra CSR matrix to the internal CSR network representation, - /// handling self-loops and ensuring undirected graph properties. - pub fn from_csr_matrix(matrix: CsrMatrix, node_weights: Vec) -> Self { - let mut edges = Vec::new(); + /// The matrix is treated as symmetric: only entries with `row <= col` are read, and a + /// diagonal entry `(v, v, w)` becomes a self-loop of weight `w`. Zero entries are skipped. + /// + /// Allocates an intermediate edge list. For large inputs prefer + /// [`from_csr_parts`](Self::from_csr_parts). + pub fn from_csr_matrix(matrix: &CsrMatrix) -> Result + where + W: FloatOpsTS + 'static, + { + Self::from_csr_matrix_with_node_weights(matrix, vec![1.0; matrix.nrows()]) + } + + /// Builds a graph from a sparse adjacency matrix with explicit node weights. + pub fn from_csr_matrix_with_node_weights( + matrix: &CsrMatrix, + node_weights: Vec, + ) -> Result + where + W: FloatOpsTS + 'static, + { + if node_weights.len() != matrix.nrows() { + return Err(ClusteringError::NodeWeightLengthMismatch { + got: node_weights.len(), + expected: matrix.nrows(), + }); + } + let mut edges = Vec::with_capacity(matrix.nnz() / 2 + 1); for (row, col, &weight) in matrix.triplet_iter() { - if weight != E::zero() { - // Only add upper triangle for undirected graphs to avoid duplicates - if row == col { - let tot_w = E::from(2.0).unwrap() * weight; - edges.push((row, col, tot_w)); - } else if row < col { - edges.push((row, col, weight)); - } + // upper triangle only; the diagonal picks up the self-loop convention later + if row <= col && weight != W::zero() { + edges.push((row, col, weight)); } } - Self::from_edges(&edges, node_weights) + Self::from_edges_with_node_weights(&edges, node_weights) } - /// Returns an iterator over the neighbors and edge weights of a node. + /// Returns an iterator over the `(neighbor, weight)` pairs of a node. /// - /// Provides efficient iteration over all neighbors of a given node using - /// unsafe pointer arithmetic for maximum performance. + /// Neighbours are yielded in ascending id order, which is what makes candidate-community + /// enumeration deterministic. #[inline] - pub fn neighbors(&self, node: usize) -> CSRNeighborIterator { - debug_assert!(node < self.node_count()); - + pub fn neighbors(&self, node: usize) -> CSRNeighborIterator<'_> { let start = self.data.node_ptrs[node]; let end = self.data.node_ptrs[node + 1]; - CSRNeighborIterator { - neighbor_ptr: unsafe { self.data.neighbors.as_ptr().add(start) }, - weight_ptr: unsafe { self.data.weights.as_ptr().add(start) }, - remaining: end - start, + neighbors: self.data.neighbors[start..end].iter(), + weights: self.data.weights[start..end].iter(), } } @@ -160,446 +409,588 @@ where pub fn node_count(&self) -> usize { self.data.node_weights.len() } - /// Returns the number of edges in the network. + + /// Returns the number of distinct undirected edges, self-loops included. #[inline] pub fn edge_count(&self) -> usize { self.data.edge_count } - /// Returns the degree (number of neighbors) of a node. + + /// Returns the degree (number of distinct neighbours) of a node. #[inline] pub fn degree(&self, node: usize) -> usize { - self.data.degrees[node] + self.data.node_ptrs[node + 1] - self.data.node_ptrs[node] } - /// Returns the strength (sum of edge weights) of a node. + + /// Returns the strength of a node: the sum of incident edge weights, with self-loops + /// counted twice. #[inline] - pub fn strength(&self, node: usize) -> E { + pub fn strength(&self, node: usize) -> f64 { self.data.strengths[node] } - /// Returns the weight of a node. + + /// Returns the weight ("size") of a node. #[inline] - pub fn node_weight(&self, node: usize) -> N { + pub fn node_weight(&self, node: usize) -> f64 { self.data.node_weights[node] } - /// Returns the total weight of all edges in the network. + + /// Returns the total weight of all edges, each undirected edge counted once. #[inline] - pub fn total_weight(&self) -> E { + pub fn total_weight(&self) -> f64 { self.data.total_weight } - /// Selects a random neighbor of a node with uniform probability. - /// - /// Returns `None` if the node has no neighbors. - pub fn random_neighbor(&self, node: usize, rng: &mut impl rand::Rng) -> Option { - let degree = self.degree(node); - if degree == 0 { - return None; - } + /// Returns the sum of all node weights. + #[inline] + pub fn total_node_weight(&self) -> f64 { + self.data.node_weights.iter().sum() + } - let random_idx = rng.random_range(0..degree); - let neighbor_idx = self.data.node_ptrs[node] + random_idx; - Some(self.data.neighbors[neighbor_idx]) + /// Returns the weight of a node's self-loop, or `0.0` if it has none. + #[inline] + pub fn self_loop_weight(&self, node: usize) -> f64 { + if !self.data.any_self_loops { + return 0.0; + } + let start = self.data.node_ptrs[node]; + let end = self.data.node_ptrs[node + 1]; + match self.data.neighbors[start..end].binary_search(&(node as u32)) { + Ok(pos) => self.data.weights[start + pos] as f64, + Err(_) => 0.0, + } } - /// Returns the weight of an edge between two nodes. - /// - /// Uses binary search on the smaller degree node for efficient lookup. - /// Returns `None` if no edge exists between the nodes. - pub fn edge_weight(&self, from: usize, to: usize) -> Option { + /// Returns the weight of the edge between two nodes, or `None` if there is none. + pub fn edge_weight(&self, from: usize, to: usize) -> Option { + // search the shorter row let (search_node, target) = if self.degree(from) <= self.degree(to) { (from, to) } else { (to, from) }; - let start = self.data.node_ptrs[search_node]; let end = self.data.node_ptrs[search_node + 1]; - - match self.data.neighbors[start..end].binary_search(&target) { - Ok(pos) => Some(self.data.weights[start + pos]), + match self.data.neighbors[start..end].binary_search(&(target as u32)) { + Ok(pos) => Some(self.data.weights[start + pos] as f64), Err(_) => None, } } - /// Creates an aggregated network where nodes are grouped according to a grouping. + /// Returns copies of the underlying CSR arrays, as accepted by + /// [`from_csr_parts`](Self::from_csr_parts). /// - /// Combines nodes within the same group into super-nodes, summing weights - /// appropriately. Used in multilevel clustering algorithms. - pub fn aggregate(&self, grouping: &G) -> Self { - let new_node_count = grouping.group_count(); - - let mut new_node_weights = vec![N::zero(); new_node_count]; - - for node in 0..self.node_count() { - let group = grouping.get_group(node); - new_node_weights[group] += self.data.node_weights[node]; - } + /// Round-tripping through these is lossless. + pub fn to_csr_parts(&self) -> (Vec, Vec, Vec) { + ( + self.data.node_ptrs.clone(), + self.data.neighbors.clone(), + self.data.weights.clone(), + ) + } - let mut edge_memo = HashMap::new(); - let mut self_loop_weights = HashMap::new(); + /// The per-node weights, as accepted by [`from_csr_parts`](Self::from_csr_parts). + pub fn node_weights(&self) -> &[f64] { + &self.data.node_weights + } - for node in 0..self.node_count() { - let start = self.data.node_ptrs[node]; - let end = self.data.node_ptrs[node + 1]; + /// Approximate resident size of the graph in bytes. + /// + /// Useful for sizing a machine before running: at 75M nodes and 750M edges this is + /// roughly 14 GB. + pub fn memory_bytes(&self) -> usize { + let d = &self.data; + d.node_ptrs.len() * size_of::() + + d.neighbors.len() * size_of::() + + d.weights.len() * size_of::() + + d.node_weights.len() * size_of::() + + d.strengths.len() * size_of::() + } - for i in start..end { - let neighbor = self.data.neighbors[i]; - let weight = self.data.weights[i]; + /// Collapses each group into a single super-node. + /// + /// Node weights are summed, a group's internal weight becomes a self-loop, and weight + /// between groups becomes an ordinary edge. `total_weight` and `Σ strength` are both + /// preserved exactly, so quality doesn't change — which is what lets the multilevel scheme + /// optimize one objective across levels. + pub fn aggregate(&self, grouping: &G) -> Self { + let n = self.node_count(); + let n_groups = grouping.group_count(); - if node <= neighbor { - let g1 = grouping.get_group(node); - let g2 = grouping.get_group(neighbor); + let mut new_node_weights = vec![0.0f64; n_groups]; + for v in 0..n { + new_node_weights[grouping.get_group(v)] += self.data.node_weights[v]; + } - if g1 == g2 { - *self_loop_weights.entry(g1).or_insert(E::zero()) += weight; - } else { - let (min_g, max_g) = if g1 < g2 { (g1, g2) } else { (g2, g1) }; - *edge_memo.entry((min_g, max_g)).or_insert(E::zero()) += weight; + // counting sort into groups, so we can walk one group at a time + let mut starts = vec![0usize; n_groups + 1]; + for v in 0..n { + starts[grouping.get_group(v) + 1] += 1; + } + for g in 0..n_groups { + starts[g + 1] += starts[g]; + } + let mut members = vec![0u32; n]; + let mut cursor = starts.clone(); + for v in 0..n { + let g = grouping.get_group(v); + members[cursor[g]] = v as u32; + cursor[g] += 1; + } + drop(cursor); + + // per group, accumulate to groups with id >= its own so each pair is seen once + let mut acc = vec![0.0f64; n_groups]; + let mut touched: Vec = Vec::new(); + let mut counts = vec![0usize; n_groups]; + let mut pairs: Vec<(u32, u32, f32)> = Vec::new(); + + for g in 0..n_groups { + let mut self_loop_weight = 0.0f64; + for &v in &members[starts[g]..starts[g + 1]] { + for (u, w) in self.neighbors(v as usize) { + if u == v as usize { + self_loop_weight += w; + } + let h = grouping.get_group(u); + if h < g { + continue; + } + if acc[h] == 0.0 { + touched.push(h); } + acc[h] += w; } } - } - - let mut edges = Vec::new(); - for (&group, &weight) in self_loop_weights.iter() { - if weight > E::zero() { - edges.push((group, group, weight)); + for &h in &touched { + let w = acc[h]; + acc[h] = 0.0; + if w <= 0.0 { + continue; + } + if h == g { + // regular edges seen from both ends, loops once: acc = 2·regular + loops + pairs.push((g as u32, g as u32, (0.5 * (w + self_loop_weight)) as f32)); + counts[g] += 1; + } else { + pairs.push((g as u32, h as u32, w as f32)); + counts[g] += 1; + counts[h] += 1; + } } + touched.clear(); } - for (&(g1, g2), &weight) in edge_memo.iter() { - edges.push((g1, g2, weight)); + let mut node_ptrs = vec![0usize; n_groups + 1]; + for g in 0..n_groups { + node_ptrs[g + 1] = node_ptrs[g] + counts[g]; + } + let slots = node_ptrs[n_groups]; + let mut neighbors = vec![0u32; slots]; + let mut weights = vec![0.0f32; slots]; + let mut cursor = node_ptrs.clone(); + for &(a, b, w) in &pairs { + neighbors[cursor[a as usize]] = b; + weights[cursor[a as usize]] = w; + cursor[a as usize] += 1; + if a != b { + neighbors[cursor[b as usize]] = a; + weights[cursor[b as usize]] = w; + cursor[b as usize] += 1; + } } + drop(cursor); - Self::from_edges(&edges, new_node_weights) + // can't fail: group ids, already-validated weights + Self::finish(node_ptrs, neighbors, weights, new_node_weights, false) + .expect("aggregate produces a structurally valid graph") } - /// Extracts a subgraph containing only nodes from a specific group. - /// - /// Creates a new network with only the nodes belonging to the specified group, - /// renumbering nodes consecutively starting from 0. - pub fn subgraph(&self, grouping: &G, group: usize) -> Self { - let group_members = &grouping.get_group_members()[group]; - let subgraph_size = group_members.len(); - - let mut node_map = HashMap::with_capacity(subgraph_size); - let mut new_node_weights = Vec::with_capacity(subgraph_size); - - for (new_id, &old_id) in group_members.iter().enumerate() { - node_map.insert(old_id, new_id); - new_node_weights.push(self.data.node_weights[old_id]); - } - - let mut edges = Vec::new(); - for &node in group_members { - let from_new = node_map[&node]; + /// Converts the network back to a symmetric sparse matrix. + pub fn to_csr_matrix(&self) -> CsrMatrix { + let n = self.node_count(); + let row_ptrs = self.data.node_ptrs.clone(); + let col_indices: Vec = self.data.neighbors.iter().map(|&u| u as usize).collect(); + let values: Vec = self.data.weights.iter().map(|&w| w as f64).collect(); + CsrMatrix::try_from_csr_data(n, n, row_ptrs, col_indices, values) + .expect("CSR invariants are maintained by construction") + } - for (neighbor, weight) in self.neighbors(node) { - if let Some(&to_new) = node_map.get(&neighbor) { - if from_new <= to_new { - edges.push((from_new, to_new, weight)); + /// Verifies that the adjacency is exactly symmetric. + /// + /// Constructors already enforce `Σ strength == 2 · total_weight`, which catches the + /// realistic mistakes (a k-NN graph nobody symmetrised). This is the exhaustive version: + /// for every stored `(v, u, w)`, check `(u, v, w)` is there too. + /// + /// O(m log d) — worth running once when wiring up a new input source, not every call. + pub fn validate_symmetry(&self) -> Result<()> { + for v in 0..self.node_count() { + for (u, w) in self.neighbors(v) { + match self.edge_weight(u, v) { + Some(back) if (back - w).abs() <= 1e-6 * w.abs().max(1.0) => {} + Some(back) => { + return Err(ClusteringError::InvalidCsr(format!( + "edge ({v}, {u}) has weight {w} but ({u}, {v}) has {back}" + ))); + } + None => { + return Err(ClusteringError::InvalidCsr(format!( + "edge ({v}, {u}) is present but ({u}, {v}) is missing" + ))); } } } } - Self::from_edges(&edges, new_node_weights) + Ok(()) } - /// Converts the network back to a nalgebra CSR matrix format. - pub fn to_csr_matrix(&self) -> CsrMatrix { - let n = self.node_count(); - let mut row_ptrs = vec![0; n + 1]; - let mut col_indices = Vec::with_capacity(self.data.neighbors.len()); - let mut values = Vec::with_capacity(self.data.weights.len()); - - for node in 0..n { - for (neighbor, weight) in self.neighbors(node) { - col_indices.push(neighbor); - values.push(weight); - } - row_ptrs[node + 1] = col_indices.len(); - } - - CsrMatrix::try_from_csr_data(n, n, row_ptrs, col_indices, values).unwrap() - } - - /// Checks if the network contains any self-loops. + /// Returns `true` if any node carries a self-loop. + #[inline] pub fn has_self_loops(&self) -> bool { - for node in 0..self.node_count() { - for (neighbor, _) in self.neighbors(node) { - if neighbor == node { - return true; - } - } - } - false + self.data.any_self_loops } - /// Calculates the density of the network (ratio of actual to possible edges). + /// Returns the fraction of possible edges that are present. pub fn density(&self) -> f64 { let n = self.node_count() as f64; - let m = self.data.edge_count as f64; let max_edges = n * (n - 1.0) / 2.0; - - if max_edges > 0.0 { m / max_edges } else { 0.0 } - } - - /// Calculates the total weight of edges from a node to a specific community. - /// - /// Uses optimized unsafe pointer arithmetic for maximum performance in - /// clustering algorithms. - #[inline] - pub fn weight_to_comm( - &self, - node: usize, - community: usize, - grouping: &impl NetworkGrouping, - ) -> E { - let start = self.data.node_ptrs[node]; - let end = self.data.node_ptrs[node + 1]; - - if start == end { - return E::zero(); + if max_edges > 0.0 { + self.data.edge_count as f64 / max_edges + } else { + 0.0 } + } +} - let mut weight = E::zero(); - - // Direct unsafe pointer access for maximum performance - unsafe { - let mut neighbor_ptr = self.data.neighbors.as_ptr().add(start); - let mut weight_ptr = self.data.weights.as_ptr().add(start); - let mut remaining = end - start; - - while remaining > 0 { - let neighbor = *neighbor_ptr; - if grouping.get_group(neighbor) == community { - weight += *weight_ptr; - } - neighbor_ptr = neighbor_ptr.add(1); - weight_ptr = weight_ptr.add(1); - remaining -= 1; - } +fn validate_node_weights(node_weights: &[f64]) -> Result<()> { + for (node, &nw) in node_weights.iter().enumerate() { + if !nw.is_finite() || nw < 0.0 { + return Err(ClusteringError::InvalidNodeWeight { node, weight: nw }); } - - weight } + Ok(()) +} - #[inline] - pub fn weight_to_two_comms( - &self, - node: usize, - comm1: usize, - comm2: usize, - grouping: &impl NetworkGrouping, - ) -> (E, E) { - let start = self.data.node_ptrs[node]; - let end = self.data.node_ptrs[node + 1]; +#[inline] +fn check_edge(from: usize, to: usize, w: f64, n_nodes: usize) -> Result<()> { + if from >= n_nodes { + return Err(ClusteringError::NodeIndexOutOfRange { + node: from, + n_nodes, + }); + } + if to >= n_nodes { + return Err(ClusteringError::NodeIndexOutOfRange { node: to, n_nodes }); + } + if !w.is_finite() { + return Err(ClusteringError::NonFiniteWeight { edge: (from, to) }); + } + if w < 0.0 { + return Err(ClusteringError::NegativeWeight { + edge: (from, to), + weight: w, + }); + } + Ok(()) +} - if start == end { - return (E::zero(), E::zero()); - } +/// Checks `Σ strength == 2 · total_weight`. +/// +/// This is the invariant the self-loop convention exists to maintain, and it is the cheapest +/// guard against the failure mode that motivated the 0.7 rewrite: a null model silently +/// switched off, producing plausible-looking but wrong clusters. +/// +/// Checked in **release builds too**, not just debug. It costs one O(n) pass against minutes +/// of clustering, and it is the only thing standing between an asymmetric input to +/// [`CSRNetwork::from_csr_parts`] and quietly corrupt results. It is a necessary condition for +/// symmetry, not a sufficient one — [`CSRNetwork::validate_symmetry`] is the exhaustive check. +/// +/// The tolerance is loose (`1e-6` relative) because both sides are naive f64 sums over +/// possibly hundreds of millions of terms, accumulated in different orders. Any real asymmetry +/// is off by whole edge weights, many orders of magnitude above that. +fn check_degree_sum(data: &CSRNetworkData) -> Result<()> { + let sum: f64 = data.strengths.iter().sum(); + let expected = 2.0 * data.total_weight; + let tol = 1e-6 * expected.abs().max(1.0); + if (sum - expected).abs() > tol { + return Err(ClusteringError::AsymmetricGraph { + degree_sum: sum, + expected, + }); + } + Ok(()) +} - let mut w1 = E::zero(); - let mut w2 = E::zero(); +impl std::fmt::Display for CSRNetwork { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "CSRNetwork({} nodes, {} edges, total_weight: {}, density: {:.4})", + self.node_count(), + self.edge_count(), + self.total_weight(), + self.density() + ) + } +} - unsafe { - let mut neighbor_ptr = self.data.neighbors.as_ptr().add(start); - let mut weight_ptr = self.data.weights.as_ptr().add(start); - let mut remaining = end - start; +#[cfg(test)] +mod tests { + use super::*; + use crate::network::grouping::{NetworkGrouping, VectorGrouping}; - while remaining > 0 { - let neighbor = *neighbor_ptr; - let neighbor_comm = grouping.get_group(neighbor); - let weight = *weight_ptr; + fn triangle_with_loop() -> CSRNetwork { + // triangle 0-1-2 (unit weights) plus a self-loop of weight 3 on node 0 + CSRNetwork::from_edges(3, &[(0, 1, 1.0), (1, 2, 1.0), (0, 2, 1.0), (0, 0, 3.0)]).unwrap() + } - if neighbor_comm == comm1 { - w1 += weight; - } else if neighbor_comm == comm2 { - w2 += weight; - } + #[test] + fn self_loop_counts_twice_toward_strength_once_toward_total() { + let g = triangle_with_loop(); + assert_eq!(g.strength(0), 8.0, "2*3 self-loop + 1 + 1"); + assert_eq!(g.strength(1), 2.0); + assert_eq!(g.strength(2), 2.0); + assert_eq!(g.total_weight(), 6.0, "1 + 1 + 1 + 3"); + assert_eq!(g.self_loop_weight(0), 3.0); + assert_eq!(g.self_loop_weight(1), 0.0); + } - neighbor_ptr = neighbor_ptr.add(1); - weight_ptr = weight_ptr.add(1); - remaining -= 1; - } + #[test] + fn degree_sum_invariant_holds() { + for g in [ + triangle_with_loop(), + CSRNetwork::from_edges(4, &[(0, 1, 2.5), (2, 3, 0.5)]).unwrap(), + CSRNetwork::from_edges(3, &[] as &[(usize, usize, f64)]).unwrap(), + ] { + let sum: f64 = (0..g.node_count()).map(|v| g.strength(v)).sum(); + assert!((sum - 2.0 * g.total_weight()).abs() < 1e-12); } - - (w1, w2) } - pub fn weight_to_comms_batch( - &self, - node: usize, - communities: &[usize], - grouping: &impl NetworkGrouping, - ) -> Vec { - let mut weights = vec![E::zero(); communities.len()]; - - // Create lookup map for community index - let community_to_idx: HashMap = communities - .iter() - .enumerate() - .map(|(i, &c)| (c, i)) - .collect(); - - let start = self.data.node_ptrs[node]; - let end = self.data.node_ptrs[node + 1]; - - unsafe { - let mut neighbor_ptr = self.data.neighbors.as_ptr().add(start); - let mut weight_ptr = self.data.weights.as_ptr().add(start); - let mut remaining = end - start; - - while remaining > 0 { - let neighbor = *neighbor_ptr; - let neighbor_comm = grouping.get_group(neighbor); + #[test] + fn parallel_edges_are_merged() { + let g = CSRNetwork::from_edges(2, &[(0, 1, 1.0), (0, 1, 2.0)]).unwrap(); + assert_eq!(g.degree(0), 1); + assert_eq!(g.edge_weight(0, 1), Some(3.0)); + assert_eq!(g.total_weight(), 3.0); + assert_eq!(g.edge_count(), 1); + } - if let Some(&idx) = community_to_idx.get(&neighbor_comm) { - weights[idx] += *weight_ptr; + #[test] + fn aggregate_preserves_total_weight_and_degree_sum() { + // three 6-cliques joined by two bridges + let mut edges = Vec::new(); + for b in 0..3usize { + for i in 0..6 { + for j in (i + 1)..6 { + edges.push((b * 6 + i, b * 6 + j, 1.0)); } - - neighbor_ptr = neighbor_ptr.add(1); - weight_ptr = weight_ptr.add(1); - remaining -= 1; } } - - weights - } - - #[inline] - pub fn self_loop_weight(&self, node: usize) -> E { - let start = self.data.node_ptrs[node]; - let end = self.data.node_ptrs[node + 1]; - - if start >= end { - return E::zero(); - } - - // Check if first neighbor is self (common optimization) - if self.data.neighbors[start] == node { - return self.data.weights[start]; + edges.push((0, 6, 1.0)); + edges.push((6, 12, 1.0)); + + let g = CSRNetwork::from_edges(18, &edges).unwrap(); + let memb: Vec = (0..18).map(|i| i / 6).collect(); + let agg = g.aggregate(&VectorGrouping::from_assignments(&memb)); + + let fine: f64 = (0..18).map(|v| g.strength(v)).sum(); + let coarse: f64 = (0..agg.node_count()).map(|v| agg.strength(v)).sum(); + assert_eq!(agg.node_count(), 3); + assert!( + (fine - coarse).abs() < 1e-12, + "Σ strength {fine} vs {coarse}" + ); + assert!((g.total_weight() - agg.total_weight()).abs() < 1e-12); + + // each super-node's strength equals the sum of its members' strengths + for c in 0..3 { + let members: f64 = (0..18) + .filter(|&v| memb[v] == c) + .map(|v| g.strength(v)) + .sum(); + assert!((members - agg.strength(c)).abs() < 1e-12, "community {c}"); } + // and node weights are summed + assert_eq!(agg.node_weight(0), 6.0); + } - // Binary search since neighbors are sorted - match self.data.neighbors[start..end].binary_search(&node) { - Ok(pos) => self.data.weights[start + pos], - Err(_) => E::zero(), + #[test] + fn aggregate_is_idempotent_on_singletons() { + let g = triangle_with_loop(); + let identity = VectorGrouping::from_assignments(&[0, 1, 2]); + let agg = g.aggregate(&identity); + for v in 0..3 { + assert!((g.strength(v) - agg.strength(v)).abs() < 1e-12); } + assert!((g.total_weight() - agg.total_weight()).abs() < 1e-12); + assert_eq!(agg.self_loop_weight(0), 3.0); } - pub fn community_internal_weight( - &self, - community: usize, - grouping: &impl NetworkGrouping, - ) -> E { - let members = &grouping.get_group_members()[community]; - let mut total_weight = E::zero(); - - // Use parallel processing for large communities - if members.len() > 100 { - total_weight = members - .par_iter() - .map(|&node| { - let mut internal_weight = E::zero(); - for (neighbor, weight) in self.neighbors(node) { - if grouping.get_group(neighbor) == community { - if node == neighbor { - internal_weight += weight; // Self-loop: full weight - } else if node < neighbor { - internal_weight += weight; // Edge: count once - } - } - } - internal_weight - }) - .sum(); - } else { - for &node in members { - for (neighbor, weight) in self.neighbors(node) { - if grouping.get_group(neighbor) == community { - if node == neighbor { - total_weight += weight; - } else if node < neighbor { - total_weight += weight; - } - } - } + #[test] + fn rejects_invalid_input() { + use crate::error::ClusteringError; + assert_eq!( + CSRNetwork::from_edges(2, &[(0, 5, 1.0)]).unwrap_err(), + ClusteringError::NodeIndexOutOfRange { + node: 5, + n_nodes: 2 } - } - - total_weight + ); + assert_eq!( + CSRNetwork::from_edges(2, &[(0, 1, -1.0)]).unwrap_err(), + ClusteringError::NegativeWeight { + edge: (0, 1), + weight: -1.0 + } + ); + assert!(matches!( + CSRNetwork::from_edges(2, &[(0, 1, f64::NAN)]).unwrap_err(), + ClusteringError::NonFiniteWeight { .. } + )); } - pub fn community_total_strength(&self, community: usize, grouping: &impl NetworkGrouping) -> E { - let members = &grouping.get_group_members()[community]; - - if members.len() > 50 { - // Parallel for large communities - members.par_iter().map(|&node| self.strength(node)).sum() - } else { - members - .iter() - .map(|&node| self.strength(node)) - .fold(E::zero(), |acc, x| acc + x) + /// The zero-copy path must produce exactly the same graph as the edge-list path. + #[test] + fn from_csr_parts_matches_from_edges() { + let edges = [ + (0usize, 1usize, 1.5f64), + (1, 2, 2.0), + (0, 2, 0.5), + (0, 0, 3.0), + (3, 3, 1.0), + ]; + let via_edges = CSRNetwork::from_edges(4, &edges).unwrap(); + + // the same graph, expressed as full symmetric CSR + let node_ptrs = vec![0usize, 3, 5, 7, 8]; + let neighbors = vec![0u32, 1, 2, 0, 2, 0, 1, 3]; + let weights = vec![3.0f32, 1.5, 0.5, 1.5, 2.0, 0.5, 2.0, 1.0]; + let via_csr = CSRNetwork::from_csr_parts(node_ptrs, neighbors, weights, None).unwrap(); + + assert_eq!(via_edges.node_count(), via_csr.node_count()); + assert_eq!(via_edges.edge_count(), via_csr.edge_count()); + assert!((via_edges.total_weight() - via_csr.total_weight()).abs() < 1e-9); + for v in 0..4 { + assert!( + (via_edges.strength(v) - via_csr.strength(v)).abs() < 1e-9, + "strength({v})" + ); + let a: Vec<_> = via_edges.neighbors(v).collect(); + let b: Vec<_> = via_csr.neighbors(v).collect(); + assert_eq!(a, b, "neighbors({v})"); } } -} -/// High-performance iterator over neighbors and edge weights. -/// -/// Uses unsafe pointer arithmetic to provide zero-cost iteration over -/// the neighbors of a node in the CSR representation. -pub struct CSRNeighborIterator { - neighbor_ptr: *const usize, - weight_ptr: *const E, - remaining: usize, -} + #[test] + fn from_csr_parts_sorts_and_merges_rows() { + // row 0 lists its neighbours out of order and twice + let node_ptrs = vec![0usize, 3, 4]; + let neighbors = vec![1u32, 1, 0, 0]; + let weights = vec![1.0f32, 2.0, 0.0, 3.0]; + let g = CSRNetwork::from_csr_parts(node_ptrs, neighbors, weights, None).unwrap(); + assert_eq!(g.edge_weight(0, 1), Some(3.0), "duplicates summed"); + assert_eq!(g.degree(0), 1, "the zero-weight self-loop is dropped"); + } -impl Iterator for CSRNeighborIterator { - type Item = (usize, E); + #[test] + fn from_csr_parts_rejects_malformed_input() { + use crate::error::ClusteringError; + // node_ptrs not matching the entry count + assert!(matches!( + CSRNetwork::from_csr_parts(vec![0, 5], vec![0u32], vec![1.0f32], None).unwrap_err(), + ClusteringError::InvalidCsr(_) + )); + // mismatched parallel arrays + assert!(matches!( + CSRNetwork::from_csr_parts(vec![0, 1], vec![0u32], vec![1.0f32, 2.0], None) + .unwrap_err(), + ClusteringError::InvalidCsr(_) + )); + // out-of-range column index + assert!(matches!( + CSRNetwork::from_csr_parts(vec![0, 1], vec![9u32], vec![1.0f32], None).unwrap_err(), + ClusteringError::NodeIndexOutOfRange { .. } + )); + // non-decreasing violation + assert!(matches!( + CSRNetwork::from_csr_parts(vec![0, 2, 1], vec![0u32, 1], vec![1.0f32, 1.0], None) + .unwrap_err(), + ClusteringError::InvalidCsr(_) + )); + } - #[inline] - fn next(&mut self) -> Option { - if self.remaining == 0 { - return None; - } + /// The realistic catastrophic mistake: handing over a k-NN graph that was never + /// symmetrised. Node 0 lists node 1, but node 1 does not list node 0. + #[test] + fn asymmetric_input_is_rejected() { + use crate::error::ClusteringError; + let node_ptrs = vec![0usize, 1, 1]; + let neighbors = vec![1u32]; + let weights = vec![1.0f32]; + let err = CSRNetwork::from_csr_parts(node_ptrs, neighbors, weights, None).unwrap_err(); + assert!( + matches!(err, ClusteringError::AsymmetricGraph { .. }), + "expected AsymmetricGraph, got {err}" + ); + assert!(err.to_string().contains("symmetrised")); + } - unsafe { - let neighbor = *self.neighbor_ptr; - let weight = *self.weight_ptr; + #[test] + fn asymmetric_weights_are_rejected() { + use crate::error::ClusteringError; + // both directions present, but with different weights + let node_ptrs = vec![0usize, 1, 2]; + let neighbors = vec![1u32, 0]; + let weights = vec![1.0f32, 5.0]; + let err = CSRNetwork::from_csr_parts(node_ptrs, neighbors, weights, None).unwrap_err(); + assert!( + matches!(err, ClusteringError::AsymmetricGraph { .. }), + "{err}" + ); + } - self.neighbor_ptr = self.neighbor_ptr.add(1); - self.weight_ptr = self.weight_ptr.add(1); - self.remaining -= 1; - Some((neighbor, weight)) + #[test] + fn validate_symmetry_accepts_well_formed_graphs() { + for g in [ + triangle_with_loop(), + CSRNetwork::from_edges(6, &[(0, 1, 2.5), (2, 3, 0.5), (4, 4, 1.0)]).unwrap(), + CSRNetwork::from_edges(3, &[] as &[(usize, usize, f64)]).unwrap(), + ] { + g.validate_symmetry().unwrap(); } } - #[inline] - fn size_hint(&self) -> (usize, Option) { - (self.remaining, Some(self.remaining)) + /// A "balanced" asymmetry that the degree-sum check cannot see: two errors that cancel in + /// the totals. This is what `validate_symmetry` is for. + #[test] + fn validate_symmetry_catches_what_the_degree_sum_cannot() { + // strengths and total both balance, but neither edge agrees with its reverse + let node_ptrs = vec![0usize, 2, 3, 4]; + let neighbors = vec![1u32, 2, 0, 0]; + let weights = vec![1.0f32, 3.0, 3.0, 1.0]; + let g = CSRNetwork::from_csr_parts(node_ptrs, neighbors, weights, None) + .expect("degree sum balances, so construction succeeds"); + assert!( + g.validate_symmetry().is_err(), + "validate_symmetry should catch mismatched reverse weights" + ); } -} - -impl ExactSizeIterator for CSRNeighborIterator where E: Copy {} -unsafe impl Send for CSRNeighborIterator {} -unsafe impl Sync for CSRNeighborIterator {} - -impl std::fmt::Display for CSRNetwork -where - N: FloatOpsTS + std::fmt::Display + 'static, - E: FloatOpsTS + std::fmt::Display + 'static, -{ - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "CSRNetwork({} nodes, {} edges, total_weight: {}, density: {:.4})", - self.node_count(), - self.edge_count(), - self.total_weight(), - self.density() - ) + #[test] + fn memory_is_eight_bytes_per_stored_entry() { + let mut edges = Vec::new(); + for i in 0..1000usize { + for d in 1..=5 { + if i + d < 1000 { + edges.push((i, i + d, 1.0)); + } + } + } + let g = CSRNetwork::from_edges(1000, &edges).unwrap(); + let entries = 2 * g.edge_count(); // both directions stored + let node_side = 1000 * (8 + 8) + 1001 * 8; + assert_eq!(g.memory_bytes(), entries * 8 + node_side); } } diff --git a/src/network/grouping.rs b/src/network/grouping.rs index e109f9b..28d38fc 100644 --- a/src/network/grouping.rs +++ b/src/network/grouping.rs @@ -83,8 +83,7 @@ pub trait NetworkGrouping: Debug + Send + Sync { /// - O(1) group assignment and lookup /// - Cached group sizes with lazy updates /// - Bulk operations for efficiency -#[derive(Debug, Clone)] -#[derive(Default)] +#[derive(Debug, Clone, Default)] pub struct VectorGrouping { /// Vector storing the group assignment for each node assignments: Vec, diff --git a/src/network/mod.rs b/src/network/mod.rs index 0edf957..4885538 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -1,390 +1,15 @@ //! # Network Module //! -//! This module provides network/graph representations and operations for clustering algorithms. -//! It includes both petgraph-based and CSR-based implementations, with support for network -//! aggregation, subgraph extraction, and format conversions. +//! This module provides the network/graph representation used by the clustering +//! algorithms, plus the grouping abstraction used to express partitions. //! //! ## Key Components -//! - `Network` - Petgraph-based network representation -//! - `CSRNetwork` - High-performance CSR-based network -//! - Network grouping and community operations -//! - Matrix format conversions (CSR, COO) - -use crate::network::grouping::NetworkGrouping; -use nalgebra_sparse::{CooMatrix, CsrMatrix}; -use petgraph::graph::{Edges, UnGraph}; -use petgraph::prelude::{EdgeRef, NodeIndex}; -use rayon::iter::ParallelIterator; -use rayon::prelude::ParallelSlice; -use single_utilities::traits::FloatOpsTS; -use std::collections::HashMap; +//! - [`CSRNetwork`] - compressed-sparse-row weighted undirected graph +//! - [`grouping`] - node-to-group assignments (partitions/clusterings) pub mod grouping; mod csr_network; -pub use csr_network::CSRNetwork; pub use csr_network::CSRNeighborIterator; - -/// Type alias for the underlying petgraph UnGraph structure. -pub type Graph = UnGraph; - -/// A network representation based on petgraph's UnGraph. -/// -/// This structure provides a convenient wrapper around petgraph's undirected graph -/// with additional methods for clustering operations and format conversions. -/// -/// # Type Parameters -/// * `N` - Node weight type (e.g., f32, f64) -/// * `E` - Edge weight type (e.g., f32, f64) -#[derive(Clone)] -pub struct Network { - /// The underlying petgraph undirected graph - pub graph: Graph, -} - -/// Iterator over neighbors and their edge weights for a specific node. -/// -/// Provides efficient iteration over the neighbors of a node in the petgraph -/// representation, yielding (neighbor_index, edge_weight) pairs. -pub struct NeighborAndWeightIterator<'a, N: 'a, E: 'a> { - edge_iter: Edges<'a, E, petgraph::Undirected>, - home_node: usize, - _phantom: std::marker::PhantomData<&'a N>, -} - -impl Iterator for NeighborAndWeightIterator<'_, N, E> -where - E: Copy, -{ - type Item = (usize, E); - - fn next(&mut self) -> Option { - self.edge_iter.next().map(|edge_ref| { - let neighbor = if edge_ref.source().index() == self.home_node { - edge_ref.target().index() - } else { - edge_ref.source().index() - }; - (neighbor, *edge_ref.weight()) - }) - } -} - -impl Default for Network -where - N: FloatOpsTS, - E: FloatOpsTS + 'static, -{ - fn default() -> Self { - Self::new() - } -} - -impl Network -where - N: FloatOpsTS, - E: FloatOpsTS + 'static, -{ - /// Creates a new empty network. - pub fn new() -> Self { - Network { - graph: Graph::new_undirected(), - } - } - - /// Creates a network from an existing petgraph Graph. - pub fn new_from_graph(graph: Graph) -> Self { - Network { graph } - } - - /// Returns the number of nodes in the network. - pub fn nodes(&self) -> usize { - self.graph.node_count() - } - - /// Returns the weight of a specific node. - pub fn weight(&self, node: usize) -> N { - *self - .graph - .node_weight(petgraph::graph::NodeIndex::new(node)) - .unwrap() - } - - /// Returns an iterator over the neighbors and edge weights of a node. - pub fn neighbors(&self, node: usize) -> NeighborAndWeightIterator<'_, N, E> { - NeighborAndWeightIterator { - edge_iter: self.graph.edges(petgraph::graph::NodeIndex::new(node)), - home_node: node, - _phantom: std::marker::PhantomData, - } - } - - /// Calculates the total weight of all nodes in the network. - pub fn get_total_node_weight(&self) -> N { - self.graph - .node_weights() - .fold(N::zero(), |sum, node| sum + *node) - } - - /// Calculates the total weight of all edges in the network. - pub fn get_total_edge_weight(&self) -> E { - self.graph - .edge_weights() - .fold(E::zero(), |sum, edge| sum + *edge) - } - - /// Calculates the total edge weight using parallel processing. - pub fn get_total_edge_weight_par(&self) -> E { - let weights: Vec<_> = self.graph.edge_weights().collect(); - weights - .par_chunks(256) - .map(|chunk| chunk.iter().fold(E::zero(), |acc, &weight| acc + *weight)) - .sum() - } - - /// Computes total edge weights per node and stores results in the provided vector. - pub fn get_total_edge_weight_per_node(&self, result: &mut Vec) { - result.clear(); - result.extend((0..self.nodes()).map(|i| { - self.graph - .edges(petgraph::graph::NodeIndex::new(i)) - .fold(E::zero(), |acc, edge| acc + *edge.weight()) - })); - } - - /// Creates a reduced network by aggregating nodes according to a grouping. - /// - /// Nodes in the same group are merged into super-nodes, with edge weights - /// combined appropriately. Used in multilevel clustering algorithms. - pub fn create_reduced_network(&self, grouping: &T) -> Self { - let mut cluster_g = - Graph::with_capacity(grouping.group_count(), grouping.group_count() * 2); - for _ in 0..grouping.group_count() { - cluster_g.add_node(N::zero()); - } - - for node_idx in self.graph.node_indices() { - let group = grouping.get_group(node_idx.index()); - let group_node = NodeIndex::new(group); - let current_weight = self.graph.node_weight(node_idx).unwrap(); - let group_weight = cluster_g.node_weight_mut(group_node).unwrap(); - *group_weight += *current_weight; - } - - let mut edge_memo = HashMap::new(); - let mut self_loop_weights = HashMap::new(); // Track self-loop weights - - for edge in self.graph.edge_references() { - let g1 = grouping.get_group(edge.source().index()); - let g2 = grouping.get_group(edge.target().index()); - - if g1 == g2 { - let new_weight = *edge.weight(); - *self_loop_weights.entry(g1).or_insert(E::zero()) += new_weight; - } else { - let (min_g, max_g) = if g1 < g2 { (g1, g2) } else { (g2, g1) }; - *edge_memo.entry((min_g, max_g)).or_insert(E::zero()) += *edge.weight(); - } - } - - for (&group, &weight) in self_loop_weights.iter() { - if weight > E::zero() { - cluster_g.add_edge(NodeIndex::new(group), NodeIndex::new(group), weight); - } - } - - for (&(g1, g2), &weight) in edge_memo.iter() { - cluster_g.add_edge(NodeIndex::new(g1), NodeIndex::new(g2), weight); - } - - Network { graph: cluster_g } - } - - /// Creates separate subnetworks for each group in the grouping. - /// - /// Returns a vector of networks, one for each group, containing only - /// the nodes and edges within that group. - pub fn create_subnetworks(&self, grouping: &T) -> Vec { - let mut graphs = vec![Graph::new_undirected(); grouping.group_count()]; - let mut new_id_map = vec![0; self.nodes()]; - let mut counts = vec![0; grouping.group_count()]; - - for node_idx in self.graph.node_indices() { - let node = node_idx.index(); - let group = grouping.get_group(node); - let new_id = counts[group]; - new_id_map[node] = new_id; - counts[group] += 1; - graphs[group].add_node(*self.graph.node_weight(node_idx).unwrap()); - } - - for edge in self.graph.edge_references() { - let n1 = edge.source().index(); - let g1 = grouping.get_group(n1); - - let n2 = edge.target().index(); - let g2 = grouping.get_group(n2); - - if g1 == g2 { - graphs[g1].add_edge( - NodeIndex::new(new_id_map[n1]), - NodeIndex::new(new_id_map[n2]), - *edge.weight(), - ); - } - } - - graphs - .into_iter() - .map(Network::new_from_graph) - .collect::>() - } - - /// Creates a subnetwork containing only nodes from a specific group. - /// - /// Extracts all nodes belonging to the specified group and their - /// internal connections, renumbering nodes consecutively. - pub fn create_subnetwork_from_group( - &self, - grouping: &T, - group: usize, - ) -> Self { - let mut subgraph = Graph::new_undirected(); - let mut old_to_new = HashMap::new(); - - for node_idx in self.graph.node_indices() { - if grouping.get_group(node_idx.index()) == group { - let new_idx = subgraph.add_node(*self.graph.node_weight(node_idx).unwrap()); - old_to_new.insert(node_idx, new_idx); - } - } - - for edge in self.graph.edge_references() { - let source = edge.source(); - let target = edge.target(); - - if let (Some(&new_source), Some(&new_target)) = - (old_to_new.get(&source), old_to_new.get(&target)) - { - subgraph.add_edge(new_source, new_target, *edge.weight()); - } - } - - Network { graph: subgraph } - } - - /// Converts the network to an upper triangular CSR matrix representation. - /// - /// Only includes edges where row <= column to avoid duplication in - /// undirected graphs. - pub fn to_upper_triangular_csr(&self) -> CsrMatrix { - let n_nodes = self.nodes(); - let mut triplets = Vec::new(); - - for i in 0..n_nodes { - for (neighbor, weight) in self.neighbors(i) { - if i <= neighbor { - triplets.push((i, neighbor, weight)); - } - } - } - - let row_indices: Vec = triplets.iter().map(|(r, _, _)| *r).collect(); - let col_indices: Vec = triplets.iter().map(|(_, c, _)| *c).collect(); - let values: Vec = triplets.iter().map(|(_, _, v)| *v).collect(); - - CsrMatrix::try_from_csr_data(n_nodes, n_nodes, row_indices, col_indices, values) - .expect("Failed to create CSR matrix from network") - } - - /// Converts the network to a full CSR matrix representation. - /// - /// Includes all edges in the undirected graph, creating a symmetric - /// matrix suitable for linear algebra operations. - pub fn to_csr_matrix(&self) -> CsrMatrix { - let n_nodes = self.nodes(); - let estimated_edges = self.graph.edge_count(); - - // Pre-allocate vectors with known capacity - let mut row_indices = Vec::with_capacity(estimated_edges); - let mut col_indices = Vec::with_capacity(estimated_edges); - let mut values = Vec::with_capacity(estimated_edges); - - // Direct collection without intermediate triplets vector - for i in 0..n_nodes { - for (neighbor, weight) in self.neighbors(i) { - row_indices.push(i); - col_indices.push(neighbor); - values.push(weight); - } - } - - // Create COO matrix directly - let coo_matrix = - CooMatrix::try_from_triplets(n_nodes, n_nodes, row_indices, col_indices, values) - .expect("Failed to create COO matrix from network"); - - // Convert COO to CSR - CsrMatrix::from(&coo_matrix) - } -} - -/// Creates a Network from a CSR matrix representation. -/// -/// Converts a nalgebra CSR matrix to a petgraph-based Network, computing -/// node weights from row sums and handling self-loops appropriately. -pub fn network_from_csr_matrix(csr_matrix: CsrMatrix) -> Network -where - T: FloatOpsTS + 'static, -{ - let n_nodes = csr_matrix.ncols(); - let mut graph = Graph::with_capacity(n_nodes, csr_matrix.nnz()); - - let mut node_indices = Vec::with_capacity(n_nodes); - - let mut node_weights = vec![T::zero(); n_nodes]; - - for (row, row_vec) in csr_matrix.row_iter().enumerate() { - let weight = row_vec.values().iter().fold(T::zero(), |acc, &x| acc + x); - node_weights[row] = weight; - node_indices.push(graph.add_node(weight)); - } - - for (row, col, &weight) in csr_matrix.triplet_iter() { - if row == col { - graph.add_edge(node_indices[row], node_indices[col], weight); - } - if row <= col { - let temp_weight = weight * T::from(2.0).unwrap(); - graph.add_edge(node_indices[row], node_indices[col], temp_weight); - } - } - - Network::new_from_graph(graph) -} - -/// Converts a CSR matrix and node weights to a petgraph Graph. -/// -/// Creates a petgraph undirected graph from CSR matrix data, handling -/// upper triangular representation to avoid edge duplication. -#[allow(dead_code)] -fn csr_to_petgraph(connectivity: CsrMatrix, node_weights: Vec) -> Graph -where - T: FloatOpsTS, -{ - let mut graph = Graph::with_capacity(node_weights.len(), connectivity.nnz()); - - let mut node_indices = Vec::with_capacity(node_weights.len()); - for weight in node_weights { - node_indices.push(graph.add_node(weight)); - } - - for (row, col, &weight) in connectivity.triplet_iter() { - if row <= col { - graph.add_edge(node_indices[row], node_indices[col], weight); - } - } - - graph -} +pub use csr_network::{CSRNetwork, MAX_NODES}; diff --git a/src/testdata.rs b/src/testdata.rs new file mode 100644 index 0000000..b33f45d --- /dev/null +++ b/src/testdata.rs @@ -0,0 +1,127 @@ +//! Shared graph fixtures for unit tests. + +use crate::network::CSRNetwork; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +/// Edges of Zachary's karate club, the standard community-detection benchmark. +pub fn karate_edges() -> Vec<(usize, usize, f64)> { + // dense on purpose — one tuple per line is unreadable + #[rustfmt::skip] + const E: &[(usize, usize)] = &[ + (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8), (0, 10), (0, 11), + (0, 12), (0, 13), (0, 17), (0, 19), (0, 21), (0, 31), (1, 2), (1, 3), (1, 7), (1, 13), + (1, 17), (1, 19), (1, 21), (1, 30), (2, 3), (2, 7), (2, 8), (2, 9), (2, 13), (2, 27), + (2, 28), (2, 32), (3, 7), (3, 12), (3, 13), (4, 6), (4, 10), (5, 6), (5, 10), (5, 16), + (6, 16), (8, 30), (8, 32), (8, 33), (9, 33), (13, 33), (14, 32), (14, 33), (15, 32), + (15, 33), (18, 32), (18, 33), (19, 33), (20, 32), (20, 33), (22, 32), (22, 33), + (23, 25), (23, 27), (23, 29), (23, 32), (23, 33), (24, 25), (24, 27), (24, 31), + (25, 31), (26, 29), (26, 33), (27, 33), (28, 31), (28, 33), (29, 32), (29, 33), + (30, 32), (30, 33), (31, 32), (31, 33), (32, 33), + ]; + E.iter().map(|&(a, b)| (a, b, 1.0)).collect() +} + +/// The karate club as a graph. +pub fn karate() -> CSRNetwork { + CSRNetwork::from_edges(34, &karate_edges()).unwrap() +} + +/// The known modularity-optimal 4-community partition of the karate club, Q = 0.4198. +pub fn karate_optimum() -> Vec { + vec![ + 0, 0, 0, 0, 1, 1, 1, 0, 2, 2, 1, 0, 0, 0, 2, 2, 1, 0, 2, 0, 2, 0, 2, 3, 3, 3, 2, 3, 3, 2, + 2, 3, 2, 2, + ] +} + +/// A stochastic block model: `blocks` groups of `n_per` nodes, with within-group edge +/// probability `p_in` and between-group probability `p_out`. Returns the edges and the +/// planted labels. +pub fn sbm( + n_per: usize, + blocks: usize, + p_in: f64, + p_out: f64, + seed: u64, +) -> (Vec<(usize, usize, f64)>, Vec) { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let n = n_per * blocks; + let truth: Vec = (0..n).map(|i| i / n_per).collect(); + let mut edges = Vec::new(); + for i in 0..n { + for j in (i + 1)..n { + let p = if truth[i] == truth[j] { p_in } else { p_out }; + if rng.random::() < p { + edges.push((i, j, 1.0)); + } + } + } + (edges, truth) +} + +/// Normalized mutual information between two labellings, in `[0, 1]`. +pub fn nmi(a: &[usize], b: &[usize]) -> f64 { + let n = a.len() as f64; + if n == 0.0 { + return 1.0; + } + let ka = a.iter().max().map_or(0, |m| m + 1); + let kb = b.iter().max().map_or(0, |m| m + 1); + let mut joint = vec![0.0f64; ka * kb]; + let mut pa = vec![0.0f64; ka]; + let mut pb = vec![0.0f64; kb]; + for i in 0..a.len() { + joint[a[i] * kb + b[i]] += 1.0; + pa[a[i]] += 1.0; + pb[b[i]] += 1.0; + } + let mut mi = 0.0; + for i in 0..ka { + for j in 0..kb { + let p = joint[i * kb + j] / n; + if p > 0.0 { + mi += p * (p / ((pa[i] / n) * (pb[j] / n))).ln(); + } + } + } + let entropy = |p: &[f64]| -> f64 { + -p.iter() + .filter(|&&x| x > 0.0) + .map(|&x| (x / n) * (x / n).ln()) + .sum::() + }; + let (ha, hb) = (entropy(&pa), entropy(&pb)); + if ha + hb == 0.0 { + 1.0 + } else { + 2.0 * mi / (ha + hb) + } +} + +/// Counts communities that are not internally connected — the guarantee Leiden's refinement +/// phase exists to provide. +pub fn disconnected_community_count(graph: &CSRNetwork, labels: &[usize]) -> usize { + let n = graph.node_count(); + let n_comms = labels.iter().max().map_or(0, |m| m + 1); + let mut seen = vec![false; n]; + let mut components = vec![0usize; n_comms]; + + for v in 0..n { + if seen[v] { + continue; + } + components[labels[v]] += 1; + let mut stack = vec![v]; + seen[v] = true; + while let Some(u) = stack.pop() { + for (w, _) in graph.neighbors(u) { + if !seen[w] && labels[w] == labels[u] { + seen[w] = true; + stack.push(w); + } + } + } + } + components.iter().filter(|&&c| c > 1).count() +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs new file mode 100644 index 0000000..7f2cc17 --- /dev/null +++ b/tests/common/mod.rs @@ -0,0 +1,194 @@ +//! Graph fixtures and metrics shared by the integration tests. +//! +//! Included by several test binaries, each of which uses a different subset. +#![allow(dead_code)] + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; +use single_clustering::network::CSRNetwork; + +/// Edges of Zachary's karate club. +pub fn karate_edges() -> Vec<(usize, usize, f64)> { + // dense on purpose — one tuple per line is unreadable + #[rustfmt::skip] + const E: &[(usize, usize)] = &[ + (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8), (0, 10), (0, 11), + (0, 12), (0, 13), (0, 17), (0, 19), (0, 21), (0, 31), (1, 2), (1, 3), (1, 7), (1, 13), + (1, 17), (1, 19), (1, 21), (1, 30), (2, 3), (2, 7), (2, 8), (2, 9), (2, 13), (2, 27), + (2, 28), (2, 32), (3, 7), (3, 12), (3, 13), (4, 6), (4, 10), (5, 6), (5, 10), (5, 16), + (6, 16), (8, 30), (8, 32), (8, 33), (9, 33), (13, 33), (14, 32), (14, 33), (15, 32), + (15, 33), (18, 32), (18, 33), (19, 33), (20, 32), (20, 33), (22, 32), (22, 33), + (23, 25), (23, 27), (23, 29), (23, 32), (23, 33), (24, 25), (24, 27), (24, 31), + (25, 31), (26, 29), (26, 33), (27, 33), (28, 31), (28, 33), (29, 32), (29, 33), + (30, 32), (30, 33), (31, 32), (31, 33), (32, 33), + ]; + E.iter().map(|&(a, b)| (a, b, 1.0)).collect() +} + +/// The karate club as a graph. +pub fn karate() -> CSRNetwork { + CSRNetwork::from_edges(34, &karate_edges()).unwrap() +} + +/// A stochastic block model with the given within/between edge probabilities. +pub fn sbm( + n_per: usize, + blocks: usize, + p_in: f64, + p_out: f64, + seed: u64, +) -> (Vec<(usize, usize, f64)>, Vec) { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let n = n_per * blocks; + let truth: Vec = (0..n).map(|i| i / n_per).collect(); + let mut edges = Vec::new(); + for i in 0..n { + for j in (i + 1)..n { + let p = if truth[i] == truth[j] { p_in } else { p_out }; + if rng.random::() < p { + edges.push((i, j, 1.0)); + } + } + } + (edges, truth) +} + +/// A varied set of graphs, including the degenerate shapes that tend to break assumptions. +pub fn test_graphs() -> Vec<(String, CSRNetwork)> { + let none: &[(usize, usize, f64)] = &[]; + let mut out: Vec<(String, CSRNetwork)> = vec![ + ("empty".into(), CSRNetwork::from_edges(0, none).unwrap()), + ( + "single_node".into(), + CSRNetwork::from_edges(1, none).unwrap(), + ), + ("no_edges".into(), CSRNetwork::from_edges(8, none).unwrap()), + ( + "one_edge".into(), + CSRNetwork::from_edges(2, &[(0, 1, 1.0)]).unwrap(), + ), + ( + "self_loops_only".into(), + CSRNetwork::from_edges(4, &[(0, 0, 1.0), (1, 1, 2.0), (2, 2, 0.5)]).unwrap(), + ), + ( + "with_self_loops".into(), + CSRNetwork::from_edges( + 5, + &[ + (0, 1, 1.0), + (1, 2, 1.0), + (0, 0, 3.0), + (3, 4, 1.0), + (3, 3, 2.0), + ], + ) + .unwrap(), + ), + ( + "star".into(), + CSRNetwork::from_edges(9, &(1..9).map(|i| (0usize, i, 1.0)).collect::>()) + .unwrap(), + ), + ( + "path".into(), + CSRNetwork::from_edges(12, &(0..11).map(|i| (i, i + 1, 1.0)).collect::>()) + .unwrap(), + ), + ("karate".into(), karate()), + ]; + + // weighted karate, so the weighted paths get the same coverage + let mut rng = ChaCha8Rng::seed_from_u64(3); + let weighted: Vec<(usize, usize, f64)> = karate_edges() + .into_iter() + .map(|(a, b, _)| (a, b, 0.25 + 3.5 * rng.random::())) + .collect(); + out.push(( + "karate_weighted".into(), + CSRNetwork::from_edges(34, &weighted).unwrap(), + )); + + let (edges, _) = sbm(25, 4, 0.35, 0.03, 1); + out.push(("sbm".into(), CSRNetwork::from_edges(100, &edges).unwrap())); + + // three cliques, fully disconnected from each other + let mut edges = Vec::new(); + for b in 0..3usize { + for i in 0..5 { + for j in (i + 1)..5 { + edges.push((b * 5 + i, b * 5 + j, 1.0)); + } + } + } + out.push(( + "disconnected_cliques".into(), + CSRNetwork::from_edges(15, &edges).unwrap(), + )); + + out +} + +/// Normalized mutual information between two labellings, in `[0, 1]`. +pub fn nmi(a: &[usize], b: &[usize]) -> f64 { + let n = a.len() as f64; + if n == 0.0 { + return 1.0; + } + let ka = a.iter().max().map_or(0, |m| m + 1); + let kb = b.iter().max().map_or(0, |m| m + 1); + let mut joint = vec![0.0f64; ka * kb]; + let (mut pa, mut pb) = (vec![0.0f64; ka], vec![0.0f64; kb]); + for i in 0..a.len() { + joint[a[i] * kb + b[i]] += 1.0; + pa[a[i]] += 1.0; + pb[b[i]] += 1.0; + } + let mut mi = 0.0; + for i in 0..ka { + for j in 0..kb { + let p = joint[i * kb + j] / n; + if p > 0.0 { + mi += p * (p / ((pa[i] / n) * (pb[j] / n))).ln(); + } + } + } + let h = |p: &[f64]| -> f64 { + -p.iter() + .filter(|&&x| x > 0.0) + .map(|&x| (x / n) * (x / n).ln()) + .sum::() + }; + let (ha, hb) = (h(&pa), h(&pb)); + if ha + hb == 0.0 { + 1.0 + } else { + 2.0 * mi / (ha + hb) + } +} + +/// Number of communities that are not internally connected. +pub fn disconnected_community_count(graph: &CSRNetwork, labels: &[usize]) -> usize { + let n = graph.node_count(); + let n_comms = labels.iter().max().map_or(0, |m| m + 1); + let mut seen = vec![false; n]; + let mut components = vec![0usize; n_comms]; + + for v in 0..n { + if seen[v] { + continue; + } + components[labels[v]] += 1; + let mut stack = vec![v]; + seen[v] = true; + while let Some(u) = stack.pop() { + for (w, _) in graph.neighbors(u) { + if !seen[w] && labels[w] == labels[u] { + seen[w] = true; + stack.push(w); + } + } + } + } + components.iter().filter(|&&c| c > 1).count() +} diff --git a/tests/fixtures/leidenalg_reference.json b/tests/fixtures/leidenalg_reference.json new file mode 100644 index 0000000..ba765df --- /dev/null +++ b/tests/fixtures/leidenalg_reference.json @@ -0,0 +1 @@ +{"graphs":{"karate/unit":{"n_nodes":34,"edges":[[0,1,1.0],[0,2,1.0],[0,3,1.0],[0,4,1.0],[0,5,1.0],[0,6,1.0],[0,7,1.0],[0,8,1.0],[0,10,1.0],[0,11,1.0],[0,12,1.0],[0,13,1.0],[0,17,1.0],[0,19,1.0],[0,21,1.0],[0,31,1.0],[1,2,1.0],[1,3,1.0],[1,7,1.0],[1,13,1.0],[1,17,1.0],[1,19,1.0],[1,21,1.0],[1,30,1.0],[2,3,1.0],[2,7,1.0],[2,8,1.0],[2,9,1.0],[2,13,1.0],[2,27,1.0],[2,28,1.0],[2,32,1.0],[3,7,1.0],[3,12,1.0],[3,13,1.0],[4,6,1.0],[4,10,1.0],[5,6,1.0],[5,10,1.0],[5,16,1.0],[6,16,1.0],[8,30,1.0],[8,32,1.0],[8,33,1.0],[9,33,1.0],[13,33,1.0],[14,32,1.0],[14,33,1.0],[15,32,1.0],[15,33,1.0],[18,32,1.0],[18,33,1.0],[19,33,1.0],[20,32,1.0],[20,33,1.0],[22,32,1.0],[22,33,1.0],[23,25,1.0],[23,27,1.0],[23,29,1.0],[23,32,1.0],[23,33,1.0],[24,25,1.0],[24,27,1.0],[24,31,1.0],[25,31,1.0],[26,29,1.0],[26,33,1.0],[27,33,1.0],[28,31,1.0],[28,33,1.0],[29,32,1.0],[29,33,1.0],[30,32,1.0],[30,33,1.0],[31,32,1.0],[31,33,1.0],[32,33,1.0]]},"karate/weighted":{"n_nodes":34,"edges":[[0,1,1.3834],[0,2,0.778],[0,3,2.5283],[0,4,0.5035],[0,5,2.1256],[0,6,1.5299],[0,7,0.453],[0,8,2.026],[0,10,0.3812],[0,11,1.7678],[0,12,0.4945],[0,13,0.5675],[0,17,1.7358],[0,19,3.144],[0,21,0.6833],[0,31,1.0313],[1,2,2.446],[1,3,3.567],[1,7,2.2699],[1,13,1.6384],[1,17,3.6669],[1,19,0.413],[1,21,3.2546],[1,30,1.2636],[2,3,0.7549],[2,7,0.6623],[2,8,1.3297],[2,9,3.1064],[2,13,0.8825],[2,27,2.2856],[2,28,2.4862],[2,32,1.5534],[3,7,2.1671],[3,12,0.4698],[3,13,0.4586],[4,6,0.9709],[4,10,2.6314],[5,6,1.7466],[5,10,1.3495],[5,16,2.2995],[6,16,1.8361],[8,30,1.2992],[8,32,3.0303],[8,33,2.6965],[9,33,1.1043],[13,33,2.2605],[14,32,2.0882],[14,33,3.313],[15,32,2.8031],[15,33,1.2578],[18,32,3.6806],[18,33,0.6632],[19,33,1.7134],[20,32,2.9],[20,33,0.7819],[22,32,1.9614],[22,33,0.3872],[23,25,2.5888],[23,27,2.926],[23,29,2.2556],[23,32,3.3142],[23,33,1.3481],[24,25,2.6835],[24,27,2.3303],[24,31,2.2796],[25,31,1.8467],[26,29,3.1899],[26,33,3.5564],[27,33,1.9093],[28,31,2.5745],[28,33,0.4623],[29,32,2.7052],[29,33,2.515],[30,32,3.7258],[30,33,3.1267],[31,32,1.2461],[31,33,1.6003],[32,33,2.5903]]},"sbm_easy/unit":{"n_nodes":120,"edges":[[0,1,1.0],[0,4,1.0],[0,9,1.0],[0,10,1.0],[0,14,1.0],[0,17,1.0],[0,20,1.0],[0,21,1.0],[0,24,1.0],[0,25,1.0],[0,27,1.0],[0,28,1.0],[0,92,1.0],[1,3,1.0],[1,6,1.0],[1,7,1.0],[1,8,1.0],[1,10,1.0],[1,11,1.0],[1,13,1.0],[1,14,1.0],[1,15,1.0],[1,17,1.0],[1,18,1.0],[1,21,1.0],[1,23,1.0],[1,24,1.0],[1,26,1.0],[1,27,1.0],[1,34,1.0],[1,62,1.0],[1,86,1.0],[2,3,1.0],[2,4,1.0],[2,8,1.0],[2,11,1.0],[2,13,1.0],[2,14,1.0],[2,15,1.0],[2,20,1.0],[2,23,1.0],[2,25,1.0],[2,26,1.0],[2,28,1.0],[2,29,1.0],[2,47,1.0],[3,7,1.0],[3,9,1.0],[3,12,1.0],[3,13,1.0],[3,20,1.0],[3,21,1.0],[3,22,1.0],[3,23,1.0],[3,27,1.0],[3,28,1.0],[3,29,1.0],[3,37,1.0],[3,114,1.0],[4,6,1.0],[4,7,1.0],[4,9,1.0],[4,11,1.0],[4,21,1.0],[4,22,1.0],[4,23,1.0],[4,25,1.0],[4,38,1.0],[4,49,1.0],[4,66,1.0],[4,104,1.0],[5,7,1.0],[5,11,1.0],[5,13,1.0],[5,15,1.0],[5,18,1.0],[5,19,1.0],[5,23,1.0],[5,26,1.0],[5,94,1.0],[6,7,1.0],[6,8,1.0],[6,11,1.0],[6,15,1.0],[6,16,1.0],[6,18,1.0],[6,26,1.0],[6,28,1.0],[7,10,1.0],[7,11,1.0],[7,15,1.0],[7,18,1.0],[7,19,1.0],[7,21,1.0],[7,22,1.0],[7,23,1.0],[7,26,1.0],[7,27,1.0],[7,65,1.0],[7,110,1.0],[8,17,1.0],[8,18,1.0],[8,20,1.0],[8,21,1.0],[8,22,1.0],[8,25,1.0],[8,28,1.0],[8,72,1.0],[9,14,1.0],[9,15,1.0],[9,19,1.0],[9,20,1.0],[9,21,1.0],[9,23,1.0],[9,24,1.0],[9,27,1.0],[9,111,1.0],[10,16,1.0],[10,18,1.0],[10,20,1.0],[10,21,1.0],[10,22,1.0],[10,23,1.0],[10,25,1.0],[10,26,1.0],[10,100,1.0],[10,107,1.0],[11,20,1.0],[11,22,1.0],[11,24,1.0],[11,29,1.0],[11,30,1.0],[12,15,1.0],[12,17,1.0],[12,19,1.0],[12,20,1.0],[12,21,1.0],[12,22,1.0],[12,24,1.0],[12,27,1.0],[12,103,1.0],[12,116,1.0],[13,15,1.0],[13,16,1.0],[13,17,1.0],[13,23,1.0],[13,24,1.0],[13,27,1.0],[13,80,1.0],[13,85,1.0],[14,29,1.0],[14,46,1.0],[14,50,1.0],[14,90,1.0],[14,106,1.0],[15,17,1.0],[15,20,1.0],[15,21,1.0],[15,22,1.0],[15,25,1.0],[15,26,1.0],[15,28,1.0],[15,29,1.0],[15,30,1.0],[15,44,1.0],[15,104,1.0],[16,17,1.0],[16,24,1.0],[16,25,1.0],[16,29,1.0],[16,54,1.0],[16,94,1.0],[16,116,1.0],[17,19,1.0],[17,20,1.0],[17,23,1.0],[17,25,1.0],[17,28,1.0],[17,29,1.0],[17,57,1.0],[17,81,1.0],[17,108,1.0],[18,20,1.0],[18,21,1.0],[18,23,1.0],[18,26,1.0],[18,27,1.0],[18,73,1.0],[19,21,1.0],[19,22,1.0],[19,28,1.0],[19,29,1.0],[19,85,1.0],[19,119,1.0],[20,41,1.0],[21,29,1.0],[21,36,1.0],[21,105,1.0],[22,28,1.0],[22,29,1.0],[22,108,1.0],[23,26,1.0],[23,28,1.0],[23,29,1.0],[23,45,1.0],[23,58,1.0],[23,80,1.0],[23,82,1.0],[23,88,1.0],[24,27,1.0],[24,29,1.0],[24,38,1.0],[24,117,1.0],[25,29,1.0],[25,43,1.0],[25,92,1.0],[26,28,1.0],[26,29,1.0],[26,73,1.0],[26,115,1.0],[27,28,1.0],[27,68,1.0],[27,106,1.0],[28,29,1.0],[28,90,1.0],[28,91,1.0],[29,94,1.0],[29,101,1.0],[30,32,1.0],[30,35,1.0],[30,37,1.0],[30,42,1.0],[30,46,1.0],[30,49,1.0],[30,51,1.0],[30,52,1.0],[30,53,1.0],[30,57,1.0],[30,58,1.0],[30,59,1.0],[30,65,1.0],[30,67,1.0],[30,80,1.0],[30,81,1.0],[31,33,1.0],[31,38,1.0],[31,43,1.0],[31,44,1.0],[31,47,1.0],[31,49,1.0],[31,50,1.0],[31,51,1.0],[31,53,1.0],[31,55,1.0],[31,56,1.0],[31,57,1.0],[31,102,1.0],[32,33,1.0],[32,34,1.0],[32,36,1.0],[32,38,1.0],[32,39,1.0],[32,47,1.0],[32,48,1.0],[32,50,1.0],[32,58,1.0],[32,95,1.0],[32,104,1.0],[32,114,1.0],[33,34,1.0],[33,36,1.0],[33,38,1.0],[33,39,1.0],[33,40,1.0],[33,44,1.0],[33,47,1.0],[33,48,1.0],[33,52,1.0],[33,53,1.0],[33,55,1.0],[33,89,1.0],[33,106,1.0],[33,118,1.0],[34,36,1.0],[34,39,1.0],[34,40,1.0],[34,42,1.0],[34,45,1.0],[34,46,1.0],[34,47,1.0],[34,51,1.0],[34,54,1.0],[34,73,1.0],[34,88,1.0],[34,95,1.0],[35,36,1.0],[35,38,1.0],[35,39,1.0],[35,46,1.0],[35,48,1.0],[35,49,1.0],[35,50,1.0],[35,51,1.0],[35,54,1.0],[35,56,1.0],[35,58,1.0],[35,59,1.0],[36,37,1.0],[36,38,1.0],[36,40,1.0],[36,42,1.0],[36,43,1.0],[36,46,1.0],[36,47,1.0],[36,48,1.0],[36,49,1.0],[36,54,1.0],[36,56,1.0],[36,84,1.0],[37,38,1.0],[37,40,1.0],[37,46,1.0],[37,48,1.0],[37,58,1.0],[37,59,1.0],[37,112,1.0],[38,40,1.0],[38,41,1.0],[38,43,1.0],[38,44,1.0],[38,46,1.0],[38,51,1.0],[38,52,1.0],[38,54,1.0],[38,56,1.0],[38,92,1.0],[39,42,1.0],[39,43,1.0],[39,44,1.0],[39,49,1.0],[39,53,1.0],[39,55,1.0],[39,57,1.0],[39,58,1.0],[39,59,1.0],[39,78,1.0],[40,42,1.0],[40,43,1.0],[40,44,1.0],[40,45,1.0],[40,49,1.0],[40,53,1.0],[40,54,1.0],[40,58,1.0],[40,62,1.0],[41,44,1.0],[41,54,1.0],[41,55,1.0],[41,56,1.0],[41,57,1.0],[41,58,1.0],[41,72,1.0],[41,102,1.0],[42,43,1.0],[42,44,1.0],[42,45,1.0],[42,47,1.0],[42,49,1.0],[42,51,1.0],[42,55,1.0],[43,46,1.0],[43,47,1.0],[43,50,1.0],[43,51,1.0],[43,53,1.0],[43,54,1.0],[43,55,1.0],[43,56,1.0],[43,58,1.0],[43,102,1.0],[44,47,1.0],[44,49,1.0],[44,50,1.0],[44,51,1.0],[44,52,1.0],[44,55,1.0],[44,57,1.0],[44,58,1.0],[44,108,1.0],[44,113,1.0],[45,48,1.0],[45,49,1.0],[45,50,1.0],[45,51,1.0],[45,53,1.0],[45,54,1.0],[45,55,1.0],[45,56,1.0],[45,58,1.0],[45,59,1.0],[45,66,1.0],[45,103,1.0],[46,49,1.0],[46,51,1.0],[46,52,1.0],[46,53,1.0],[46,55,1.0],[46,59,1.0],[46,68,1.0],[46,78,1.0],[46,82,1.0],[46,116,1.0],[46,117,1.0],[47,48,1.0],[47,49,1.0],[47,50,1.0],[47,51,1.0],[47,58,1.0],[47,59,1.0],[48,53,1.0],[48,54,1.0],[48,55,1.0],[48,56,1.0],[48,57,1.0],[48,58,1.0],[48,59,1.0],[49,53,1.0],[49,56,1.0],[49,59,1.0],[49,84,1.0],[50,57,1.0],[51,53,1.0],[51,55,1.0],[51,57,1.0],[51,59,1.0],[51,98,1.0],[51,102,1.0],[51,103,1.0],[52,53,1.0],[52,54,1.0],[52,57,1.0],[53,55,1.0],[53,56,1.0],[53,58,1.0],[54,73,1.0],[55,57,1.0],[55,59,1.0],[55,110,1.0],[56,59,1.0],[57,75,1.0],[58,59,1.0],[59,78,1.0],[59,89,1.0],[60,64,1.0],[60,65,1.0],[60,69,1.0],[60,81,1.0],[60,83,1.0],[60,85,1.0],[60,88,1.0],[60,107,1.0],[61,62,1.0],[61,63,1.0],[61,66,1.0],[61,67,1.0],[61,71,1.0],[61,75,1.0],[61,79,1.0],[61,81,1.0],[61,83,1.0],[61,84,1.0],[61,85,1.0],[61,87,1.0],[61,93,1.0],[62,63,1.0],[62,65,1.0],[62,66,1.0],[62,69,1.0],[62,71,1.0],[62,78,1.0],[62,79,1.0],[62,84,1.0],[62,85,1.0],[62,86,1.0],[62,87,1.0],[63,64,1.0],[63,68,1.0],[63,70,1.0],[63,75,1.0],[63,77,1.0],[63,81,1.0],[63,86,1.0],[63,88,1.0],[64,69,1.0],[64,70,1.0],[64,77,1.0],[64,79,1.0],[64,81,1.0],[64,83,1.0],[64,87,1.0],[64,93,1.0],[64,94,1.0],[64,118,1.0],[65,66,1.0],[65,69,1.0],[65,70,1.0],[65,71,1.0],[65,72,1.0],[65,74,1.0],[65,77,1.0],[65,80,1.0],[65,84,1.0],[65,85,1.0],[65,87,1.0],[65,88,1.0],[65,89,1.0],[66,72,1.0],[66,74,1.0],[66,75,1.0],[66,84,1.0],[66,90,1.0],[67,72,1.0],[67,73,1.0],[67,78,1.0],[67,79,1.0],[67,83,1.0],[67,87,1.0],[67,111,1.0],[68,73,1.0],[68,75,1.0],[68,78,1.0],[68,81,1.0],[68,82,1.0],[68,86,1.0],[68,89,1.0],[69,73,1.0],[69,78,1.0],[69,80,1.0],[69,82,1.0],[69,83,1.0],[69,85,1.0],[69,86,1.0],[69,87,1.0],[70,71,1.0],[70,72,1.0],[70,76,1.0],[70,77,1.0],[70,79,1.0],[70,83,1.0],[70,84,1.0],[70,86,1.0],[71,78,1.0],[71,82,1.0],[71,84,1.0],[71,85,1.0],[71,86,1.0],[71,88,1.0],[72,73,1.0],[72,74,1.0],[72,75,1.0],[72,76,1.0],[72,80,1.0],[72,82,1.0],[72,83,1.0],[72,87,1.0],[72,88,1.0],[72,89,1.0],[72,107,1.0],[73,77,1.0],[73,79,1.0],[73,80,1.0],[73,82,1.0],[73,83,1.0],[73,84,1.0],[73,85,1.0],[73,87,1.0],[73,89,1.0],[74,75,1.0],[74,77,1.0],[74,80,1.0],[74,81,1.0],[74,83,1.0],[74,85,1.0],[75,76,1.0],[75,77,1.0],[75,78,1.0],[75,79,1.0],[75,80,1.0],[75,82,1.0],[75,83,1.0],[75,85,1.0],[75,87,1.0],[75,119,1.0],[76,78,1.0],[76,81,1.0],[76,82,1.0],[76,83,1.0],[76,85,1.0],[76,102,1.0],[77,79,1.0],[77,82,1.0],[77,85,1.0],[77,87,1.0],[77,89,1.0],[77,108,1.0],[77,113,1.0],[78,89,1.0],[79,85,1.0],[80,81,1.0],[80,82,1.0],[80,83,1.0],[80,84,1.0],[80,87,1.0],[81,84,1.0],[81,86,1.0],[81,87,1.0],[81,88,1.0],[82,84,1.0],[82,87,1.0],[82,89,1.0],[82,115,1.0],[83,84,1.0],[83,85,1.0],[83,86,1.0],[83,89,1.0],[83,119,1.0],[84,86,1.0],[84,96,1.0],[85,86,1.0],[85,89,1.0],[86,88,1.0],[86,89,1.0],[89,106,1.0],[89,108,1.0],[90,95,1.0],[90,100,1.0],[90,102,1.0],[90,103,1.0],[90,105,1.0],[90,109,1.0],[90,115,1.0],[90,116,1.0],[90,117,1.0],[90,119,1.0],[91,93,1.0],[91,95,1.0],[91,97,1.0],[91,98,1.0],[91,99,1.0],[91,101,1.0],[91,102,1.0],[91,103,1.0],[91,107,1.0],[91,108,1.0],[91,109,1.0],[91,113,1.0],[91,114,1.0],[91,116,1.0],[92,94,1.0],[92,95,1.0],[92,101,1.0],[92,105,1.0],[92,107,1.0],[92,108,1.0],[92,110,1.0],[92,114,1.0],[92,116,1.0],[92,119,1.0],[93,95,1.0],[93,96,1.0],[93,97,1.0],[93,98,1.0],[93,100,1.0],[93,101,1.0],[93,103,1.0],[93,105,1.0],[93,107,1.0],[93,109,1.0],[93,114,1.0],[93,116,1.0],[93,118,1.0],[93,119,1.0],[94,95,1.0],[94,97,1.0],[94,98,1.0],[94,101,1.0],[94,105,1.0],[94,107,1.0],[94,114,1.0],[95,96,1.0],[95,101,1.0],[95,104,1.0],[95,106,1.0],[95,108,1.0],[95,111,1.0],[95,116,1.0],[95,118,1.0],[96,97,1.0],[96,99,1.0],[96,100,1.0],[96,101,1.0],[96,103,1.0],[96,106,1.0],[96,107,1.0],[96,108,1.0],[96,109,1.0],[97,98,1.0],[97,100,1.0],[97,103,1.0],[97,104,1.0],[97,108,1.0],[97,109,1.0],[97,119,1.0],[98,102,1.0],[98,115,1.0],[98,116,1.0],[98,117,1.0],[98,119,1.0],[99,100,1.0],[99,103,1.0],[99,106,1.0],[99,108,1.0],[99,112,1.0],[99,116,1.0],[99,118,1.0],[100,102,1.0],[100,104,1.0],[100,106,1.0],[100,107,1.0],[100,108,1.0],[100,111,1.0],[100,113,1.0],[100,114,1.0],[100,116,1.0],[100,117,1.0],[100,119,1.0],[101,102,1.0],[101,108,1.0],[101,110,1.0],[101,112,1.0],[101,113,1.0],[101,115,1.0],[102,106,1.0],[102,110,1.0],[102,111,1.0],[102,112,1.0],[102,116,1.0],[102,117,1.0],[102,118,1.0],[103,104,1.0],[103,107,1.0],[103,108,1.0],[103,109,1.0],[103,110,1.0],[103,115,1.0],[103,118,1.0],[104,110,1.0],[104,112,1.0],[104,115,1.0],[104,116,1.0],[104,117,1.0],[104,118,1.0],[104,119,1.0],[105,107,1.0],[105,109,1.0],[105,112,1.0],[105,114,1.0],[105,116,1.0],[105,119,1.0],[106,107,1.0],[106,110,1.0],[106,111,1.0],[106,115,1.0],[106,116,1.0],[107,109,1.0],[107,113,1.0],[107,114,1.0],[107,115,1.0],[107,116,1.0],[107,117,1.0],[107,118,1.0],[108,109,1.0],[108,111,1.0],[108,114,1.0],[108,119,1.0],[109,111,1.0],[109,112,1.0],[109,116,1.0],[109,117,1.0],[109,118,1.0],[109,119,1.0],[110,113,1.0],[110,119,1.0],[111,112,1.0],[111,116,1.0],[111,119,1.0],[112,117,1.0],[112,118,1.0],[113,116,1.0],[113,118,1.0],[114,118,1.0],[115,116,1.0],[116,117,1.0],[117,118,1.0],[117,119,1.0]]},"sbm_easy/weighted":{"n_nodes":120,"edges":[[0,1,1.3834],[0,4,0.778],[0,9,2.5283],[0,10,0.5035],[0,14,2.1256],[0,17,1.5299],[0,20,0.453],[0,21,2.026],[0,24,0.3812],[0,25,1.7678],[0,27,0.4945],[0,28,0.5675],[0,92,1.7358],[1,3,3.144],[1,6,0.6833],[1,7,1.0313],[1,8,2.446],[1,10,3.567],[1,11,2.2699],[1,13,1.6384],[1,14,3.6669],[1,15,0.413],[1,17,3.2546],[1,18,1.2636],[1,21,0.7549],[1,23,0.6623],[1,24,1.3297],[1,26,3.1064],[1,27,0.8825],[1,34,2.2856],[1,62,2.4862],[1,86,1.5534],[2,3,2.1671],[2,4,0.4698],[2,8,0.4586],[2,11,0.9709],[2,13,2.6314],[2,14,1.7466],[2,15,1.3495],[2,20,2.2995],[2,23,1.8361],[2,25,1.2992],[2,26,3.0303],[2,28,2.6965],[2,29,1.1043],[2,47,2.2605],[3,7,2.0882],[3,9,3.313],[3,12,2.8031],[3,13,1.2578],[3,20,3.6806],[3,21,0.6632],[3,22,1.7134],[3,23,2.9],[3,27,0.7819],[3,28,1.9614],[3,29,0.3872],[3,37,2.5888],[3,114,2.926],[4,6,2.2556],[4,7,3.3142],[4,9,1.3481],[4,11,2.6835],[4,21,2.3303],[4,22,2.2796],[4,23,1.8467],[4,25,3.1899],[4,38,3.5564],[4,49,1.9093],[4,66,2.5745],[4,104,0.4623],[5,7,2.7052],[5,11,2.515],[5,13,3.7258],[5,15,3.1267],[5,18,1.2461],[5,19,1.6003],[5,23,2.5903],[5,26,0.329],[5,94,1.8659],[6,7,0.8382],[6,8,0.6598],[6,11,0.4563],[6,15,2.9388],[6,16,0.7027],[6,18,1.1167],[6,26,1.6183],[6,28,3.3],[7,10,0.532],[7,11,1.8222],[7,15,2.173],[7,18,3.3418],[7,19,3.1175],[7,21,3.2739],[7,22,1.2245],[7,23,1.7035],[7,26,1.5057],[7,27,3.3447],[7,65,3.6021],[7,110,0.7782],[8,17,0.8668],[8,18,1.0618],[8,20,1.0667],[8,21,1.9474],[8,22,2.3119],[8,25,1.1696],[8,28,0.2643],[8,72,1.7163],[9,14,1.5424],[9,15,2.2322],[9,19,3.5858],[9,20,2.6667],[9,21,2.0542],[9,23,2.4116],[9,24,2.6167],[9,27,0.439],[9,111,3.3984],[10,16,2.9799],[10,18,3.3108],[10,20,3.0426],[10,21,1.6233],[10,22,1.6464],[10,23,0.6124],[10,25,2.47],[10,26,0.4679],[10,100,0.4857],[10,107,0.9807],[11,20,0.8181],[11,22,1.4402],[11,24,0.434],[11,29,0.2508],[11,30,0.7794],[12,15,0.6051],[12,17,1.5226],[12,19,0.3393],[12,20,3.3102],[12,21,2.3992],[12,22,0.7699],[12,24,1.1329],[12,27,1.4659],[12,103,1.5246],[12,116,0.6799],[13,15,3.2213],[13,16,3.7259],[13,17,1.881],[13,23,1.9434],[13,24,0.5506],[13,27,0.6077],[13,80,1.4492],[13,85,1.1766],[14,29,3.151],[14,46,0.815],[14,50,0.3308],[14,90,3.5784],[14,106,2.0989],[15,17,0.7631],[15,20,2.1511],[15,21,0.3446],[15,22,2.0984],[15,25,3.6748],[15,26,3.2716],[15,28,2.6867],[15,29,1.1639],[15,30,1.5334],[15,44,0.8346],[15,104,2.9518],[16,17,2.1141],[16,24,2.9767],[16,25,1.4038],[16,29,1.0306],[16,54,3.0903],[16,94,3.6972],[16,116,3.2342],[17,19,3.0713],[17,20,3.1142],[17,23,2.8396],[17,25,1.0436],[17,28,2.0617],[17,29,1.4945],[17,57,0.3514],[17,81,0.3478],[17,108,1.228],[18,20,1.1571],[18,21,2.6738],[18,23,3.5978],[18,26,1.8153],[18,27,3.5296],[18,73,3.7081],[19,21,3.5925],[19,22,1.5262],[19,28,1.0216],[19,29,1.044],[19,85,0.9385],[19,119,0.9653],[20,41,2.4342],[21,29,3.4011],[21,36,3.1915],[21,105,1.9282],[22,28,2.5354],[22,29,3.0488],[22,108,0.5467],[23,26,2.562],[23,28,3.4342],[23,29,2.9881],[23,45,2.8755],[23,58,1.9231],[23,80,0.8748],[23,82,3.012],[23,88,1.4138],[24,27,3.0529],[24,29,3.6508],[24,38,1.6354],[24,117,1.6549],[25,29,3.5638],[25,43,2.7868],[25,92,0.845],[26,28,0.6946],[26,29,0.779],[26,73,3.417],[26,115,3.0728],[27,28,0.7616],[27,68,3.1428],[27,106,3.6811],[28,29,2.5504],[28,90,1.4764],[28,91,2.1703],[29,94,0.7084],[29,101,0.2999],[30,32,3.6481],[30,35,2.5239],[30,37,2.093],[30,42,3.5177],[30,46,1.7683],[30,49,3.3011],[30,51,3.1415],[30,52,0.9886],[30,53,1.1314],[30,57,1.2754],[30,58,1.0919],[30,59,2.3025],[30,65,1.1578],[30,67,1.7165],[30,80,0.7088],[30,81,3.4351],[31,33,1.4882],[31,38,1.8536],[31,43,2.2917],[31,44,3.415],[31,47,1.7222],[31,49,3.462],[31,50,2.0058],[31,51,2.1114],[31,53,2.0823],[31,55,0.3155],[31,56,1.7904],[31,57,0.8909],[31,102,0.2638],[32,33,3.0471],[32,34,0.8532],[32,36,1.9072],[32,38,2.7882],[32,39,2.1977],[32,47,1.3909],[32,48,2.0642],[32,50,2.194],[32,58,2.995],[32,95,0.6214],[32,104,2.211],[32,114,1.1197],[33,34,1.2192],[33,36,2.9529],[33,38,2.027],[33,39,2.2161],[33,40,2.91],[33,44,3.4437],[33,47,1.8014],[33,48,2.3938],[33,52,2.0194],[33,53,2.0426],[33,55,2.6746],[33,89,1.8332],[33,106,2.1165],[33,118,1.9231],[34,36,3.5453],[34,39,2.6973],[34,40,3.3179],[34,42,3.5476],[34,45,1.1586],[34,46,2.2083],[34,47,3.5514],[34,51,3.19],[34,54,0.73],[34,73,0.6757],[34,88,1.7974],[34,95,0.5039],[35,36,1.0922],[35,38,0.5059],[35,39,2.5932],[35,46,2.9938],[35,48,3.3896],[35,49,0.7906],[35,50,2.7564],[35,51,2.5609],[35,54,0.7504],[35,56,3.3399],[35,58,3.6364],[35,59,1.0186],[36,37,3.5838],[36,38,1.6439],[36,40,1.9554],[36,42,3.7146],[36,43,3.1636],[36,46,0.8151],[36,47,1.7603],[36,48,2.0546],[36,49,1.4369],[36,54,0.9351],[36,56,1.3648],[36,84,2.7775],[37,38,0.3182],[37,40,2.1892],[37,46,1.7916],[37,48,0.3133],[37,58,1.4102],[37,59,2.4337],[37,112,2.0429],[38,40,0.475],[38,41,3.6978],[38,43,3.0093],[38,44,3.6509],[38,46,0.6167],[38,51,1.1795],[38,52,0.3886],[38,54,2.9765],[38,56,1.1966],[38,92,0.7034],[39,42,1.7279],[39,43,3.4399],[39,44,3.1164],[39,49,1.1551],[39,53,0.7728],[39,55,3.4671],[39,57,2.2471],[39,58,2.7015],[39,59,0.5631],[39,78,0.4513],[40,42,2.6587],[40,43,1.7386],[40,44,0.5034],[40,45,3.5342],[40,49,2.4705],[40,53,3.0557],[40,54,0.5431],[40,58,3.2468],[40,62,0.4832],[41,44,3.2697],[41,54,1.8382],[41,55,1.437],[41,56,2.1857],[41,57,3.4933],[41,58,1.1875],[41,72,0.7023],[41,102,2.0942],[42,43,1.0845],[42,44,0.6331],[42,45,0.8151],[42,47,0.4263],[42,49,0.9562],[42,51,1.342],[42,55,1.3175],[43,46,2.9082],[43,47,1.2649],[43,50,2.0003],[43,51,0.8726],[43,53,1.4645],[43,54,0.3136],[43,55,1.1266],[43,56,0.3037],[43,58,2.8158],[43,102,2.1787],[44,47,0.9131],[44,49,1.9117],[44,50,3.5212],[44,51,0.622],[44,52,3.1162],[44,55,1.7626],[44,57,1.9825],[44,58,3.1711],[44,108,1.6258],[44,113,2.0234],[45,48,2.6571],[45,49,3.6885],[45,50,1.4495],[45,51,3.163],[45,53,2.7235],[45,54,2.4759],[45,55,1.6664],[45,56,1.4664],[45,58,0.4404],[45,59,0.7044],[45,66,0.4975],[45,103,2.8431],[46,49,1.1446],[46,51,0.8214],[46,52,0.5457],[46,53,3.1944],[46,55,3.2969],[46,59,2.5969],[46,68,1.2368],[46,78,1.0977],[46,82,1.2757],[46,116,1.8581],[46,117,0.8014],[47,48,1.8104],[47,49,1.1714],[47,50,3.6163],[47,51,3.6542],[47,58,2.1648],[47,59,1.1056],[48,53,3.6298],[48,54,1.3334],[48,55,1.498],[48,56,0.2537],[48,57,1.5857],[48,58,1.9113],[48,59,2.0097],[49,53,0.9534],[49,56,2.0166],[49,59,0.2673],[49,84,1.1746],[50,57,0.5641],[51,53,1.6483],[51,55,0.3958],[51,57,0.3287],[51,59,1.3149],[51,98,1.0648],[51,102,2.2995],[51,103,2.1022],[52,53,2.8769],[52,54,2.5514],[52,57,2.756],[53,55,3.3268],[53,56,1.6133],[53,58,1.3915],[54,73,3.6966],[55,57,0.7731],[55,59,2.7845],[55,110,2.5013],[56,59,0.4033],[57,75,3.1735],[58,59,3.3718],[59,78,2.4457],[59,89,2.8185],[60,64,3.0928],[60,65,0.7376],[60,69,2.0832],[60,81,2.0153],[60,83,3.1723],[60,85,3.0664],[60,88,3.1424],[60,107,2.2942],[61,62,3.3749],[61,63,2.6401],[61,66,2.6766],[61,67,1.0548],[61,71,0.3591],[61,75,0.7158],[61,79,1.5125],[61,81,0.6172],[61,83,3.1754],[61,84,2.2048],[61,85,2.4472],[61,87,2.4418],[61,93,2.6323],[62,63,1.9625],[62,65,0.2616],[62,66,3.0419],[62,69,2.8689],[62,71,2.0104],[62,78,2.1232],[62,79,2.5575],[62,84,0.4812],[62,85,2.8288],[62,86,1.1327],[62,87,0.5106],[63,64,1.1795],[63,68,2.8027],[63,70,0.9683],[63,75,2.8394],[63,77,3.6651],[63,81,1.9788],[63,86,1.589],[63,88,1.9265],[64,69,2.6429],[64,70,2.9344],[64,77,2.4094],[64,79,2.4997],[64,81,0.5212],[64,83,0.766],[64,87,1.1388],[64,93,2.8513],[64,94,1.3155],[64,118,2.2372],[65,66,0.2936],[65,69,0.4623],[65,70,1.1907],[65,71,2.602],[65,72,2.6726],[65,74,2.615],[65,77,1.268],[65,80,2.0579],[65,84,1.8763],[65,85,1.8822],[65,87,0.6648],[65,88,3.3778],[65,89,0.9474],[66,72,3.6734],[66,74,3.5269],[66,75,0.3113],[66,84,1.8564],[66,90,3.1196],[67,72,3.6384],[67,73,1.8231],[67,78,1.1903],[67,79,0.9844],[67,83,3.5596],[67,87,0.9875],[67,111,2.2852],[68,73,0.7461],[68,75,2.0842],[68,78,3.5846],[68,81,0.7141],[68,82,3.1208],[68,86,2.0306],[68,89,3.354],[69,73,2.7117],[69,78,1.0598],[69,80,3.392],[69,82,1.9515],[69,83,0.3369],[69,85,0.2626],[69,86,1.9709],[69,87,1.8277],[70,71,1.3068],[70,72,0.7425],[70,76,1.4539],[70,77,1.3563],[70,79,3.1908],[70,83,0.2561],[70,84,2.8776],[70,86,3.1869],[71,78,0.6701],[71,82,3.4924],[71,84,2.7456],[71,85,3.4055],[71,86,1.2644],[71,88,1.5528],[72,73,1.6251],[72,74,3.7458],[72,75,2.3121],[72,76,1.5125],[72,80,1.7482],[72,82,1.213],[72,83,0.4189],[72,87,0.606],[72,88,3.1714],[72,89,1.2497],[72,107,3.5246],[73,77,1.1226],[73,79,1.18],[73,80,2.0384],[73,82,0.9145],[73,83,1.5567],[73,84,3.5966],[73,85,3.3449],[73,87,3.0919],[73,89,2.4581],[74,75,3.447],[74,77,3.5424],[74,80,2.1723],[74,81,2.7685],[74,83,0.4232],[74,85,2.8132],[75,76,1.828],[75,77,2.8843],[75,78,2.5057],[75,79,1.2517],[75,80,0.4214],[75,82,3.4937],[75,83,0.6956],[75,85,1.9026],[75,87,1.4528],[75,119,1.2922],[76,78,2.8366],[76,81,3.667],[76,82,1.1606],[76,83,2.546],[76,85,1.3029],[76,102,2.2006],[77,79,1.6303],[77,82,0.8357],[77,85,0.8158],[77,87,0.9776],[77,89,3.4209],[77,108,1.9898],[77,113,1.0201],[78,89,3.4219],[79,85,3.7377],[80,81,1.8249],[80,82,0.7386],[80,83,0.9234],[80,84,0.5675],[80,87,1.4468],[81,84,0.5688],[81,86,1.0869],[81,87,1.1543],[81,88,2.2437],[82,84,3.3554],[82,87,2.8738],[82,89,1.6947],[82,115,1.6986],[83,84,2.0846],[83,85,1.569],[83,86,1.4337],[83,89,0.4672],[83,119,1.2213],[84,86,3.6369],[84,96,0.6906],[85,86,2.0119],[85,89,2.4537],[86,88,3.27],[86,89,1.0059],[89,106,1.1986],[89,108,1.1196],[90,95,1.6491],[90,100,1.8105],[90,102,3.5888],[90,103,3.2204],[90,105,3.3051],[90,109,0.3263],[90,115,0.3629],[90,116,2.7333],[90,117,3.3849],[90,119,1.9064],[91,93,2.3051],[91,95,0.2506],[91,97,1.6203],[91,98,3.4939],[91,99,3.1396],[91,101,3.2441],[91,102,3.6528],[91,103,1.1196],[91,107,0.6317],[91,108,0.7903],[91,109,2.0783],[91,113,2.6373],[91,114,3.5452],[91,116,2.7761],[92,94,2.5157],[92,95,2.9268],[92,101,1.8506],[92,105,2.1803],[92,107,0.3884],[92,108,2.988],[92,110,1.064],[92,114,3.4697],[92,116,2.5093],[92,119,1.3132],[93,95,0.6979],[93,96,1.1313],[93,97,2.477],[93,98,2.695],[93,100,0.6425],[93,101,0.4962],[93,103,2.0855],[93,105,2.2901],[93,107,1.6083],[93,109,1.0325],[93,114,2.3537],[93,116,0.2866],[93,118,1.3053],[93,119,1.8624],[94,95,3.6063],[94,97,2.506],[94,98,3.3432],[94,101,1.9136],[94,105,1.0717],[94,107,1.1147],[94,114,3.6121],[95,96,2.7163],[95,101,1.3259],[95,104,0.3263],[95,106,1.9941],[95,108,2.6106],[95,111,1.7201],[95,116,1.1504],[95,118,2.5857],[96,97,3.4881],[96,99,1.0438],[96,100,0.3693],[96,101,1.4332],[96,103,1.7219],[96,106,2.639],[96,107,0.9433],[96,108,3.0397],[96,109,2.837],[97,98,2.0171],[97,100,0.9683],[97,103,3.6445],[97,104,1.341],[97,108,3.12],[97,109,1.0578],[97,119,1.025],[98,102,2.9116],[98,115,1.2823],[98,116,3.5817],[98,117,1.9852],[98,119,0.9056],[99,100,1.0316],[99,103,1.7096],[99,106,2.5785],[99,108,3.5707],[99,112,0.7623],[99,116,1.6271],[99,118,0.9953],[100,102,3.6594],[100,104,0.7467],[100,106,0.4314],[100,107,0.4605],[100,108,1.6266],[100,111,3.3936],[100,113,3.3425],[100,114,2.8145],[100,116,3.7414],[100,117,3.5106],[100,119,1.4023],[101,102,0.8993],[101,108,3.5256],[101,110,2.8621],[101,112,0.3616],[101,113,2.5755],[101,115,1.5752],[102,106,1.5586],[102,110,1.4109],[102,111,0.8424],[102,112,0.26],[102,116,1.2293],[102,117,1.4801],[102,118,3.5943],[103,104,0.683],[103,107,3.6249],[103,108,0.9759],[103,109,1.4982],[103,110,3.1255],[103,115,3.127],[103,118,1.7636],[104,110,0.4224],[104,112,1.9071],[104,115,1.5545],[104,116,3.4683],[104,117,0.9256],[104,118,1.5249],[104,119,3.3895],[105,107,0.356],[105,109,1.6878],[105,112,3.0914],[105,114,2.9333],[105,116,0.3923],[105,119,0.372],[106,107,0.469],[106,110,3.4703],[106,111,1.1496],[106,115,2.8655],[106,116,3.3949],[107,109,1.4367],[107,113,1.2031],[107,114,3.6019],[107,115,2.4094],[107,116,1.1676],[107,117,2.7582],[107,118,1.3577],[108,109,1.2147],[108,111,0.2632],[108,114,2.8948],[108,119,3.4576],[109,111,2.4689],[109,112,3.5514],[109,116,0.3349],[109,117,1.0685],[109,118,1.9132],[109,119,3.5987],[110,113,3.5887],[110,119,1.6028],[111,112,1.1287],[111,116,1.7548],[111,119,1.9772],[112,117,3.4983],[112,118,0.8903],[113,116,3.059],[113,118,2.8347],[114,118,3.1296],[115,116,2.9548],[116,117,2.3754],[117,118,1.3973],[117,119,1.3684]]},"sbm_medium/unit":{"n_nodes":200,"edges":[[0,3,1.0],[0,4,1.0],[0,12,1.0],[0,21,1.0],[0,22,1.0],[0,29,1.0],[0,30,1.0],[0,32,1.0],[0,36,1.0],[0,101,1.0],[0,107,1.0],[0,109,1.0],[0,119,1.0],[0,125,1.0],[0,149,1.0],[0,153,1.0],[0,171,1.0],[0,172,1.0],[1,7,1.0],[1,9,1.0],[1,13,1.0],[1,17,1.0],[1,19,1.0],[1,23,1.0],[1,25,1.0],[1,31,1.0],[1,32,1.0],[1,33,1.0],[1,38,1.0],[1,96,1.0],[1,119,1.0],[1,125,1.0],[1,148,1.0],[1,158,1.0],[1,173,1.0],[2,5,1.0],[2,7,1.0],[2,10,1.0],[2,15,1.0],[2,17,1.0],[2,26,1.0],[2,27,1.0],[2,28,1.0],[2,33,1.0],[2,63,1.0],[2,129,1.0],[2,188,1.0],[3,7,1.0],[3,8,1.0],[3,9,1.0],[3,12,1.0],[3,18,1.0],[3,29,1.0],[3,33,1.0],[3,39,1.0],[3,66,1.0],[3,77,1.0],[3,131,1.0],[3,163,1.0],[3,166,1.0],[3,196,1.0],[4,6,1.0],[4,11,1.0],[4,23,1.0],[4,25,1.0],[4,27,1.0],[4,37,1.0],[4,42,1.0],[4,112,1.0],[4,113,1.0],[4,162,1.0],[4,169,1.0],[4,189,1.0],[4,197,1.0],[5,6,1.0],[5,7,1.0],[5,13,1.0],[5,15,1.0],[5,16,1.0],[5,17,1.0],[5,27,1.0],[5,29,1.0],[5,30,1.0],[5,35,1.0],[5,114,1.0],[5,119,1.0],[5,144,1.0],[5,188,1.0],[6,7,1.0],[6,8,1.0],[6,14,1.0],[6,17,1.0],[6,20,1.0],[6,24,1.0],[6,30,1.0],[6,41,1.0],[6,47,1.0],[6,48,1.0],[6,50,1.0],[6,64,1.0],[6,73,1.0],[6,130,1.0],[6,140,1.0],[6,165,1.0],[6,166,1.0],[7,18,1.0],[7,22,1.0],[7,30,1.0],[7,57,1.0],[7,68,1.0],[7,121,1.0],[7,134,1.0],[7,174,1.0],[7,181,1.0],[7,184,1.0],[7,198,1.0],[7,199,1.0],[8,9,1.0],[8,16,1.0],[8,17,1.0],[8,19,1.0],[8,27,1.0],[8,29,1.0],[8,37,1.0],[8,75,1.0],[8,88,1.0],[8,105,1.0],[8,112,1.0],[8,126,1.0],[8,158,1.0],[8,168,1.0],[8,180,1.0],[9,16,1.0],[9,18,1.0],[9,19,1.0],[9,20,1.0],[9,24,1.0],[9,27,1.0],[9,29,1.0],[9,32,1.0],[9,33,1.0],[9,34,1.0],[9,36,1.0],[9,37,1.0],[9,38,1.0],[9,39,1.0],[9,44,1.0],[9,56,1.0],[9,85,1.0],[9,103,1.0],[9,107,1.0],[9,144,1.0],[9,172,1.0],[9,174,1.0],[9,177,1.0],[10,23,1.0],[10,39,1.0],[10,88,1.0],[10,116,1.0],[10,161,1.0],[11,13,1.0],[11,16,1.0],[11,25,1.0],[11,35,1.0],[11,38,1.0],[11,47,1.0],[11,50,1.0],[11,55,1.0],[11,58,1.0],[11,87,1.0],[11,95,1.0],[11,101,1.0],[11,109,1.0],[11,146,1.0],[12,14,1.0],[12,17,1.0],[12,20,1.0],[12,21,1.0],[12,27,1.0],[12,30,1.0],[12,32,1.0],[12,37,1.0],[12,39,1.0],[12,77,1.0],[12,84,1.0],[12,96,1.0],[12,98,1.0],[12,135,1.0],[12,159,1.0],[13,16,1.0],[13,19,1.0],[13,24,1.0],[13,34,1.0],[13,35,1.0],[13,39,1.0],[13,74,1.0],[13,83,1.0],[13,97,1.0],[13,146,1.0],[13,153,1.0],[13,159,1.0],[13,172,1.0],[13,183,1.0],[14,15,1.0],[14,16,1.0],[14,17,1.0],[14,22,1.0],[14,27,1.0],[14,28,1.0],[14,35,1.0],[14,60,1.0],[14,68,1.0],[14,96,1.0],[14,101,1.0],[14,131,1.0],[14,134,1.0],[14,142,1.0],[14,154,1.0],[14,193,1.0],[15,18,1.0],[15,20,1.0],[15,27,1.0],[15,29,1.0],[15,30,1.0],[15,32,1.0],[15,37,1.0],[15,41,1.0],[15,47,1.0],[15,68,1.0],[15,130,1.0],[15,185,1.0],[16,21,1.0],[16,29,1.0],[16,33,1.0],[16,65,1.0],[16,66,1.0],[16,98,1.0],[16,111,1.0],[16,144,1.0],[16,155,1.0],[16,179,1.0],[17,19,1.0],[17,20,1.0],[17,21,1.0],[17,22,1.0],[17,27,1.0],[17,33,1.0],[17,36,1.0],[17,50,1.0],[17,92,1.0],[17,94,1.0],[17,97,1.0],[17,99,1.0],[17,105,1.0],[17,173,1.0],[17,181,1.0],[17,190,1.0],[18,24,1.0],[18,25,1.0],[18,34,1.0],[18,37,1.0],[18,45,1.0],[18,67,1.0],[18,78,1.0],[18,81,1.0],[18,142,1.0],[18,147,1.0],[18,151,1.0],[19,25,1.0],[19,26,1.0],[19,27,1.0],[19,29,1.0],[19,31,1.0],[19,37,1.0],[19,141,1.0],[19,148,1.0],[19,154,1.0],[20,23,1.0],[20,25,1.0],[20,26,1.0],[20,27,1.0],[20,29,1.0],[20,33,1.0],[20,39,1.0],[20,40,1.0],[20,50,1.0],[20,60,1.0],[20,101,1.0],[20,131,1.0],[20,182,1.0],[21,25,1.0],[21,29,1.0],[21,34,1.0],[21,36,1.0],[21,70,1.0],[21,74,1.0],[21,112,1.0],[21,117,1.0],[21,119,1.0],[21,127,1.0],[21,129,1.0],[22,27,1.0],[22,29,1.0],[22,34,1.0],[22,39,1.0],[22,99,1.0],[22,114,1.0],[22,125,1.0],[22,126,1.0],[22,153,1.0],[22,183,1.0],[22,187,1.0],[22,189,1.0],[22,191,1.0],[23,25,1.0],[23,30,1.0],[23,31,1.0],[23,58,1.0],[23,70,1.0],[23,102,1.0],[23,114,1.0],[23,134,1.0],[23,144,1.0],[23,172,1.0],[23,190,1.0],[23,193,1.0],[24,27,1.0],[24,30,1.0],[24,33,1.0],[24,101,1.0],[24,109,1.0],[24,117,1.0],[24,120,1.0],[24,146,1.0],[25,30,1.0],[25,58,1.0],[25,121,1.0],[25,125,1.0],[25,182,1.0],[25,195,1.0],[26,32,1.0],[26,37,1.0],[26,69,1.0],[26,88,1.0],[26,119,1.0],[26,135,1.0],[26,151,1.0],[26,167,1.0],[26,185,1.0],[27,31,1.0],[27,35,1.0],[27,40,1.0],[27,47,1.0],[27,71,1.0],[27,86,1.0],[27,102,1.0],[27,121,1.0],[27,128,1.0],[27,151,1.0],[27,164,1.0],[27,175,1.0],[27,191,1.0],[28,29,1.0],[28,30,1.0],[28,31,1.0],[28,32,1.0],[28,40,1.0],[28,82,1.0],[28,99,1.0],[28,123,1.0],[28,153,1.0],[29,32,1.0],[29,38,1.0],[29,48,1.0],[29,153,1.0],[29,166,1.0],[29,172,1.0],[29,180,1.0],[29,199,1.0],[30,35,1.0],[30,37,1.0],[30,47,1.0],[30,76,1.0],[30,81,1.0],[30,125,1.0],[30,127,1.0],[30,148,1.0],[30,150,1.0],[31,43,1.0],[31,57,1.0],[31,81,1.0],[31,83,1.0],[31,94,1.0],[31,168,1.0],[31,177,1.0],[32,34,1.0],[32,38,1.0],[32,47,1.0],[32,68,1.0],[32,87,1.0],[32,98,1.0],[32,145,1.0],[32,168,1.0],[33,51,1.0],[33,75,1.0],[33,89,1.0],[33,104,1.0],[33,109,1.0],[33,111,1.0],[33,112,1.0],[33,127,1.0],[33,132,1.0],[33,156,1.0],[34,35,1.0],[34,37,1.0],[34,38,1.0],[34,45,1.0],[34,64,1.0],[34,68,1.0],[34,89,1.0],[34,108,1.0],[34,133,1.0],[34,152,1.0],[35,36,1.0],[35,37,1.0],[35,42,1.0],[35,48,1.0],[35,66,1.0],[35,130,1.0],[35,185,1.0],[36,50,1.0],[36,132,1.0],[36,149,1.0],[36,160,1.0],[36,165,1.0],[37,57,1.0],[37,95,1.0],[37,111,1.0],[37,118,1.0],[37,126,1.0],[38,46,1.0],[38,117,1.0],[38,150,1.0],[38,158,1.0],[38,161,1.0],[38,168,1.0],[39,55,1.0],[39,61,1.0],[39,69,1.0],[39,91,1.0],[39,192,1.0],[40,47,1.0],[40,51,1.0],[40,54,1.0],[40,58,1.0],[40,59,1.0],[40,61,1.0],[40,66,1.0],[40,68,1.0],[40,69,1.0],[40,71,1.0],[40,72,1.0],[40,75,1.0],[40,79,1.0],[40,89,1.0],[40,106,1.0],[40,133,1.0],[41,48,1.0],[41,50,1.0],[41,51,1.0],[41,54,1.0],[41,61,1.0],[41,64,1.0],[41,65,1.0],[41,66,1.0],[41,72,1.0],[41,73,1.0],[41,74,1.0],[41,77,1.0],[41,78,1.0],[41,79,1.0],[41,109,1.0],[41,117,1.0],[41,129,1.0],[41,143,1.0],[41,171,1.0],[42,47,1.0],[42,50,1.0],[42,54,1.0],[42,58,1.0],[42,59,1.0],[42,69,1.0],[42,70,1.0],[42,77,1.0],[42,86,1.0],[42,96,1.0],[42,121,1.0],[42,148,1.0],[42,153,1.0],[42,174,1.0],[43,46,1.0],[43,47,1.0],[43,63,1.0],[43,64,1.0],[43,67,1.0],[43,76,1.0],[43,77,1.0],[43,91,1.0],[43,97,1.0],[43,117,1.0],[43,156,1.0],[44,52,1.0],[44,54,1.0],[44,55,1.0],[44,59,1.0],[44,61,1.0],[44,64,1.0],[44,69,1.0],[44,72,1.0],[44,73,1.0],[44,78,1.0],[44,119,1.0],[44,128,1.0],[44,133,1.0],[44,145,1.0],[44,189,1.0],[45,46,1.0],[45,48,1.0],[45,53,1.0],[45,55,1.0],[45,66,1.0],[45,70,1.0],[45,74,1.0],[45,75,1.0],[45,79,1.0],[45,99,1.0],[45,102,1.0],[45,107,1.0],[45,112,1.0],[45,198,1.0],[46,49,1.0],[46,50,1.0],[46,64,1.0],[46,67,1.0],[46,77,1.0],[46,79,1.0],[46,94,1.0],[46,95,1.0],[46,101,1.0],[46,106,1.0],[46,124,1.0],[47,52,1.0],[47,54,1.0],[47,57,1.0],[47,60,1.0],[47,71,1.0],[47,72,1.0],[47,75,1.0],[48,49,1.0],[48,68,1.0],[48,71,1.0],[48,72,1.0],[48,76,1.0],[48,88,1.0],[48,92,1.0],[48,100,1.0],[48,114,1.0],[48,135,1.0],[48,140,1.0],[48,171,1.0],[49,52,1.0],[49,56,1.0],[49,58,1.0],[49,60,1.0],[49,70,1.0],[49,74,1.0],[49,115,1.0],[49,119,1.0],[49,125,1.0],[49,161,1.0],[49,165,1.0],[49,180,1.0],[49,187,1.0],[50,52,1.0],[50,58,1.0],[50,72,1.0],[50,74,1.0],[50,85,1.0],[50,112,1.0],[50,138,1.0],[50,177,1.0],[50,178,1.0],[51,57,1.0],[51,62,1.0],[51,63,1.0],[51,66,1.0],[51,69,1.0],[51,76,1.0],[51,77,1.0],[51,88,1.0],[51,95,1.0],[51,98,1.0],[51,109,1.0],[51,111,1.0],[51,116,1.0],[51,121,1.0],[51,122,1.0],[51,123,1.0],[51,151,1.0],[51,196,1.0],[52,61,1.0],[52,68,1.0],[52,71,1.0],[52,72,1.0],[52,74,1.0],[52,76,1.0],[52,77,1.0],[52,124,1.0],[53,58,1.0],[53,59,1.0],[53,64,1.0],[53,73,1.0],[53,75,1.0],[53,77,1.0],[53,94,1.0],[53,140,1.0],[53,148,1.0],[54,56,1.0],[54,57,1.0],[54,58,1.0],[54,63,1.0],[54,68,1.0],[54,70,1.0],[54,101,1.0],[54,132,1.0],[54,133,1.0],[54,135,1.0],[54,149,1.0],[54,152,1.0],[54,160,1.0],[54,176,1.0],[54,197,1.0],[55,58,1.0],[55,68,1.0],[55,71,1.0],[55,76,1.0],[55,77,1.0],[55,110,1.0],[55,140,1.0],[55,172,1.0],[55,191,1.0],[56,57,1.0],[56,59,1.0],[56,60,1.0],[56,67,1.0],[56,70,1.0],[56,74,1.0],[56,77,1.0],[56,79,1.0],[56,94,1.0],[56,98,1.0],[56,118,1.0],[56,146,1.0],[56,158,1.0],[57,63,1.0],[57,66,1.0],[57,71,1.0],[57,72,1.0],[57,73,1.0],[57,74,1.0],[57,75,1.0],[57,96,1.0],[57,102,1.0],[57,132,1.0],[57,145,1.0],[57,163,1.0],[57,171,1.0],[57,197,1.0],[58,63,1.0],[58,72,1.0],[58,74,1.0],[58,75,1.0],[58,90,1.0],[58,102,1.0],[58,107,1.0],[58,131,1.0],[58,137,1.0],[58,158,1.0],[58,173,1.0],[58,177,1.0],[59,60,1.0],[59,61,1.0],[59,68,1.0],[59,69,1.0],[59,74,1.0],[59,151,1.0],[59,169,1.0],[59,175,1.0],[60,64,1.0],[60,71,1.0],[60,75,1.0],[60,102,1.0],[60,105,1.0],[60,132,1.0],[60,143,1.0],[60,161,1.0],[60,184,1.0],[60,198,1.0],[61,62,1.0],[61,63,1.0],[61,64,1.0],[61,66,1.0],[61,68,1.0],[61,71,1.0],[61,74,1.0],[61,114,1.0],[61,157,1.0],[62,64,1.0],[62,67,1.0],[62,74,1.0],[62,75,1.0],[62,77,1.0],[63,64,1.0],[63,66,1.0],[63,72,1.0],[63,73,1.0],[63,77,1.0],[63,93,1.0],[63,116,1.0],[63,139,1.0],[63,154,1.0],[63,163,1.0],[64,66,1.0],[64,69,1.0],[64,74,1.0],[64,75,1.0],[64,76,1.0],[64,78,1.0],[64,83,1.0],[64,141,1.0],[64,174,1.0],[65,69,1.0],[65,72,1.0],[65,75,1.0],[65,77,1.0],[65,89,1.0],[65,173,1.0],[65,198,1.0],[66,67,1.0],[66,68,1.0],[66,70,1.0],[66,71,1.0],[66,72,1.0],[66,73,1.0],[66,77,1.0],[66,80,1.0],[66,81,1.0],[66,86,1.0],[66,122,1.0],[66,159,1.0],[66,195,1.0],[66,196,1.0],[67,69,1.0],[67,72,1.0],[67,77,1.0],[67,78,1.0],[67,79,1.0],[67,80,1.0],[67,101,1.0],[67,104,1.0],[68,73,1.0],[68,75,1.0],[68,171,1.0],[69,79,1.0],[69,156,1.0],[70,71,1.0],[70,73,1.0],[70,78,1.0],[70,95,1.0],[70,96,1.0],[70,110,1.0],[70,112,1.0],[70,166,1.0],[70,183,1.0],[70,199,1.0],[71,72,1.0],[71,75,1.0],[71,136,1.0],[71,161,1.0],[71,168,1.0],[71,176,1.0],[72,77,1.0],[72,78,1.0],[72,79,1.0],[72,123,1.0],[72,127,1.0],[72,143,1.0],[73,77,1.0],[73,78,1.0],[73,85,1.0],[73,147,1.0],[73,169,1.0],[74,75,1.0],[74,92,1.0],[74,99,1.0],[74,120,1.0],[74,163,1.0],[74,164,1.0],[74,184,1.0],[75,87,1.0],[75,93,1.0],[75,126,1.0],[75,160,1.0],[75,189,1.0],[76,130,1.0],[76,157,1.0],[77,87,1.0],[77,94,1.0],[77,122,1.0],[77,151,1.0],[77,153,1.0],[78,85,1.0],[78,94,1.0],[78,109,1.0],[78,177,1.0],[78,196,1.0],[79,98,1.0],[79,108,1.0],[79,154,1.0],[79,191,1.0],[80,82,1.0],[80,91,1.0],[80,93,1.0],[80,102,1.0],[80,105,1.0],[80,106,1.0],[80,107,1.0],[80,113,1.0],[80,114,1.0],[80,115,1.0],[80,118,1.0],[80,143,1.0],[81,82,1.0],[81,83,1.0],[81,84,1.0],[81,90,1.0],[81,92,1.0],[81,102,1.0],[81,105,1.0],[81,107,1.0],[81,109,1.0],[81,112,1.0],[81,113,1.0],[81,114,1.0],[81,136,1.0],[81,149,1.0],[81,187,1.0],[82,84,1.0],[82,91,1.0],[82,94,1.0],[82,96,1.0],[82,97,1.0],[82,101,1.0],[82,102,1.0],[82,103,1.0],[82,108,1.0],[82,110,1.0],[82,111,1.0],[82,119,1.0],[82,122,1.0],[82,132,1.0],[82,198,1.0],[83,84,1.0],[83,91,1.0],[83,94,1.0],[83,99,1.0],[83,107,1.0],[83,108,1.0],[83,111,1.0],[83,115,1.0],[83,118,1.0],[83,119,1.0],[83,154,1.0],[83,158,1.0],[83,165,1.0],[83,168,1.0],[83,183,1.0],[84,99,1.0],[84,101,1.0],[84,102,1.0],[84,103,1.0],[84,107,1.0],[84,111,1.0],[84,113,1.0],[84,123,1.0],[84,132,1.0],[84,134,1.0],[84,150,1.0],[84,167,1.0],[84,188,1.0],[85,88,1.0],[85,95,1.0],[85,97,1.0],[85,101,1.0],[85,108,1.0],[85,111,1.0],[85,114,1.0],[85,118,1.0],[85,120,1.0],[85,165,1.0],[85,174,1.0],[85,197,1.0],[86,87,1.0],[86,88,1.0],[86,89,1.0],[86,91,1.0],[86,92,1.0],[86,94,1.0],[86,96,1.0],[86,103,1.0],[86,109,1.0],[86,114,1.0],[86,116,1.0],[86,118,1.0],[86,149,1.0],[86,152,1.0],[86,191,1.0],[86,194,1.0],[87,91,1.0],[87,97,1.0],[87,98,1.0],[87,101,1.0],[87,104,1.0],[87,110,1.0],[87,111,1.0],[87,114,1.0],[87,115,1.0],[87,117,1.0],[87,118,1.0],[87,119,1.0],[87,124,1.0],[87,129,1.0],[87,134,1.0],[87,167,1.0],[87,171,1.0],[87,193,1.0],[87,199,1.0],[88,90,1.0],[88,91,1.0],[88,92,1.0],[88,97,1.0],[88,103,1.0],[88,105,1.0],[88,106,1.0],[88,109,1.0],[88,112,1.0],[88,114,1.0],[88,116,1.0],[88,118,1.0],[88,134,1.0],[88,176,1.0],[89,92,1.0],[89,95,1.0],[89,101,1.0],[89,102,1.0],[89,106,1.0],[89,112,1.0],[89,113,1.0],[89,115,1.0],[89,122,1.0],[89,157,1.0],[89,198,1.0],[90,96,1.0],[90,97,1.0],[90,104,1.0],[90,107,1.0],[90,108,1.0],[90,115,1.0],[90,130,1.0],[90,136,1.0],[90,147,1.0],[90,155,1.0],[90,194,1.0],[91,95,1.0],[91,97,1.0],[91,99,1.0],[91,104,1.0],[91,106,1.0],[91,109,1.0],[91,110,1.0],[91,115,1.0],[91,116,1.0],[91,118,1.0],[91,119,1.0],[91,130,1.0],[91,148,1.0],[91,163,1.0],[91,166,1.0],[91,167,1.0],[91,185,1.0],[92,95,1.0],[92,100,1.0],[92,103,1.0],[92,108,1.0],[92,110,1.0],[92,111,1.0],[92,112,1.0],[92,113,1.0],[92,116,1.0],[92,119,1.0],[93,94,1.0],[93,95,1.0],[93,101,1.0],[93,102,1.0],[93,106,1.0],[93,108,1.0],[93,116,1.0],[93,117,1.0],[93,122,1.0],[93,136,1.0],[93,166,1.0],[93,170,1.0],[94,99,1.0],[94,100,1.0],[94,101,1.0],[94,106,1.0],[94,110,1.0],[94,116,1.0],[94,123,1.0],[94,126,1.0],[94,136,1.0],[94,137,1.0],[94,164,1.0],[94,166,1.0],[94,194,1.0],[95,100,1.0],[95,101,1.0],[95,103,1.0],[95,105,1.0],[95,114,1.0],[95,118,1.0],[95,119,1.0],[95,121,1.0],[95,148,1.0],[95,167,1.0],[95,185,1.0],[96,108,1.0],[96,113,1.0],[96,119,1.0],[96,139,1.0],[96,143,1.0],[96,157,1.0],[96,158,1.0],[96,175,1.0],[96,195,1.0],[97,99,1.0],[97,100,1.0],[97,102,1.0],[97,105,1.0],[97,108,1.0],[97,111,1.0],[97,113,1.0],[97,116,1.0],[97,119,1.0],[97,155,1.0],[97,158,1.0],[97,164,1.0],[97,165,1.0],[98,102,1.0],[98,106,1.0],[98,108,1.0],[98,111,1.0],[98,112,1.0],[98,113,1.0],[98,114,1.0],[98,115,1.0],[98,120,1.0],[98,135,1.0],[98,137,1.0],[98,143,1.0],[98,147,1.0],[99,100,1.0],[99,107,1.0],[99,111,1.0],[99,131,1.0],[99,158,1.0],[99,175,1.0],[100,103,1.0],[100,112,1.0],[100,114,1.0],[100,158,1.0],[101,115,1.0],[101,116,1.0],[101,118,1.0],[102,103,1.0],[102,106,1.0],[102,131,1.0],[102,145,1.0],[103,104,1.0],[103,111,1.0],[103,117,1.0],[103,118,1.0],[103,119,1.0],[103,147,1.0],[103,165,1.0],[103,176,1.0],[103,178,1.0],[104,107,1.0],[104,122,1.0],[104,130,1.0],[104,142,1.0],[104,156,1.0],[104,161,1.0],[104,183,1.0],[105,118,1.0],[105,139,1.0],[105,150,1.0],[105,175,1.0],[106,111,1.0],[106,112,1.0],[106,113,1.0],[106,117,1.0],[106,118,1.0],[106,121,1.0],[106,152,1.0],[107,109,1.0],[107,135,1.0],[107,143,1.0],[107,185,1.0],[107,186,1.0],[108,117,1.0],[108,133,1.0],[108,166,1.0],[108,188,1.0],[109,115,1.0],[109,194,1.0],[110,119,1.0],[110,149,1.0],[110,163,1.0],[111,112,1.0],[111,116,1.0],[111,149,1.0],[111,193,1.0],[111,198,1.0],[112,118,1.0],[112,140,1.0],[112,152,1.0],[112,180,1.0],[113,114,1.0],[113,118,1.0],[113,147,1.0],[113,195,1.0],[114,130,1.0],[114,134,1.0],[114,142,1.0],[114,152,1.0],[114,154,1.0],[115,117,1.0],[115,119,1.0],[115,125,1.0],[115,172,1.0],[116,119,1.0],[116,138,1.0],[116,139,1.0],[116,163,1.0],[117,119,1.0],[117,126,1.0],[117,130,1.0],[117,146,1.0],[117,153,1.0],[117,165,1.0],[117,173,1.0],[117,181,1.0],[118,124,1.0],[118,176,1.0],[119,122,1.0],[119,129,1.0],[119,170,1.0],[120,124,1.0],[120,128,1.0],[120,130,1.0],[120,136,1.0],[120,140,1.0],[120,142,1.0],[120,143,1.0],[120,146,1.0],[120,149,1.0],[120,152,1.0],[120,153,1.0],[120,155,1.0],[120,162,1.0],[120,168,1.0],[121,126,1.0],[121,131,1.0],[121,133,1.0],[121,141,1.0],[121,144,1.0],[121,153,1.0],[121,164,1.0],[121,183,1.0],[122,133,1.0],[122,135,1.0],[122,139,1.0],[122,142,1.0],[122,149,1.0],[122,157,1.0],[122,158,1.0],[122,194,1.0],[123,129,1.0],[123,130,1.0],[123,134,1.0],[123,148,1.0],[123,157,1.0],[123,182,1.0],[124,125,1.0],[124,131,1.0],[124,132,1.0],[124,134,1.0],[124,135,1.0],[124,137,1.0],[124,139,1.0],[124,142,1.0],[124,143,1.0],[124,151,1.0],[124,152,1.0],[124,156,1.0],[124,168,1.0],[125,130,1.0],[125,131,1.0],[125,134,1.0],[125,136,1.0],[125,138,1.0],[125,144,1.0],[125,148,1.0],[125,149,1.0],[125,151,1.0],[125,154,1.0],[125,158,1.0],[125,194,1.0],[126,133,1.0],[126,135,1.0],[126,136,1.0],[126,143,1.0],[126,146,1.0],[126,148,1.0],[126,151,1.0],[126,153,1.0],[126,159,1.0],[126,199,1.0],[127,135,1.0],[127,142,1.0],[127,148,1.0],[127,155,1.0],[127,159,1.0],[127,168,1.0],[127,169,1.0],[127,189,1.0],[128,142,1.0],[128,144,1.0],[128,145,1.0],[128,151,1.0],[128,152,1.0],[128,156,1.0],[128,178,1.0],[129,132,1.0],[129,134,1.0],[129,135,1.0],[129,136,1.0],[129,140,1.0],[129,143,1.0],[129,144,1.0],[129,156,1.0],[129,158,1.0],[129,164,1.0],[130,132,1.0],[130,135,1.0],[130,140,1.0],[130,141,1.0],[130,143,1.0],[130,144,1.0],[130,149,1.0],[130,155,1.0],[130,156,1.0],[130,170,1.0],[131,145,1.0],[131,152,1.0],[131,161,1.0],[131,193,1.0],[131,198,1.0],[132,136,1.0],[132,139,1.0],[132,148,1.0],[132,149,1.0],[132,151,1.0],[132,154,1.0],[132,162,1.0],[133,139,1.0],[133,140,1.0],[133,142,1.0],[133,144,1.0],[133,153,1.0],[133,164,1.0],[133,169,1.0],[133,193,1.0],[134,143,1.0],[134,145,1.0],[134,147,1.0],[134,148,1.0],[134,151,1.0],[134,155,1.0],[134,156,1.0],[134,158,1.0],[134,159,1.0],[135,151,1.0],[135,160,1.0],[135,161,1.0],[135,163,1.0],[135,179,1.0],[135,192,1.0],[136,141,1.0],[136,143,1.0],[136,144,1.0],[136,145,1.0],[136,149,1.0],[136,150,1.0],[136,154,1.0],[136,157,1.0],[136,158,1.0],[136,159,1.0],[136,174,1.0],[136,182,1.0],[137,139,1.0],[137,143,1.0],[137,147,1.0],[137,156,1.0],[137,157,1.0],[137,159,1.0],[137,192,1.0],[137,193,1.0],[138,139,1.0],[138,140,1.0],[138,142,1.0],[138,153,1.0],[138,154,1.0],[138,157,1.0],[138,192,1.0],[139,142,1.0],[139,152,1.0],[139,155,1.0],[139,156,1.0],[139,159,1.0],[139,162,1.0],[139,175,1.0],[139,196,1.0],[140,142,1.0],[140,148,1.0],[140,152,1.0],[140,154,1.0],[140,155,1.0],[140,156,1.0],[140,157,1.0],[140,199,1.0],[141,144,1.0],[141,146,1.0],[141,149,1.0],[141,151,1.0],[141,152,1.0],[141,159,1.0],[141,163,1.0],[141,170,1.0],[141,193,1.0],[142,145,1.0],[142,149,1.0],[142,152,1.0],[142,158,1.0],[143,146,1.0],[143,148,1.0],[143,151,1.0],[143,157,1.0],[143,166,1.0],[144,146,1.0],[144,147,1.0],[144,150,1.0],[144,154,1.0],[144,195,1.0],[144,197,1.0],[145,152,1.0],[145,157,1.0],[146,156,1.0],[146,158,1.0],[146,160,1.0],[146,163,1.0],[146,176,1.0],[146,186,1.0],[147,151,1.0],[147,152,1.0],[147,154,1.0],[147,155,1.0],[147,158,1.0],[147,180,1.0],[148,152,1.0],[148,153,1.0],[148,154,1.0],[148,157,1.0],[148,158,1.0],[148,161,1.0],[149,153,1.0],[149,154,1.0],[149,155,1.0],[149,158,1.0],[149,167,1.0],[150,151,1.0],[150,152,1.0],[150,159,1.0],[150,167,1.0],[151,152,1.0],[151,154,1.0],[151,155,1.0],[151,191,1.0],[152,155,1.0],[152,159,1.0],[152,162,1.0],[152,171,1.0],[152,174,1.0],[153,157,1.0],[154,180,1.0],[154,190,1.0],[155,165,1.0],[155,166,1.0],[158,184,1.0],[158,198,1.0],[159,167,1.0],[160,172,1.0],[160,175,1.0],[160,181,1.0],[160,185,1.0],[160,186,1.0],[160,195,1.0],[161,170,1.0],[161,174,1.0],[161,179,1.0],[161,180,1.0],[161,183,1.0],[161,189,1.0],[161,190,1.0],[161,191,1.0],[162,164,1.0],[162,173,1.0],[162,175,1.0],[162,190,1.0],[162,193,1.0],[162,194,1.0],[163,169,1.0],[163,175,1.0],[163,177,1.0],[163,179,1.0],[163,187,1.0],[163,189,1.0],[163,197,1.0],[164,168,1.0],[164,174,1.0],[164,177,1.0],[164,179,1.0],[164,181,1.0],[164,182,1.0],[164,183,1.0],[164,188,1.0],[164,190,1.0],[164,191,1.0],[164,194,1.0],[165,171,1.0],[165,177,1.0],[165,180,1.0],[165,190,1.0],[165,191,1.0],[165,193,1.0],[165,195,1.0],[165,198,1.0],[165,199,1.0],[166,167,1.0],[166,173,1.0],[166,182,1.0],[166,184,1.0],[166,187,1.0],[166,190,1.0],[166,194,1.0],[166,199,1.0],[167,169,1.0],[167,176,1.0],[167,177,1.0],[167,178,1.0],[167,181,1.0],[167,188,1.0],[167,194,1.0],[167,196,1.0],[168,169,1.0],[168,172,1.0],[168,178,1.0],[168,181,1.0],[168,183,1.0],[168,187,1.0],[168,198,1.0],[169,173,1.0],[169,174,1.0],[169,184,1.0],[169,186,1.0],[169,187,1.0],[169,189,1.0],[169,193,1.0],[169,195,1.0],[169,197,1.0],[169,198,1.0],[170,175,1.0],[170,176,1.0],[170,186,1.0],[170,193,1.0],[170,195,1.0],[171,175,1.0],[171,176,1.0],[171,178,1.0],[171,185,1.0],[171,195,1.0],[172,173,1.0],[172,175,1.0],[172,177,1.0],[172,178,1.0],[172,182,1.0],[172,183,1.0],[172,184,1.0],[172,190,1.0],[172,194,1.0],[172,199,1.0],[173,174,1.0],[173,177,1.0],[173,182,1.0],[173,185,1.0],[173,190,1.0],[173,191,1.0],[173,196,1.0],[174,175,1.0],[174,181,1.0],[174,187,1.0],[174,192,1.0],[174,196,1.0],[174,198,1.0],[175,177,1.0],[175,182,1.0],[175,190,1.0],[175,194,1.0],[175,197,1.0],[176,184,1.0],[176,191,1.0],[176,194,1.0],[176,197,1.0],[177,178,1.0],[177,182,1.0],[177,184,1.0],[177,189,1.0],[177,191,1.0],[177,196,1.0],[178,183,1.0],[178,187,1.0],[178,190,1.0],[178,195,1.0],[178,197,1.0],[179,183,1.0],[180,181,1.0],[180,186,1.0],[180,198,1.0],[181,188,1.0],[181,191,1.0],[181,193,1.0],[181,197,1.0],[182,187,1.0],[182,192,1.0],[182,197,1.0],[184,185,1.0],[184,187,1.0],[184,192,1.0],[184,199,1.0],[185,187,1.0],[185,189,1.0],[185,191,1.0],[185,196,1.0],[185,198,1.0],[186,191,1.0],[186,193,1.0],[186,196,1.0],[187,189,1.0],[187,192,1.0],[187,198,1.0],[188,195,1.0],[189,191,1.0],[190,192,1.0],[191,197,1.0],[191,199,1.0],[192,195,1.0],[192,199,1.0],[194,199,1.0],[195,197,1.0],[195,198,1.0],[196,198,1.0],[196,199,1.0]]},"sbm_medium/weighted":{"n_nodes":200,"edges":[[0,3,1.3834],[0,4,0.778],[0,12,2.5283],[0,21,0.5035],[0,22,2.1256],[0,29,1.5299],[0,30,0.453],[0,32,2.026],[0,36,0.3812],[0,101,1.7678],[0,107,0.4945],[0,109,0.5675],[0,119,1.7358],[0,125,3.144],[0,149,0.6833],[0,153,1.0313],[0,171,2.446],[0,172,3.567],[1,7,2.2699],[1,9,1.6384],[1,13,3.6669],[1,17,0.413],[1,19,3.2546],[1,23,1.2636],[1,25,0.7549],[1,31,0.6623],[1,32,1.3297],[1,33,3.1064],[1,38,0.8825],[1,96,2.2856],[1,119,2.4862],[1,125,1.5534],[1,148,2.1671],[1,158,0.4698],[1,173,0.4586],[2,5,0.9709],[2,7,2.6314],[2,10,1.7466],[2,15,1.3495],[2,17,2.2995],[2,26,1.8361],[2,27,1.2992],[2,28,3.0303],[2,33,2.6965],[2,63,1.1043],[2,129,2.2605],[2,188,2.0882],[3,7,3.313],[3,8,2.8031],[3,9,1.2578],[3,12,3.6806],[3,18,0.6632],[3,29,1.7134],[3,33,2.9],[3,39,0.7819],[3,66,1.9614],[3,77,0.3872],[3,131,2.5888],[3,163,2.926],[3,166,2.2556],[3,196,3.3142],[4,6,1.3481],[4,11,2.6835],[4,23,2.3303],[4,25,2.2796],[4,27,1.8467],[4,37,3.1899],[4,42,3.5564],[4,112,1.9093],[4,113,2.5745],[4,162,0.4623],[4,169,2.7052],[4,189,2.515],[4,197,3.7258],[5,6,3.1267],[5,7,1.2461],[5,13,1.6003],[5,15,2.5903],[5,16,0.329],[5,17,1.8659],[5,27,0.8382],[5,29,0.6598],[5,30,0.4563],[5,35,2.9388],[5,114,0.7027],[5,119,1.1167],[5,144,1.6183],[5,188,3.3],[6,7,0.532],[6,8,1.8222],[6,14,2.173],[6,17,3.3418],[6,20,3.1175],[6,24,3.2739],[6,30,1.2245],[6,41,1.7035],[6,47,1.5057],[6,48,3.3447],[6,50,3.6021],[6,64,0.7782],[6,73,0.8668],[6,130,1.0618],[6,140,1.0667],[6,165,1.9474],[6,166,2.3119],[7,18,1.1696],[7,22,0.2643],[7,30,1.7163],[7,57,1.5424],[7,68,2.2322],[7,121,3.5858],[7,134,2.6667],[7,174,2.0542],[7,181,2.4116],[7,184,2.6167],[7,198,0.439],[7,199,3.3984],[8,9,2.9799],[8,16,3.3108],[8,17,3.0426],[8,19,1.6233],[8,27,1.6464],[8,29,0.6124],[8,37,2.47],[8,75,0.4679],[8,88,0.4857],[8,105,0.9807],[8,112,0.8181],[8,126,1.4402],[8,158,0.434],[8,168,0.2508],[8,180,0.7794],[9,16,0.6051],[9,18,1.5226],[9,19,0.3393],[9,20,3.3102],[9,24,2.3992],[9,27,0.7699],[9,29,1.1329],[9,32,1.4659],[9,33,1.5246],[9,34,0.6799],[9,36,3.2213],[9,37,3.7259],[9,38,1.881],[9,39,1.9434],[9,44,0.5506],[9,56,0.6077],[9,85,1.4492],[9,103,1.1766],[9,107,3.151],[9,144,0.815],[9,172,0.3308],[9,174,3.5784],[9,177,2.0989],[10,23,0.7631],[10,39,2.1511],[10,88,0.3446],[10,116,2.0984],[10,161,3.6748],[11,13,3.2716],[11,16,2.6867],[11,25,1.1639],[11,35,1.5334],[11,38,0.8346],[11,47,2.9518],[11,50,2.1141],[11,55,2.9767],[11,58,1.4038],[11,87,1.0306],[11,95,3.0903],[11,101,3.6972],[11,109,3.2342],[11,146,3.0713],[12,14,3.1142],[12,17,2.8396],[12,20,1.0436],[12,21,2.0617],[12,27,1.4945],[12,30,0.3514],[12,32,0.3478],[12,37,1.228],[12,39,1.1571],[12,77,2.6738],[12,84,3.5978],[12,96,1.8153],[12,98,3.5296],[12,135,3.7081],[12,159,3.5925],[13,16,1.5262],[13,19,1.0216],[13,24,1.044],[13,34,0.9385],[13,35,0.9653],[13,39,2.4342],[13,74,3.4011],[13,83,3.1915],[13,97,1.9282],[13,146,2.5354],[13,153,3.0488],[13,159,0.5467],[13,172,2.562],[13,183,3.4342],[14,15,2.9881],[14,16,2.8755],[14,17,1.9231],[14,22,0.8748],[14,27,3.012],[14,28,1.4138],[14,35,3.0529],[14,60,3.6508],[14,68,1.6354],[14,96,1.6549],[14,101,3.5638],[14,131,2.7868],[14,134,0.845],[14,142,0.6946],[14,154,0.779],[14,193,3.417],[15,18,3.0728],[15,20,0.7616],[15,27,3.1428],[15,29,3.6811],[15,30,2.5504],[15,32,1.4764],[15,37,2.1703],[15,41,0.7084],[15,47,0.2999],[15,68,3.6481],[15,130,2.5239],[15,185,2.093],[16,21,3.5177],[16,29,1.7683],[16,33,3.3011],[16,65,3.1415],[16,66,0.9886],[16,98,1.1314],[16,111,1.2754],[16,144,1.0919],[16,155,2.3025],[16,179,1.1578],[17,19,1.7165],[17,20,0.7088],[17,21,3.4351],[17,22,1.4882],[17,27,1.8536],[17,33,2.2917],[17,36,3.415],[17,50,1.7222],[17,92,3.462],[17,94,2.0058],[17,97,2.1114],[17,99,2.0823],[17,105,0.3155],[17,173,1.7904],[17,181,0.8909],[17,190,0.2638],[18,24,3.0471],[18,25,0.8532],[18,34,1.9072],[18,37,2.7882],[18,45,2.1977],[18,67,1.3909],[18,78,2.0642],[18,81,2.194],[18,142,2.995],[18,147,0.6214],[18,151,2.211],[19,25,1.1197],[19,26,1.2192],[19,27,2.9529],[19,29,2.027],[19,31,2.2161],[19,37,2.91],[19,141,3.4437],[19,148,1.8014],[19,154,2.3938],[20,23,2.0194],[20,25,2.0426],[20,26,2.6746],[20,27,1.8332],[20,29,2.1165],[20,33,1.9231],[20,39,3.5453],[20,40,2.6973],[20,50,3.3179],[20,60,3.5476],[20,101,1.1586],[20,131,2.2083],[20,182,3.5514],[21,25,3.19],[21,29,0.73],[21,34,0.6757],[21,36,1.7974],[21,70,0.5039],[21,74,1.0922],[21,112,0.5059],[21,117,2.5932],[21,119,2.9938],[21,127,3.3896],[21,129,0.7906],[22,27,2.7564],[22,29,2.5609],[22,34,0.7504],[22,39,3.3399],[22,99,3.6364],[22,114,1.0186],[22,125,3.5838],[22,126,1.6439],[22,153,1.9554],[22,183,3.7146],[22,187,3.1636],[22,189,0.8151],[22,191,1.7603],[23,25,2.0546],[23,30,1.4369],[23,31,0.9351],[23,58,1.3648],[23,70,2.7775],[23,102,0.3182],[23,114,2.1892],[23,134,1.7916],[23,144,0.3133],[23,172,1.4102],[23,190,2.4337],[23,193,2.0429],[24,27,0.475],[24,30,3.6978],[24,33,3.0093],[24,101,3.6509],[24,109,0.6167],[24,117,1.1795],[24,120,0.3886],[24,146,2.9765],[25,30,1.1966],[25,58,0.7034],[25,121,1.7279],[25,125,3.4399],[25,182,3.1164],[25,195,1.1551],[26,32,0.7728],[26,37,3.4671],[26,69,2.2471],[26,88,2.7015],[26,119,0.5631],[26,135,0.4513],[26,151,2.6587],[26,167,1.7386],[26,185,0.5034],[27,31,3.5342],[27,35,2.4705],[27,40,3.0557],[27,47,0.5431],[27,71,3.2468],[27,86,0.4832],[27,102,3.2697],[27,121,1.8382],[27,128,1.437],[27,151,2.1857],[27,164,3.4933],[27,175,1.1875],[27,191,0.7023],[28,29,2.0942],[28,30,1.0845],[28,31,0.6331],[28,32,0.8151],[28,40,0.4263],[28,82,0.9562],[28,99,1.342],[28,123,1.3175],[28,153,2.9082],[29,32,1.2649],[29,38,2.0003],[29,48,0.8726],[29,153,1.4645],[29,166,0.3136],[29,172,1.1266],[29,180,0.3037],[29,199,2.8158],[30,35,2.1787],[30,37,0.9131],[30,47,1.9117],[30,76,3.5212],[30,81,0.622],[30,125,3.1162],[30,127,1.7626],[30,148,1.9825],[30,150,3.1711],[31,43,1.6258],[31,57,2.0234],[31,81,2.6571],[31,83,3.6885],[31,94,1.4495],[31,168,3.163],[31,177,2.7235],[32,34,2.4759],[32,38,1.6664],[32,47,1.4664],[32,68,0.4404],[32,87,0.7044],[32,98,0.4975],[32,145,2.8431],[32,168,1.1446],[33,51,0.8214],[33,75,0.5457],[33,89,3.1944],[33,104,3.2969],[33,109,2.5969],[33,111,1.2368],[33,112,1.0977],[33,127,1.2757],[33,132,1.8581],[33,156,0.8014],[34,35,1.8104],[34,37,1.1714],[34,38,3.6163],[34,45,3.6542],[34,64,2.1648],[34,68,1.1056],[34,89,3.6298],[34,108,1.3334],[34,133,1.498],[34,152,0.2537],[35,36,1.5857],[35,37,1.9113],[35,42,2.0097],[35,48,0.9534],[35,66,2.0166],[35,130,0.2673],[35,185,1.1746],[36,50,0.5641],[36,132,1.6483],[36,149,0.3958],[36,160,0.3287],[36,165,1.3149],[37,57,1.0648],[37,95,2.2995],[37,111,2.1022],[37,118,2.8769],[37,126,2.5514],[38,46,2.756],[38,117,3.3268],[38,150,1.6133],[38,158,1.3915],[38,161,3.6966],[38,168,0.7731],[39,55,2.7845],[39,61,2.5013],[39,69,0.4033],[39,91,3.1735],[39,192,3.3718],[40,47,2.4457],[40,51,2.8185],[40,54,3.0928],[40,58,0.7376],[40,59,2.0832],[40,61,2.0153],[40,66,3.1723],[40,68,3.0664],[40,69,3.1424],[40,71,2.2942],[40,72,3.3749],[40,75,2.6401],[40,79,2.6766],[40,89,1.0548],[40,106,0.3591],[40,133,0.7158],[41,48,1.5125],[41,50,0.6172],[41,51,3.1754],[41,54,2.2048],[41,61,2.4472],[41,64,2.4418],[41,65,2.6323],[41,66,1.9625],[41,72,0.2616],[41,73,3.0419],[41,74,2.8689],[41,77,2.0104],[41,78,2.1232],[41,79,2.5575],[41,109,0.4812],[41,117,2.8288],[41,129,1.1327],[41,143,0.5106],[41,171,1.1795],[42,47,2.8027],[42,50,0.9683],[42,54,2.8394],[42,58,3.6651],[42,59,1.9788],[42,69,1.589],[42,70,1.9265],[42,77,2.6429],[42,86,2.9344],[42,96,2.4094],[42,121,2.4997],[42,148,0.5212],[42,153,0.766],[42,174,1.1388],[43,46,2.8513],[43,47,1.3155],[43,63,2.2372],[43,64,0.2936],[43,67,0.4623],[43,76,1.1907],[43,77,2.602],[43,91,2.6726],[43,97,2.615],[43,117,1.268],[43,156,2.0579],[44,52,1.8763],[44,54,1.8822],[44,55,0.6648],[44,59,3.3778],[44,61,0.9474],[44,64,3.6734],[44,69,3.5269],[44,72,0.3113],[44,73,1.8564],[44,78,3.1196],[44,119,3.6384],[44,128,1.8231],[44,133,1.1903],[44,145,0.9844],[44,189,3.5596],[45,46,0.9875],[45,48,2.2852],[45,53,0.7461],[45,55,2.0842],[45,66,3.5846],[45,70,0.7141],[45,74,3.1208],[45,75,2.0306],[45,79,3.354],[45,99,2.7117],[45,102,1.0598],[45,107,3.392],[45,112,1.9515],[45,198,0.3369],[46,49,0.2626],[46,50,1.9709],[46,64,1.8277],[46,67,1.3068],[46,77,0.7425],[46,79,1.4539],[46,94,1.3563],[46,95,3.1908],[46,101,0.2561],[46,106,2.8776],[46,124,3.1869],[47,52,0.6701],[47,54,3.4924],[47,57,2.7456],[47,60,3.4055],[47,71,1.2644],[47,72,1.5528],[47,75,1.6251],[48,49,3.7458],[48,68,2.3121],[48,71,1.5125],[48,72,1.7482],[48,76,1.213],[48,88,0.4189],[48,92,0.606],[48,100,3.1714],[48,114,1.2497],[48,135,3.5246],[48,140,1.1226],[48,171,1.18],[49,52,2.0384],[49,56,0.9145],[49,58,1.5567],[49,60,3.5966],[49,70,3.3449],[49,74,3.0919],[49,115,2.4581],[49,119,3.447],[49,125,3.5424],[49,161,2.1723],[49,165,2.7685],[49,180,0.4232],[49,187,2.8132],[50,52,1.828],[50,58,2.8843],[50,72,2.5057],[50,74,1.2517],[50,85,0.4214],[50,112,3.4937],[50,138,0.6956],[50,177,1.9026],[50,178,1.4528],[51,57,1.2922],[51,62,2.8366],[51,63,3.667],[51,66,1.1606],[51,69,2.546],[51,76,1.3029],[51,77,2.2006],[51,88,1.6303],[51,95,0.8357],[51,98,0.8158],[51,109,0.9776],[51,111,3.4209],[51,116,1.9898],[51,121,1.0201],[51,122,3.4219],[51,123,3.7377],[51,151,1.8249],[51,196,0.7386],[52,61,0.9234],[52,68,0.5675],[52,71,1.4468],[52,72,0.5688],[52,74,1.0869],[52,76,1.1543],[52,77,2.2437],[52,124,3.3554],[53,58,2.8738],[53,59,1.6947],[53,64,1.6986],[53,73,2.0846],[53,75,1.569],[53,77,1.4337],[53,94,0.4672],[53,140,1.2213],[53,148,3.6369],[54,56,0.6906],[54,57,2.0119],[54,58,2.4537],[54,63,3.27],[54,68,1.0059],[54,70,1.1986],[54,101,1.1196],[54,132,1.6491],[54,133,1.8105],[54,135,3.5888],[54,149,3.2204],[54,152,3.3051],[54,160,0.3263],[54,176,0.3629],[54,197,2.7333],[55,58,3.3849],[55,68,1.9064],[55,71,2.3051],[55,76,0.2506],[55,77,1.6203],[55,110,3.4939],[55,140,3.1396],[55,172,3.2441],[55,191,3.6528],[56,57,1.1196],[56,59,0.6317],[56,60,0.7903],[56,67,2.0783],[56,70,2.6373],[56,74,3.5452],[56,77,2.7761],[56,79,2.5157],[56,94,2.9268],[56,98,1.8506],[56,118,2.1803],[56,146,0.3884],[56,158,2.988],[57,63,1.064],[57,66,3.4697],[57,71,2.5093],[57,72,1.3132],[57,73,0.6979],[57,74,1.1313],[57,75,2.477],[57,96,2.695],[57,102,0.6425],[57,132,0.4962],[57,145,2.0855],[57,163,2.2901],[57,171,1.6083],[57,197,1.0325],[58,63,2.3537],[58,72,0.2866],[58,74,1.3053],[58,75,1.8624],[58,90,3.6063],[58,102,2.506],[58,107,3.3432],[58,131,1.9136],[58,137,1.0717],[58,158,1.1147],[58,173,3.6121],[58,177,2.7163],[59,60,1.3259],[59,61,0.3263],[59,68,1.9941],[59,69,2.6106],[59,74,1.7201],[59,151,1.1504],[59,169,2.5857],[59,175,3.4881],[60,64,1.0438],[60,71,0.3693],[60,75,1.4332],[60,102,1.7219],[60,105,2.639],[60,132,0.9433],[60,143,3.0397],[60,161,2.837],[60,184,2.0171],[60,198,0.9683],[61,62,3.6445],[61,63,1.341],[61,64,3.12],[61,66,1.0578],[61,68,1.025],[61,71,2.9116],[61,74,1.2823],[61,114,3.5817],[61,157,1.9852],[62,64,0.9056],[62,67,1.0316],[62,74,1.7096],[62,75,2.5785],[62,77,3.5707],[63,64,0.7623],[63,66,1.6271],[63,72,0.9953],[63,73,3.6594],[63,77,0.7467],[63,93,0.4314],[63,116,0.4605],[63,139,1.6266],[63,154,3.3936],[63,163,3.3425],[64,66,2.8145],[64,69,3.7414],[64,74,3.5106],[64,75,1.4023],[64,76,0.8993],[64,78,3.5256],[64,83,2.8621],[64,141,0.3616],[64,174,2.5755],[65,69,1.5752],[65,72,1.5586],[65,75,1.4109],[65,77,0.8424],[65,89,0.26],[65,173,1.2293],[65,198,1.4801],[66,67,3.5943],[66,68,0.683],[66,70,3.6249],[66,71,0.9759],[66,72,1.4982],[66,73,3.1255],[66,77,3.127],[66,80,1.7636],[66,81,0.4224],[66,86,1.9071],[66,122,1.5545],[66,159,3.4683],[66,195,0.9256],[66,196,1.5249],[67,69,3.3895],[67,72,0.356],[67,77,1.6878],[67,78,3.0914],[67,79,2.9333],[67,80,0.3923],[67,101,0.372],[67,104,0.469],[68,73,3.4703],[68,75,1.1496],[68,171,2.8655],[69,79,3.3949],[69,156,1.4367],[70,71,1.2031],[70,73,3.6019],[70,78,2.4094],[70,95,1.1676],[70,96,2.7582],[70,110,1.3577],[70,112,1.2147],[70,166,0.2632],[70,183,2.8948],[70,199,3.4576],[71,72,2.4689],[71,75,3.5514],[71,136,0.3349],[71,161,1.0685],[71,168,1.9132],[71,176,3.5987],[72,77,3.5887],[72,78,1.6028],[72,79,1.1287],[72,123,1.7548],[72,127,1.9772],[72,143,3.4983],[73,77,0.8903],[73,78,3.059],[73,85,2.8347],[73,147,3.1296],[73,169,2.9548],[74,75,2.3754],[74,92,1.3973],[74,99,1.3684],[74,120,1.5165],[74,163,2.9879],[74,164,0.5266],[74,184,0.9406],[75,87,2.8851],[75,93,1.1156],[75,126,0.4766],[75,160,0.3685],[75,189,2.1841],[76,130,1.3902],[76,157,3.6809],[77,87,3.3422],[77,94,3.7074],[77,122,1.1771],[77,151,0.5443],[77,153,0.5875],[78,85,1.9947],[78,94,2.7342],[78,109,1.8144],[78,177,1.0697],[78,196,1.7089],[79,98,2.4211],[79,108,2.6094],[79,154,2.8679],[79,191,3.2145],[80,82,2.5755],[80,91,0.6741],[80,93,3.193],[80,102,1.2782],[80,105,2.2341],[80,106,1.5554],[80,107,2.8332],[80,113,0.9472],[80,114,1.116],[80,115,1.1087],[80,118,0.7866],[80,143,3.3446],[81,82,2.274],[81,83,1.3922],[81,84,1.6362],[81,90,3.7236],[81,92,2.0256],[81,102,1.0598],[81,105,3.0796],[81,107,2.5366],[81,109,3.7183],[81,112,0.6082],[81,113,1.9117],[81,114,3.1169],[81,136,3.1919],[81,149,3.4503],[81,187,0.3913],[82,84,1.2779],[82,91,0.6673],[82,94,0.9135],[82,96,3.6554],[82,97,2.2912],[82,101,3.5056],[82,102,1.5528],[82,103,3.2814],[82,108,1.8219],[82,110,1.1598],[82,111,2.9722],[82,119,3.56],[82,122,0.6202],[82,132,2.3365],[82,198,2.4198],[83,84,1.0118],[83,91,1.5405],[83,94,0.7448],[83,99,0.9639],[83,107,1.1422],[83,108,2.348],[83,111,2.5307],[83,115,0.962],[83,118,0.2898],[83,119,1.3954],[83,154,2.6241],[83,158,0.898],[83,165,1.3427],[83,168,0.9619],[83,183,3.0335],[84,99,2.1682],[84,101,0.4714],[84,102,0.6049],[84,103,1.6335],[84,107,2.1755],[84,111,2.4871],[84,113,0.569],[84,123,0.8229],[84,132,2.6839],[84,134,1.6843],[84,150,1.2416],[84,167,1.3266],[84,188,3.5862],[85,88,1.3433],[85,95,2.2328],[85,97,1.5001],[85,101,1.7076],[85,108,3.2749],[85,111,3.7382],[85,114,1.5232],[85,118,0.9402],[85,120,2.7981],[85,165,0.9628],[85,174,0.2706],[85,197,3.4057],[86,87,1.7331],[86,88,3.1213],[86,89,1.6718],[86,91,3.3399],[86,92,1.8632],[86,94,0.8189],[86,96,0.3019],[86,103,2.1804],[86,109,2.4923],[86,114,3.4343],[86,116,0.5616],[86,118,2.4277],[86,149,1.548],[86,152,2.0156],[86,191,0.7606],[86,194,1.2415],[87,91,2.0741],[87,97,3.4892],[87,98,0.6308],[87,101,1.9668],[87,104,3.0668],[87,110,3.6341],[87,111,0.9407],[87,114,0.6933],[87,115,3.5508],[87,117,3.6644],[87,118,1.9396],[87,119,0.4368],[87,124,3.4916],[87,129,1.6076],[87,134,3.4148],[87,167,2.4212],[87,171,3.1359],[87,193,0.811],[87,199,3.0004],[88,90,1.0273],[88,91,1.6657],[88,92,3.2122],[88,97,3.1522],[88,103,0.8904],[88,105,1.0135],[88,106,1.6491],[88,109,2.0626],[88,112,1.5925],[88,114,0.6807],[88,116,1.1147],[88,118,2.7871],[88,134,3.3905],[88,176,0.3938],[89,92,2.2182],[89,95,2.9011],[89,101,0.3835],[89,102,3.1837],[89,106,0.6621],[89,112,2.3483],[89,113,2.1752],[89,115,2.4446],[89,122,1.3217],[89,157,1.7203],[89,198,2.2892],[90,96,1.7401],[90,97,2.5559],[90,104,1.8138],[90,107,1.7842],[90,108,0.3318],[90,115,2.4161],[90,130,1.9633],[90,136,1.0734],[90,147,2.9225],[90,155,2.9799],[90,194,1.854],[91,95,0.8785],[91,97,1.9063],[91,99,0.6248],[91,104,0.6996],[91,106,1.7571],[91,109,0.571],[91,110,1.7969],[91,115,2.0356],[91,116,0.3927],[91,118,2.4775],[91,119,0.5378],[91,130,2.8172],[91,148,2.9717],[91,163,2.0402],[91,166,0.4399],[91,167,2.0137],[91,185,1.5725],[92,95,3.578],[92,100,0.7266],[92,103,3.2497],[92,108,3.7364],[92,110,2.8123],[92,111,3.1025],[92,112,0.928],[92,113,3.686],[92,116,1.9715],[92,119,3.5982],[93,94,3.4561],[93,95,0.8279],[93,101,3.0093],[93,102,3.507],[93,106,0.4793],[93,108,1.4781],[93,116,2.8966],[93,117,0.8057],[93,122,3.3879],[93,136,1.2125],[93,166,3.1047],[93,170,0.7525],[94,99,2.0078],[94,100,3.4697],[94,101,0.9791],[94,106,1.17],[94,110,2.021],[94,116,1.3668],[94,123,0.3789],[94,126,0.8873],[94,136,0.8143],[94,137,3.5274],[94,164,2.6289],[94,166,3.3839],[94,194,0.8406],[95,100,2.997],[95,101,0.6528],[95,103,2.1075],[95,105,2.4771],[95,114,1.5092],[95,118,3.3053],[95,119,2.1931],[95,121,2.2802],[95,148,3.3389],[95,167,0.6161],[95,185,3.7253],[96,108,2.4542],[96,113,1.6299],[96,119,3.0418],[96,139,1.1766],[96,143,3.7167],[96,157,2.2708],[96,158,1.5109],[96,175,2.9262],[96,195,1.798],[97,99,0.8686],[97,100,2.8526],[97,102,0.419],[97,105,3.1194],[97,108,1.1378],[97,111,2.4873],[97,113,3.6942],[97,116,2.3005],[97,119,2.5729],[97,155,1.3443],[97,158,0.2563],[97,164,0.3683],[97,165,0.7728],[98,102,2.4062],[98,106,1.7628],[98,108,2.0444],[98,111,3.3844],[98,112,0.7121],[98,113,1.0454],[98,114,2.5359],[98,115,0.328],[98,120,0.2592],[98,135,1.4924],[98,137,0.6223],[98,143,1.5],[98,147,1.0349],[99,100,2.2926],[99,107,2.3118],[99,111,0.9646],[99,131,2.4338],[99,158,1.9122],[99,175,0.7216],[100,103,3.5281],[100,112,1.1026],[100,114,0.7726],[100,158,0.5853],[101,115,2.4837],[101,116,3.2995],[101,118,2.9875],[102,103,1.6568],[102,106,1.1748],[102,131,0.2902],[102,145,2.5073],[103,104,2.2182],[103,111,1.4762],[103,117,2.5096],[103,118,1.8031],[103,119,3.53],[103,147,2.8173],[103,165,1.1197],[103,176,3.4123],[103,178,0.404],[104,107,2.1103],[104,122,1.671],[104,130,1.0818],[104,142,0.4543],[104,156,2.9761],[104,161,0.2932],[104,183,2.1782],[105,118,3.5432],[105,139,0.7479],[105,150,0.9483],[105,175,2.3783],[106,111,2.0243],[106,112,2.4955],[106,113,3.0968],[106,117,0.8612],[106,118,1.3328],[106,121,1.3009],[106,152,0.4197],[107,109,3.3627],[107,135,2.9904],[107,143,2.7539],[107,185,0.2722],[107,186,3.2055],[108,117,2.8582],[108,133,1.8784],[108,166,2.8461],[108,188,1.8337],[109,115,1.0408],[109,194,0.6185],[110,119,1.063],[110,149,0.3859],[110,163,1.4243],[111,112,2.8738],[111,116,2.6829],[111,149,3.2087],[111,193,2.7409],[111,198,1.181],[112,118,2.1883],[112,140,1.7762],[112,152,3.0096],[112,180,2.0814],[113,114,1.1785],[113,118,2.497],[113,147,3.628],[113,195,1.0095],[114,130,3.3302],[114,134,0.3033],[114,142,1.1613],[114,152,1.0764],[114,154,2.8536],[115,117,3.5564],[115,119,2.8615],[115,125,1.394],[115,172,3.3306],[116,119,1.3999],[116,138,1.0871],[116,139,3.4265],[116,163,2.4574],[117,119,2.675],[117,126,2.5783],[117,130,3.6765],[117,146,1.8932],[117,153,3.189],[117,165,2.6917],[117,173,3.2513],[117,181,1.7802],[118,124,2.7862],[118,176,2.2462],[119,122,1.3271],[119,129,0.9919],[119,170,2.4292],[120,124,0.5223],[120,128,3.4378],[120,130,0.7561],[120,136,0.3442],[120,140,0.6234],[120,142,3.5013],[120,143,1.457],[120,146,0.7464],[120,149,0.3506],[120,152,0.3958],[120,153,2.6742],[120,155,2.4686],[120,162,2.6895],[120,168,2.8287],[121,126,0.4802],[121,131,2.3167],[121,133,1.5219],[121,141,3.1115],[121,144,3.1185],[121,153,3.3695],[121,164,0.4808],[121,183,3.2873],[122,133,3.4504],[122,135,3.5551],[122,139,0.6249],[122,142,0.97],[122,149,0.6419],[122,157,0.3705],[122,158,3.217],[122,194,3.0921],[123,129,2.4696],[123,130,3.1377],[123,134,2.4604],[123,148,1.2558],[123,157,0.5996],[123,182,0.5925],[124,125,2.9008],[124,131,0.9675],[124,132,1.367],[124,134,1.7332],[124,135,0.3232],[124,137,1.1485],[124,139,1.2391],[124,142,2.7552],[124,143,1.5381],[124,151,1.3729],[124,152,3.624],[124,156,2.0131],[124,168,3.2298],[125,130,2.414],[125,131,0.3584],[125,134,1.6952],[125,136,1.7776],[125,138,2.9556],[125,144,1.4637],[125,148,2.7163],[125,149,2.1326],[125,151,1.008],[125,154,3.2678],[125,158,0.5681],[125,194,3.1193],[126,133,0.8463],[126,135,0.2545],[126,136,0.9571],[126,143,2.9176],[126,146,3.6725],[126,148,0.2653],[126,151,1.9679],[126,153,1.9702],[126,159,3.0387],[126,199,0.8958],[127,135,1.981],[127,142,1.4651],[127,148,3.1614],[127,155,1.162],[127,159,3.5535],[127,168,1.2431],[127,169,1.0015],[127,189,2.6982],[128,142,1.9941],[128,144,0.6347],[128,145,2.4779],[128,151,0.5331],[128,152,3.0077],[128,156,2.6901],[128,178,3.0043],[129,132,2.4478],[129,134,1.4947],[129,135,1.6544],[129,136,1.6311],[129,140,3.3664],[129,143,0.5516],[129,144,3.3596],[129,156,0.3381],[129,158,0.9714],[129,164,1.1712],[130,132,3.4043],[130,135,2.0042],[130,140,1.5776],[130,141,3.3439],[130,143,1.0675],[130,144,1.8632],[130,149,2.1104],[130,155,2.8907],[130,156,2.8855],[130,170,2.512],[131,145,1.4697],[131,152,1.3933],[131,161,0.7936],[131,193,3.2009],[131,198,2.5674],[132,136,2.847],[132,139,0.8434],[132,148,1.7858],[132,149,2.957],[132,151,2.2771],[132,154,0.6912],[132,162,1.8671],[133,139,3.3479],[133,140,1.0828],[133,142,0.9205],[133,144,1.3053],[133,153,2.7111],[133,164,3.2028],[133,169,0.7911],[133,193,0.796],[134,143,1.1165],[134,145,1.393],[134,147,2.0776],[134,148,0.8132],[134,151,1.3983],[134,155,0.9125],[134,156,3.663],[134,158,2.8006],[134,159,0.6063],[135,151,3.6183],[135,160,0.6057],[135,161,1.5948],[135,163,3.6934],[135,179,3.0321],[135,192,2.8165],[136,141,1.7722],[136,143,0.9367],[136,144,2.4829],[136,145,0.624],[136,149,0.9726],[136,150,1.6092],[136,154,0.3688],[136,157,1.6466],[136,158,3.0185],[136,159,2.677],[136,174,2.0017],[136,182,2.4633],[137,139,1.8715],[137,143,0.7463],[137,147,2.363],[137,156,1.6665],[137,157,2.8433],[137,159,3.428],[137,192,1.7551],[137,193,2.2589],[138,139,2.8719],[138,140,1.724],[138,142,1.05],[138,153,2.7778],[138,154,3.3303],[138,157,2.9592],[138,192,2.7003],[139,142,3.2336],[139,152,2.6286],[139,155,2.4954],[139,156,1.8387],[139,159,1.3455],[139,162,2.449],[139,175,0.5925],[139,196,1.7185],[140,142,2.9883],[140,148,2.746],[140,152,2.4537],[140,154,1.1252],[140,155,1.7325],[140,156,1.8432],[140,157,2.4255],[140,199,1.6827],[141,144,2.6134],[141,146,3.5057],[141,149,0.8907],[141,151,2.5407],[141,152,2.9736],[141,159,1.6105],[141,163,1.9644],[141,170,3.6612],[141,193,0.3835],[142,145,2.1518],[142,149,0.8129],[142,152,2.9863],[142,158,3.5421],[143,146,2.0673],[143,148,0.6038],[143,151,2.261],[143,157,2.1436],[143,166,2.7605],[144,146,2.0427],[144,147,2.4874],[144,150,3.1514],[144,154,2.0759],[144,195,1.6862],[144,197,3.5679],[145,152,0.9853],[145,157,2.6453],[146,156,1.6237],[146,158,2.9195],[146,160,0.6784],[146,163,3.6956],[146,176,1.4942],[146,186,0.4482],[147,151,1.2103],[147,152,1.6489],[147,154,0.2966],[147,155,1.715],[147,158,1.7219],[147,180,2.6939],[148,152,1.4824],[148,153,1.1781],[148,154,1.0355],[148,157,2.8451],[148,158,3.5398],[148,161,2.0948],[149,153,1.0162],[149,154,3.0552],[149,155,1.6219],[149,158,0.992],[149,167,0.7025],[150,151,2.9681],[150,152,3.0835],[150,159,2.47],[150,167,1.8921],[151,152,2.2172],[151,154,1.041],[151,155,3.6235],[151,191,1.486],[152,155,2.4858],[152,159,3.1156],[152,162,3.1066],[152,171,1.8884],[152,174,1.2802],[153,157,2.1689],[154,180,0.6881],[154,190,3.1681],[155,165,1.4916],[155,166,3.2273],[158,184,1.186],[158,198,1.5665],[159,167,1.1374],[160,172,1.7414],[160,175,0.9006],[160,181,0.2594],[160,185,2.7763],[160,186,1.2342],[160,195,1.1074],[161,170,1.3064],[161,174,1.9284],[161,179,1.7497],[161,180,2.4806],[161,183,2.5574],[161,189,1.5185],[161,190,3.5005],[161,191,3.2406],[162,164,0.4497],[162,173,3.1476],[162,175,3.4203],[162,190,2.9941],[162,193,0.7414],[162,194,3.1596],[163,169,2.4661],[163,175,0.3025],[163,177,0.2902],[163,179,3.5812],[163,187,2.5458],[163,189,1.1251],[163,197,0.6053],[164,168,0.7496],[164,174,1.0677],[164,177,2.9671],[164,179,1.4626],[164,181,0.7844],[164,182,3.4143],[164,183,3.0209],[164,188,0.8377],[164,190,3.369],[164,191,2.3793],[164,194,2.9845],[165,171,2.5896],[165,177,3.3787],[165,180,3.0083],[165,190,3.1858],[165,191,0.9408],[165,193,2.6748],[165,195,2.1078],[165,198,2.8467],[165,199,1.7851],[166,167,3.3394],[166,173,2.1927],[166,182,1.1757],[166,184,1.0696],[166,187,0.7377],[166,190,1.9758],[166,194,0.4546],[166,199,1.8848],[167,169,0.7555],[167,176,1.9698],[167,177,1.9936],[167,178,2.1384],[167,181,3.2701],[167,188,0.2731],[167,194,3.1927],[167,196,1.8879],[168,169,2.219],[168,172,2.5786],[168,178,3.192],[168,181,1.5624],[168,183,1.7159],[168,187,3.6121],[168,198,0.5139],[169,173,2.4796],[169,174,2.4764],[169,184,0.3499],[169,186,2.3839],[169,187,2.6391],[169,189,3.5102],[169,193,1.4066],[169,195,3.686],[169,197,2.0372],[169,198,1.9464],[170,175,3.3915],[170,176,0.3686],[170,186,2.7636],[170,193,2.4385],[170,195,1.4351],[171,175,3.2659],[171,176,1.5316],[171,178,1.9109],[171,185,2.0894],[171,195,2.947],[172,173,0.9875],[172,175,1.7732],[172,177,1.7284],[172,178,2.1891],[172,182,3.1435],[172,183,1.2751],[172,184,3.1471],[172,190,1.6631],[172,194,2.0131],[172,199,1.2009],[173,174,2.0225],[173,177,3.6625],[173,182,2.541],[173,185,3.0218],[173,190,1.4081],[173,191,1.3598],[173,196,1.2973],[174,175,2.3026],[174,181,2.4719],[174,187,2.9948],[174,192,0.3902],[174,196,2.7794],[174,198,3.3496],[175,177,2.1589],[175,182,0.4239],[175,190,1.3014],[175,194,0.2717],[175,197,0.9148],[176,184,3.475],[176,191,2.3804],[176,194,2.5531],[176,197,3.0116],[177,178,3.4344],[177,182,2.3911],[177,184,2.4084],[177,189,2.4438],[177,191,2.6874],[177,196,2.3371],[178,183,2.6334],[178,187,0.9938],[178,190,2.5845],[178,195,1.8526],[178,197,2.9194],[179,183,0.6048],[180,181,0.8845],[180,186,0.3794],[180,198,2.9609],[181,188,3.4493],[181,191,2.545],[181,193,1.541],[181,197,3.1291],[182,187,3.0029],[182,192,2.2174],[182,197,1.153],[184,185,1.3071],[184,187,1.7262],[184,192,1.3647],[184,199,1.7574],[185,187,2.4962],[185,189,3.5185],[185,191,0.4412],[185,196,2.2363],[185,198,0.3878],[186,191,0.666],[186,193,3.0862],[186,196,2.2636],[187,189,3.4652],[187,192,1.8127],[187,198,0.2995],[188,195,1.605],[189,191,2.3219],[190,192,3.532],[191,197,3.6827],[191,199,1.9141],[192,195,1.6935],[192,199,0.6072],[194,199,2.5058],[195,197,0.993],[195,198,0.7812],[196,198,0.3044],[196,199,0.2667]]},"sbm_hard/unit":{"n_nodes":200,"edges":[[0,6,1.0],[0,7,1.0],[0,16,1.0],[0,22,1.0],[0,26,1.0],[0,38,1.0],[0,39,1.0],[0,66,1.0],[0,76,1.0],[0,78,1.0],[0,88,1.0],[0,93,1.0],[0,114,1.0],[0,117,1.0],[0,120,1.0],[0,127,1.0],[0,128,1.0],[0,142,1.0],[0,185,1.0],[1,2,1.0],[1,6,1.0],[1,13,1.0],[1,15,1.0],[1,31,1.0],[1,36,1.0],[1,39,1.0],[1,46,1.0],[1,47,1.0],[1,48,1.0],[1,49,1.0],[1,50,1.0],[1,105,1.0],[1,108,1.0],[1,113,1.0],[1,122,1.0],[1,129,1.0],[1,178,1.0],[2,21,1.0],[2,22,1.0],[2,28,1.0],[2,37,1.0],[2,40,1.0],[2,43,1.0],[2,80,1.0],[2,89,1.0],[2,121,1.0],[2,136,1.0],[2,144,1.0],[2,154,1.0],[2,177,1.0],[2,198,1.0],[3,5,1.0],[3,9,1.0],[3,11,1.0],[3,12,1.0],[3,13,1.0],[3,14,1.0],[3,16,1.0],[3,17,1.0],[3,21,1.0],[3,24,1.0],[3,27,1.0],[3,29,1.0],[3,32,1.0],[3,33,1.0],[3,45,1.0],[3,49,1.0],[3,50,1.0],[3,53,1.0],[3,54,1.0],[3,63,1.0],[3,95,1.0],[3,119,1.0],[3,123,1.0],[3,138,1.0],[4,7,1.0],[4,15,1.0],[4,18,1.0],[4,19,1.0],[4,26,1.0],[4,40,1.0],[4,41,1.0],[4,44,1.0],[4,45,1.0],[4,52,1.0],[4,54,1.0],[4,57,1.0],[4,65,1.0],[4,80,1.0],[4,110,1.0],[4,148,1.0],[4,151,1.0],[4,156,1.0],[4,166,1.0],[4,172,1.0],[4,196,1.0],[5,16,1.0],[5,19,1.0],[5,22,1.0],[5,26,1.0],[5,27,1.0],[5,34,1.0],[5,37,1.0],[5,67,1.0],[5,71,1.0],[5,98,1.0],[5,118,1.0],[5,120,1.0],[5,137,1.0],[5,138,1.0],[5,151,1.0],[5,176,1.0],[5,189,1.0],[5,194,1.0],[6,9,1.0],[6,18,1.0],[6,25,1.0],[6,39,1.0],[6,44,1.0],[6,66,1.0],[6,86,1.0],[6,97,1.0],[6,139,1.0],[6,149,1.0],[6,168,1.0],[6,177,1.0],[6,181,1.0],[7,12,1.0],[7,13,1.0],[7,25,1.0],[7,28,1.0],[7,33,1.0],[7,35,1.0],[7,38,1.0],[7,44,1.0],[7,46,1.0],[7,50,1.0],[7,60,1.0],[7,73,1.0],[7,74,1.0],[7,83,1.0],[7,96,1.0],[7,110,1.0],[7,156,1.0],[7,191,1.0],[8,9,1.0],[8,16,1.0],[8,18,1.0],[8,21,1.0],[8,23,1.0],[8,34,1.0],[8,35,1.0],[8,44,1.0],[8,64,1.0],[8,82,1.0],[8,85,1.0],[8,94,1.0],[8,157,1.0],[8,167,1.0],[8,170,1.0],[8,180,1.0],[8,185,1.0],[8,186,1.0],[8,194,1.0],[9,14,1.0],[9,17,1.0],[9,26,1.0],[9,29,1.0],[9,36,1.0],[9,54,1.0],[9,110,1.0],[9,131,1.0],[9,134,1.0],[9,161,1.0],[9,177,1.0],[9,193,1.0],[9,198,1.0],[10,12,1.0],[10,13,1.0],[10,23,1.0],[10,24,1.0],[10,27,1.0],[10,36,1.0],[10,42,1.0],[10,46,1.0],[10,48,1.0],[10,61,1.0],[10,64,1.0],[10,66,1.0],[10,109,1.0],[10,149,1.0],[10,157,1.0],[10,181,1.0],[10,187,1.0],[10,189,1.0],[11,12,1.0],[11,24,1.0],[11,31,1.0],[11,35,1.0],[11,38,1.0],[11,65,1.0],[11,68,1.0],[11,119,1.0],[11,126,1.0],[11,137,1.0],[11,169,1.0],[11,176,1.0],[11,190,1.0],[12,13,1.0],[12,23,1.0],[12,39,1.0],[12,44,1.0],[12,47,1.0],[12,87,1.0],[12,99,1.0],[12,130,1.0],[12,147,1.0],[12,149,1.0],[12,166,1.0],[12,189,1.0],[13,29,1.0],[13,32,1.0],[13,33,1.0],[13,75,1.0],[13,98,1.0],[13,126,1.0],[13,140,1.0],[13,181,1.0],[13,196,1.0],[13,197,1.0],[14,25,1.0],[14,28,1.0],[14,30,1.0],[14,33,1.0],[14,68,1.0],[14,91,1.0],[14,102,1.0],[14,142,1.0],[14,171,1.0],[14,196,1.0],[15,28,1.0],[15,31,1.0],[15,34,1.0],[15,43,1.0],[15,45,1.0],[15,47,1.0],[15,70,1.0],[15,71,1.0],[15,77,1.0],[15,88,1.0],[15,124,1.0],[15,125,1.0],[15,129,1.0],[15,137,1.0],[15,143,1.0],[15,152,1.0],[15,196,1.0],[16,22,1.0],[16,25,1.0],[16,34,1.0],[16,41,1.0],[16,44,1.0],[16,53,1.0],[16,66,1.0],[16,67,1.0],[16,101,1.0],[16,106,1.0],[16,108,1.0],[16,134,1.0],[16,138,1.0],[16,152,1.0],[16,153,1.0],[16,158,1.0],[16,170,1.0],[16,180,1.0],[16,187,1.0],[17,27,1.0],[17,36,1.0],[17,55,1.0],[17,58,1.0],[17,125,1.0],[17,133,1.0],[17,140,1.0],[17,142,1.0],[17,144,1.0],[17,178,1.0],[17,189,1.0],[17,190,1.0],[18,27,1.0],[18,29,1.0],[18,30,1.0],[18,31,1.0],[18,34,1.0],[18,38,1.0],[18,44,1.0],[18,53,1.0],[18,72,1.0],[18,76,1.0],[18,92,1.0],[18,96,1.0],[18,131,1.0],[18,147,1.0],[18,153,1.0],[18,185,1.0],[19,21,1.0],[19,26,1.0],[19,28,1.0],[19,30,1.0],[19,32,1.0],[19,35,1.0],[19,36,1.0],[19,37,1.0],[19,38,1.0],[19,51,1.0],[19,67,1.0],[19,79,1.0],[19,87,1.0],[19,92,1.0],[19,99,1.0],[19,126,1.0],[19,127,1.0],[19,128,1.0],[19,134,1.0],[19,135,1.0],[19,148,1.0],[19,157,1.0],[19,174,1.0],[19,193,1.0],[20,33,1.0],[20,36,1.0],[20,37,1.0],[20,46,1.0],[20,48,1.0],[20,54,1.0],[20,79,1.0],[20,80,1.0],[20,115,1.0],[20,135,1.0],[20,153,1.0],[20,159,1.0],[20,160,1.0],[20,169,1.0],[20,181,1.0],[20,185,1.0],[20,188,1.0],[21,30,1.0],[21,39,1.0],[21,41,1.0],[21,48,1.0],[21,68,1.0],[21,76,1.0],[21,91,1.0],[21,135,1.0],[21,137,1.0],[21,172,1.0],[21,181,1.0],[22,29,1.0],[22,36,1.0],[22,37,1.0],[22,41,1.0],[22,47,1.0],[22,53,1.0],[22,54,1.0],[22,84,1.0],[22,100,1.0],[22,113,1.0],[22,124,1.0],[22,129,1.0],[22,131,1.0],[22,139,1.0],[22,172,1.0],[22,188,1.0],[23,27,1.0],[23,31,1.0],[23,40,1.0],[23,43,1.0],[23,52,1.0],[23,76,1.0],[23,85,1.0],[23,90,1.0],[23,91,1.0],[23,93,1.0],[23,104,1.0],[23,115,1.0],[23,154,1.0],[23,178,1.0],[23,183,1.0],[23,189,1.0],[24,27,1.0],[24,42,1.0],[24,50,1.0],[24,64,1.0],[24,74,1.0],[24,75,1.0],[24,84,1.0],[24,98,1.0],[24,109,1.0],[24,120,1.0],[24,122,1.0],[24,135,1.0],[24,145,1.0],[24,155,1.0],[24,174,1.0],[24,198,1.0],[25,38,1.0],[25,42,1.0],[25,44,1.0],[25,46,1.0],[25,63,1.0],[25,71,1.0],[25,75,1.0],[25,95,1.0],[25,106,1.0],[25,134,1.0],[25,152,1.0],[25,171,1.0],[25,173,1.0],[25,182,1.0],[25,193,1.0],[25,195,1.0],[26,31,1.0],[26,37,1.0],[26,45,1.0],[26,66,1.0],[26,67,1.0],[26,94,1.0],[26,120,1.0],[26,137,1.0],[26,146,1.0],[26,150,1.0],[26,153,1.0],[26,195,1.0],[27,43,1.0],[27,54,1.0],[27,61,1.0],[27,64,1.0],[27,75,1.0],[27,78,1.0],[27,119,1.0],[27,123,1.0],[27,126,1.0],[27,149,1.0],[27,164,1.0],[27,169,1.0],[27,176,1.0],[27,178,1.0],[27,196,1.0],[27,199,1.0],[28,29,1.0],[28,36,1.0],[28,41,1.0],[28,65,1.0],[28,69,1.0],[28,83,1.0],[28,105,1.0],[28,107,1.0],[28,110,1.0],[28,129,1.0],[28,150,1.0],[28,153,1.0],[29,36,1.0],[29,45,1.0],[29,48,1.0],[29,60,1.0],[29,71,1.0],[29,83,1.0],[29,130,1.0],[29,145,1.0],[29,147,1.0],[29,153,1.0],[29,165,1.0],[29,174,1.0],[29,176,1.0],[29,180,1.0],[29,191,1.0],[30,32,1.0],[30,33,1.0],[30,46,1.0],[30,55,1.0],[30,70,1.0],[30,76,1.0],[30,78,1.0],[30,90,1.0],[30,92,1.0],[30,95,1.0],[30,114,1.0],[30,123,1.0],[30,131,1.0],[30,142,1.0],[30,171,1.0],[30,178,1.0],[30,181,1.0],[30,199,1.0],[31,36,1.0],[31,38,1.0],[31,40,1.0],[31,44,1.0],[31,47,1.0],[31,50,1.0],[31,67,1.0],[31,84,1.0],[32,40,1.0],[32,46,1.0],[32,77,1.0],[32,79,1.0],[32,92,1.0],[32,104,1.0],[32,111,1.0],[32,153,1.0],[32,190,1.0],[32,191,1.0],[32,197,1.0],[33,35,1.0],[33,38,1.0],[33,43,1.0],[33,51,1.0],[33,68,1.0],[33,78,1.0],[33,80,1.0],[33,104,1.0],[33,107,1.0],[33,115,1.0],[33,139,1.0],[33,157,1.0],[33,182,1.0],[33,193,1.0],[34,39,1.0],[34,41,1.0],[34,43,1.0],[34,48,1.0],[34,53,1.0],[34,55,1.0],[34,60,1.0],[34,77,1.0],[34,79,1.0],[34,80,1.0],[34,83,1.0],[34,90,1.0],[34,125,1.0],[34,131,1.0],[34,138,1.0],[34,172,1.0],[34,180,1.0],[34,199,1.0],[35,40,1.0],[35,41,1.0],[35,72,1.0],[35,81,1.0],[35,86,1.0],[35,89,1.0],[35,111,1.0],[35,126,1.0],[35,134,1.0],[35,139,1.0],[35,159,1.0],[35,163,1.0],[35,168,1.0],[35,186,1.0],[35,189,1.0],[36,42,1.0],[36,54,1.0],[36,60,1.0],[36,69,1.0],[36,88,1.0],[36,108,1.0],[36,120,1.0],[36,131,1.0],[36,133,1.0],[36,138,1.0],[36,151,1.0],[36,170,1.0],[36,178,1.0],[36,189,1.0],[36,194,1.0],[36,195,1.0],[37,39,1.0],[37,40,1.0],[37,41,1.0],[37,48,1.0],[37,49,1.0],[37,66,1.0],[37,67,1.0],[37,68,1.0],[37,81,1.0],[37,116,1.0],[37,129,1.0],[37,150,1.0],[37,168,1.0],[37,177,1.0],[37,180,1.0],[38,40,1.0],[38,41,1.0],[38,46,1.0],[38,67,1.0],[38,73,1.0],[38,78,1.0],[38,81,1.0],[38,89,1.0],[38,96,1.0],[38,101,1.0],[38,124,1.0],[38,137,1.0],[38,183,1.0],[39,54,1.0],[39,73,1.0],[39,76,1.0],[39,97,1.0],[39,98,1.0],[39,125,1.0],[39,188,1.0],[40,45,1.0],[40,69,1.0],[40,88,1.0],[40,89,1.0],[40,97,1.0],[40,103,1.0],[40,113,1.0],[40,129,1.0],[40,130,1.0],[40,131,1.0],[40,146,1.0],[40,165,1.0],[40,197,1.0],[41,46,1.0],[41,48,1.0],[41,59,1.0],[41,78,1.0],[41,88,1.0],[41,101,1.0],[41,110,1.0],[41,122,1.0],[41,135,1.0],[41,142,1.0],[41,169,1.0],[41,187,1.0],[41,198,1.0],[42,47,1.0],[42,48,1.0],[42,51,1.0],[42,66,1.0],[42,77,1.0],[42,97,1.0],[42,100,1.0],[42,118,1.0],[42,121,1.0],[42,139,1.0],[42,143,1.0],[42,168,1.0],[42,181,1.0],[42,186,1.0],[43,53,1.0],[43,111,1.0],[43,119,1.0],[43,132,1.0],[43,142,1.0],[43,155,1.0],[44,45,1.0],[44,47,1.0],[44,48,1.0],[44,87,1.0],[44,101,1.0],[44,107,1.0],[44,131,1.0],[44,136,1.0],[44,138,1.0],[44,143,1.0],[44,146,1.0],[44,160,1.0],[44,178,1.0],[44,181,1.0],[44,190,1.0],[45,47,1.0],[45,48,1.0],[45,50,1.0],[45,57,1.0],[45,59,1.0],[45,73,1.0],[45,78,1.0],[45,90,1.0],[45,103,1.0],[45,110,1.0],[45,119,1.0],[45,149,1.0],[45,150,1.0],[45,155,1.0],[45,169,1.0],[45,176,1.0],[45,177,1.0],[46,54,1.0],[46,63,1.0],[46,167,1.0],[46,177,1.0],[47,63,1.0],[47,64,1.0],[47,73,1.0],[47,117,1.0],[47,153,1.0],[47,165,1.0],[47,176,1.0],[48,51,1.0],[48,53,1.0],[48,69,1.0],[48,98,1.0],[48,108,1.0],[48,114,1.0],[48,145,1.0],[48,150,1.0],[48,161,1.0],[48,171,1.0],[48,172,1.0],[48,187,1.0],[48,193,1.0],[48,195,1.0],[49,65,1.0],[49,144,1.0],[49,148,1.0],[49,155,1.0],[49,168,1.0],[49,190,1.0],[50,52,1.0],[50,58,1.0],[50,64,1.0],[50,68,1.0],[50,69,1.0],[50,76,1.0],[50,95,1.0],[50,102,1.0],[50,110,1.0],[50,129,1.0],[50,154,1.0],[51,56,1.0],[51,57,1.0],[51,60,1.0],[51,65,1.0],[51,73,1.0],[51,83,1.0],[51,93,1.0],[51,115,1.0],[51,145,1.0],[51,163,1.0],[51,164,1.0],[51,171,1.0],[51,179,1.0],[51,197,1.0],[52,53,1.0],[52,57,1.0],[52,63,1.0],[52,78,1.0],[52,81,1.0],[52,85,1.0],[52,88,1.0],[52,89,1.0],[52,103,1.0],[52,112,1.0],[52,132,1.0],[52,160,1.0],[52,167,1.0],[52,186,1.0],[53,54,1.0],[53,58,1.0],[53,61,1.0],[53,63,1.0],[53,66,1.0],[53,70,1.0],[53,75,1.0],[53,78,1.0],[53,80,1.0],[53,85,1.0],[53,95,1.0],[53,139,1.0],[53,150,1.0],[53,167,1.0],[53,178,1.0],[53,180,1.0],[53,193,1.0],[54,58,1.0],[54,59,1.0],[54,60,1.0],[54,65,1.0],[54,69,1.0],[54,80,1.0],[54,87,1.0],[54,89,1.0],[54,96,1.0],[54,97,1.0],[54,121,1.0],[54,123,1.0],[54,126,1.0],[54,129,1.0],[54,133,1.0],[54,156,1.0],[54,158,1.0],[54,160,1.0],[54,161,1.0],[54,165,1.0],[55,57,1.0],[55,60,1.0],[55,64,1.0],[55,65,1.0],[55,67,1.0],[55,112,1.0],[55,114,1.0],[55,122,1.0],[55,125,1.0],[55,138,1.0],[55,171,1.0],[55,179,1.0],[55,183,1.0],[56,68,1.0],[56,79,1.0],[56,82,1.0],[56,83,1.0],[56,94,1.0],[56,105,1.0],[56,112,1.0],[56,144,1.0],[56,148,1.0],[56,156,1.0],[56,160,1.0],[56,171,1.0],[56,176,1.0],[56,181,1.0],[56,189,1.0],[57,60,1.0],[57,68,1.0],[57,69,1.0],[57,70,1.0],[57,82,1.0],[57,85,1.0],[57,89,1.0],[57,93,1.0],[57,98,1.0],[57,113,1.0],[57,122,1.0],[57,148,1.0],[57,155,1.0],[57,162,1.0],[57,176,1.0],[57,188,1.0],[57,196,1.0],[58,65,1.0],[58,66,1.0],[58,83,1.0],[58,86,1.0],[58,107,1.0],[58,111,1.0],[58,128,1.0],[58,132,1.0],[58,137,1.0],[58,141,1.0],[58,167,1.0],[58,169,1.0],[58,173,1.0],[58,178,1.0],[59,70,1.0],[59,73,1.0],[59,78,1.0],[59,89,1.0],[59,90,1.0],[59,97,1.0],[59,98,1.0],[59,99,1.0],[59,102,1.0],[59,107,1.0],[59,109,1.0],[59,126,1.0],[59,152,1.0],[59,155,1.0],[59,168,1.0],[59,172,1.0],[60,61,1.0],[60,63,1.0],[60,70,1.0],[60,86,1.0],[60,89,1.0],[60,112,1.0],[60,116,1.0],[60,124,1.0],[60,146,1.0],[60,154,1.0],[60,173,1.0],[60,189,1.0],[60,192,1.0],[60,197,1.0],[61,63,1.0],[61,64,1.0],[61,65,1.0],[61,68,1.0],[61,69,1.0],[61,73,1.0],[61,79,1.0],[61,87,1.0],[61,96,1.0],[61,118,1.0],[61,125,1.0],[61,130,1.0],[61,131,1.0],[61,141,1.0],[61,144,1.0],[61,152,1.0],[61,174,1.0],[61,189,1.0],[61,194,1.0],[62,68,1.0],[62,74,1.0],[62,85,1.0],[62,90,1.0],[62,95,1.0],[62,98,1.0],[62,102,1.0],[62,104,1.0],[62,156,1.0],[62,160,1.0],[63,64,1.0],[63,66,1.0],[63,69,1.0],[63,73,1.0],[63,85,1.0],[63,87,1.0],[63,91,1.0],[63,95,1.0],[63,107,1.0],[63,121,1.0],[63,134,1.0],[63,139,1.0],[63,165,1.0],[63,193,1.0],[63,194,1.0],[64,74,1.0],[64,80,1.0],[64,86,1.0],[64,88,1.0],[64,92,1.0],[64,105,1.0],[64,110,1.0],[64,111,1.0],[64,121,1.0],[64,123,1.0],[64,128,1.0],[64,131,1.0],[64,152,1.0],[64,160,1.0],[64,161,1.0],[64,170,1.0],[64,173,1.0],[64,178,1.0],[65,66,1.0],[65,69,1.0],[65,70,1.0],[65,71,1.0],[65,72,1.0],[65,81,1.0],[65,83,1.0],[65,84,1.0],[65,90,1.0],[65,107,1.0],[65,128,1.0],[65,137,1.0],[65,140,1.0],[65,144,1.0],[65,167,1.0],[65,176,1.0],[65,184,1.0],[65,192,1.0],[66,75,1.0],[66,79,1.0],[66,81,1.0],[66,84,1.0],[66,85,1.0],[66,98,1.0],[66,111,1.0],[66,146,1.0],[66,150,1.0],[66,151,1.0],[66,181,1.0],[66,197,1.0],[67,68,1.0],[67,69,1.0],[67,76,1.0],[67,77,1.0],[67,96,1.0],[67,110,1.0],[67,132,1.0],[67,143,1.0],[67,153,1.0],[67,171,1.0],[68,70,1.0],[68,80,1.0],[68,83,1.0],[68,91,1.0],[68,95,1.0],[68,98,1.0],[68,112,1.0],[68,113,1.0],[68,116,1.0],[68,128,1.0],[68,149,1.0],[68,163,1.0],[68,179,1.0],[68,191,1.0],[69,72,1.0],[69,96,1.0],[69,116,1.0],[69,117,1.0],[69,128,1.0],[69,143,1.0],[69,151,1.0],[69,158,1.0],[69,162,1.0],[69,185,1.0],[69,192,1.0],[70,72,1.0],[70,76,1.0],[70,79,1.0],[70,81,1.0],[70,96,1.0],[70,98,1.0],[70,100,1.0],[70,104,1.0],[70,118,1.0],[70,138,1.0],[70,174,1.0],[70,176,1.0],[70,178,1.0],[71,92,1.0],[71,102,1.0],[71,120,1.0],[71,126,1.0],[71,154,1.0],[71,163,1.0],[71,175,1.0],[72,78,1.0],[72,84,1.0],[72,96,1.0],[72,102,1.0],[72,110,1.0],[72,112,1.0],[72,139,1.0],[72,143,1.0],[72,150,1.0],[72,151,1.0],[72,176,1.0],[73,81,1.0],[73,123,1.0],[73,134,1.0],[73,141,1.0],[73,150,1.0],[73,154,1.0],[73,164,1.0],[73,192,1.0],[74,78,1.0],[74,81,1.0],[74,83,1.0],[74,91,1.0],[74,97,1.0],[74,105,1.0],[74,113,1.0],[74,144,1.0],[74,154,1.0],[74,175,1.0],[74,181,1.0],[74,192,1.0],[75,88,1.0],[75,92,1.0],[75,96,1.0],[75,101,1.0],[75,106,1.0],[75,126,1.0],[75,132,1.0],[75,135,1.0],[75,165,1.0],[75,173,1.0],[75,180,1.0],[75,182,1.0],[75,192,1.0],[76,144,1.0],[76,153,1.0],[76,193,1.0],[77,80,1.0],[77,81,1.0],[77,84,1.0],[77,87,1.0],[77,91,1.0],[77,92,1.0],[77,93,1.0],[77,97,1.0],[77,132,1.0],[77,145,1.0],[77,148,1.0],[77,172,1.0],[77,186,1.0],[77,189,1.0],[77,198,1.0],[77,199,1.0],[78,80,1.0],[78,82,1.0],[78,84,1.0],[78,99,1.0],[78,147,1.0],[78,152,1.0],[78,183,1.0],[78,189,1.0],[78,193,1.0],[78,198,1.0],[79,85,1.0],[79,87,1.0],[79,88,1.0],[79,95,1.0],[79,99,1.0],[79,126,1.0],[79,129,1.0],[79,133,1.0],[79,145,1.0],[79,156,1.0],[79,160,1.0],[79,178,1.0],[80,85,1.0],[80,87,1.0],[80,91,1.0],[80,97,1.0],[80,115,1.0],[80,118,1.0],[80,176,1.0],[80,178,1.0],[80,194,1.0],[81,88,1.0],[81,89,1.0],[81,92,1.0],[81,94,1.0],[81,98,1.0],[81,99,1.0],[81,114,1.0],[81,119,1.0],[81,124,1.0],[81,127,1.0],[81,132,1.0],[81,138,1.0],[81,142,1.0],[81,154,1.0],[81,190,1.0],[81,198,1.0],[82,89,1.0],[82,90,1.0],[82,94,1.0],[82,96,1.0],[82,98,1.0],[82,107,1.0],[82,111,1.0],[82,112,1.0],[82,113,1.0],[82,116,1.0],[82,117,1.0],[82,134,1.0],[82,135,1.0],[82,153,1.0],[82,183,1.0],[82,189,1.0],[82,197,1.0],[82,198,1.0],[83,84,1.0],[83,92,1.0],[83,95,1.0],[83,97,1.0],[83,113,1.0],[83,119,1.0],[83,134,1.0],[83,150,1.0],[83,168,1.0],[83,192,1.0],[84,91,1.0],[84,95,1.0],[84,96,1.0],[84,106,1.0],[84,124,1.0],[84,130,1.0],[84,141,1.0],[84,143,1.0],[84,161,1.0],[84,168,1.0],[84,174,1.0],[84,190,1.0],[85,136,1.0],[85,142,1.0],[85,169,1.0],[85,181,1.0],[86,95,1.0],[86,99,1.0],[86,114,1.0],[86,123,1.0],[86,125,1.0],[86,155,1.0],[86,186,1.0],[87,104,1.0],[87,106,1.0],[87,108,1.0],[87,121,1.0],[87,127,1.0],[87,156,1.0],[87,157,1.0],[87,163,1.0],[87,188,1.0],[87,197,1.0],[88,89,1.0],[88,98,1.0],[88,99,1.0],[88,106,1.0],[88,107,1.0],[88,118,1.0],[88,120,1.0],[88,125,1.0],[88,157,1.0],[88,168,1.0],[88,175,1.0],[88,184,1.0],[89,94,1.0],[89,99,1.0],[89,107,1.0],[89,108,1.0],[89,114,1.0],[89,138,1.0],[89,143,1.0],[89,150,1.0],[89,173,1.0],[89,191,1.0],[90,91,1.0],[90,92,1.0],[90,97,1.0],[90,141,1.0],[90,150,1.0],[90,154,1.0],[90,155,1.0],[90,158,1.0],[90,167,1.0],[90,195,1.0],[91,95,1.0],[91,96,1.0],[91,112,1.0],[91,116,1.0],[91,142,1.0],[91,178,1.0],[91,184,1.0],[91,199,1.0],[92,96,1.0],[92,98,1.0],[92,145,1.0],[92,156,1.0],[92,166,1.0],[92,193,1.0],[93,97,1.0],[93,99,1.0],[93,100,1.0],[93,105,1.0],[93,128,1.0],[93,131,1.0],[93,152,1.0],[93,161,1.0],[93,166,1.0],[93,188,1.0],[93,190,1.0],[93,195,1.0],[94,103,1.0],[94,134,1.0],[94,154,1.0],[94,183,1.0],[94,185,1.0],[94,194,1.0],[95,96,1.0],[95,115,1.0],[95,124,1.0],[95,129,1.0],[95,184,1.0],[96,105,1.0],[96,119,1.0],[96,137,1.0],[96,169,1.0],[97,145,1.0],[97,161,1.0],[97,170,1.0],[97,182,1.0],[97,185,1.0],[98,99,1.0],[98,125,1.0],[98,145,1.0],[98,171,1.0],[98,185,1.0],[99,103,1.0],[99,126,1.0],[99,128,1.0],[99,132,1.0],[99,139,1.0],[99,141,1.0],[99,147,1.0],[99,149,1.0],[99,178,1.0],[99,179,1.0],[99,191,1.0],[99,199,1.0],[100,111,1.0],[100,116,1.0],[100,117,1.0],[100,121,1.0],[100,135,1.0],[100,143,1.0],[100,148,1.0],[100,163,1.0],[100,166,1.0],[100,193,1.0],[101,107,1.0],[101,116,1.0],[101,118,1.0],[101,119,1.0],[101,131,1.0],[101,137,1.0],[101,142,1.0],[101,143,1.0],[101,144,1.0],[101,150,1.0],[101,158,1.0],[101,161,1.0],[102,114,1.0],[102,126,1.0],[102,136,1.0],[102,156,1.0],[102,160,1.0],[103,104,1.0],[103,107,1.0],[103,110,1.0],[103,117,1.0],[103,120,1.0],[103,127,1.0],[103,139,1.0],[103,156,1.0],[103,160,1.0],[103,173,1.0],[104,106,1.0],[104,109,1.0],[104,117,1.0],[104,118,1.0],[104,125,1.0],[104,131,1.0],[104,132,1.0],[104,135,1.0],[104,137,1.0],[104,142,1.0],[104,144,1.0],[104,145,1.0],[104,151,1.0],[104,198,1.0],[105,108,1.0],[105,110,1.0],[105,116,1.0],[105,119,1.0],[105,126,1.0],[105,130,1.0],[105,134,1.0],[105,141,1.0],[105,144,1.0],[105,147,1.0],[105,148,1.0],[105,163,1.0],[105,166,1.0],[105,169,1.0],[106,123,1.0],[106,124,1.0],[106,129,1.0],[106,130,1.0],[106,132,1.0],[106,142,1.0],[106,143,1.0],[106,151,1.0],[106,172,1.0],[106,175,1.0],[106,187,1.0],[106,189,1.0],[106,198,1.0],[107,117,1.0],[107,121,1.0],[107,122,1.0],[107,129,1.0],[107,133,1.0],[107,154,1.0],[108,110,1.0],[108,115,1.0],[108,117,1.0],[108,118,1.0],[108,119,1.0],[108,121,1.0],[108,126,1.0],[108,130,1.0],[108,131,1.0],[108,140,1.0],[108,141,1.0],[108,151,1.0],[108,155,1.0],[108,186,1.0],[108,189,1.0],[108,198,1.0],[109,116,1.0],[109,124,1.0],[109,133,1.0],[109,137,1.0],[109,143,1.0],[109,144,1.0],[109,149,1.0],[109,162,1.0],[109,180,1.0],[110,111,1.0],[110,115,1.0],[110,127,1.0],[110,135,1.0],[110,137,1.0],[110,148,1.0],[110,159,1.0],[110,163,1.0],[110,197,1.0],[111,117,1.0],[111,126,1.0],[111,130,1.0],[111,134,1.0],[111,138,1.0],[111,139,1.0],[111,149,1.0],[111,184,1.0],[111,188,1.0],[111,190,1.0],[111,196,1.0],[111,199,1.0],[112,118,1.0],[112,119,1.0],[112,132,1.0],[112,139,1.0],[112,155,1.0],[112,166,1.0],[112,168,1.0],[112,196,1.0],[113,114,1.0],[113,116,1.0],[113,118,1.0],[113,120,1.0],[113,129,1.0],[113,132,1.0],[113,136,1.0],[113,138,1.0],[113,149,1.0],[113,154,1.0],[113,175,1.0],[114,119,1.0],[114,135,1.0],[114,140,1.0],[114,156,1.0],[114,171,1.0],[114,178,1.0],[114,183,1.0],[115,119,1.0],[115,120,1.0],[115,123,1.0],[115,125,1.0],[115,128,1.0],[115,131,1.0],[115,179,1.0],[115,189,1.0],[115,199,1.0],[116,128,1.0],[116,130,1.0],[116,138,1.0],[116,139,1.0],[116,140,1.0],[116,144,1.0],[116,149,1.0],[116,154,1.0],[116,156,1.0],[116,157,1.0],[116,174,1.0],[117,121,1.0],[117,130,1.0],[117,135,1.0],[117,139,1.0],[117,143,1.0],[117,146,1.0],[117,179,1.0],[117,181,1.0],[117,183,1.0],[117,190,1.0],[118,127,1.0],[118,133,1.0],[118,134,1.0],[118,136,1.0],[118,137,1.0],[118,149,1.0],[118,168,1.0],[119,123,1.0],[119,126,1.0],[119,128,1.0],[119,131,1.0],[119,133,1.0],[119,135,1.0],[119,138,1.0],[119,140,1.0],[119,148,1.0],[119,169,1.0],[119,180,1.0],[119,183,1.0],[120,124,1.0],[120,127,1.0],[120,130,1.0],[120,138,1.0],[120,147,1.0],[120,148,1.0],[120,154,1.0],[120,160,1.0],[120,166,1.0],[120,180,1.0],[120,181,1.0],[120,196,1.0],[120,199,1.0],[121,123,1.0],[121,124,1.0],[121,135,1.0],[121,141,1.0],[121,146,1.0],[121,172,1.0],[121,175,1.0],[121,188,1.0],[121,193,1.0],[122,132,1.0],[122,146,1.0],[122,147,1.0],[122,152,1.0],[122,165,1.0],[122,182,1.0],[122,188,1.0],[122,192,1.0],[122,197,1.0],[123,139,1.0],[123,140,1.0],[123,161,1.0],[123,175,1.0],[123,182,1.0],[123,184,1.0],[124,125,1.0],[124,132,1.0],[124,141,1.0],[124,155,1.0],[124,159,1.0],[124,196,1.0],[125,129,1.0],[125,130,1.0],[125,146,1.0],[125,164,1.0],[125,175,1.0],[125,178,1.0],[125,185,1.0],[125,191,1.0],[125,198,1.0],[126,136,1.0],[126,137,1.0],[126,138,1.0],[126,139,1.0],[126,144,1.0],[126,169,1.0],[126,183,1.0],[126,199,1.0],[127,128,1.0],[127,131,1.0],[127,134,1.0],[127,137,1.0],[127,145,1.0],[127,149,1.0],[127,158,1.0],[127,186,1.0],[127,191,1.0],[128,135,1.0],[128,142,1.0],[128,145,1.0],[128,153,1.0],[128,154,1.0],[128,155,1.0],[128,156,1.0],[128,167,1.0],[128,168,1.0],[128,177,1.0],[129,133,1.0],[129,149,1.0],[129,160,1.0],[129,191,1.0],[129,195,1.0],[130,131,1.0],[130,143,1.0],[130,144,1.0],[130,148,1.0],[130,163,1.0],[130,172,1.0],[131,133,1.0],[131,134,1.0],[131,141,1.0],[131,146,1.0],[132,133,1.0],[132,142,1.0],[132,144,1.0],[132,147,1.0],[132,155,1.0],[132,165,1.0],[132,171,1.0],[132,176,1.0],[134,142,1.0],[134,143,1.0],[134,144,1.0],[134,155,1.0],[134,170,1.0],[134,183,1.0],[134,192,1.0],[135,154,1.0],[135,176,1.0],[135,180,1.0],[135,181,1.0],[135,185,1.0],[135,197,1.0],[136,138,1.0],[136,152,1.0],[136,166,1.0],[136,169,1.0],[136,196,1.0],[137,139,1.0],[137,143,1.0],[137,144,1.0],[137,146,1.0],[137,149,1.0],[137,171,1.0],[137,177,1.0],[137,198,1.0],[138,139,1.0],[138,158,1.0],[138,161,1.0],[138,166,1.0],[138,195,1.0],[139,140,1.0],[139,146,1.0],[139,148,1.0],[140,143,1.0],[140,177,1.0],[140,183,1.0],[140,192,1.0],[141,143,1.0],[141,144,1.0],[141,177,1.0],[141,184,1.0],[141,196,1.0],[142,143,1.0],[142,163,1.0],[142,167,1.0],[142,172,1.0],[142,178,1.0],[142,187,1.0],[142,189,1.0],[142,196,1.0],[142,197,1.0],[143,145,1.0],[143,190,1.0],[144,180,1.0],[144,185,1.0],[144,187,1.0],[144,194,1.0],[145,147,1.0],[145,164,1.0],[145,194,1.0],[146,148,1.0],[146,149,1.0],[146,165,1.0],[146,166,1.0],[146,170,1.0],[146,174,1.0],[146,177,1.0],[146,181,1.0],[146,185,1.0],[147,153,1.0],[147,169,1.0],[147,182,1.0],[147,190,1.0],[148,168,1.0],[148,179,1.0],[148,191,1.0],[148,198,1.0],[149,156,1.0],[149,179,1.0],[149,181,1.0],[150,158,1.0],[150,162,1.0],[150,176,1.0],[150,182,1.0],[150,186,1.0],[150,195,1.0],[151,161,1.0],[151,174,1.0],[151,178,1.0],[151,182,1.0],[151,187,1.0],[151,197,1.0],[152,161,1.0],[152,163,1.0],[152,166,1.0],[152,173,1.0],[152,175,1.0],[152,176,1.0],[152,177,1.0],[152,184,1.0],[152,190,1.0],[152,191,1.0],[152,195,1.0],[152,196,1.0],[153,159,1.0],[153,161,1.0],[153,163,1.0],[153,165,1.0],[153,167,1.0],[153,173,1.0],[153,176,1.0],[153,177,1.0],[153,179,1.0],[153,189,1.0],[153,190,1.0],[153,198,1.0],[154,156,1.0],[154,158,1.0],[154,159,1.0],[154,160,1.0],[154,161,1.0],[154,168,1.0],[154,174,1.0],[154,192,1.0],[154,198,1.0],[155,162,1.0],[155,167,1.0],[155,170,1.0],[155,174,1.0],[155,178,1.0],[155,183,1.0],[155,195,1.0],[156,163,1.0],[156,182,1.0],[156,185,1.0],[156,188,1.0],[156,197,1.0],[156,199,1.0],[157,162,1.0],[157,163,1.0],[157,169,1.0],[157,171,1.0],[157,173,1.0],[157,175,1.0],[157,179,1.0],[157,184,1.0],[157,185,1.0],[158,165,1.0],[158,173,1.0],[158,175,1.0],[158,178,1.0],[158,182,1.0],[158,184,1.0],[158,187,1.0],[158,193,1.0],[158,194,1.0],[158,198,1.0],[159,162,1.0],[159,169,1.0],[159,174,1.0],[159,175,1.0],[159,180,1.0],[159,181,1.0],[159,184,1.0],[159,188,1.0],[159,194,1.0],[159,198,1.0],[160,170,1.0],[160,174,1.0],[160,175,1.0],[160,186,1.0],[160,193,1.0],[160,196,1.0],[160,198,1.0],[161,165,1.0],[161,169,1.0],[161,172,1.0],[161,173,1.0],[161,180,1.0],[161,188,1.0],[162,164,1.0],[162,166,1.0],[162,172,1.0],[162,174,1.0],[162,190,1.0],[163,167,1.0],[163,168,1.0],[163,176,1.0],[163,178,1.0],[163,179,1.0],[163,191,1.0],[164,169,1.0],[164,171,1.0],[164,173,1.0],[164,181,1.0],[164,188,1.0],[164,191,1.0],[164,193,1.0],[164,194,1.0],[165,167,1.0],[165,170,1.0],[165,175,1.0],[165,181,1.0],[165,186,1.0],[165,198,1.0],[166,187,1.0],[166,188,1.0],[166,189,1.0],[166,191,1.0],[167,177,1.0],[167,178,1.0],[167,180,1.0],[167,183,1.0],[167,190,1.0],[167,197,1.0],[167,198,1.0],[168,174,1.0],[168,185,1.0],[168,186,1.0],[168,189,1.0],[168,190,1.0],[168,191,1.0],[168,193,1.0],[168,195,1.0],[169,173,1.0],[169,177,1.0],[169,178,1.0],[169,182,1.0],[169,184,1.0],[169,192,1.0],[169,195,1.0],[170,171,1.0],[170,172,1.0],[170,173,1.0],[170,182,1.0],[170,184,1.0],[170,185,1.0],[170,190,1.0],[170,198,1.0],[171,175,1.0],[171,176,1.0],[171,179,1.0],[171,181,1.0],[171,191,1.0],[171,193,1.0],[172,175,1.0],[172,179,1.0],[172,192,1.0],[172,194,1.0],[172,195,1.0],[172,196,1.0],[172,199,1.0],[173,174,1.0],[173,176,1.0],[173,179,1.0],[173,183,1.0],[173,184,1.0],[173,188,1.0],[173,194,1.0],[173,198,1.0],[174,178,1.0],[174,179,1.0],[174,184,1.0],[174,195,1.0],[174,197,1.0],[174,198,1.0],[175,176,1.0],[175,179,1.0],[175,190,1.0],[175,192,1.0],[176,179,1.0],[176,183,1.0],[176,189,1.0],[176,191,1.0],[176,194,1.0],[176,199,1.0],[177,179,1.0],[177,180,1.0],[177,181,1.0],[177,183,1.0],[177,192,1.0],[177,197,1.0],[178,191,1.0],[178,198,1.0],[179,180,1.0],[179,185,1.0],[179,191,1.0],[179,196,1.0],[179,199,1.0],[180,184,1.0],[180,185,1.0],[180,186,1.0],[180,189,1.0],[181,189,1.0],[181,193,1.0],[181,196,1.0],[182,187,1.0],[182,192,1.0],[182,193,1.0],[183,189,1.0],[183,191,1.0],[184,186,1.0],[184,190,1.0],[184,194,1.0],[184,199,1.0],[185,195,1.0],[186,192,1.0],[186,198,1.0],[187,188,1.0],[187,189,1.0],[187,199,1.0],[188,189,1.0],[188,194,1.0],[189,190,1.0],[189,194,1.0],[189,199,1.0],[190,193,1.0],[190,199,1.0],[192,194,1.0],[195,197,1.0],[195,199,1.0]]},"sbm_hard/weighted":{"n_nodes":200,"edges":[[0,6,1.3834],[0,7,0.778],[0,16,2.5283],[0,22,0.5035],[0,26,2.1256],[0,38,1.5299],[0,39,0.453],[0,66,2.026],[0,76,0.3812],[0,78,1.7678],[0,88,0.4945],[0,93,0.5675],[0,114,1.7358],[0,117,3.144],[0,120,0.6833],[0,127,1.0313],[0,128,2.446],[0,142,3.567],[0,185,2.2699],[1,2,1.6384],[1,6,3.6669],[1,13,0.413],[1,15,3.2546],[1,31,1.2636],[1,36,0.7549],[1,39,0.6623],[1,46,1.3297],[1,47,3.1064],[1,48,0.8825],[1,49,2.2856],[1,50,2.4862],[1,105,1.5534],[1,108,2.1671],[1,113,0.4698],[1,122,0.4586],[1,129,0.9709],[1,178,2.6314],[2,21,1.7466],[2,22,1.3495],[2,28,2.2995],[2,37,1.8361],[2,40,1.2992],[2,43,3.0303],[2,80,2.6965],[2,89,1.1043],[2,121,2.2605],[2,136,2.0882],[2,144,3.313],[2,154,2.8031],[2,177,1.2578],[2,198,3.6806],[3,5,0.6632],[3,9,1.7134],[3,11,2.9],[3,12,0.7819],[3,13,1.9614],[3,14,0.3872],[3,16,2.5888],[3,17,2.926],[3,21,2.2556],[3,24,3.3142],[3,27,1.3481],[3,29,2.6835],[3,32,2.3303],[3,33,2.2796],[3,45,1.8467],[3,49,3.1899],[3,50,3.5564],[3,53,1.9093],[3,54,2.5745],[3,63,0.4623],[3,95,2.7052],[3,119,2.515],[3,123,3.7258],[3,138,3.1267],[4,7,1.2461],[4,15,1.6003],[4,18,2.5903],[4,19,0.329],[4,26,1.8659],[4,40,0.8382],[4,41,0.6598],[4,44,0.4563],[4,45,2.9388],[4,52,0.7027],[4,54,1.1167],[4,57,1.6183],[4,65,3.3],[4,80,0.532],[4,110,1.8222],[4,148,2.173],[4,151,3.3418],[4,156,3.1175],[4,166,3.2739],[4,172,1.2245],[4,196,1.7035],[5,16,1.5057],[5,19,3.3447],[5,22,3.6021],[5,26,0.7782],[5,27,0.8668],[5,34,1.0618],[5,37,1.0667],[5,67,1.9474],[5,71,2.3119],[5,98,1.1696],[5,118,0.2643],[5,120,1.7163],[5,137,1.5424],[5,138,2.2322],[5,151,3.5858],[5,176,2.6667],[5,189,2.0542],[5,194,2.4116],[6,9,2.6167],[6,18,0.439],[6,25,3.3984],[6,39,2.9799],[6,44,3.3108],[6,66,3.0426],[6,86,1.6233],[6,97,1.6464],[6,139,0.6124],[6,149,2.47],[6,168,0.4679],[6,177,0.4857],[6,181,0.9807],[7,12,0.8181],[7,13,1.4402],[7,25,0.434],[7,28,0.2508],[7,33,0.7794],[7,35,0.6051],[7,38,1.5226],[7,44,0.3393],[7,46,3.3102],[7,50,2.3992],[7,60,0.7699],[7,73,1.1329],[7,74,1.4659],[7,83,1.5246],[7,96,0.6799],[7,110,3.2213],[7,156,3.7259],[7,191,1.881],[8,9,1.9434],[8,16,0.5506],[8,18,0.6077],[8,21,1.4492],[8,23,1.1766],[8,34,3.151],[8,35,0.815],[8,44,0.3308],[8,64,3.5784],[8,82,2.0989],[8,85,0.7631],[8,94,2.1511],[8,157,0.3446],[8,167,2.0984],[8,170,3.6748],[8,180,3.2716],[8,185,2.6867],[8,186,1.1639],[8,194,1.5334],[9,14,0.8346],[9,17,2.9518],[9,26,2.1141],[9,29,2.9767],[9,36,1.4038],[9,54,1.0306],[9,110,3.0903],[9,131,3.6972],[9,134,3.2342],[9,161,3.0713],[9,177,3.1142],[9,193,2.8396],[9,198,1.0436],[10,12,2.0617],[10,13,1.4945],[10,23,0.3514],[10,24,0.3478],[10,27,1.228],[10,36,1.1571],[10,42,2.6738],[10,46,3.5978],[10,48,1.8153],[10,61,3.5296],[10,64,3.7081],[10,66,3.5925],[10,109,1.5262],[10,149,1.0216],[10,157,1.044],[10,181,0.9385],[10,187,0.9653],[10,189,2.4342],[11,12,3.4011],[11,24,3.1915],[11,31,1.9282],[11,35,2.5354],[11,38,3.0488],[11,65,0.5467],[11,68,2.562],[11,119,3.4342],[11,126,2.9881],[11,137,2.8755],[11,169,1.9231],[11,176,0.8748],[11,190,3.012],[12,13,1.4138],[12,23,3.0529],[12,39,3.6508],[12,44,1.6354],[12,47,1.6549],[12,87,3.5638],[12,99,2.7868],[12,130,0.845],[12,147,0.6946],[12,149,0.779],[12,166,3.417],[12,189,3.0728],[13,29,0.7616],[13,32,3.1428],[13,33,3.6811],[13,75,2.5504],[13,98,1.4764],[13,126,2.1703],[13,140,0.7084],[13,181,0.2999],[13,196,3.6481],[13,197,2.5239],[14,25,2.093],[14,28,3.5177],[14,30,1.7683],[14,33,3.3011],[14,68,3.1415],[14,91,0.9886],[14,102,1.1314],[14,142,1.2754],[14,171,1.0919],[14,196,2.3025],[15,28,1.1578],[15,31,1.7165],[15,34,0.7088],[15,43,3.4351],[15,45,1.4882],[15,47,1.8536],[15,70,2.2917],[15,71,3.415],[15,77,1.7222],[15,88,3.462],[15,124,2.0058],[15,125,2.1114],[15,129,2.0823],[15,137,0.3155],[15,143,1.7904],[15,152,0.8909],[15,196,0.2638],[16,22,3.0471],[16,25,0.8532],[16,34,1.9072],[16,41,2.7882],[16,44,2.1977],[16,53,1.3909],[16,66,2.0642],[16,67,2.194],[16,101,2.995],[16,106,0.6214],[16,108,2.211],[16,134,1.1197],[16,138,1.2192],[16,152,2.9529],[16,153,2.027],[16,158,2.2161],[16,170,2.91],[16,180,3.4437],[16,187,1.8014],[17,27,2.3938],[17,36,2.0194],[17,55,2.0426],[17,58,2.6746],[17,125,1.8332],[17,133,2.1165],[17,140,1.9231],[17,142,3.5453],[17,144,2.6973],[17,178,3.3179],[17,189,3.5476],[17,190,1.1586],[18,27,2.2083],[18,29,3.5514],[18,30,3.19],[18,31,0.73],[18,34,0.6757],[18,38,1.7974],[18,44,0.5039],[18,53,1.0922],[18,72,0.5059],[18,76,2.5932],[18,92,2.9938],[18,96,3.3896],[18,131,0.7906],[18,147,2.7564],[18,153,2.5609],[18,185,0.7504],[19,21,3.3399],[19,26,3.6364],[19,28,1.0186],[19,30,3.5838],[19,32,1.6439],[19,35,1.9554],[19,36,3.7146],[19,37,3.1636],[19,38,0.8151],[19,51,1.7603],[19,67,2.0546],[19,79,1.4369],[19,87,0.9351],[19,92,1.3648],[19,99,2.7775],[19,126,0.3182],[19,127,2.1892],[19,128,1.7916],[19,134,0.3133],[19,135,1.4102],[19,148,2.4337],[19,157,2.0429],[19,174,0.475],[19,193,3.6978],[20,33,3.0093],[20,36,3.6509],[20,37,0.6167],[20,46,1.1795],[20,48,0.3886],[20,54,2.9765],[20,79,1.1966],[20,80,0.7034],[20,115,1.7279],[20,135,3.4399],[20,153,3.1164],[20,159,1.1551],[20,160,0.7728],[20,169,3.4671],[20,181,2.2471],[20,185,2.7015],[20,188,0.5631],[21,30,0.4513],[21,39,2.6587],[21,41,1.7386],[21,48,0.5034],[21,68,3.5342],[21,76,2.4705],[21,91,3.0557],[21,135,0.5431],[21,137,3.2468],[21,172,0.4832],[21,181,3.2697],[22,29,1.8382],[22,36,1.437],[22,37,2.1857],[22,41,3.4933],[22,47,1.1875],[22,53,0.7023],[22,54,2.0942],[22,84,1.0845],[22,100,0.6331],[22,113,0.8151],[22,124,0.4263],[22,129,0.9562],[22,131,1.342],[22,139,1.3175],[22,172,2.9082],[22,188,1.2649],[23,27,2.0003],[23,31,0.8726],[23,40,1.4645],[23,43,0.3136],[23,52,1.1266],[23,76,0.3037],[23,85,2.8158],[23,90,2.1787],[23,91,0.9131],[23,93,1.9117],[23,104,3.5212],[23,115,0.622],[23,154,3.1162],[23,178,1.7626],[23,183,1.9825],[23,189,3.1711],[24,27,1.6258],[24,42,2.0234],[24,50,2.6571],[24,64,3.6885],[24,74,1.4495],[24,75,3.163],[24,84,2.7235],[24,98,2.4759],[24,109,1.6664],[24,120,1.4664],[24,122,0.4404],[24,135,0.7044],[24,145,0.4975],[24,155,2.8431],[24,174,1.1446],[24,198,0.8214],[25,38,0.5457],[25,42,3.1944],[25,44,3.2969],[25,46,2.5969],[25,63,1.2368],[25,71,1.0977],[25,75,1.2757],[25,95,1.8581],[25,106,0.8014],[25,134,1.8104],[25,152,1.1714],[25,171,3.6163],[25,173,3.6542],[25,182,2.1648],[25,193,1.1056],[25,195,3.6298],[26,31,1.3334],[26,37,1.498],[26,45,0.2537],[26,66,1.5857],[26,67,1.9113],[26,94,2.0097],[26,120,0.9534],[26,137,2.0166],[26,146,0.2673],[26,150,1.1746],[26,153,0.5641],[26,195,1.6483],[27,43,0.3958],[27,54,0.3287],[27,61,1.3149],[27,64,1.0648],[27,75,2.2995],[27,78,2.1022],[27,119,2.8769],[27,123,2.5514],[27,126,2.756],[27,149,3.3268],[27,164,1.6133],[27,169,1.3915],[27,176,3.6966],[27,178,0.7731],[27,196,2.7845],[27,199,2.5013],[28,29,0.4033],[28,36,3.1735],[28,41,3.3718],[28,65,2.4457],[28,69,2.8185],[28,83,3.0928],[28,105,0.7376],[28,107,2.0832],[28,110,2.0153],[28,129,3.1723],[28,150,3.0664],[28,153,3.1424],[29,36,2.2942],[29,45,3.3749],[29,48,2.6401],[29,60,2.6766],[29,71,1.0548],[29,83,0.3591],[29,130,0.7158],[29,145,1.5125],[29,147,0.6172],[29,153,3.1754],[29,165,2.2048],[29,174,2.4472],[29,176,2.4418],[29,180,2.6323],[29,191,1.9625],[30,32,0.2616],[30,33,3.0419],[30,46,2.8689],[30,55,2.0104],[30,70,2.1232],[30,76,2.5575],[30,78,0.4812],[30,90,2.8288],[30,92,1.1327],[30,95,0.5106],[30,114,1.1795],[30,123,2.8027],[30,131,0.9683],[30,142,2.8394],[30,171,3.6651],[30,178,1.9788],[30,181,1.589],[30,199,1.9265],[31,36,2.6429],[31,38,2.9344],[31,40,2.4094],[31,44,2.4997],[31,47,0.5212],[31,50,0.766],[31,67,1.1388],[31,84,2.8513],[32,40,1.3155],[32,46,2.2372],[32,77,0.2936],[32,79,0.4623],[32,92,1.1907],[32,104,2.602],[32,111,2.6726],[32,153,2.615],[32,190,1.268],[32,191,2.0579],[32,197,1.8763],[33,35,1.8822],[33,38,0.6648],[33,43,3.3778],[33,51,0.9474],[33,68,3.6734],[33,78,3.5269],[33,80,0.3113],[33,104,1.8564],[33,107,3.1196],[33,115,3.6384],[33,139,1.8231],[33,157,1.1903],[33,182,0.9844],[33,193,3.5596],[34,39,0.9875],[34,41,2.2852],[34,43,0.7461],[34,48,2.0842],[34,53,3.5846],[34,55,0.7141],[34,60,3.1208],[34,77,2.0306],[34,79,3.354],[34,80,2.7117],[34,83,1.0598],[34,90,3.392],[34,125,1.9515],[34,131,0.3369],[34,138,0.2626],[34,172,1.9709],[34,180,1.8277],[34,199,1.3068],[35,40,0.7425],[35,41,1.4539],[35,72,1.3563],[35,81,3.1908],[35,86,0.2561],[35,89,2.8776],[35,111,3.1869],[35,126,0.6701],[35,134,3.4924],[35,139,2.7456],[35,159,3.4055],[35,163,1.2644],[35,168,1.5528],[35,186,1.6251],[35,189,3.7458],[36,42,2.3121],[36,54,1.5125],[36,60,1.7482],[36,69,1.213],[36,88,0.4189],[36,108,0.606],[36,120,3.1714],[36,131,1.2497],[36,133,3.5246],[36,138,1.1226],[36,151,1.18],[36,170,2.0384],[36,178,0.9145],[36,189,1.5567],[36,194,3.5966],[36,195,3.3449],[37,39,3.0919],[37,40,2.4581],[37,41,3.447],[37,48,3.5424],[37,49,2.1723],[37,66,2.7685],[37,67,0.4232],[37,68,2.8132],[37,81,1.828],[37,116,2.8843],[37,129,2.5057],[37,150,1.2517],[37,168,0.4214],[37,177,3.4937],[37,180,0.6956],[38,40,1.9026],[38,41,1.4528],[38,46,1.2922],[38,67,2.8366],[38,73,3.667],[38,78,1.1606],[38,81,2.546],[38,89,1.3029],[38,96,2.2006],[38,101,1.6303],[38,124,0.8357],[38,137,0.8158],[38,183,0.9776],[39,54,3.4209],[39,73,1.9898],[39,76,1.0201],[39,97,3.4219],[39,98,3.7377],[39,125,1.8249],[39,188,0.7386],[40,45,0.9234],[40,69,0.5675],[40,88,1.4468],[40,89,0.5688],[40,97,1.0869],[40,103,1.1543],[40,113,2.2437],[40,129,3.3554],[40,130,2.8738],[40,131,1.6947],[40,146,1.6986],[40,165,2.0846],[40,197,1.569],[41,46,1.4337],[41,48,0.4672],[41,59,1.2213],[41,78,3.6369],[41,88,0.6906],[41,101,2.0119],[41,110,2.4537],[41,122,3.27],[41,135,1.0059],[41,142,1.1986],[41,169,1.1196],[41,187,1.6491],[41,198,1.8105],[42,47,3.5888],[42,48,3.2204],[42,51,3.3051],[42,66,0.3263],[42,77,0.3629],[42,97,2.7333],[42,100,3.3849],[42,118,1.9064],[42,121,2.3051],[42,139,0.2506],[42,143,1.6203],[42,168,3.4939],[42,181,3.1396],[42,186,3.2441],[43,53,3.6528],[43,111,1.1196],[43,119,0.6317],[43,132,0.7903],[43,142,2.0783],[43,155,2.6373],[44,45,3.5452],[44,47,2.7761],[44,48,2.5157],[44,87,2.9268],[44,101,1.8506],[44,107,2.1803],[44,131,0.3884],[44,136,2.988],[44,138,1.064],[44,143,3.4697],[44,146,2.5093],[44,160,1.3132],[44,178,0.6979],[44,181,1.1313],[44,190,2.477],[45,47,2.695],[45,48,0.6425],[45,50,0.4962],[45,57,2.0855],[45,59,2.2901],[45,73,1.6083],[45,78,1.0325],[45,90,2.3537],[45,103,0.2866],[45,110,1.3053],[45,119,1.8624],[45,149,3.6063],[45,150,2.506],[45,155,3.3432],[45,169,1.9136],[45,176,1.0717],[45,177,1.1147],[46,54,3.6121],[46,63,2.7163],[46,167,1.3259],[46,177,0.3263],[47,63,1.9941],[47,64,2.6106],[47,73,1.7201],[47,117,1.1504],[47,153,2.5857],[47,165,3.4881],[47,176,1.0438],[48,51,0.3693],[48,53,1.4332],[48,69,1.7219],[48,98,2.639],[48,108,0.9433],[48,114,3.0397],[48,145,2.837],[48,150,2.0171],[48,161,0.9683],[48,171,3.6445],[48,172,1.341],[48,187,3.12],[48,193,1.0578],[48,195,1.025],[49,65,2.9116],[49,144,1.2823],[49,148,3.5817],[49,155,1.9852],[49,168,0.9056],[49,190,1.0316],[50,52,1.7096],[50,58,2.5785],[50,64,3.5707],[50,68,0.7623],[50,69,1.6271],[50,76,0.9953],[50,95,3.6594],[50,102,0.7467],[50,110,0.4314],[50,129,0.4605],[50,154,1.6266],[51,56,3.3936],[51,57,3.3425],[51,60,2.8145],[51,65,3.7414],[51,73,3.5106],[51,83,1.4023],[51,93,0.8993],[51,115,3.5256],[51,145,2.8621],[51,163,0.3616],[51,164,2.5755],[51,171,1.5752],[51,179,1.5586],[51,197,1.4109],[52,53,0.8424],[52,57,0.26],[52,63,1.2293],[52,78,1.4801],[52,81,3.5943],[52,85,0.683],[52,88,3.6249],[52,89,0.9759],[52,103,1.4982],[52,112,3.1255],[52,132,3.127],[52,160,1.7636],[52,167,0.4224],[52,186,1.9071],[53,54,1.5545],[53,58,3.4683],[53,61,0.9256],[53,63,1.5249],[53,66,3.3895],[53,70,0.356],[53,75,1.6878],[53,78,3.0914],[53,80,2.9333],[53,85,0.3923],[53,95,0.372],[53,139,0.469],[53,150,3.4703],[53,167,1.1496],[53,178,2.8655],[53,180,3.3949],[53,193,1.4367],[54,58,1.2031],[54,59,3.6019],[54,60,2.4094],[54,65,1.1676],[54,69,2.7582],[54,80,1.3577],[54,87,1.2147],[54,89,0.2632],[54,96,2.8948],[54,97,3.4576],[54,121,2.4689],[54,123,3.5514],[54,126,0.3349],[54,129,1.0685],[54,133,1.9132],[54,156,3.5987],[54,158,3.5887],[54,160,1.6028],[54,161,1.1287],[54,165,1.7548],[55,57,1.9772],[55,60,3.4983],[55,64,0.8903],[55,65,3.059],[55,67,2.8347],[55,112,3.1296],[55,114,2.9548],[55,122,2.3754],[55,125,1.3973],[55,138,1.3684],[55,171,1.5165],[55,179,2.9879],[55,183,0.5266],[56,68,0.9406],[56,79,2.8851],[56,82,1.1156],[56,83,0.4766],[56,94,0.3685],[56,105,2.1841],[56,112,1.3902],[56,144,3.6809],[56,148,3.3422],[56,156,3.7074],[56,160,1.1771],[56,171,0.5443],[56,176,0.5875],[56,181,1.9947],[56,189,2.7342],[57,60,1.8144],[57,68,1.0697],[57,69,1.7089],[57,70,2.4211],[57,82,2.6094],[57,85,2.8679],[57,89,3.2145],[57,93,2.5755],[57,98,0.6741],[57,113,3.193],[57,122,1.2782],[57,148,2.2341],[57,155,1.5554],[57,162,2.8332],[57,176,0.9472],[57,188,1.116],[57,196,1.1087],[58,65,0.7866],[58,66,3.3446],[58,83,2.274],[58,86,1.3922],[58,107,1.6362],[58,111,3.7236],[58,128,2.0256],[58,132,1.0598],[58,137,3.0796],[58,141,2.5366],[58,167,3.7183],[58,169,0.6082],[58,173,1.9117],[58,178,3.1169],[59,70,3.1919],[59,73,3.4503],[59,78,0.3913],[59,89,1.2779],[59,90,0.6673],[59,97,0.9135],[59,98,3.6554],[59,99,2.2912],[59,102,3.5056],[59,107,1.5528],[59,109,3.2814],[59,126,1.8219],[59,152,1.1598],[59,155,2.9722],[59,168,3.56],[59,172,0.6202],[60,61,2.3365],[60,63,2.4198],[60,70,1.0118],[60,86,1.5405],[60,89,0.7448],[60,112,0.9639],[60,116,1.1422],[60,124,2.348],[60,146,2.5307],[60,154,0.962],[60,173,0.2898],[60,189,1.3954],[60,192,2.6241],[60,197,0.898],[61,63,1.3427],[61,64,0.9619],[61,65,3.0335],[61,68,2.1682],[61,69,0.4714],[61,73,0.6049],[61,79,1.6335],[61,87,2.1755],[61,96,2.4871],[61,118,0.569],[61,125,0.8229],[61,130,2.6839],[61,131,1.6843],[61,141,1.2416],[61,144,1.3266],[61,152,3.5862],[61,174,1.3433],[61,189,2.2328],[61,194,1.5001],[62,68,1.7076],[62,74,3.2749],[62,85,3.7382],[62,90,1.5232],[62,95,0.9402],[62,98,2.7981],[62,102,0.9628],[62,104,0.2706],[62,156,3.4057],[62,160,1.7331],[63,64,3.1213],[63,66,1.6718],[63,69,3.3399],[63,73,1.8632],[63,85,0.8189],[63,87,0.3019],[63,91,2.1804],[63,95,2.4923],[63,107,3.4343],[63,121,0.5616],[63,134,2.4277],[63,139,1.548],[63,165,2.0156],[63,193,0.7606],[63,194,1.2415],[64,74,2.0741],[64,80,3.4892],[64,86,0.6308],[64,88,1.9668],[64,92,3.0668],[64,105,3.6341],[64,110,0.9407],[64,111,0.6933],[64,121,3.5508],[64,123,3.6644],[64,128,1.9396],[64,131,0.4368],[64,152,3.4916],[64,160,1.6076],[64,161,3.4148],[64,170,2.4212],[64,173,3.1359],[64,178,0.811],[65,66,3.0004],[65,69,1.0273],[65,70,1.6657],[65,71,3.2122],[65,72,3.1522],[65,81,0.8904],[65,83,1.0135],[65,84,1.6491],[65,90,2.0626],[65,107,1.5925],[65,128,0.6807],[65,137,1.1147],[65,140,2.7871],[65,144,3.3905],[65,167,0.3938],[65,176,2.2182],[65,184,2.9011],[65,192,0.3835],[66,75,3.1837],[66,79,0.6621],[66,81,2.3483],[66,84,2.1752],[66,85,2.4446],[66,98,1.3217],[66,111,1.7203],[66,146,2.2892],[66,150,1.7401],[66,151,2.5559],[66,181,1.8138],[66,197,1.7842],[67,68,0.3318],[67,69,2.4161],[67,76,1.9633],[67,77,1.0734],[67,96,2.9225],[67,110,2.9799],[67,132,1.854],[67,143,0.8785],[67,153,1.9063],[67,171,0.6248],[68,70,0.6996],[68,80,1.7571],[68,83,0.571],[68,91,1.7969],[68,95,2.0356],[68,98,0.3927],[68,112,2.4775],[68,113,0.5378],[68,116,2.8172],[68,128,2.9717],[68,149,2.0402],[68,163,0.4399],[68,179,2.0137],[68,191,1.5725],[69,72,3.578],[69,96,0.7266],[69,116,3.2497],[69,117,3.7364],[69,128,2.8123],[69,143,3.1025],[69,151,0.928],[69,158,3.686],[69,162,1.9715],[69,185,3.5982],[69,192,3.4561],[70,72,0.8279],[70,76,3.0093],[70,79,3.507],[70,81,0.4793],[70,96,1.4781],[70,98,2.8966],[70,100,0.8057],[70,104,3.3879],[70,118,1.2125],[70,138,3.1047],[70,174,0.7525],[70,176,2.0078],[70,178,3.4697],[71,92,0.9791],[71,102,1.17],[71,120,2.021],[71,126,1.3668],[71,154,0.3789],[71,163,0.8873],[71,175,0.8143],[72,78,3.5274],[72,84,2.6289],[72,96,3.3839],[72,102,0.8406],[72,110,2.997],[72,112,0.6528],[72,139,2.1075],[72,143,2.4771],[72,150,1.5092],[72,151,3.3053],[72,176,2.1931],[73,81,2.2802],[73,123,3.3389],[73,134,0.6161],[73,141,3.7253],[73,150,2.4542],[73,154,1.6299],[73,164,3.0418],[73,192,1.1766],[74,78,3.7167],[74,81,2.2708],[74,83,1.5109],[74,91,2.9262],[74,97,1.798],[74,105,0.8686],[74,113,2.8526],[74,144,0.419],[74,154,3.1194],[74,175,1.1378],[74,181,2.4873],[74,192,3.6942],[75,88,2.3005],[75,92,2.5729],[75,96,1.3443],[75,101,0.2563],[75,106,0.3683],[75,126,0.7728],[75,132,2.4062],[75,135,1.7628],[75,165,2.0444],[75,173,3.3844],[75,180,0.7121],[75,182,1.0454],[75,192,2.5359],[76,144,0.328],[76,153,0.2592],[76,193,1.4924],[77,80,0.6223],[77,81,1.5],[77,84,1.0349],[77,87,2.2926],[77,91,2.3118],[77,92,0.9646],[77,93,2.4338],[77,97,1.9122],[77,132,0.7216],[77,145,3.5281],[77,148,1.1026],[77,172,0.7726],[77,186,0.5853],[77,189,2.4837],[77,198,3.2995],[77,199,2.9875],[78,80,1.6568],[78,82,1.1748],[78,84,0.2902],[78,99,2.5073],[78,147,2.2182],[78,152,1.4762],[78,183,2.5096],[78,189,1.8031],[78,193,3.53],[78,198,2.8173],[79,85,1.1197],[79,87,3.4123],[79,88,0.404],[79,95,2.1103],[79,99,1.671],[79,126,1.0818],[79,129,0.4543],[79,133,2.9761],[79,145,0.2932],[79,156,2.1782],[79,160,3.5432],[79,178,0.7479],[80,85,0.9483],[80,87,2.3783],[80,91,2.0243],[80,97,2.4955],[80,115,3.0968],[80,118,0.8612],[80,176,1.3328],[80,178,1.3009],[80,194,0.4197],[81,88,3.3627],[81,89,2.9904],[81,92,2.7539],[81,94,0.2722],[81,98,3.2055],[81,99,2.8582],[81,114,1.8784],[81,119,2.8461],[81,124,1.8337],[81,127,1.0408],[81,132,0.6185],[81,138,1.063],[81,142,0.3859],[81,154,1.4243],[81,190,2.8738],[81,198,2.6829],[82,89,3.2087],[82,90,2.7409],[82,94,1.181],[82,96,2.1883],[82,98,1.7762],[82,107,3.0096],[82,111,2.0814],[82,112,1.1785],[82,113,2.497],[82,116,3.628],[82,117,1.0095],[82,134,3.3302],[82,135,0.3033],[82,153,1.1613],[82,183,1.0764],[82,189,2.8536],[82,197,3.5564],[82,198,2.8615],[83,84,1.394],[83,92,3.3306],[83,95,1.3999],[83,97,1.0871],[83,113,3.4265],[83,119,2.4574],[83,134,2.675],[83,150,2.5783],[83,168,3.6765],[83,192,1.8932],[84,91,3.189],[84,95,2.6917],[84,96,3.2513],[84,106,1.7802],[84,124,2.7862],[84,130,2.2462],[84,141,1.3271],[84,143,0.9919],[84,161,2.4292],[84,168,0.5223],[84,174,3.4378],[84,190,0.7561],[85,136,0.3442],[85,142,0.6234],[85,169,3.5013],[85,181,1.457],[86,95,0.7464],[86,99,0.3506],[86,114,0.3958],[86,123,2.6742],[86,125,2.4686],[86,155,2.6895],[86,186,2.8287],[87,104,0.4802],[87,106,2.3167],[87,108,1.5219],[87,121,3.1115],[87,127,3.1185],[87,156,3.3695],[87,157,0.4808],[87,163,3.2873],[87,188,3.4504],[87,197,3.5551],[88,89,0.6249],[88,98,0.97],[88,99,0.6419],[88,106,0.3705],[88,107,3.217],[88,118,3.0921],[88,120,2.4696],[88,125,3.1377],[88,157,2.4604],[88,168,1.2558],[88,175,0.5996],[88,184,0.5925],[89,94,2.9008],[89,99,0.9675],[89,107,1.367],[89,108,1.7332],[89,114,0.3232],[89,138,1.1485],[89,143,1.2391],[89,150,2.7552],[89,173,1.5381],[89,191,1.3729],[90,91,3.624],[90,92,2.0131],[90,97,3.2298],[90,141,2.414],[90,150,0.3584],[90,154,1.6952],[90,155,1.7776],[90,158,2.9556],[90,167,1.4637],[90,195,2.7163],[91,95,2.1326],[91,96,1.008],[91,112,3.2678],[91,116,0.5681],[91,142,3.1193],[91,178,0.8463],[91,184,0.2545],[91,199,0.9571],[92,96,2.9176],[92,98,3.6725],[92,145,0.2653],[92,156,1.9679],[92,166,1.9702],[92,193,3.0387],[93,97,0.8958],[93,99,1.981],[93,100,1.4651],[93,105,3.1614],[93,128,1.162],[93,131,3.5535],[93,152,1.2431],[93,161,1.0015],[93,166,2.6982],[93,188,1.9941],[93,190,0.6347],[93,195,2.4779],[94,103,0.5331],[94,134,3.0077],[94,154,2.6901],[94,183,3.0043],[94,185,2.4478],[94,194,1.4947],[95,96,1.6544],[95,115,1.6311],[95,124,3.3664],[95,129,0.5516],[95,184,3.3596],[96,105,0.3381],[96,119,0.9714],[96,137,1.1712],[96,169,3.4043],[97,145,2.0042],[97,161,1.5776],[97,170,3.3439],[97,182,1.0675],[97,185,1.8632],[98,99,2.1104],[98,125,2.8907],[98,145,2.8855],[98,171,2.512],[98,185,1.4697],[99,103,1.3933],[99,126,0.7936],[99,128,3.2009],[99,132,2.5674],[99,139,2.847],[99,141,0.8434],[99,147,1.7858],[99,149,2.957],[99,178,2.2771],[99,179,0.6912],[99,191,1.8671],[99,199,3.3479],[100,111,1.0828],[100,116,0.9205],[100,117,1.3053],[100,121,2.7111],[100,135,3.2028],[100,143,0.7911],[100,148,0.796],[100,163,1.1165],[100,166,1.393],[100,193,2.0776],[101,107,0.8132],[101,116,1.3983],[101,118,0.9125],[101,119,3.663],[101,131,2.8006],[101,137,0.6063],[101,142,3.6183],[101,143,0.6057],[101,144,1.5948],[101,150,3.6934],[101,158,3.0321],[101,161,2.8165],[102,114,1.7722],[102,126,0.9367],[102,136,2.4829],[102,156,0.624],[102,160,0.9726],[103,104,1.6092],[103,107,0.3688],[103,110,1.6466],[103,117,3.0185],[103,120,2.677],[103,127,2.0017],[103,139,2.4633],[103,156,1.8715],[103,160,0.7463],[103,173,2.363],[104,106,1.6665],[104,109,2.8433],[104,117,3.428],[104,118,1.7551],[104,125,2.2589],[104,131,2.8719],[104,132,1.724],[104,135,1.05],[104,137,2.7778],[104,142,3.3303],[104,144,2.9592],[104,145,2.7003],[104,151,3.2336],[104,198,2.6286],[105,108,2.4954],[105,110,1.8387],[105,116,1.3455],[105,119,2.449],[105,126,0.5925],[105,130,1.7185],[105,134,2.9883],[105,141,2.746],[105,144,2.4537],[105,147,1.1252],[105,148,1.7325],[105,163,1.8432],[105,166,2.4255],[105,169,1.6827],[106,123,2.6134],[106,124,3.5057],[106,129,0.8907],[106,130,2.5407],[106,132,2.9736],[106,142,1.6105],[106,143,1.9644],[106,151,3.6612],[106,172,0.3835],[106,175,2.1518],[106,187,0.8129],[106,189,2.9863],[106,198,3.5421],[107,117,2.0673],[107,121,0.6038],[107,122,2.261],[107,129,2.1436],[107,133,2.7605],[107,154,2.0427],[108,110,2.4874],[108,115,3.1514],[108,117,2.0759],[108,118,1.6862],[108,119,3.5679],[108,121,0.9853],[108,126,2.6453],[108,130,1.6237],[108,131,2.9195],[108,140,0.6784],[108,141,3.6956],[108,151,1.4942],[108,155,0.4482],[108,186,1.2103],[108,189,1.6489],[108,198,0.2966],[109,116,1.715],[109,124,1.7219],[109,133,2.6939],[109,137,1.4824],[109,143,1.1781],[109,144,1.0355],[109,149,2.8451],[109,162,3.5398],[109,180,2.0948],[110,111,1.0162],[110,115,3.0552],[110,127,1.6219],[110,135,0.992],[110,137,0.7025],[110,148,2.9681],[110,159,3.0835],[110,163,2.47],[110,197,1.8921],[111,117,2.2172],[111,126,1.041],[111,130,3.6235],[111,134,1.486],[111,138,2.4858],[111,139,3.1156],[111,149,3.1066],[111,184,1.8884],[111,188,1.2802],[111,190,2.1689],[111,196,0.6881],[111,199,3.1681],[112,118,1.4916],[112,119,3.2273],[112,132,1.186],[112,139,1.5665],[112,155,1.1374],[112,166,1.7414],[112,168,0.9006],[112,196,0.2594],[113,114,2.7763],[113,116,1.2342],[113,118,1.1074],[113,120,1.3064],[113,129,1.9284],[113,132,1.7497],[113,136,2.4806],[113,138,2.5574],[113,149,1.5185],[113,154,3.5005],[113,175,3.2406],[114,119,0.4497],[114,135,3.1476],[114,140,3.4203],[114,156,2.9941],[114,171,0.7414],[114,178,3.1596],[114,183,2.4661],[115,119,0.3025],[115,120,0.2902],[115,123,3.5812],[115,125,2.5458],[115,128,1.1251],[115,131,0.6053],[115,179,0.7496],[115,189,1.0677],[115,199,2.9671],[116,128,1.4626],[116,130,0.7844],[116,138,3.4143],[116,139,3.0209],[116,140,0.8377],[116,144,3.369],[116,149,2.3793],[116,154,2.9845],[116,156,2.5896],[116,157,3.3787],[116,174,3.0083],[117,121,3.1858],[117,130,0.9408],[117,135,2.6748],[117,139,2.1078],[117,143,2.8467],[117,146,1.7851],[117,179,3.3394],[117,181,2.1927],[117,183,1.1757],[117,190,1.0696],[118,127,0.7377],[118,133,1.9758],[118,134,0.4546],[118,136,1.8848],[118,137,0.7555],[118,149,1.9698],[118,168,1.9936],[119,123,2.1384],[119,126,3.2701],[119,128,0.2731],[119,131,3.1927],[119,133,1.8879],[119,135,2.219],[119,138,2.5786],[119,140,3.192],[119,148,1.5624],[119,169,1.7159],[119,180,3.6121],[119,183,0.5139],[120,124,2.4796],[120,127,2.4764],[120,130,0.3499],[120,138,2.3839],[120,147,2.6391],[120,148,3.5102],[120,154,1.4066],[120,160,3.686],[120,166,2.0372],[120,180,1.9464],[120,181,3.3915],[120,196,0.3686],[120,199,2.7636],[121,123,2.4385],[121,124,1.4351],[121,135,3.2659],[121,141,1.5316],[121,146,1.9109],[121,172,2.0894],[121,175,2.947],[121,188,0.9875],[121,193,1.7732],[122,132,1.7284],[122,146,2.1891],[122,147,3.1435],[122,152,1.2751],[122,165,3.1471],[122,182,1.6631],[122,188,2.0131],[122,192,1.2009],[122,197,2.0225],[123,139,3.6625],[123,140,2.541],[123,161,3.0218],[123,175,1.4081],[123,182,1.3598],[123,184,1.2973],[124,125,2.3026],[124,132,2.4719],[124,141,2.9948],[124,155,0.3902],[124,159,2.7794],[124,196,3.3496],[125,129,2.1589],[125,130,0.4239],[125,146,1.3014],[125,164,0.2717],[125,175,0.9148],[125,178,3.475],[125,185,2.3804],[125,191,2.5531],[125,198,3.0116],[126,136,3.4344],[126,137,2.3911],[126,138,2.4084],[126,139,2.4438],[126,144,2.6874],[126,169,2.3371],[126,183,2.6334],[126,199,0.9938],[127,128,2.5845],[127,131,1.8526],[127,134,2.9194],[127,137,0.6048],[127,145,0.8845],[127,149,0.3794],[127,158,2.9609],[127,186,3.4493],[127,191,2.545],[128,135,1.541],[128,142,3.1291],[128,145,3.0029],[128,153,2.2174],[128,154,1.153],[128,155,1.3071],[128,156,1.7262],[128,167,1.3647],[128,168,1.7574],[128,177,2.4962],[129,133,3.5185],[129,149,0.4412],[129,160,2.2363],[129,191,0.3878],[129,195,0.666],[130,131,3.0862],[130,143,2.2636],[130,144,3.4652],[130,148,1.8127],[130,163,0.2995],[130,172,1.605],[131,133,2.3219],[131,134,3.532],[131,141,3.6827],[131,146,1.9141],[132,133,1.6935],[132,142,0.6072],[132,144,2.5058],[132,147,0.993],[132,155,0.7812],[132,165,0.3044],[132,171,0.2667],[132,176,2.6432],[134,142,0.6758],[134,143,3.6322],[134,144,0.5585],[134,155,3.2934],[134,170,0.7014],[134,183,0.3122],[134,192,2.7677],[135,154,1.0979],[135,176,2.8175],[135,180,0.9059],[135,181,0.4255],[135,185,2.9591],[135,197,2.7474],[136,138,3.2442],[136,152,2.804],[136,166,0.545],[136,169,2.4502],[136,196,2.7323],[137,139,1.862],[137,143,3.5132],[137,144,1.1392],[137,146,3.6251],[137,149,2.7602],[137,171,0.2899],[137,177,0.3016],[137,198,2.5274],[138,139,3.1107],[138,158,0.5289],[138,161,1.3387],[138,166,2.803],[138,195,0.831],[139,140,3.2634],[139,146,1.9521],[139,148,0.4592],[140,143,1.5365],[140,177,2.2624],[140,183,1.7855],[140,192,2.6191],[141,143,0.7572],[141,144,3.0408],[141,177,1.5214],[141,184,2.5071],[141,196,2.454],[142,143,1.7129],[142,163,1.6001],[142,167,3.0018],[142,172,3.5572],[142,178,2.9962],[142,187,2.2339],[142,189,1.2734],[142,196,0.4622],[142,197,3.6588],[143,145,2.7114],[143,190,3.1459],[144,180,1.4121],[144,185,2.3704],[144,187,3.6711],[144,194,3.1595],[145,147,2.354],[145,164,1.3301],[145,194,1.75],[146,148,3.3584],[146,149,1.5684],[146,165,2.6469],[146,166,2.3562],[146,170,3.3864],[146,174,3.0762],[146,177,1.2416],[146,181,0.2559],[146,185,1.1707],[147,153,1.7288],[147,169,2.3033],[147,182,3.106],[147,190,3.356],[148,168,0.398],[148,179,3.1663],[148,191,3.0911],[148,198,3.2852],[149,156,2.2517],[149,179,1.2085],[149,181,3.2291],[150,158,3.0746],[150,162,2.6462],[150,176,3.4481],[150,182,1.464],[150,186,0.5477],[150,195,2.1879],[151,161,3.0409],[151,174,0.9515],[151,178,2.8756],[151,182,3.511],[151,187,1.0691],[151,197,2.3741],[152,161,2.6218],[152,163,1.8786],[152,166,0.9731],[152,173,1.1416],[152,175,2.879],[152,176,3.0208],[152,177,1.859],[152,184,0.557],[152,190,3.073],[152,191,2.9526],[152,195,1.065],[152,196,2.2786],[153,159,3.3893],[153,161,3.3478],[153,163,2.0765],[153,165,1.9181],[153,167,2.3127],[153,173,0.912],[153,176,0.9231],[153,177,0.8824],[153,179,2.7037],[153,189,1.5199],[153,190,2.2255],[153,198,1.6587],[154,156,2.0603],[154,158,0.7715],[154,159,0.4061],[154,160,3.74],[154,161,1.5591],[154,168,0.6214],[154,174,2.4646],[154,192,3.0057],[154,198,0.7965],[155,162,2.3402],[155,167,1.4572],[155,170,2.0681],[155,174,0.322],[155,178,0.3675],[155,183,3.7164],[155,195,3.2813],[156,163,1.9521],[156,182,2.2351],[156,185,1.1656],[156,188,2.9772],[156,197,1.7408],[156,199,3.5627],[157,162,2.9354],[157,163,3.1159],[157,169,3.6221],[157,171,1.139],[157,173,0.3825],[157,175,0.9535],[157,179,0.8826],[157,184,0.5428],[157,185,0.4285],[158,165,2.2008],[158,173,3.2973],[158,175,1.854],[158,178,3.5652],[158,182,3.4347],[158,184,0.4747],[158,187,2.3432],[158,193,1.6409],[158,194,0.6697],[158,198,3.6075],[159,162,1.1502],[159,169,2.2257],[159,174,2.4922],[159,175,3.5975],[159,180,2.594],[159,181,1.6259],[159,184,1.8192],[159,188,0.809],[159,194,3.6302],[159,198,3.721],[160,170,1.026],[160,174,0.3852],[160,175,1.1455],[160,186,1.482],[160,193,3.4096],[160,196,3.416],[160,198,3.1803],[161,165,0.4146],[161,169,3.0023],[161,172,2.7336],[161,173,2.5134],[161,180,3.699],[161,188,0.4452],[162,164,0.7568],[162,166,2.8923],[162,172,3.5378],[162,174,2.6191],[162,190,1.2958],[163,167,2.3201],[163,168,2.9026],[163,176,0.619],[163,178,1.3837],[163,179,1.1495],[163,191,0.6845],[164,169,1.9346],[164,171,0.84],[164,173,1.0846],[164,181,0.751],[164,188,2.6217],[164,191,0.2941],[164,193,2.7603],[164,194,0.9329],[165,167,0.376],[165,170,3.4969],[165,175,1.0219],[165,181,3.5189],[165,186,3.2836],[165,198,3.3605],[166,187,0.7392],[166,188,1.8154],[166,189,0.5895],[166,191,3.5007],[167,177,3.1979],[167,178,2.4493],[167,180,1.8332],[167,183,1.4392],[167,190,3.1307],[167,197,1.9214],[167,198,2.4486],[168,174,0.7497],[168,185,1.0258],[168,186,0.4485],[168,189,2.748],[168,190,2.1868],[168,191,0.7565],[168,193,3.2975],[168,195,1.1824],[169,173,1.6912],[169,177,0.7949],[169,178,1.1989],[169,182,3.1885],[169,184,1.4208],[169,192,0.8373],[169,195,1.9685],[170,171,1.3632],[170,172,3.4111],[170,173,0.6496],[170,182,3.6752],[170,184,0.449],[170,185,3.3826],[170,190,2.589],[170,198,0.9891],[171,175,1.9211],[171,176,1.2518],[171,179,1.1523],[171,181,0.9557],[171,191,1.525],[171,193,3.7186],[172,175,3.7433],[172,179,3.4878],[172,192,0.5915],[172,194,1.263],[172,195,3.3867],[172,196,0.4512],[172,199,2.7927],[173,174,1.2773],[173,176,3.6752],[173,179,0.3061],[173,183,3.0746],[173,184,1.4432],[173,188,0.7405],[173,194,0.2567],[173,198,3.1629],[174,178,2.0931],[174,179,0.9004],[174,184,1.7734],[174,195,3.4419],[174,197,1.0139],[174,198,2.2497],[175,176,0.7333],[175,179,0.8805],[175,190,2.9466],[175,192,2.7407],[176,179,0.9385],[176,183,0.5274],[176,189,0.556],[176,191,2.3799],[176,194,1.9842],[176,199,1.2086],[177,179,0.9711],[177,180,2.3935],[177,181,2.7272],[177,183,3.0905],[177,192,2.2903],[177,197,0.958],[178,191,0.4799],[178,198,2.8145],[179,180,1.6784],[179,185,2.7758],[179,191,0.4438],[179,196,3.0873],[179,199,1.4233],[180,184,3.1967],[180,185,3.2758],[180,186,1.9756],[180,189,0.3041],[181,189,3.4358],[181,193,1.9182],[181,196,3.302],[182,187,1.1819],[182,192,0.9012],[182,193,3.1607],[183,189,1.5349],[183,191,0.8222],[184,186,1.5491],[184,190,2.3321],[184,194,0.2662],[184,199,2.0694],[185,195,1.8102],[186,192,2.0547],[186,198,0.6727],[187,188,2.7511],[187,189,3.1079],[187,199,3.2792],[188,189,1.3734],[188,194,2.7392],[189,190,1.5849],[189,194,2.8796],[189,199,0.4642],[190,193,3.3048],[190,199,3.5892],[192,194,1.9818],[195,197,2.0466],[195,199,2.1068]]},"ring_of_cliques/unit":{"n_nodes":72,"edges":[[0,1,1.0],[0,2,1.0],[0,3,1.0],[0,4,1.0],[0,5,1.0],[1,2,1.0],[1,3,1.0],[1,4,1.0],[1,5,1.0],[2,3,1.0],[2,4,1.0],[2,5,1.0],[3,4,1.0],[3,5,1.0],[4,5,1.0],[0,6,1.0],[6,7,1.0],[6,8,1.0],[6,9,1.0],[6,10,1.0],[6,11,1.0],[7,8,1.0],[7,9,1.0],[7,10,1.0],[7,11,1.0],[8,9,1.0],[8,10,1.0],[8,11,1.0],[9,10,1.0],[9,11,1.0],[10,11,1.0],[6,12,1.0],[12,13,1.0],[12,14,1.0],[12,15,1.0],[12,16,1.0],[12,17,1.0],[13,14,1.0],[13,15,1.0],[13,16,1.0],[13,17,1.0],[14,15,1.0],[14,16,1.0],[14,17,1.0],[15,16,1.0],[15,17,1.0],[16,17,1.0],[12,18,1.0],[18,19,1.0],[18,20,1.0],[18,21,1.0],[18,22,1.0],[18,23,1.0],[19,20,1.0],[19,21,1.0],[19,22,1.0],[19,23,1.0],[20,21,1.0],[20,22,1.0],[20,23,1.0],[21,22,1.0],[21,23,1.0],[22,23,1.0],[18,24,1.0],[24,25,1.0],[24,26,1.0],[24,27,1.0],[24,28,1.0],[24,29,1.0],[25,26,1.0],[25,27,1.0],[25,28,1.0],[25,29,1.0],[26,27,1.0],[26,28,1.0],[26,29,1.0],[27,28,1.0],[27,29,1.0],[28,29,1.0],[24,30,1.0],[30,31,1.0],[30,32,1.0],[30,33,1.0],[30,34,1.0],[30,35,1.0],[31,32,1.0],[31,33,1.0],[31,34,1.0],[31,35,1.0],[32,33,1.0],[32,34,1.0],[32,35,1.0],[33,34,1.0],[33,35,1.0],[34,35,1.0],[30,36,1.0],[36,37,1.0],[36,38,1.0],[36,39,1.0],[36,40,1.0],[36,41,1.0],[37,38,1.0],[37,39,1.0],[37,40,1.0],[37,41,1.0],[38,39,1.0],[38,40,1.0],[38,41,1.0],[39,40,1.0],[39,41,1.0],[40,41,1.0],[36,42,1.0],[42,43,1.0],[42,44,1.0],[42,45,1.0],[42,46,1.0],[42,47,1.0],[43,44,1.0],[43,45,1.0],[43,46,1.0],[43,47,1.0],[44,45,1.0],[44,46,1.0],[44,47,1.0],[45,46,1.0],[45,47,1.0],[46,47,1.0],[42,48,1.0],[48,49,1.0],[48,50,1.0],[48,51,1.0],[48,52,1.0],[48,53,1.0],[49,50,1.0],[49,51,1.0],[49,52,1.0],[49,53,1.0],[50,51,1.0],[50,52,1.0],[50,53,1.0],[51,52,1.0],[51,53,1.0],[52,53,1.0],[48,54,1.0],[54,55,1.0],[54,56,1.0],[54,57,1.0],[54,58,1.0],[54,59,1.0],[55,56,1.0],[55,57,1.0],[55,58,1.0],[55,59,1.0],[56,57,1.0],[56,58,1.0],[56,59,1.0],[57,58,1.0],[57,59,1.0],[58,59,1.0],[54,60,1.0],[60,61,1.0],[60,62,1.0],[60,63,1.0],[60,64,1.0],[60,65,1.0],[61,62,1.0],[61,63,1.0],[61,64,1.0],[61,65,1.0],[62,63,1.0],[62,64,1.0],[62,65,1.0],[63,64,1.0],[63,65,1.0],[64,65,1.0],[60,66,1.0],[66,67,1.0],[66,68,1.0],[66,69,1.0],[66,70,1.0],[66,71,1.0],[67,68,1.0],[67,69,1.0],[67,70,1.0],[67,71,1.0],[68,69,1.0],[68,70,1.0],[68,71,1.0],[69,70,1.0],[69,71,1.0],[70,71,1.0],[66,0,1.0]]},"ring_of_cliques/weighted":{"n_nodes":72,"edges":[[0,1,1.3834],[0,2,0.778],[0,3,2.5283],[0,4,0.5035],[0,5,2.1256],[1,2,1.5299],[1,3,0.453],[1,4,2.026],[1,5,0.3812],[2,3,1.7678],[2,4,0.4945],[2,5,0.5675],[3,4,1.7358],[3,5,3.144],[4,5,0.6833],[0,6,1.0313],[6,7,2.446],[6,8,3.567],[6,9,2.2699],[6,10,1.6384],[6,11,3.6669],[7,8,0.413],[7,9,3.2546],[7,10,1.2636],[7,11,0.7549],[8,9,0.6623],[8,10,1.3297],[8,11,3.1064],[9,10,0.8825],[9,11,2.2856],[10,11,2.4862],[6,12,1.5534],[12,13,2.1671],[12,14,0.4698],[12,15,0.4586],[12,16,0.9709],[12,17,2.6314],[13,14,1.7466],[13,15,1.3495],[13,16,2.2995],[13,17,1.8361],[14,15,1.2992],[14,16,3.0303],[14,17,2.6965],[15,16,1.1043],[15,17,2.2605],[16,17,2.0882],[12,18,3.313],[18,19,2.8031],[18,20,1.2578],[18,21,3.6806],[18,22,0.6632],[18,23,1.7134],[19,20,2.9],[19,21,0.7819],[19,22,1.9614],[19,23,0.3872],[20,21,2.5888],[20,22,2.926],[20,23,2.2556],[21,22,3.3142],[21,23,1.3481],[22,23,2.6835],[18,24,2.3303],[24,25,2.2796],[24,26,1.8467],[24,27,3.1899],[24,28,3.5564],[24,29,1.9093],[25,26,2.5745],[25,27,0.4623],[25,28,2.7052],[25,29,2.515],[26,27,3.7258],[26,28,3.1267],[26,29,1.2461],[27,28,1.6003],[27,29,2.5903],[28,29,0.329],[24,30,1.8659],[30,31,0.8382],[30,32,0.6598],[30,33,0.4563],[30,34,2.9388],[30,35,0.7027],[31,32,1.1167],[31,33,1.6183],[31,34,3.3],[31,35,0.532],[32,33,1.8222],[32,34,2.173],[32,35,3.3418],[33,34,3.1175],[33,35,3.2739],[34,35,1.2245],[30,36,1.7035],[36,37,1.5057],[36,38,3.3447],[36,39,3.6021],[36,40,0.7782],[36,41,0.8668],[37,38,1.0618],[37,39,1.0667],[37,40,1.9474],[37,41,2.3119],[38,39,1.1696],[38,40,0.2643],[38,41,1.7163],[39,40,1.5424],[39,41,2.2322],[40,41,3.5858],[36,42,2.6667],[42,43,2.0542],[42,44,2.4116],[42,45,2.6167],[42,46,0.439],[42,47,3.3984],[43,44,2.9799],[43,45,3.3108],[43,46,3.0426],[43,47,1.6233],[44,45,1.6464],[44,46,0.6124],[44,47,2.47],[45,46,0.4679],[45,47,0.4857],[46,47,0.9807],[42,48,0.8181],[48,49,1.4402],[48,50,0.434],[48,51,0.2508],[48,52,0.7794],[48,53,0.6051],[49,50,1.5226],[49,51,0.3393],[49,52,3.3102],[49,53,2.3992],[50,51,0.7699],[50,52,1.1329],[50,53,1.4659],[51,52,1.5246],[51,53,0.6799],[52,53,3.2213],[48,54,3.7259],[54,55,1.881],[54,56,1.9434],[54,57,0.5506],[54,58,0.6077],[54,59,1.4492],[55,56,1.1766],[55,57,3.151],[55,58,0.815],[55,59,0.3308],[56,57,3.5784],[56,58,2.0989],[56,59,0.7631],[57,58,2.1511],[57,59,0.3446],[58,59,2.0984],[54,60,3.6748],[60,61,3.2716],[60,62,2.6867],[60,63,1.1639],[60,64,1.5334],[60,65,0.8346],[61,62,2.9518],[61,63,2.1141],[61,64,2.9767],[61,65,1.4038],[62,63,1.0306],[62,64,3.0903],[62,65,3.6972],[63,64,3.2342],[63,65,3.0713],[64,65,3.1142],[60,66,2.8396],[66,67,1.0436],[66,68,2.0617],[66,69,1.4945],[66,70,0.3514],[66,71,0.3478],[67,68,1.228],[67,69,1.1571],[67,70,2.6738],[67,71,3.5978],[68,69,1.8153],[68,70,3.5296],[68,71,3.7081],[69,70,3.5925],[69,71,1.5262],[70,71,1.0216],[66,0,1.044]]}},"cases":[{"name":"karate/unit/rb/res=0.25/seed=1","graph":"karate/unit","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":117.0,"reference_modularity":0.0},{"name":"karate/unit/rb/res=0.25/seed=2","graph":"karate/unit","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":117.0,"reference_modularity":0.0},{"name":"karate/unit/rb/res=0.5/seed=1","graph":"karate/unit","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":2,"reference_quality":97.0,"reference_modularity":0.3717948717948718},{"name":"karate/unit/rb/res=0.5/seed=2","graph":"karate/unit","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":2,"reference_quality":97.0,"reference_modularity":0.3717948717948718},{"name":"karate/unit/rb/res=1.0/seed=1","graph":"karate/unit","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[1,1,1,1,3,3,3,1,0,0,3,1,1,1,0,0,3,1,0,1,0,1,0,2,2,2,0,2,2,0,0,2,0,0],"reference_n_clusters":4,"reference_quality":65.48717948717949,"reference_modularity":0.41978961209730437},{"name":"karate/unit/rb/res=1.0/seed=2","graph":"karate/unit","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[1,1,1,1,3,3,3,1,0,0,3,1,1,1,0,0,3,1,0,1,0,1,0,2,2,2,0,2,2,0,0,2,0,0],"reference_n_clusters":4,"reference_quality":65.48717948717949,"reference_modularity":0.41978961209730437},{"name":"karate/unit/rb/res=2.0/seed=1","graph":"karate/unit","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[0,5,2,2,0,0,0,2,6,2,0,0,2,2,1,1,0,5,1,5,1,5,1,4,3,3,4,4,3,4,6,3,1,1],"reference_n_clusters":7,"reference_quality":25.666666666666664,"reference_modularity":0.33867521367521364},{"name":"karate/unit/rb/res=2.0/seed=2","graph":"karate/unit","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[1,4,2,2,1,1,1,2,6,2,1,1,2,2,0,0,1,4,0,4,0,4,0,5,3,3,5,5,3,5,6,3,0,0],"reference_n_clusters":7,"reference_quality":25.666666666666664,"reference_modularity":0.33867521367521364},{"name":"karate/unit/rb/res=4.0/seed=1","graph":"karate/unit","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[8,5,3,2,1,1,1,2,6,3,1,8,2,12,9,0,1,5,0,11,0,5,0,7,4,4,10,7,3,10,6,4,0,9],"reference_n_clusters":13,"reference_quality":-8.923076923076925,"reference_modularity":0.21646942800788965},{"name":"karate/unit/rb/res=4.0/seed=2","graph":"karate/unit","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[8,2,5,4,1,1,1,4,6,5,1,8,4,11,0,10,1,2,0,2,0,2,0,7,3,3,9,7,5,9,6,3,0,10],"reference_n_clusters":12,"reference_quality":-8.923076923076925,"reference_modularity":0.22608481262327432},{"name":"karate/unit/cpm/res=0.02/seed=1","graph":"karate/unit","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":133.56,"reference_modularity":0.0},{"name":"karate/unit/cpm/res=0.02/seed=2","graph":"karate/unit","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":133.56,"reference_modularity":0.0},{"name":"karate/unit/cpm/res=0.05/seed=1","graph":"karate/unit","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[1,1,1,1,1,1,1,1,0,1,1,1,1,1,0,0,1,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":2,"reference_quality":108.8,"reference_modularity":0.3717948717948718},{"name":"karate/unit/cpm/res=0.05/seed=2","graph":"karate/unit","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":2,"reference_quality":108.8,"reference_modularity":0.3717948717948718},{"name":"karate/unit/cpm/res=0.1/seed=1","graph":"karate/unit","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[1,1,1,1,2,2,2,1,0,4,2,1,1,1,0,0,2,1,0,1,0,1,0,0,3,3,0,0,3,0,0,3,0,0],"reference_n_clusters":5,"reference_quality":86.2,"reference_modularity":0.4126397107166339},{"name":"karate/unit/cpm/res=0.1/seed=2","graph":"karate/unit","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[1,1,1,1,2,2,2,1,0,4,2,1,1,1,0,0,2,1,0,1,0,1,0,0,3,3,0,0,3,0,0,3,0,0],"reference_n_clusters":5,"reference_quality":86.2,"reference_modularity":0.4126397107166339},{"name":"karate/weighted/rb/res=0.25/seed=1","graph":"karate/weighted","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[0,0,0,0,1,1,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":2,"reference_quality":226.9112978958639,"reference_modularity":0.1296788244082178},{"name":"karate/weighted/rb/res=0.25/seed=2","graph":"karate/weighted","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[0,0,0,0,1,1,1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":2,"reference_quality":226.9112978958639,"reference_modularity":0.1296788244082178},{"name":"karate/weighted/rb/res=0.5/seed=1","graph":"karate/weighted","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":2,"reference_quality":188.16303704276032,"reference_modularity":0.3739924359208955},{"name":"karate/weighted/rb/res=0.5/seed=2","graph":"karate/weighted","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,1,0,0,1,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":2,"reference_quality":188.16303704276032,"reference_modularity":0.3739924359208955},{"name":"karate/weighted/rb/res=1.0/seed=1","graph":"karate/weighted","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[1,1,2,1,3,3,3,1,0,2,3,1,1,1,0,0,3,1,0,1,0,1,0,2,2,2,0,2,2,0,0,2,0,0],"reference_n_clusters":4,"reference_quality":140.25880506587515,"reference_modularity":0.46953834829917335},{"name":"karate/weighted/rb/res=1.0/seed=2","graph":"karate/weighted","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[1,1,2,1,3,3,3,1,0,2,3,1,1,1,0,0,3,1,0,1,0,1,0,2,2,2,0,2,2,0,0,2,0,0],"reference_n_clusters":4,"reference_quality":140.25880506587515,"reference_modularity":0.46953834829917335},{"name":"karate/weighted/rb/res=2.0/seed=1","graph":"karate/weighted","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[4,1,6,1,2,2,2,1,0,6,2,4,4,1,5,0,2,1,0,4,0,1,0,3,3,3,5,3,6,5,0,3,0,5],"reference_n_clusters":7,"reference_quality":75.72512452921903,"reference_modularity":0.41292531064450927},{"name":"karate/weighted/rb/res=2.0/seed=2","graph":"karate/weighted","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[5,1,6,1,3,3,3,1,0,6,3,5,5,1,4,0,3,1,0,5,0,1,0,2,2,2,4,2,6,4,0,2,0,4],"reference_n_clusters":7,"reference_quality":75.72512452921904,"reference_modularity":0.41292531064450927},{"name":"karate/weighted/rb/res=4.0/seed=1","graph":"karate/weighted","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[5,6,3,2,1,1,1,2,10,3,1,5,2,11,7,0,1,6,0,5,0,6,0,8,4,4,9,8,3,9,12,4,0,7],"reference_n_clusters":13,"reference_quality":4.674000530268855,"reference_modularity":0.29757120845245594},{"name":"karate/weighted/rb/res=4.0/seed=2","graph":"karate/weighted","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[5,4,3,6,0,0,0,6,10,3,0,5,6,11,7,1,0,4,1,5,1,4,1,8,2,2,9,8,3,9,12,2,1,7],"reference_n_clusters":13,"reference_quality":4.674000530268855,"reference_modularity":0.29757120845245594},{"name":"karate/weighted/cpm/res=0.02/seed=1","graph":"karate/weighted","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":276.27639999999997,"reference_modularity":0.0},{"name":"karate/weighted/cpm/res=0.02/seed=2","graph":"karate/weighted","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":276.27639999999997,"reference_modularity":0.0},{"name":"karate/weighted/cpm/res=0.05/seed=1","graph":"karate/weighted","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[0,0,0,0,1,1,1,0,0,0,1,0,2,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":248.90740000000005,"reference_modularity":0.12909153302831938},{"name":"karate/weighted/cpm/res=0.05/seed=2","graph":"karate/weighted","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[0,0,0,0,1,1,1,0,0,0,1,0,2,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":248.90740000000005,"reference_modularity":0.12909153302831938},{"name":"karate/weighted/cpm/res=0.1/seed=1","graph":"karate/weighted","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[1,1,0,1,2,2,2,1,0,0,2,1,1,1,0,0,2,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":216.7990000000001,"reference_modularity":0.38154613824201683},{"name":"karate/weighted/cpm/res=0.1/seed=2","graph":"karate/weighted","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[1,1,0,1,2,2,2,1,0,0,2,1,1,1,0,0,2,1,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":216.7990000000001,"reference_modularity":0.38154613824201683},{"name":"sbm_easy/unit/rb/res=0.25/seed=1","graph":"sbm_easy/unit","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":1279.2565217391304,"reference_modularity":0.5136807993518768},{"name":"sbm_easy/unit/rb/res=0.25/seed=2","graph":"sbm_easy/unit","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":1279.2565217391304,"reference_modularity":0.5136807993518768},{"name":"sbm_easy/unit/rb/res=0.5/seed=1","graph":"sbm_easy/unit","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":1174.3739130434783,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/rb/res=0.5/seed=2","graph":"sbm_easy/unit","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":1174.3739130434783,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/rb/res=1.0/seed=1","graph":"sbm_easy/unit","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":972.7478260869566,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/rb/res=1.0/seed=2","graph":"sbm_easy/unit","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],"reference_n_clusters":4,"reference_quality":972.7478260869566,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/rb/res=2.0/seed=1","graph":"sbm_easy/unit","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],"reference_n_clusters":4,"reference_quality":569.4956521739131,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/rb/res=2.0/seed=2","graph":"sbm_easy/unit","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":4,"reference_quality":569.495652173913,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/rb/res=4.0/seed=1","graph":"sbm_easy/unit","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[7,0,17,17,16,0,0,0,0,7,0,0,16,8,7,8,8,8,0,16,0,16,16,17,7,8,0,7,17,17,4,11,19,19,13,4,13,4,19,10,13,10,13,11,10,18,9,11,4,18,11,11,19,18,13,10,18,10,4,4,12,1,1,1,6,5,5,6,9,12,1,1,5,6,5,5,12,6,9,6,5,1,9,12,1,12,1,6,1,9,21,2,15,2,15,3,3,2,2,3,3,20,14,2,14,15,3,15,3,2,20,3,14,20,15,21,21,14,14,2],"reference_n_clusters":22,"reference_quality":151.51801242236021,"reference_modularity":0.2415400640407391},{"name":"sbm_easy/unit/rb/res=4.0/seed=2","graph":"sbm_easy/unit","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[3,3,0,3,14,8,14,8,0,3,8,14,20,3,3,0,0,0,8,20,0,20,14,8,3,0,8,3,0,0,2,1,2,1,13,2,12,2,12,2,12,1,13,12,1,16,22,13,2,16,1,13,1,16,12,1,16,1,2,2,5,6,6,10,5,7,7,6,10,5,5,6,7,15,7,7,5,15,6,6,7,10,15,5,6,5,10,7,10,15,21,4,11,4,11,19,4,4,17,4,21,18,9,4,9,11,19,11,4,4,18,19,9,18,11,17,17,9,9,21],"reference_n_clusters":23,"reference_quality":149.70434782608694,"reference_modularity":0.24498514717796377},{"name":"sbm_easy/unit/cpm/res=0.02/seed=1","graph":"sbm_easy/unit","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":2,"reference_quality":1328.4,"reference_modularity":0.41303653408433305},{"name":"sbm_easy/unit/cpm/res=0.02/seed=2","graph":"sbm_easy/unit","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":2,"reference_quality":1328.4,"reference_modularity":0.41303653408433305},{"name":"sbm_easy/unit/cpm/res=0.05/seed=1","graph":"sbm_easy/unit","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":1202.0,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/cpm/res=0.05/seed=2","graph":"sbm_easy/unit","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":1202.0,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/cpm/res=0.1/seed=1","graph":"sbm_easy/unit","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":1028.0,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/unit/cpm/res=0.1/seed=2","graph":"sbm_easy/unit","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":1028.0,"reference_modularity":0.6041911963273021},{"name":"sbm_easy/weighted/rb/res=0.25/seed=1","graph":"sbm_easy/weighted","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":2509.7849954477833,"reference_modularity":0.5203870459884219},{"name":"sbm_easy/weighted/rb/res=0.25/seed=2","graph":"sbm_easy/weighted","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":2509.7849954477833,"reference_modularity":0.5203870459884219},{"name":"sbm_easy/weighted/rb/res=0.5/seed=1","graph":"sbm_easy/weighted","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],"reference_n_clusters":4,"reference_quality":2312.326798482199,"reference_modularity":0.6126574078044142},{"name":"sbm_easy/weighted/rb/res=0.5/seed=2","graph":"sbm_easy/weighted","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],"reference_n_clusters":4,"reference_quality":2312.326798482199,"reference_modularity":0.6126574078044142},{"name":"sbm_easy/weighted/rb/res=1.0/seed=1","graph":"sbm_easy/weighted","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":1919.7701969643983,"reference_modularity":0.6126574078044142},{"name":"sbm_easy/weighted/rb/res=1.0/seed=2","graph":"sbm_easy/weighted","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":4,"reference_quality":1919.7701969643983,"reference_modularity":0.6126574078044142},{"name":"sbm_easy/weighted/rb/res=2.0/seed=1","graph":"sbm_easy/weighted","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":4,"reference_quality":1134.6569939287963,"reference_modularity":0.6126574078044142},{"name":"sbm_easy/weighted/rb/res=2.0/seed=2","graph":"sbm_easy/weighted","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":4,"reference_quality":1134.6569939287963,"reference_modularity":0.6126574078044142},{"name":"sbm_easy/weighted/rb/res=4.0/seed=1","graph":"sbm_easy/weighted","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[10,8,2,16,10,2,2,10,8,10,8,2,16,2,22,2,12,16,8,10,16,10,2,8,12,12,8,21,2,12,18,1,13,9,9,1,9,9,6,13,9,6,9,13,6,1,11,1,11,1,1,1,6,11,6,11,1,6,13,11,0,0,0,3,0,7,7,0,3,0,17,17,7,0,7,3,18,3,3,0,7,18,3,0,17,0,17,0,7,3,22,14,4,4,4,4,15,15,14,15,5,20,14,19,5,4,21,19,15,5,20,5,5,20,4,19,14,5,4,5],"reference_n_clusters":23,"reference_quality":448.2040428101055,"reference_modularity":0.29367982300714784},{"name":"sbm_easy/weighted/rb/res=4.0/seed=2","graph":"sbm_easy/weighted","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[16,9,4,9,19,4,4,17,9,16,10,19,9,4,21,4,10,9,17,16,9,16,19,4,10,10,4,17,4,10,15,2,12,2,14,7,14,15,2,12,20,2,14,12,2,20,7,14,7,20,12,14,2,7,2,7,7,2,12,7,1,3,3,6,1,11,11,1,6,1,3,3,11,1,11,6,15,6,6,3,1,15,6,1,3,3,3,1,11,6,21,8,0,0,0,0,13,13,8,13,5,8,8,13,5,0,18,0,13,5,8,5,5,8,0,18,18,5,0,5],"reference_n_clusters":22,"reference_quality":458.2136062289694,"reference_modularity":0.2988460817040841},{"name":"sbm_easy/weighted/cpm/res=0.02/seed=1","graph":"sbm_easy/weighted","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":2847.9133999999995,"reference_modularity":0.0},{"name":"sbm_easy/weighted/cpm/res=0.02/seed=2","graph":"sbm_easy/weighted","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":2847.9133999999995,"reference_modularity":0.0},{"name":"sbm_easy/weighted/cpm/res=0.05/seed=1","graph":"sbm_easy/weighted","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":2538.833399999999,"reference_modularity":0.5203870459884219},{"name":"sbm_easy/weighted/cpm/res=0.05/seed=2","graph":"sbm_easy/weighted","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":2538.833399999999,"reference_modularity":0.5203870459884219},{"name":"sbm_easy/weighted/cpm/res=0.1/seed=1","graph":"sbm_easy/weighted","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":4,"reference_quality":2356.8834,"reference_modularity":0.6126574078044142},{"name":"sbm_easy/weighted/cpm/res=0.1/seed=2","graph":"sbm_easy/weighted","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":4,"reference_quality":2356.8834,"reference_modularity":0.6126574078044142},{"name":"sbm_medium/unit/rb/res=0.25/seed=1","graph":"sbm_medium/unit","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":2404.5,"reference_modularity":0.0},{"name":"sbm_medium/unit/rb/res=0.25/seed=2","graph":"sbm_medium/unit","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":2404.5,"reference_modularity":0.0},{"name":"sbm_medium/unit/rb/res=0.5/seed=1","graph":"sbm_medium/unit","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":2,"reference_quality":1614.3028696194633,"reference_modularity":0.1499082156078998},{"name":"sbm_medium/unit/rb/res=0.5/seed=2","graph":"sbm_medium/unit","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":2,"reference_quality":1628.2354959451031,"reference_modularity":0.16858109541179217},{"name":"sbm_medium/unit/rb/res=1.0/seed=1","graph":"sbm_medium/unit","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":5,"reference_quality":1262.065502183406,"reference_modularity":0.39365736187879163},{"name":"sbm_medium/unit/rb/res=1.0/seed=2","graph":"sbm_medium/unit","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":5,"reference_quality":1262.0655021834061,"reference_modularity":0.39365736187879163},{"name":"sbm_medium/unit/rb/res=2.0/seed=1","graph":"sbm_medium/unit","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[4,4,5,4,4,5,5,5,4,4,5,8,5,8,5,5,8,5,4,4,5,4,5,4,8,4,4,5,5,4,5,4,4,8,4,5,5,4,4,5,0,0,9,0,0,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,7,3,3,3,7,3,3,10,7,3,7,7,3,10,3,3,7,3,7,3,3,6,3,3,6,10,8,7,3,3,3,3,7,7,7,3,7,2,9,6,2,2,2,9,8,2,2,2,6,2,9,2,6,2,2,2,2,2,2,2,2,2,2,8,2,2,2,2,2,2,9,2,2,2,2,2,2,1,6,1,1,1,1,1,1,1,1,6,1,1,1,1,1,1,1,1,6,6,1,1,6,1,1,6,1,1,1,1,1,1,6,1,1,1,1,1,1],"reference_n_clusters":11,"reference_quality":658.4703680598877,"reference_modularity":0.3313272564036007},{"name":"sbm_medium/unit/rb/res=2.0/seed=2","graph":"sbm_medium/unit","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[3,4,3,3,4,3,3,3,3,4,7,4,3,4,3,3,3,3,4,4,3,3,3,4,4,4,4,3,3,3,3,4,4,4,4,3,3,4,4,3,0,0,8,0,0,0,0,0,0,0,8,0,0,0,0,0,0,0,8,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,8,1,1,1,1,1,8,1,1,1,1,1,1,1,7,1,1,7,1,4,1,1,1,1,1,1,1,9,1,1,5,7,5,6,5,6,6,5,5,6,6,7,6,5,6,7,6,5,5,5,5,7,5,6,6,5,7,6,6,6,6,6,5,5,6,5,5,5,6,5,2,7,2,7,2,2,2,2,2,2,7,2,2,2,2,2,2,2,2,7,7,2,2,7,2,2,7,2,2,2,2,2,2,7,2,2,2,2,2,2],"reference_n_clusters":10,"reference_quality":649.1228945726762,"reference_modularity":0.33579583508619415},{"name":"sbm_medium/unit/rb/res=4.0/seed=1","graph":"sbm_medium/unit","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[10,11,19,10,4,19,21,2,17,17,16,7,10,7,33,19,7,21,32,17,21,10,18,11,31,11,17,19,11,10,11,11,10,31,7,7,21,17,7,0,22,15,26,6,0,36,6,22,15,3,21,20,0,6,26,0,6,20,29,0,22,0,0,20,0,15,20,6,0,0,26,22,15,20,0,22,0,6,15,6,23,12,12,24,12,30,16,9,16,14,29,9,16,14,6,14,26,28,30,28,28,14,12,12,29,23,23,29,30,31,9,12,36,23,30,9,16,9,23,9,27,18,14,13,5,8,18,4,5,13,27,33,8,18,13,4,8,5,8,5,27,34,5,13,34,5,34,32,13,8,35,32,5,18,8,27,5,13,28,35,3,3,1,4,1,3,1,35,24,4,25,25,1,1,2,1,25,1,24,4,3,3,1,24,2,2,3,2,19,4,1,3,2,33,1,25,2,4,2,2],"reference_n_clusters":37,"reference_quality":291.92888334373055,"reference_modularity":0.18230886488956108},{"name":"sbm_medium/unit/rb/res=4.0/seed=2","graph":"sbm_medium/unit","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[10,14,5,30,14,5,5,5,34,20,15,25,30,37,33,5,37,34,20,14,30,31,10,14,26,14,21,33,10,10,5,14,10,26,20,33,35,20,20,30,4,3,4,0,4,31,0,18,11,0,25,26,0,36,4,25,27,18,25,4,18,0,0,3,0,3,3,3,4,4,31,18,3,3,0,18,0,3,3,27,1,19,32,19,19,15,24,1,15,24,28,1,11,1,36,11,24,15,27,19,11,1,17,11,28,34,1,19,27,26,32,15,31,24,11,1,15,1,1,32,13,7,6,16,13,16,7,12,17,16,28,17,35,6,16,21,7,13,6,6,6,7,6,13,7,17,13,23,16,35,7,23,17,10,23,28,13,6,16,7,21,8,2,12,8,29,9,9,12,12,22,29,2,2,8,2,9,2,2,8,23,8,2,8,9,21,22,12,5,12,2,8,9,22,9,29,22,2,29,9],"reference_n_clusters":38,"reference_quality":281.9987523393637,"reference_modularity":0.17732367064405521},{"name":"sbm_medium/unit/cpm/res=0.02/seed=1","graph":"sbm_medium/unit","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":2410.0,"reference_modularity":0.0},{"name":"sbm_medium/unit/cpm/res=0.02/seed=2","graph":"sbm_medium/unit","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":2410.0,"reference_modularity":0.0},{"name":"sbm_medium/unit/cpm/res=0.05/seed=1","graph":"sbm_medium/unit","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2],"reference_n_clusters":4,"reference_quality":1461.3,"reference_modularity":0.27360057502911916},{"name":"sbm_medium/unit/cpm/res=0.05/seed=2","graph":"sbm_medium/unit","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":1520.0,"reference_modularity":0.35518691754270787},{"name":"sbm_medium/unit/cpm/res=0.1/seed=1","graph":"sbm_medium/unit","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[2,2,2,2,2,2,2,2,2,2,5,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,5,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,5,3,3,3,3,3,3,3,5,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,4,5,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,5,4,4,4,5,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4],"reference_n_clusters":6,"reference_quality":1125.6,"reference_modularity":0.3873867580631917},{"name":"sbm_medium/unit/cpm/res=0.1/seed=2","graph":"sbm_medium/unit","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[4,4,4,4,4,4,4,4,4,4,5,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":6,"reference_quality":1127.8,"reference_modularity":0.39251672141559274},{"name":"sbm_medium/weighted/rb/res=0.25/seed=1","graph":"sbm_medium/weighted","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":4714.085999999991,"reference_modularity":0.0},{"name":"sbm_medium/weighted/rb/res=0.25/seed=2","graph":"sbm_medium/weighted","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":4714.085999999991,"reference_modularity":0.0},{"name":"sbm_medium/weighted/rb/res=0.5/seed=1","graph":"sbm_medium/weighted","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":3,"reference_quality":3233.1248554701406,"reference_modularity":0.33408191602894244},{"name":"sbm_medium/weighted/rb/res=0.5/seed=2","graph":"sbm_medium/weighted","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":2,"reference_quality":3223.3251983016385,"reference_modularity":0.17460581912430762},{"name":"sbm_medium/weighted/rb/res=1.0/seed=1","graph":"sbm_medium/weighted","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,1,1,1,1,1,1,1,1,4,4,4,0,4,4,0,4,4,4,1,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,2,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,2,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":5,"reference_quality":2513.7117408992476,"reference_modularity":0.3999256283560459},{"name":"sbm_medium/weighted/rb/res=1.0/seed=2","graph":"sbm_medium/weighted","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,4,0,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,0,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4],"reference_n_clusters":5,"reference_quality":2516.75788554107,"reference_modularity":0.4004102628072137},{"name":"sbm_medium/weighted/rb/res=2.0/seed=1","graph":"sbm_medium/weighted","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[7,3,3,3,8,11,8,3,3,3,6,8,8,11,6,3,3,8,3,3,8,8,7,8,3,8,3,11,3,3,7,11,3,3,3,11,8,3,3,14,0,0,0,9,0,10,9,0,13,6,8,0,0,0,0,14,0,0,10,0,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,0,0,0,1,10,1,11,10,1,1,9,1,1,10,14,1,1,12,1,1,1,1,10,1,1,1,1,9,1,1,10,1,10,14,1,1,1,1,9,1,9,1,1,5,4,4,4,9,7,4,5,5,4,4,6,4,4,9,13,4,5,7,5,7,4,5,4,4,5,4,5,7,4,5,5,5,7,7,5,9,7,4,5,2,6,5,13,2,6,12,12,2,2,6,2,2,2,2,2,2,2,2,13,6,2,2,11,2,2,6,2,10,2,2,2,2,6,12,2,2,2,6,12],"reference_n_clusters":15,"reference_quality":1463.1081241598356,"reference_modularity":0.32402948239805945},{"name":"sbm_medium/weighted/rb/res=2.0/seed=2","graph":"sbm_medium/weighted","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[15,13,6,12,15,4,4,13,6,6,9,4,12,13,4,6,4,4,6,6,9,4,15,9,4,15,6,6,6,6,4,6,6,4,6,4,4,6,6,9,0,0,0,1,0,14,1,0,12,9,4,0,0,8,0,2,0,0,2,0,9,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,0,0,0,11,7,10,13,14,1,1,10,1,1,7,1,1,11,11,1,10,1,12,14,1,4,11,1,14,1,1,14,10,4,10,1,1,1,7,10,1,10,1,10,5,13,8,7,5,15,11,4,5,7,7,3,7,8,5,12,7,5,8,5,8,7,5,11,7,5,11,5,8,7,5,5,5,8,7,5,5,8,8,5,3,9,5,12,2,3,11,2,2,3,3,3,2,2,3,3,2,2,2,12,3,2,2,13,2,3,3,3,14,3,9,2,9,3,2,3,3,2,3,2],"reference_n_clusters":16,"reference_quality":1452.249404042657,"reference_modularity":0.30992072514502245},{"name":"sbm_medium/weighted/rb/res=4.0/seed=1","graph":"sbm_medium/weighted","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[17,3,21,15,20,23,9,21,3,3,18,36,15,6,23,23,36,9,3,3,9,9,26,17,3,17,34,23,21,22,17,6,22,3,22,23,9,3,22,26,2,0,30,14,0,32,14,2,18,18,9,24,14,12,30,26,38,2,30,27,18,2,2,30,0,36,0,0,27,0,0,2,2,0,38,2,12,14,0,0,5,11,5,6,37,10,8,29,8,22,19,8,13,5,5,13,28,19,10,32,13,5,5,13,35,8,10,32,10,8,29,10,10,19,31,29,5,29,8,13,4,21,24,24,14,17,28,33,4,11,31,16,11,24,35,15,11,33,25,4,12,31,4,28,20,4,28,19,12,11,34,34,4,21,25,19,35,12,38,33,1,18,4,15,1,16,7,7,6,20,31,27,6,1,1,27,7,1,6,15,16,37,1,6,7,1,32,1,37,1,25,26,25,16,7,20,1,20,16,7],"reference_n_clusters":39,"reference_quality":812.4232316434903,"reference_modularity":0.2167254041256687},{"name":"sbm_medium/weighted/rb/res=4.0/seed=2","graph":"sbm_medium/weighted","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[30,9,19,18,21,7,7,19,7,20,14,21,18,9,33,4,23,7,4,9,20,23,37,21,22,21,20,7,19,4,22,9,4,34,4,7,7,20,4,20,6,17,21,5,1,4,26,6,14,14,26,17,26,3,6,30,1,6,15,1,14,17,17,29,1,23,29,1,4,1,29,6,6,29,1,6,22,17,1,1,13,15,13,9,31,32,5,35,5,34,15,5,16,13,38,16,32,5,10,37,16,13,13,16,34,5,10,15,32,15,30,10,10,10,25,35,13,35,5,16,2,19,36,3,26,27,28,23,2,3,25,33,31,36,3,18,24,38,27,2,3,25,2,28,24,2,28,10,3,27,24,28,2,19,27,2,3,22,3,24,0,14,2,18,12,8,11,11,9,0,25,8,30,0,0,8,11,12,8,18,10,12,12,9,11,0,0,0,31,0,8,12,8,33,11,8,0,12,0,11],"reference_n_clusters":39,"reference_quality":818.7288237797392,"reference_modularity":0.2177456254422815},{"name":"sbm_medium/weighted/cpm/res=0.02/seed=1","graph":"sbm_medium/weighted","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":5489.447999999992,"reference_modularity":0.0},{"name":"sbm_medium/weighted/cpm/res=0.02/seed=2","graph":"sbm_medium/weighted","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":5489.447999999992,"reference_modularity":0.0},{"name":"sbm_medium/weighted/cpm/res=0.05/seed=1","graph":"sbm_medium/weighted","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":4295.447999999992,"reference_modularity":0.0},{"name":"sbm_medium/weighted/cpm/res=0.05/seed=2","graph":"sbm_medium/weighted","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":4295.447999999992,"reference_modularity":0.0},{"name":"sbm_medium/weighted/cpm/res=0.1/seed=1","graph":"sbm_medium/weighted","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,0,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":4,"reference_quality":3003.0430000000006,"reference_modularity":0.3616902730593236},{"name":"sbm_medium/weighted/cpm/res=0.1/seed=2","graph":"sbm_medium/weighted","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,2,2,2,2,2,2,2,2,4,4,4,0,4,4,0,4,4,4,2,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,1,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3],"reference_n_clusters":5,"reference_quality":2999.0204000000012,"reference_modularity":0.3998788310572819},{"name":"sbm_hard/unit/rb/res=0.25/seed=1","graph":"sbm_hard/unit","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":2889.0,"reference_modularity":0.0},{"name":"sbm_hard/unit/rb/res=0.25/seed=2","graph":"sbm_hard/unit","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":2889.0,"reference_modularity":0.0},{"name":"sbm_hard/unit/rb/res=0.5/seed=1","graph":"sbm_hard/unit","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":1926.0,"reference_modularity":0.0},{"name":"sbm_hard/unit/rb/res=0.5/seed=2","graph":"sbm_hard/unit","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":1926.0,"reference_modularity":0.0},{"name":"sbm_hard/unit/rb/res=1.0/seed=1","graph":"sbm_hard/unit","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[1,1,1,5,1,5,1,1,5,5,5,3,5,5,5,1,5,5,1,1,3,1,1,5,4,4,1,5,1,5,5,1,5,5,5,1,5,1,1,1,1,1,4,5,1,1,1,1,1,1,1,3,2,2,2,5,0,2,2,1,2,2,4,2,5,2,2,1,2,2,2,4,2,1,4,4,1,4,2,2,2,4,0,2,2,2,2,2,1,1,4,2,4,3,0,2,2,4,4,2,0,0,4,0,0,0,5,1,0,0,0,0,2,0,0,3,0,0,0,0,4,0,4,5,2,3,0,0,2,1,0,0,4,0,0,0,3,0,5,0,0,0,5,0,0,4,0,4,0,0,1,2,3,3,4,2,4,3,4,3,4,3,3,3,3,4,3,2,1,3,3,3,3,3,2,3,3,0,2,3,5,4,4,0,3,3,4,5,3,5,3,3,4,4,3,3,5,2,4,3],"reference_n_clusters":6,"reference_quality":833.0768431983386,"reference_modularity":0.216271246936225},{"name":"sbm_hard/unit/rb/res=1.0/seed=2","graph":"sbm_hard/unit","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[0,0,0,5,0,0,0,2,3,5,4,2,5,5,5,0,0,5,0,0,3,0,0,4,4,0,0,4,0,3,5,0,5,5,0,2,0,0,0,0,0,0,1,5,0,0,5,0,0,1,4,2,4,4,0,2,2,2,5,5,2,4,4,4,4,2,4,0,4,0,4,2,2,2,2,4,0,1,5,4,4,2,2,2,1,4,2,1,4,2,0,4,4,1,2,4,4,0,4,5,1,0,2,1,1,1,1,5,1,1,1,1,2,2,2,1,2,1,1,2,1,1,5,2,1,3,2,1,5,0,1,1,5,5,2,1,2,1,2,2,2,1,5,1,1,4,1,5,1,1,0,0,3,3,2,5,2,3,0,3,3,3,3,3,3,3,1,5,1,3,3,3,3,3,3,3,3,5,4,3,3,1,0,2,3,3,3,0,3,1,1,3,2,0,3,0,1,5,3,1],"reference_n_clusters":6,"reference_quality":810.9413291796469,"reference_modularity":0.21052474796979406},{"name":"sbm_hard/unit/rb/res=2.0/seed=1","graph":"sbm_hard/unit","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[17,9,15,1,14,12,17,1,4,0,18,1,1,1,1,9,11,0,17,12,5,5,0,18,18,1,12,18,0,0,1,9,1,1,19,25,0,12,1,17,15,11,25,18,9,9,1,9,5,6,9,5,14,14,0,19,10,14,0,7,19,23,2,23,18,16,12,12,10,16,16,2,16,23,2,13,17,24,14,15,14,24,21,10,10,14,25,23,15,15,7,10,24,8,21,10,16,17,24,15,6,11,2,2,22,6,11,0,20,22,6,21,10,2,5,3,6,21,22,20,2,23,13,20,19,19,20,22,3,0,6,0,13,0,21,5,8,22,8,20,20,23,11,11,6,24,19,13,6,22,12,16,8,3,2,7,2,4,4,4,2,8,7,6,5,13,8,3,25,4,4,5,7,4,7,2,16,3,18,3,3,5,13,21,4,17,25,11,8,3,3,15,13,5,4,7,8,7,4,3],"reference_n_clusters":26,"reference_quality":468.8473520249222,"reference_modularity":0.16262296885058702},{"name":"sbm_hard/unit/rb/res=2.0/seed=2","graph":"sbm_hard/unit","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[0,1,24,0,1,23,9,0,20,16,21,0,0,0,0,1,20,4,9,23,7,9,16,24,21,0,23,21,15,16,9,1,19,0,20,0,16,23,0,9,1,0,6,24,1,1,0,1,6,17,1,6,10,3,16,8,10,3,15,13,22,22,3,22,21,15,15,23,3,14,3,13,14,22,21,12,9,6,0,13,3,10,10,15,14,3,8,22,10,10,24,3,9,6,10,3,14,6,3,13,5,2,13,1,4,2,4,15,8,2,1,5,10,18,8,8,2,5,2,8,18,5,12,8,14,4,13,2,19,1,5,16,12,16,2,5,18,2,18,5,8,14,4,14,2,6,5,12,5,2,15,4,11,19,24,17,13,11,7,7,18,6,17,11,7,12,18,19,17,7,20,11,6,7,17,11,11,19,4,11,20,9,12,8,7,20,12,4,7,4,17,11,12,9,7,17,18,19,4,13],"reference_n_clusters":25,"reference_quality":474.7777777777779,"reference_modularity":0.16598880812276454},{"name":"sbm_hard/unit/rb/res=4.0/seed=1","graph":"sbm_hard/unit","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[13,8,33,6,7,11,13,25,32,6,25,26,25,25,6,8,4,6,24,11,22,21,40,27,14,46,11,27,36,24,43,8,43,6,31,17,40,11,33,13,33,36,17,27,37,15,43,8,5,2,8,5,16,16,35,31,2,7,28,15,31,41,3,16,47,9,16,11,21,9,9,46,9,15,1,14,13,10,45,30,35,10,23,1,19,16,17,35,42,33,15,21,10,12,23,21,9,13,10,45,12,4,3,3,0,2,19,30,26,0,36,39,31,1,3,38,29,39,0,26,38,12,14,29,19,20,26,0,44,30,2,40,14,30,23,22,37,0,37,29,29,19,44,19,2,10,20,24,2,0,4,34,18,24,1,15,3,42,4,22,3,28,7,18,5,14,12,32,17,28,20,5,7,28,34,1,18,32,27,18,32,22,4,23,42,20,17,4,12,41,39,18,1,5,41,34,7,34,48,38],"reference_n_clusters":49,"reference_quality":214.1661474558672,"reference_modularity":0.12137630759708386},{"name":"sbm_hard/unit/rb/res=4.0/seed=2","graph":"sbm_hard/unit","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[19,21,14,34,22,4,15,10,42,34,10,20,10,10,34,22,42,41,31,4,3,19,14,43,33,15,4,39,5,35,45,20,36,34,31,16,35,4,20,19,20,49,15,31,42,29,10,32,17,21,24,17,6,31,3,41,26,27,5,13,35,9,24,32,39,5,38,4,24,27,45,13,30,32,44,33,19,36,30,3,47,6,26,5,30,38,16,47,6,6,43,43,36,12,26,24,30,12,17,6,7,1,13,29,8,21,8,5,9,1,29,7,26,11,25,40,1,7,1,25,11,14,46,39,22,41,13,29,18,3,9,9,46,3,32,33,11,1,11,15,25,9,8,7,1,17,23,46,21,1,28,8,12,18,44,27,48,0,28,2,3,12,27,0,2,23,11,18,16,2,23,0,14,2,37,0,0,18,45,0,33,38,28,25,2,48,16,8,2,40,7,0,44,28,2,37,22,37,23,40],"reference_n_clusters":50,"reference_quality":210.932502596054,"reference_modularity":0.1203876234810524},{"name":"sbm_hard/unit/cpm/res=0.02/seed=1","graph":"sbm_hard/unit","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":3056.0,"reference_modularity":0.0},{"name":"sbm_hard/unit/cpm/res=0.02/seed=2","graph":"sbm_hard/unit","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":3056.0,"reference_modularity":0.0},{"name":"sbm_hard/unit/cpm/res=0.05/seed=1","graph":"sbm_hard/unit","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":2,"reference_quality":1863.8999999999999,"reference_modularity":-1.0917984103304252e-05},{"name":"sbm_hard/unit/cpm/res=0.05/seed=2","graph":"sbm_hard/unit","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":3,"reference_quality":1865.3,"reference_modularity":0.0009425859609282474},{"name":"sbm_hard/unit/cpm/res=0.1/seed=1","graph":"sbm_hard/unit","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[2,3,3,6,3,4,2,3,0,6,6,5,6,6,6,3,5,4,2,3,8,7,3,6,6,7,3,6,3,1,2,3,6,6,4,5,1,3,3,2,3,3,7,6,5,3,3,3,7,3,3,7,2,2,8,5,8,1,2,2,1,4,8,2,0,2,2,3,1,1,2,1,2,3,0,0,2,4,2,8,4,5,1,2,2,2,2,4,0,5,1,1,2,0,5,2,2,0,2,2,7,5,8,2,4,5,4,2,5,6,3,6,1,1,5,4,1,7,5,5,4,7,0,0,4,4,5,5,1,3,4,5,4,8,5,7,5,5,5,2,5,5,4,5,4,7,0,0,3,6,3,1,0,0,1,1,8,0,1,0,8,0,1,1,7,0,4,1,1,0,0,7,4,0,1,0,4,0,1,0,0,7,0,5,0,0,0,4,4,4,0,1,0,7,4,1,6,1,1,4],"reference_n_clusters":9,"reference_quality":766.3999999999999,"reference_modularity":0.19645914948634255},{"name":"sbm_hard/unit/cpm/res=0.1/seed=2","graph":"sbm_hard/unit","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[0,6,0,6,0,0,0,6,1,6,2,6,6,6,6,6,4,2,5,0,1,5,0,2,5,4,0,2,4,1,5,6,6,6,5,3,2,0,3,5,0,3,5,2,0,0,6,6,1,6,6,1,3,4,0,3,0,3,4,3,3,2,7,4,2,4,4,0,0,4,5,7,5,4,5,4,5,5,3,0,5,3,3,4,5,4,3,0,3,3,5,5,5,1,3,4,5,5,3,3,0,4,7,0,2,2,2,4,2,2,0,0,3,0,3,2,0,0,0,2,0,0,3,2,3,2,0,0,0,0,2,2,3,2,4,0,0,0,0,0,2,2,2,2,2,5,0,3,0,0,4,2,1,1,0,3,0,1,4,1,0,1,1,1,1,1,0,1,1,1,1,1,1,1,1,1,1,0,2,1,1,0,4,3,1,1,1,2,1,2,1,1,4,4,1,1,6,0,1,1],"reference_n_clusters":8,"reference_quality":815.3999999999999,"reference_modularity":0.2055981761305371},{"name":"sbm_hard/weighted/rb/res=0.25/seed=1","graph":"sbm_hard/weighted","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":5686.726499999995,"reference_modularity":0.0},{"name":"sbm_hard/weighted/rb/res=0.25/seed=2","graph":"sbm_hard/weighted","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":5686.726499999995,"reference_modularity":0.0},{"name":"sbm_hard/weighted/rb/res=0.5/seed=1","graph":"sbm_hard/weighted","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":3791.150999999996,"reference_modularity":0.0},{"name":"sbm_hard/weighted/rb/res=0.5/seed=2","graph":"sbm_hard/weighted","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":3791.150999999996,"reference_modularity":0.0},{"name":"sbm_hard/weighted/rb/res=1.0/seed=1","graph":"sbm_hard/weighted","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[5,4,4,3,6,4,1,1,2,1,1,3,1,3,3,4,2,5,2,4,1,3,4,0,3,1,4,3,4,2,5,4,2,3,2,0,4,4,4,1,4,4,1,4,1,3,1,1,1,6,3,6,0,2,1,6,6,6,2,0,6,6,0,1,1,6,2,4,3,5,5,4,5,6,0,3,5,0,0,4,1,0,0,0,3,0,5,1,4,0,0,3,0,6,0,3,3,1,0,0,1,2,3,5,5,6,5,4,6,3,6,2,3,0,5,6,3,5,4,3,4,1,2,5,3,5,3,0,0,4,6,6,0,4,0,1,3,3,3,5,5,6,5,5,6,0,2,2,6,3,2,5,1,2,0,0,1,3,2,0,0,1,2,1,1,2,1,2,0,3,2,1,2,1,2,0,1,2,5,2,2,1,2,0,2,2,0,2,1,0,2,1,0,1,0,2,3,2,0,2],"reference_n_clusters":7,"reference_quality":1806.2287001004233,"reference_modularity":0.23821640183949702},{"name":"sbm_hard/weighted/rb/res=1.0/seed=2","graph":"sbm_hard/weighted","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[6,7,4,0,1,4,7,1,2,2,1,0,5,0,4,7,6,6,5,4,2,4,4,3,0,7,4,0,4,2,4,7,0,4,4,1,2,4,5,5,4,4,1,4,7,7,7,7,4,3,0,3,1,6,5,3,1,3,6,5,3,1,3,3,1,3,6,5,4,0,5,7,0,5,3,6,5,5,4,1,4,5,3,3,0,3,3,1,1,3,3,3,5,1,3,0,5,5,5,5,1,6,0,1,0,1,0,3,0,0,1,0,3,3,3,4,0,0,0,0,1,1,2,0,0,6,0,1,6,4,0,0,0,0,3,1,0,0,0,0,3,0,6,0,0,5,2,2,1,0,6,0,2,2,3,3,1,2,6,2,1,2,2,1,5,2,1,6,1,2,2,4,2,6,2,2,2,2,6,2,2,1,2,3,2,2,1,6,1,1,2,1,3,4,2,2,0,1,6,2],"reference_n_clusters":8,"reference_quality":1836.0607042802512,"reference_modularity":0.24215082758247464},{"name":"sbm_hard/weighted/rb/res=2.0/seed=1","graph":"sbm_hard/weighted","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[5,12,2,6,0,11,12,0,9,0,15,6,15,20,2,3,11,5,1,11,23,2,11,15,6,12,11,13,3,1,1,3,21,2,9,18,3,11,20,12,3,11,19,2,12,14,12,16,1,14,6,17,18,2,12,17,10,17,5,14,17,17,7,16,13,17,15,11,2,4,1,17,4,20,7,13,1,21,2,10,2,18,10,7,4,15,19,10,3,18,14,2,1,0,9,6,4,12,1,18,19,22,6,4,4,0,4,3,0,8,0,21,2,7,5,2,8,4,3,6,7,19,23,13,20,3,6,0,5,3,4,0,18,3,0,19,6,8,6,8,5,20,5,4,22,1,8,23,0,8,22,4,13,16,7,14,10,8,22,16,7,13,8,10,1,16,0,5,14,23,9,1,9,13,8,7,13,5,5,9,9,15,23,5,21,9,19,22,10,15,21,0,7,1,16,14,20,10,16,21],"reference_n_clusters":24,"reference_quality":1228.9558666940347,"reference_modularity":0.20680996791568296},{"name":"sbm_hard/weighted/rb/res=2.0/seed=2","graph":"sbm_hard/weighted","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[0,4,12,1,8,0,4,8,2,2,15,1,4,1,3,4,0,19,3,0,13,11,0,5,20,4,0,1,13,16,3,4,21,3,12,10,13,0,10,4,6,0,15,12,4,4,13,4,7,18,20,18,14,12,13,18,8,18,12,10,18,15,5,20,20,18,12,0,11,17,3,4,3,10,5,12,3,21,3,8,12,10,2,5,20,5,14,8,14,10,5,11,3,6,2,20,3,5,10,10,17,0,10,14,22,6,22,13,6,11,8,21,11,5,17,18,11,17,15,1,14,17,9,1,22,19,1,14,0,13,6,6,22,13,2,17,1,11,1,1,1,6,0,17,6,9,6,9,8,11,7,22,16,16,5,2,8,7,19,9,14,16,7,8,9,19,6,2,15,9,2,7,7,16,7,7,16,2,19,7,2,15,9,2,21,2,14,0,9,15,21,16,5,3,9,7,1,8,19,21],"reference_n_clusters":23,"reference_quality":1204.1969376991094,"reference_modularity":0.20479716698827835},{"name":"sbm_hard/weighted/rb/res=4.0/seed=1","graph":"sbm_hard/weighted","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[39,41,18,13,15,0,26,15,6,34,3,13,40,3,8,41,0,12,38,0,33,8,0,40,13,2,0,28,1,38,43,1,3,8,18,17,1,0,36,26,1,0,2,18,31,24,3,6,2,5,13,10,20,18,26,10,5,10,12,35,10,28,4,6,6,10,3,0,8,11,43,41,25,36,7,3,43,23,25,4,18,20,17,7,25,4,32,4,20,17,24,8,23,34,17,13,25,26,23,37,2,9,35,19,16,5,16,1,14,22,15,19,8,7,29,44,11,19,20,14,21,2,30,44,16,12,14,32,39,1,5,34,16,1,17,33,14,22,14,19,29,36,39,31,5,23,22,30,5,22,9,16,28,38,7,24,4,11,9,27,21,42,11,4,27,6,15,12,35,30,6,2,45,9,11,7,28,29,12,45,42,21,30,29,42,33,32,37,27,40,31,15,7,2,27,24,21,3,9,37],"reference_n_clusters":46,"reference_quality":701.3904503345708,"reference_modularity":0.16480086292838814},{"name":"sbm_hard/weighted/rb/res=4.0/seed=2","graph":"sbm_hard/weighted","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[3,2,15,29,2,0,9,36,12,1,34,31,9,40,5,2,0,3,27,0,39,5,0,30,29,9,0,18,8,14,27,31,40,5,15,25,8,0,31,9,31,0,11,15,9,14,39,14,13,2,29,10,43,15,39,38,4,23,3,10,38,34,30,38,24,2,34,0,5,35,27,2,28,10,6,18,27,44,28,4,15,43,23,6,28,30,24,4,43,23,30,5,13,1,23,29,28,44,13,42,32,33,10,42,16,1,16,8,1,7,36,26,5,6,22,36,35,32,11,19,20,32,21,24,16,45,19,11,3,8,37,1,16,8,1,32,19,7,19,42,22,1,3,7,37,44,7,21,2,7,33,16,41,14,6,17,4,35,33,25,20,24,17,4,10,14,41,3,11,21,12,13,17,18,17,6,18,22,3,12,12,20,21,22,26,12,11,37,4,25,26,41,6,13,25,17,20,40,45,26],"reference_n_clusters":46,"reference_quality":732.6218405431697,"reference_modularity":0.16798589796816235},{"name":"sbm_hard/weighted/cpm/res=0.02/seed=1","graph":"sbm_hard/weighted","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":6786.301999999994,"reference_modularity":0.0},{"name":"sbm_hard/weighted/cpm/res=0.02/seed=2","graph":"sbm_hard/weighted","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":6786.301999999994,"reference_modularity":0.0},{"name":"sbm_hard/weighted/cpm/res=0.05/seed=1","graph":"sbm_hard/weighted","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":5592.301999999994,"reference_modularity":0.0},{"name":"sbm_hard/weighted/cpm/res=0.05/seed=2","graph":"sbm_hard/weighted","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":1,"reference_quality":5592.301999999994,"reference_modularity":0.0},{"name":"sbm_hard/weighted/cpm/res=0.1/seed=1","graph":"sbm_hard/weighted","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,3,0,0,0,0,0,0,0,0,1,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":4,"reference_quality":3636.9919999999947,"reference_modularity":0.002767168074020031},{"name":"sbm_hard/weighted/cpm/res=0.1/seed=2","graph":"sbm_hard/weighted","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,2,0,0,0,0,0,0,0,0,1,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":4,"reference_quality":3636.9919999999947,"reference_modularity":0.002767168074020031},{"name":"ring_of_cliques/unit/rb/res=0.25/seed=1","graph":"ring_of_cliques/unit","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[6,6,6,6,6,6,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,4,4,4,4,4,4,4,4,4,4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2],"reference_n_clusters":7,"reference_quality":355.3333333333333,"reference_modularity":0.8107638888888888},{"name":"ring_of_cliques/unit/rb/res=0.25/seed=2","graph":"ring_of_cliques/unit","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[6,6,6,6,6,6,3,3,3,3,3,3,3,3,3,3,3,3,4,4,4,4,4,4,4,4,4,4,4,4,5,5,5,5,5,5,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":7,"reference_quality":355.3333333333333,"reference_modularity":0.8107638888888888},{"name":"ring_of_cliques/unit/rb/res=0.5/seed=1","graph":"ring_of_cliques/unit","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[1,1,1,1,1,1,2,2,2,2,2,2,11,11,11,11,11,11,9,9,9,9,9,9,4,4,4,4,4,4,3,3,3,3,3,3,10,10,10,10,10,10,5,5,5,5,5,5,6,6,6,6,6,6,8,8,8,8,8,8,7,7,7,7,7,7,0,0,0,0,0,0],"reference_n_clusters":12,"reference_quality":344.00000000000006,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/rb/res=0.5/seed=2","graph":"ring_of_cliques/unit","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[0,0,0,0,0,0,10,10,10,10,10,10,7,7,7,7,7,7,3,3,3,3,3,3,1,1,1,1,1,1,5,5,5,5,5,5,2,2,2,2,2,2,4,4,4,4,4,4,11,11,11,11,11,11,9,9,9,9,9,9,8,8,8,8,8,8,6,6,6,6,6,6],"reference_n_clusters":12,"reference_quality":344.00000000000006,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/rb/res=1.0/seed=1","graph":"ring_of_cliques/unit","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[1,1,1,1,1,1,2,2,2,2,2,2,11,11,11,11,11,11,9,9,9,9,9,9,4,4,4,4,4,4,3,3,3,3,3,3,10,10,10,10,10,10,5,5,5,5,5,5,6,6,6,6,6,6,8,8,8,8,8,8,7,7,7,7,7,7,0,0,0,0,0,0],"reference_n_clusters":12,"reference_quality":328.0,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/rb/res=1.0/seed=2","graph":"ring_of_cliques/unit","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[0,0,0,0,0,0,10,10,10,10,10,10,7,7,7,7,7,7,3,3,3,3,3,3,1,1,1,1,1,1,5,5,5,5,5,5,2,2,2,2,2,2,4,4,4,4,4,4,11,11,11,11,11,11,9,9,9,9,9,9,8,8,8,8,8,8,6,6,6,6,6,6],"reference_n_clusters":12,"reference_quality":328.0,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/rb/res=2.0/seed=1","graph":"ring_of_cliques/unit","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[1,1,1,1,1,1,2,2,2,2,2,2,11,11,11,11,11,11,9,9,9,9,9,9,4,4,4,4,4,4,3,3,3,3,3,3,10,10,10,10,10,10,5,5,5,5,5,5,6,6,6,6,6,6,8,8,8,8,8,8,7,7,7,7,7,7,0,0,0,0,0,0],"reference_n_clusters":12,"reference_quality":296.0,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/rb/res=2.0/seed=2","graph":"ring_of_cliques/unit","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[0,0,0,0,0,0,10,10,10,10,10,10,7,7,7,7,7,7,3,3,3,3,3,3,1,1,1,1,1,1,5,5,5,5,5,5,2,2,2,2,2,2,4,4,4,4,4,4,11,11,11,11,11,11,9,9,9,9,9,9,8,8,8,8,8,8,6,6,6,6,6,6],"reference_n_clusters":12,"reference_quality":296.0,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/rb/res=4.0/seed=1","graph":"ring_of_cliques/unit","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[1,1,1,1,1,1,2,2,2,2,2,2,11,11,11,11,11,11,9,9,9,9,9,9,4,4,4,4,4,4,3,3,3,3,3,3,10,10,10,10,10,10,5,5,5,5,5,5,6,6,6,6,6,6,8,8,8,8,8,8,7,7,7,7,7,7,0,0,0,0,0,0],"reference_n_clusters":12,"reference_quality":232.00000000000009,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/rb/res=4.0/seed=2","graph":"ring_of_cliques/unit","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[0,0,0,0,0,0,10,10,10,10,10,10,7,7,7,7,7,7,3,3,3,3,3,3,1,1,1,1,1,1,5,5,5,5,5,5,2,2,2,2,2,2,4,4,4,4,4,4,11,11,11,11,11,11,9,9,9,9,9,9,8,8,8,8,8,8,6,6,6,6,6,6],"reference_n_clusters":12,"reference_quality":232.00000000000009,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/cpm/res=0.02/seed=1","graph":"ring_of_cliques/unit","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[6,6,6,6,6,6,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,4,4,4,4,4,4,4,4,4,4,4,4,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2],"reference_n_clusters":7,"reference_quality":355.59999999999997,"reference_modularity":0.8107638888888888},{"name":"ring_of_cliques/unit/cpm/res=0.02/seed=2","graph":"ring_of_cliques/unit","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[6,6,6,6,6,6,3,3,3,3,3,3,3,3,3,3,3,3,4,4,4,4,4,4,4,4,4,4,4,4,5,5,5,5,5,5,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":7,"reference_quality":355.59999999999997,"reference_modularity":0.8107638888888888},{"name":"ring_of_cliques/unit/cpm/res=0.05/seed=1","graph":"ring_of_cliques/unit","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[1,1,1,1,1,1,2,2,2,2,2,2,11,11,11,11,11,11,9,9,9,9,9,9,4,4,4,4,4,4,3,3,3,3,3,3,10,10,10,10,10,10,5,5,5,5,5,5,6,6,6,6,6,6,8,8,8,8,8,8,7,7,7,7,7,7,0,0,0,0,0,0],"reference_n_clusters":12,"reference_quality":342.0,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/cpm/res=0.05/seed=2","graph":"ring_of_cliques/unit","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[0,0,0,0,0,0,10,10,10,10,10,10,7,7,7,7,7,7,3,3,3,3,3,3,1,1,1,1,1,1,5,5,5,5,5,5,2,2,2,2,2,2,4,4,4,4,4,4,11,11,11,11,11,11,9,9,9,9,9,9,8,8,8,8,8,8,6,6,6,6,6,6],"reference_n_clusters":12,"reference_quality":342.0,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/cpm/res=0.1/seed=1","graph":"ring_of_cliques/unit","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[1,1,1,1,1,1,2,2,2,2,2,2,11,11,11,11,11,11,9,9,9,9,9,9,4,4,4,4,4,4,3,3,3,3,3,3,10,10,10,10,10,10,5,5,5,5,5,5,6,6,6,6,6,6,8,8,8,8,8,8,7,7,7,7,7,7,0,0,0,0,0,0],"reference_n_clusters":12,"reference_quality":324.0,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/unit/cpm/res=0.1/seed=2","graph":"ring_of_cliques/unit","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[0,0,0,0,0,0,10,10,10,10,10,10,7,7,7,7,7,7,3,3,3,3,3,3,1,1,1,1,1,1,5,5,5,5,5,5,2,2,2,2,2,2,4,4,4,4,4,4,11,11,11,11,11,11,9,9,9,9,9,9,8,8,8,8,8,8,6,6,6,6,6,6],"reference_n_clusters":12,"reference_quality":324.0,"reference_modularity":0.854166666666667},{"name":"ring_of_cliques/weighted/rb/res=0.25/seed=1","graph":"ring_of_cliques/weighted","objective":"rb","resolution":0.25,"seed":1,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,5,5,5,5,5,5,5,5,5,5,5,5],"reference_n_clusters":6,"reference_quality":665.0303674599738,"reference_modularity":0.7998174964618237},{"name":"ring_of_cliques/weighted/rb/res=0.25/seed=2","graph":"ring_of_cliques/weighted","objective":"rb","resolution":0.25,"seed":2,"reference_membership":[2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,1,1,1,1,1,1,1,1,1,1,1,1,3,3,3,3,3,3,3,3,3,3,3,3,4,4,4,4,4,4,4,4,4,4,4,4],"reference_n_clusters":6,"reference_quality":662.5683737348439,"reference_modularity":0.7822118210915358},{"name":"ring_of_cliques/weighted/rb/res=0.5/seed=1","graph":"ring_of_cliques/weighted","objective":"rb","resolution":0.5,"seed":1,"reference_membership":[7,7,7,7,7,7,6,6,6,6,6,6,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,5,5,5,5,5,5,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,3,8,8,8,8,8,8],"reference_n_clusters":9,"reference_quality":639.4879687119058,"reference_modularity":0.8292104204889368},{"name":"ring_of_cliques/weighted/rb/res=0.5/seed=2","graph":"ring_of_cliques/weighted","objective":"rb","resolution":0.5,"seed":2,"reference_membership":[7,7,7,7,7,7,6,6,6,6,6,6,0,0,0,0,0,0,0,0,0,0,0,0,5,5,5,5,5,5,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,8,8,8,8,8,8],"reference_n_clusters":9,"reference_quality":639.4879687119059,"reference_modularity":0.8292104204889368},{"name":"ring_of_cliques/weighted/rb/res=1.0/seed=1","graph":"ring_of_cliques/weighted","objective":"rb","resolution":1.0,"seed":1,"reference_membership":[6,6,6,6,6,6,9,9,9,9,9,9,3,3,3,3,3,3,8,8,8,8,8,8,4,4,4,4,4,4,5,5,5,5,5,5,1,1,1,1,1,1,7,7,7,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2,10,10,10,10,10,10],"reference_n_clusters":11,"reference_quality":603.9179062871883,"reference_modularity":0.8415440171843079},{"name":"ring_of_cliques/weighted/rb/res=1.0/seed=2","graph":"ring_of_cliques/weighted","objective":"rb","resolution":1.0,"seed":2,"reference_membership":[7,7,7,7,7,7,5,5,5,5,5,5,3,3,3,3,3,3,2,2,2,2,2,2,10,10,10,10,10,10,8,8,8,8,8,8,6,6,6,6,6,6,9,9,9,9,9,9,0,0,0,0,0,0,0,0,0,0,0,0,4,4,4,4,4,4,1,1,1,1,1,1],"reference_n_clusters":11,"reference_quality":603.9179062871883,"reference_modularity":0.8415440171843079},{"name":"ring_of_cliques/weighted/rb/res=2.0/seed=1","graph":"ring_of_cliques/weighted","objective":"rb","resolution":2.0,"seed":1,"reference_membership":[7,7,7,7,7,7,8,8,8,8,8,8,2,2,2,2,2,2,9,9,9,9,9,9,1,1,1,1,1,1,3,3,3,3,3,3,0,0,0,0,0,0,4,4,4,4,4,4,6,6,6,6,6,6,10,10,10,10,10,10,11,11,11,11,11,11,5,5,5,5,5,5],"reference_n_clusters":12,"reference_quality":541.5889738132755,"reference_modularity":0.8403253969961128},{"name":"ring_of_cliques/weighted/rb/res=2.0/seed=2","graph":"ring_of_cliques/weighted","objective":"rb","resolution":2.0,"seed":2,"reference_membership":[0,0,0,0,0,0,10,10,10,10,10,10,7,7,7,7,7,7,3,3,3,3,3,3,1,1,1,1,1,1,5,5,5,5,5,5,2,2,2,2,2,2,4,4,4,4,4,4,11,11,11,11,11,11,9,9,9,9,9,9,8,8,8,8,8,8,6,6,6,6,6,6],"reference_n_clusters":12,"reference_quality":541.5889738132755,"reference_modularity":0.8403253969961128},{"name":"ring_of_cliques/weighted/rb/res=4.0/seed=1","graph":"ring_of_cliques/weighted","objective":"rb","resolution":4.0,"seed":1,"reference_membership":[0,0,0,0,0,0,4,4,4,4,4,4,9,9,9,9,9,9,5,5,5,5,5,5,2,2,2,2,2,2,1,1,1,1,1,1,11,11,11,11,11,11,7,7,7,7,7,7,3,3,3,3,3,3,6,6,6,6,6,6,10,10,10,10,10,10,8,8,8,8,8,8],"reference_n_clusters":12,"reference_quality":418.6801476265511,"reference_modularity":0.8403253969961128},{"name":"ring_of_cliques/weighted/rb/res=4.0/seed=2","graph":"ring_of_cliques/weighted","objective":"rb","resolution":4.0,"seed":2,"reference_membership":[5,5,5,5,5,5,2,2,2,2,2,2,8,8,8,8,8,8,1,1,1,1,1,1,4,4,4,4,4,4,3,3,3,3,3,3,10,10,10,10,10,10,7,7,7,7,7,7,9,9,9,9,9,9,6,6,6,6,6,6,0,0,0,0,0,0,11,11,11,11,11,11],"reference_n_clusters":12,"reference_quality":418.68014762655116,"reference_modularity":0.8403253969961128},{"name":"ring_of_cliques/weighted/cpm/res=0.02/seed=1","graph":"ring_of_cliques/weighted","objective":"cpm","resolution":0.02,"seed":1,"reference_membership":[3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":4,"reference_quality":681.1479999999999,"reference_modularity":0.7170583723083411},{"name":"ring_of_cliques/weighted/cpm/res=0.02/seed=2","graph":"ring_of_cliques/weighted","objective":"cpm","resolution":0.02,"seed":2,"reference_membership":[2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,4,4,4,4,4,4,4,4,4,4,4,4,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,2,2,2,2,2],"reference_n_clusters":5,"reference_quality":680.0251999999999,"reference_modularity":0.7651292010824565},{"name":"ring_of_cliques/weighted/cpm/res=0.05/seed=1","graph":"ring_of_cliques/weighted","objective":"cpm","resolution":0.05,"seed":1,"reference_membership":[6,6,6,6,6,6,5,5,5,5,5,5,2,2,2,2,2,2,2,2,2,2,2,2,4,4,4,4,4,4,4,4,4,4,4,4,3,3,3,3,3,3,3,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1],"reference_n_clusters":7,"reference_quality":657.32,"reference_modularity":0.8072280791819045},{"name":"ring_of_cliques/weighted/cpm/res=0.05/seed=2","graph":"ring_of_cliques/weighted","objective":"cpm","resolution":0.05,"seed":2,"reference_membership":[5,5,5,5,5,5,6,6,6,6,6,6,4,4,4,4,4,4,4,4,4,4,4,4,2,2,2,2,2,2,2,2,2,2,2,2,3,3,3,3,3,3,3,3,3,3,3,3,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0],"reference_n_clusters":7,"reference_quality":657.32,"reference_modularity":0.8072280791819045},{"name":"ring_of_cliques/weighted/cpm/res=0.1/seed=1","graph":"ring_of_cliques/weighted","objective":"cpm","resolution":0.1,"seed":1,"reference_membership":[8,8,8,8,8,8,5,5,5,5,5,5,4,4,4,4,4,4,1,1,1,1,1,1,9,9,9,9,9,9,6,6,6,6,6,6,3,3,3,3,3,3,7,7,7,7,7,7,0,0,0,0,0,0,0,0,0,0,0,0,10,10,10,10,10,10,2,2,2,2,2,2],"reference_n_clusters":11,"reference_quality":628.7495999999999,"reference_modularity":0.8415440171843079},{"name":"ring_of_cliques/weighted/cpm/res=0.1/seed=2","graph":"ring_of_cliques/weighted","objective":"cpm","resolution":0.1,"seed":2,"reference_membership":[7,7,7,7,7,7,5,5,5,5,5,5,3,3,3,3,3,3,2,2,2,2,2,2,10,10,10,10,10,10,8,8,8,8,8,8,6,6,6,6,6,6,9,9,9,9,9,9,0,0,0,0,0,0,0,0,0,0,0,0,4,4,4,4,4,4,1,1,1,1,1,1],"reference_n_clusters":11,"reference_quality":628.7496,"reference_modularity":0.8415440171843079}]} \ No newline at end of file diff --git a/tests/invariants.rs b/tests/invariants.rs new file mode 100644 index 0000000..ce1b908 --- /dev/null +++ b/tests/invariants.rs @@ -0,0 +1,242 @@ +//! Properties that must hold for any input, exercised through the public API. +//! +//! Each of the four below maps to a bug that shipped in 0.6.x. Cheap, no fixtures needed, and +//! any one failing means the algorithm is wrong rather than unlucky. + +use single_clustering::community_search::leiden::{ + Cpm, LeidenConfig, Objective, ObjectiveKind, Partition, Rb, leiden, +}; +use single_clustering::network::CSRNetwork; + +mod common; +use common::{disconnected_community_count, sbm, test_graphs}; + +/// Invariant 1: collapsing communities must not change what a partition scores. Broken in +/// 0.6.x, where a self-loop added its weight once instead of twice to strength, switching the +/// null model off above level 0. +/// +/// Holds to stored-weight precision. Adjacency is `f32`, so a collapsed self-loop carrying a +/// whole community's internal weight rounds once per level — `1e-6` relative, about 10x f32 +/// epsilon. The bug this guards against was a factor of ~1.75, not 1e-8. +#[test] +fn quality_is_invariant_under_aggregation() { + for (name, graph) in test_graphs() { + if graph.node_count() == 0 { + continue; + } + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + let mut partition = Partition::from_membership(&graph, clustering.labels()); + + let grouping = partition.renumber_into_grouping(); + let coarse = graph.aggregate(&grouping); + let coarse_labels: Vec = (0..coarse.node_count()).collect(); + let coarse_partition = Partition::from_membership(&coarse, &coarse_labels); + + for gamma in [0.25, 1.0, 4.0] { + let fine = Rb::new(gamma).quality(&partition); + let agg = Rb::new(gamma).quality(&coarse_partition); + assert!( + (fine - agg).abs() < 1e-6 * fine.abs().max(1.0), + "{name} at gamma={gamma}: fine {fine} != aggregated {agg}" + ); + } + let fine = Cpm::new(0.05).quality(&partition); + let agg = Cpm::new(0.05).quality(&coarse_partition); + assert!( + (fine - agg).abs() < 1e-6 * fine.abs().max(1.0), + "{name} under CPM: fine {fine} != aggregated {agg}" + ); + } +} + +/// Invariant 2: the graph's degree sum must equal twice its total edge weight, before and +/// after aggregation. The cheapest possible guard on the self-loop convention. +#[test] +fn degree_sum_matches_total_weight() { + for (name, graph) in test_graphs() { + let check = |g: &CSRNetwork, label: &str| { + let sum: f64 = (0..g.node_count()).map(|v| g.strength(v)).sum(); + let expected = 2.0 * g.total_weight(); + assert!( + (sum - expected).abs() < 1e-9 * expected.abs().max(1.0), + "{name} ({label}): degree sum {sum} != 2*total_weight {expected}" + ); + }; + check(&graph, "original"); + + if graph.node_count() > 0 { + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + let mut partition = Partition::from_membership(&graph, clustering.labels()); + let coarse = graph.aggregate(&partition.renumber_into_grouping()); + check(&coarse, "aggregated"); + let (fine_w, coarse_w) = (graph.total_weight(), coarse.total_weight()); + assert!( + (fine_w - coarse_w).abs() < 1e-6 * fine_w.abs().max(1.0), + "{name}: aggregation changed total weight, {fine_w} -> {coarse_w}" + ); + } + } +} + +/// Invariant 3: every community internally connected — the guarantee refinement exists for. +/// 0.6.x lost it by hanging the "only singletons may merge" guard off the wrong statement. +#[test] +fn communities_are_internally_connected() { + for (name, graph) in test_graphs() { + for seed in 0..5u64 { + for objective in [ + ObjectiveKind::Rb { resolution: 0.5 }, + ObjectiveKind::Rb { resolution: 1.0 }, + ObjectiveKind::Rb { resolution: 3.0 }, + ObjectiveKind::Cpm { resolution: 0.05 }, + ] { + let clustering = leiden( + &graph, + &LeidenConfig { + objective, + seed: Some(seed), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + disconnected_community_count(&graph, clustering.labels()), + 0, + "{name} (seed {seed}, {objective:?}) produced a disconnected community" + ); + } + } + } +} + +/// Invariant 4: a fixed seed gives bit-identical results. 0.6.x iterated candidates out of a +/// `HashSet`, whose order varies per process, so `seed: Some(42)` still drifted run to run. +#[test] +fn a_fixed_seed_is_reproducible() { + for (name, graph) in test_graphs() { + let config = LeidenConfig { + seed: Some(7), + ..Default::default() + }; + let first = leiden(&graph, &config).unwrap(); + for attempt in 0..10 { + let again = leiden(&graph, &config).unwrap(); + assert_eq!(first, again, "{name} differed on attempt {attempt}"); + } + } +} + +/// The optimizer must never return a partition worse than the singleton partition it starts +/// from — a blunt check that the reported search actually improves the objective. +#[test] +fn result_beats_the_starting_point() { + for (name, graph) in test_graphs() { + if graph.node_count() == 0 { + continue; + } + for gamma in [0.5, 1.0, 2.0] { + let objective = Rb::new(gamma); + let singletons: Vec = (0..graph.node_count()).collect(); + let start = objective.quality(&Partition::from_membership(&graph, &singletons)); + + let clustering = leiden(&graph, &LeidenConfig::with_resolution(gamma)).unwrap(); + let end = objective.quality(&Partition::from_membership(&graph, clustering.labels())); + assert!( + end >= start - 1e-9, + "{name} at gamma={gamma}: ended at {end}, worse than singletons {start}" + ); + } + } +} + +/// More resolution must not mean fewer communities. +#[test] +fn cluster_count_is_monotone_in_resolution() { + let (edges, _) = sbm(40, 5, 0.30, 0.02, 4); + let graph = CSRNetwork::from_edges(200, &edges).unwrap(); + + let counts: Vec = [0.1, 0.5, 1.0, 2.0, 8.0, 32.0] + .iter() + .map(|&r| { + leiden(&graph, &LeidenConfig::with_resolution(r)) + .unwrap() + .n_clusters() + }) + .collect(); + + assert!( + counts.windows(2).all(|w| w[0] <= w[1]), + "cluster counts should be non-decreasing in resolution: {counts:?}" + ); + assert!( + counts[0] < *counts.last().unwrap(), + "resolution had no effect at all: {counts:?}" + ); +} + +/// Labels must always be consecutive from 0 and cover every node. +#[test] +fn labels_are_well_formed() { + for (name, graph) in test_graphs() { + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + assert_eq!(clustering.len(), graph.node_count(), "{name}: wrong length"); + assert_eq!( + clustering.n_clusters(), + clustering + .labels() + .iter() + .collect::>() + .len(), + "{name}: n_clusters disagrees with distinct labels" + ); + for &l in clustering.labels() { + assert!( + l < clustering.n_clusters(), + "{name}: label {l} out of range" + ); + } + let sizes = clustering.cluster_sizes(); + assert_eq!(sizes.iter().sum::(), graph.node_count()); + assert!( + sizes.iter().all(|&s| s > 0), + "{name}: an empty cluster survived" + ); + } +} + +/// Nodes in different connected components must never share a community. +#[test] +fn connected_components_are_never_merged() { + // three cliques with no edges between them + let mut edges = Vec::new(); + for b in 0..3usize { + for i in 0..6 { + for j in (i + 1)..6 { + edges.push((b * 6 + i, b * 6 + j, 1.0)); + } + } + } + let graph = CSRNetwork::from_edges(18, &edges).unwrap(); + + for seed in 0..10u64 { + let clustering = leiden( + &graph, + &LeidenConfig { + seed: Some(seed), + ..Default::default() + }, + ) + .unwrap(); + let labels = clustering.labels(); + for a in 0..18 { + for b in 0..18 { + if a / 6 != b / 6 { + assert_ne!( + labels[a], labels[b], + "seed {seed}: nodes {a} and {b} are in different components" + ); + } + } + } + } +} diff --git a/tests/known_answers.rs b/tests/known_answers.rs new file mode 100644 index 0000000..193c943 --- /dev/null +++ b/tests/known_answers.rs @@ -0,0 +1,198 @@ +//! Graphs whose right answer is known independently of any implementation. + +use single_clustering::community_search::leiden::{ + LeidenConfig, ObjectiveKind, leiden, modularity, +}; +use single_clustering::network::CSRNetwork; + +mod common; +use common::{karate, nmi, sbm}; + +/// Zachary's karate club has a known modularity optimum of Q = 0.4198 with 4 communities. +#[test] +fn karate_club_reaches_the_known_optimum() { + let graph = karate(); + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + let q = modularity(&graph, clustering.labels(), 1.0); + + assert_eq!( + clustering.n_clusters(), + 4, + "sizes: {:?}", + clustering.cluster_sizes() + ); + assert!( + (q - 0.4198).abs() < 1e-3, + "expected Q = 0.4198, got {q} with {} communities", + clustering.n_clusters() + ); +} + +/// The karate club's classic 2-way split (the factions the club actually broke into) should +/// appear at a lower resolution. +#[test] +fn karate_club_splits_in_two_at_low_resolution() { + let graph = karate(); + let clustering = leiden(&graph, &LeidenConfig::with_resolution(0.5)).unwrap(); + assert!( + (2..=3).contains(&clustering.n_clusters()), + "expected the 2-faction split, got {} communities", + clustering.n_clusters() + ); +} + +/// A well-separated planted partition must be recovered essentially exactly. +#[test] +fn recovers_a_well_separated_planted_partition() { + for (n_per, blocks, p_in, p_out, seed) in [ + (60, 6, 0.25, 0.01, 11), + (40, 4, 0.35, 0.02, 5), + (80, 3, 0.20, 0.01, 9), + ] { + let (edges, truth) = sbm(n_per, blocks, p_in, p_out, seed); + let graph = CSRNetwork::from_edges(n_per * blocks, &edges).unwrap(); + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + + let score = nmi(clustering.labels(), &truth); + assert!( + score > 0.95, + "{blocks} blocks of {n_per}: NMI {score}, {} clusters", + clustering.n_clusters() + ); + assert_eq!( + clustering.n_clusters(), + blocks, + "{blocks} blocks of {n_per}: found {} clusters", + clustering.n_clusters() + ); + } +} + +/// A found partition should never score worse than the planted one it is meant to recover. +#[test] +fn found_partition_scores_at_least_the_planted_one() { + let (edges, truth) = sbm(60, 6, 0.25, 0.01, 11); + let graph = CSRNetwork::from_edges(360, &edges).unwrap(); + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + + let found = modularity(&graph, clustering.labels(), 1.0); + let planted = modularity(&graph, &truth, 1.0); + assert!( + found >= planted - 1e-9, + "found Q={found} is below the planted Q={planted}" + ); +} + +/// A ring of cliques is the textbook resolution-limit case: modularity merges adjacent +/// cliques, CPM does not. +#[test] +fn cpm_avoids_the_resolution_limit() { + let n_cliques = 20; + let size = 5; + let mut edges = Vec::new(); + for c in 0..n_cliques { + let base = c * size; + for i in 0..size { + for j in (i + 1)..size { + edges.push((base + i, base + j, 1.0)); + } + } + edges.push((base, ((c + 1) % n_cliques) * size, 1.0)); + } + let graph = CSRNetwork::from_edges(n_cliques * size, &edges).unwrap(); + + let cpm = leiden( + &graph, + &LeidenConfig { + objective: ObjectiveKind::Cpm { resolution: 0.1 }, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + cpm.n_clusters(), + n_cliques, + "CPM should recover every clique, got {:?}", + cpm.cluster_sizes() + ); +} + +/// Degenerate inputs must produce sensible answers rather than panicking. +#[test] +fn degenerate_graphs_behave() { + let none: &[(usize, usize, f64)] = &[]; + + let empty = leiden( + &CSRNetwork::from_edges(0, none).unwrap(), + &LeidenConfig::default(), + ) + .unwrap(); + assert_eq!(empty.n_clusters(), 0); + assert!(empty.is_empty()); + + let single = leiden( + &CSRNetwork::from_edges(1, none).unwrap(), + &LeidenConfig::default(), + ) + .unwrap(); + assert_eq!(single.n_clusters(), 1); + + // Isolated nodes each form their own community, since merging them cannot help. + let isolated = leiden( + &CSRNetwork::from_edges(6, none).unwrap(), + &LeidenConfig::default(), + ) + .unwrap(); + assert_eq!(isolated.n_clusters(), 6); + + // A graph of nothing but self-loops likewise stays fully split. + let loops = CSRNetwork::from_edges(3, &[(0, 0, 1.0), (1, 1, 1.0), (2, 2, 1.0)]).unwrap(); + let clustered = leiden(&loops, &LeidenConfig::default()).unwrap(); + assert_eq!(clustered.n_clusters(), 3); +} + +/// Scaling every edge weight by the same factor must not change the partition under RB, whose +/// null model is scale-free. +#[test] +fn rb_is_invariant_to_uniform_edge_scaling() { + let (edges, _) = sbm(30, 4, 0.30, 0.03, 2); + let graph = CSRNetwork::from_edges(120, &edges).unwrap(); + let scaled: Vec<(usize, usize, f64)> = + edges.iter().map(|&(a, b, w)| (a, b, w * 17.5)).collect(); + let scaled_graph = CSRNetwork::from_edges(120, &scaled).unwrap(); + + let a = leiden(&graph, &LeidenConfig::default()).unwrap(); + let b = leiden(&scaled_graph, &LeidenConfig::default()).unwrap(); + assert_eq!( + a.labels(), + b.labels(), + "uniform scaling changed the partition" + ); +} + +/// Relabelling nodes must not change the partition, only its labels. +#[test] +fn results_are_stable_under_node_relabelling() { + let (edges, _) = sbm(25, 4, 0.35, 0.02, 6); + let n = 100; + let graph = CSRNetwork::from_edges(n, &edges).unwrap(); + + // reverse the node numbering + let permuted: Vec<(usize, usize, f64)> = edges + .iter() + .map(|&(a, b, w)| (n - 1 - a, n - 1 - b, w)) + .collect(); + let permuted_graph = CSRNetwork::from_edges(n, &permuted).unwrap(); + + let a = leiden(&graph, &LeidenConfig::default()).unwrap(); + let b = leiden(&permuted_graph, &LeidenConfig::default()).unwrap(); + + // The RNG sees a different node order, so labels differ; the grouping should not. + let a_labels = a.labels().to_vec(); + let b_reversed: Vec = b.labels().iter().rev().copied().collect(); + assert!( + nmi(&a_labels, &b_reversed) > 0.95, + "relabelling changed the grouping: NMI {}", + nmi(&a_labels, &b_reversed) + ); +} diff --git a/tests/optimality.rs b/tests/optimality.rs new file mode 100644 index 0000000..7e0e737 --- /dev/null +++ b/tests/optimality.rs @@ -0,0 +1,227 @@ +//! Exhaustive optimality checks on small graphs. +//! +//! Small enough to enumerate every set partition, so the true optimum is computable. The only +//! test here that checks ground truth rather than another implementation — nothing in it can +//! be fooled by a shared misunderstanding of the objective. +//! +//! Kept small enough for the normal suite; `exhaustive_optimality_larger` does `n = 10..12` +//! and is `#[ignore]`d. + +use single_clustering::community_search::leiden::{LeidenConfig, leiden, modularity}; +use single_clustering::network::CSRNetwork; + +mod common; + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +/// Visits every set partition of `n` items via restricted growth strings, calling `f` with +/// each labelling. There are `Bell(n)` of them: 4140 for n=8, 115975 for n=10, 4213597 for +/// n=12. +fn for_each_partition(n: usize, mut f: impl FnMut(&[usize])) { + let mut a = vec![0usize; n]; + let mut max_seen = vec![0usize; n]; + + loop { + f(&a); + + // advance the restricted growth string, right to left + let mut i = n - 1; + loop { + if i == 0 { + return; + } + if a[i] <= max_seen[i - 1] { + a[i] += 1; + let new_max = max_seen[i - 1].max(a[i]); + for j in (i + 1)..n { + a[j] = 0; + max_seen[j] = new_max; + } + if i < n { + max_seen[i] = new_max; + } + break; + } + a[i] = 0; + i -= 1; + } + } +} + +/// True modularity optimum of `graph` at the given resolution, by exhaustive enumeration. +fn brute_force_optimum(graph: &CSRNetwork, resolution: f64) -> f64 { + let n = graph.node_count(); + let mut best = f64::NEG_INFINITY; + for_each_partition(n, |labels| { + let q = modularity(graph, labels, resolution); + if q > best { + best = q; + } + }); + best +} + +/// Random graphs spanning a range of densities, plus a few structured ones. +fn small_graphs(n: usize, count: usize, seed: u64) -> Vec<(String, CSRNetwork)> { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let mut out = Vec::new(); + + for i in 0..count { + let p = 0.15 + 0.5 * (i as f64 / count as f64); + let mut edges = Vec::new(); + for a in 0..n { + for b in (a + 1)..n { + if rng.random::() < p { + edges.push((a, b, 1.0)); + } + } + } + if edges.is_empty() { + continue; + } + out.push(( + format!("random_n{n}_p{p:.2}_{i}"), + CSRNetwork::from_edges(n, &edges).unwrap(), + )); + } + + // two cliques joined by one edge: an unambiguous optimum + if n >= 6 { + let half = n / 2; + let mut edges = Vec::new(); + for a in 0..half { + for b in (a + 1)..half { + edges.push((a, b, 1.0)); + } + } + for a in half..n { + for b in (a + 1)..n { + edges.push((a, b, 1.0)); + } + } + edges.push((0, half, 1.0)); + out.push(( + format!("barbell_n{n}"), + CSRNetwork::from_edges(n, &edges).unwrap(), + )); + } + + out +} + +fn check_optimality(sizes: &[usize], graphs_per_size: usize, resolutions: &[f64]) { + let mut checked = 0; + let mut optimal = 0; + let mut worst_gap: f64 = 0.0; + let mut worst_case = String::new(); + + for &n in sizes { + for (name, graph) in small_graphs(n, graphs_per_size, 17 + n as u64) { + for &resolution in resolutions { + let best = brute_force_optimum(&graph, resolution); + + // Give the optimizer the same budget a user would. + let clustering = leiden( + &graph, + &LeidenConfig { + objective: single_clustering::community_search::leiden::ObjectiveKind::Rb { + resolution, + }, + seed: Some(42), + n_iterations: std::env::var("OPT_ITERS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(2), + ..Default::default() + }, + ) + .unwrap(); + let ours = modularity(&graph, clustering.labels(), resolution); + + checked += 1; + if ours >= best - 1e-9 { + optimal += 1; + } else { + // absolute, not relative: modularity is in [-1, 1] and these optima sit + // near zero, where relative error explodes meaninglessly — -0.034 vs + // -0.018 is "87% worse" relatively but 0.016 absolutely + let gap = best - ours; + if gap > worst_gap { + worst_gap = gap; + worst_case = + format!("{name} at resolution {resolution}: {ours:.6} vs {best:.6}"); + } + } + assert!( + ours <= best + 1e-9, + "{name}: scored {ours} above the exhaustive optimum {best} - the \ + objective and the reported modularity disagree" + ); + } + } + } + + let rate = 100.0 * optimal as f64 / checked as f64; + println!( + "exhaustive: optimal on {optimal}/{checked} ({rate:.1}%), worst gap {worst_gap:.4} \ + modularity ({worst_case})" + ); + + // Measured on these fixtures: the true optimum is found on 93.5% of 108 cases, worst gap + // 0.027 modularity (0.050 for the n=10..12 variant). `leidenalg` on the identical graphs gets 95.4% at its default budget + // and 96.3% at 10x that, hitting the same hardest instance — it defeats both. These are + // small dense random graphs with no community structure, i.e. a deliberately flat, + // adversarial landscape; the bounds below sit just outside the measured values so a + // genuine regression trips them. + assert!( + rate >= 90.0, + "found the true optimum in only {rate:.1}% of {checked} cases (expected ~93.5%)" + ); + assert!( + worst_gap < 0.05, + "worst gap {worst_gap:.4} modularity on {worst_case}" + ); +} + +/// Every partition enumerated, for n = 6..9. +#[test] +fn finds_the_true_optimum_on_small_graphs() { + check_optimality(&[6, 7, 8, 9], 8, &[0.5, 1.0, 2.0]); +} + +/// Same, for n = 10..12. Slow (Bell(12) = 4.2M partitions per graph per resolution). +#[test] +#[ignore = "exhaustive over Bell(12); run with --ignored"] +fn exhaustive_optimality_larger() { + check_optimality(&[10, 11, 12], 6, &[0.5, 1.0, 2.0]); +} + +/// The enumerator itself must be right, or the whole test is worthless. +#[test] +fn partition_enumerator_is_correct() { + // Bell numbers + for (n, expected) in [ + (1usize, 1usize), + (2, 2), + (3, 5), + (4, 15), + (5, 52), + (6, 203), + (7, 877), + (8, 4140), + ] { + let mut count = 0; + let mut seen = std::collections::HashSet::new(); + for_each_partition(n, |labels| { + count += 1; + // canonical form: labels are already a restricted growth string, so they are a + // unique representative of the set partition + seen.insert(labels.to_vec()); + assert_eq!(labels.len(), n); + assert_eq!(labels[0], 0, "restricted growth strings start at 0"); + }); + assert_eq!(count, expected, "Bell({n})"); + assert_eq!(seen.len(), expected, "Bell({n}): duplicates emitted"); + } +} diff --git a/tests/properties.rs b/tests/properties.rs new file mode 100644 index 0000000..985b090 --- /dev/null +++ b/tests/properties.rs @@ -0,0 +1,359 @@ +//! Randomised property tests. +//! +//! The rest of the suite uses hand-picked graphs, which only covers what somebody thought to +//! write down. This generates graphs and configs at random and checks the invariants hold — +//! that's what catches the combination nobody considered. +//! +//! Failures print the seed, so counterexamples are reproducible. + +use single_clustering::community_search::leiden::{ + Cpm, LeidenConfig, Objective, ObjectiveKind, Partition, Rb, leiden, modularity, +}; +use single_clustering::network::CSRNetwork; + +mod common; +use common::disconnected_community_count; + +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha8Rng; + +/// A randomly shaped graph: varying size, density, weight distribution and self-loops. +fn random_graph(seed: u64) -> (String, CSRNetwork) { + let mut rng = ChaCha8Rng::seed_from_u64(seed); + let n = rng.random_range(1..=120usize); + let density = rng.random::().powi(2) * 0.6 + 0.01; + let weighted = rng.random::(); + let self_loops = rng.random::() < 0.3; + // Sometimes plant block structure, sometimes leave it structureless. + let blocks = if rng.random::() { + rng.random_range(2..=6usize).min(n) + } else { + 1 + }; + + let mut edges = Vec::new(); + for a in 0..n { + for b in (a + 1)..n { + let same = blocks > 1 && (a * blocks / n) == (b * blocks / n); + let p = if same { density * 4.0 } else { density }; + if rng.random::() < p.min(1.0) { + let w = if weighted { + 0.05 + 4.0 * rng.random::() + } else { + 1.0 + }; + edges.push((a, b, w)); + } + } + } + if self_loops { + for v in 0..n { + if rng.random::() < 0.2 { + edges.push((v, v, 0.5 + 2.0 * rng.random::())); + } + } + } + + let name = format!( + "seed={seed} n={n} density={density:.3} blocks={blocks} weighted={weighted} loops={self_loops}" + ); + (name, CSRNetwork::from_edges(n, &edges).unwrap()) +} + +fn random_config(rng: &mut ChaCha8Rng, seed: u64) -> LeidenConfig { + let objective = if rng.random::() { + ObjectiveKind::Rb { + resolution: [0.1, 0.5, 1.0, 2.0, 5.0][rng.random_range(0..5)], + } + } else { + ObjectiveKind::Cpm { + resolution: [0.01, 0.05, 0.2, 1.0][rng.random_range(0..4)], + } + }; + LeidenConfig { + objective, + seed: Some(seed), + n_iterations: rng.random_range(1..=4), + refine: rng.random::(), + refine_randomness: [0.0, 0.01, 0.1][rng.random_range(0..3)], + max_community_weight: if rng.random::() < 0.2 { + Some(rng.random_range(2..=40) as f64) + } else { + None + }, + ..Default::default() + } +} + +fn quality(graph: &CSRNetwork, objective: ObjectiveKind, labels: &[usize]) -> f64 { + let p = Partition::from_membership(graph, labels); + match objective { + ObjectiveKind::Rb { resolution } => Rb::new(resolution).quality(&p), + ObjectiveKind::Cpm { resolution } => Cpm::new(resolution).quality(&p), + } +} + +/// Every invariant, over 400 random graph/config combinations. +#[test] +fn invariants_hold_on_random_inputs() { + for seed in 0..400u64 { + let (name, graph) = random_graph(seed); + let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x9e37_79b9); + let config = random_config(&mut rng, seed); + + let clustering = leiden(&graph, &config).unwrap(); + let labels = clustering.labels(); + let ctx = format!("{name} {:?}", config.objective); + + // labels are well formed + assert_eq!(clustering.len(), graph.node_count(), "{ctx}: label count"); + for &l in labels { + assert!(l < clustering.n_clusters(), "{ctx}: label {l} out of range"); + } + assert!( + clustering.cluster_sizes().iter().all(|&s| s > 0), + "{ctx}: empty cluster survived" + ); + + // never worse than the singleton partition it started from + let singletons: Vec = (0..graph.node_count()).collect(); + let start = quality(&graph, config.objective, &singletons); + let end = quality(&graph, config.objective, labels); + assert!( + end >= start - 1e-6 * start.abs().max(1.0), + "{ctx}: ended at {end}, worse than singletons {start}" + ); + + // communities are internally connected (refinement's guarantee) + if config.refine { + assert_eq!( + disconnected_community_count(&graph, labels), + 0, + "{ctx}: disconnected community" + ); + } + + // a max size cap is honoured + if let Some(limit) = config.max_community_weight { + for (id, size) in clustering.cluster_sizes().iter().enumerate() { + assert!( + *size as f64 <= limit, + "{ctx}: cluster {id} has {size} nodes, limit {limit}" + ); + } + } + + // deterministic + let again = leiden(&graph, &config).unwrap(); + assert_eq!(clustering, again, "{ctx}: not reproducible"); + } +} + +/// Quality must survive aggregation, on random graphs rather than chosen ones. +#[test] +fn aggregation_preserves_quality_on_random_inputs() { + for seed in 0..250u64 { + let (name, graph) = random_graph(seed); + if graph.node_count() == 0 { + continue; + } + let clustering = leiden(&graph, &LeidenConfig::default()).unwrap(); + let mut partition = Partition::from_membership(&graph, clustering.labels()); + + let grouping = partition.renumber_into_grouping(); + let coarse = graph.aggregate(&grouping); + let coarse_labels: Vec = (0..coarse.node_count()).collect(); + let coarse_partition = Partition::from_membership(&coarse, &coarse_labels); + + // structural invariants of the collapsed graph + let fine_deg: f64 = (0..graph.node_count()).map(|v| graph.strength(v)).sum(); + let coarse_deg: f64 = (0..coarse.node_count()).map(|v| coarse.strength(v)).sum(); + assert!( + (fine_deg - coarse_deg).abs() < 1e-6 * fine_deg.abs().max(1.0), + "{name}: degree sum {fine_deg} -> {coarse_deg}" + ); + coarse.validate_symmetry().unwrap(); + + for gamma in [0.25, 1.0, 3.0] { + let a = Rb::new(gamma).quality(&partition); + let b = Rb::new(gamma).quality(&coarse_partition); + assert!( + (a - b).abs() < 1e-6 * a.abs().max(1.0), + "{name} gamma={gamma}: {a} != {b}" + ); + } + let a = Cpm::new(0.05).quality(&partition); + let b = Cpm::new(0.05).quality(&coarse_partition); + assert!( + (a - b).abs() < 1e-6 * a.abs().max(1.0), + "{name} cpm: {a} != {b}" + ); + } +} + +/// The delta a move reports must equal the change it causes, on random inputs. +#[test] +fn move_deltas_match_quality_changes_on_random_inputs() { + use single_clustering::community_search::leiden::NeighborWeights; + use single_clustering::community_search::leiden::objective::InsertContext; + + for seed in 0..150u64 { + let (name, graph) = random_graph(seed); + let n = graph.node_count(); + if n < 2 { + continue; + } + let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0xabcd); + let labels: Vec = (0..n).map(|_| rng.random_range(0..4usize)).collect(); + + for objective in [ + &Rb::new(1.0) as &dyn Objective, + &Rb::new(0.3), + &Cpm::new(0.05), + ] { + let mut p = Partition::from_membership(&graph, &labels); + let mut acc = NeighborWeights::with_capacity(p.slots() + 1); + + for v in 0..n.min(25) { + for target in 0..p.slots() { + let before = objective.quality(&p); + let ctx = InsertContext::for_node(&graph, v); + + acc.collect(&graph, p.membership_raw(), v); + let own = p.membership(v); + p.remove_node(v, &graph, acc.weight_to(own)); + let leave = objective.delta_insert(&p, &ctx, own, acc.weight_to(own)); + let join = objective.delta_insert(&p, &ctx, target, acc.weight_to(target)); + p.insert_node(v, target, &graph, acc.weight_to(target)); + + let actual = objective.quality(&p) - before; + let predicted = join - leave; + assert!( + (actual - predicted).abs() < 1e-6 * actual.abs().max(1.0), + "{name}: node {v} -> {target}: predicted {predicted}, actual {actual}" + ); + + // restore + acc.collect(&graph, p.membership_raw(), v); + p.remove_node(v, &graph, acc.weight_to(target)); + p.insert_node(v, own, &graph, acc.weight_to(own)); + } + } + p.verify_against(&graph).unwrap(); + } + } +} + +/// The zero-copy constructor is the path used at scale, so it has to be indistinguishable +/// from the edge-list one, not just close. +#[test] +fn both_constructors_produce_identical_results() { + for seed in 0..300u64 { + let (name, via_edges) = random_graph(seed); + + let (node_ptrs, neighbors, weights) = via_edges.to_csr_parts(); + let node_weights = via_edges.node_weights().to_vec(); + let via_csr = + CSRNetwork::from_csr_parts(node_ptrs, neighbors, weights, Some(node_weights)).unwrap(); + + // identical structure + assert_eq!( + via_edges.node_count(), + via_csr.node_count(), + "{name}: nodes" + ); + assert_eq!( + via_edges.edge_count(), + via_csr.edge_count(), + "{name}: edges" + ); + assert_eq!( + via_edges.total_weight(), + via_csr.total_weight(), + "{name}: total weight" + ); + for v in 0..via_edges.node_count() { + assert_eq!( + via_edges.strength(v), + via_csr.strength(v), + "{name}: strength({v})" + ); + assert_eq!( + via_edges.neighbors(v).collect::>(), + via_csr.neighbors(v).collect::>(), + "{name}: neighbors({v})" + ); + } + via_csr.validate_symmetry().unwrap(); + + // and identical clustering, bit for bit + let mut rng = ChaCha8Rng::seed_from_u64(seed ^ 0x5555); + let config = random_config(&mut rng, seed); + assert_eq!( + leiden(&via_edges, &config).unwrap(), + leiden(&via_csr, &config).unwrap(), + "{name}: clusterings differ between constructors" + ); + } +} + +/// Raising the resolution must never reduce the number of communities. +#[test] +fn resolution_is_monotone_on_random_inputs() { + for seed in 0..120u64 { + let (name, graph) = random_graph(seed); + if graph.node_count() < 10 || graph.edge_count() == 0 { + continue; + } + let counts: Vec = [0.05, 0.25, 1.0, 4.0, 16.0, 64.0] + .iter() + .map(|&r| { + leiden(&graph, &LeidenConfig::with_resolution(r)) + .unwrap() + .n_clusters() + }) + .collect(); + // Heuristics can wobble by a cluster at adjacent resolutions; the trend must hold. + assert!( + counts[0] <= counts[counts.len() - 1], + "{name}: {counts:?} is not increasing overall" + ); + for w in counts.windows(2) { + assert!( + w[1] + 2 >= w[0], + "{name}: {counts:?} drops sharply as resolution rises" + ); + } + } +} + +/// Uniformly scaling every edge weight must not change an RB partition. +#[test] +fn rb_is_scale_invariant_on_random_inputs() { + for seed in 0..120u64 { + let (name, graph) = random_graph(seed); + if graph.edge_count() == 0 { + continue; + } + let (ptrs, nbrs, w) = graph.to_csr_parts(); + // 2^6 is exact in binary, so scaling introduces no f32 rounding of its own. + let scaled: Vec = w.iter().map(|x| x * 64.0).collect(); + let scaled_graph = + CSRNetwork::from_csr_parts(ptrs, nbrs, scaled, Some(graph.node_weights().to_vec())) + .unwrap(); + + let a = leiden(&graph, &LeidenConfig::default()).unwrap(); + let b = leiden(&scaled_graph, &LeidenConfig::default()).unwrap(); + assert_eq!( + a.labels(), + b.labels(), + "{name}: scaling changed the partition" + ); + assert!( + (modularity(&graph, a.labels(), 1.0) - modularity(&scaled_graph, b.labels(), 1.0)) + .abs() + < 1e-6, + "{name}: scaling changed modularity" + ); + } +} diff --git a/tests/reference.rs b/tests/reference.rs new file mode 100644 index 0000000..ee925d9 --- /dev/null +++ b/tests/reference.rs @@ -0,0 +1,386 @@ +//! Differential tests against `leidenalg`. +//! +//! Fixtures are generated once by `tools/gen_fixtures.py` and committed, so this needs no +//! Python. Two separate things, which fail for different reasons: +//! +//! 1. **Definition** — on the *same membership*, our objective must give exactly +//! `leidenalg`'s value (up to the documented factor of 2). Pins the quality functions, and +//! would have caught the 2x resolution bug. +//! 2. **Optimization** — our algorithm must reach a partition at least as good as theirs. +//! Checked on quality and agreement, never exact labels — different RNGs, so identical +//! partitions aren't an achievable bar. + +use single_clustering::community_search::leiden::{ + Cpm, LeidenConfig, Objective, ObjectiveKind, Partition, Rb, leiden, modularity, +}; +use single_clustering::network::CSRNetwork; + +/// `leidenalg` multiplies its quality by `(2 - is_directed)`; ours omits that factor. +const LEIDENALG_SCALE: f64 = 2.0; + +struct Case { + name: String, + graph: CSRNetwork, + objective: ObjectiveKind, + reference_membership: Vec, + reference_n_clusters: usize, + reference_quality: f64, + reference_modularity: f64, +} + +fn load_cases() -> Vec { + let raw = include_str!("fixtures/leidenalg_reference.json"); + let doc: serde_json::Value = serde_json::from_str(raw).expect("fixture is valid JSON"); + + let graphs: std::collections::HashMap = doc["graphs"] + .as_object() + .expect("graphs object") + .iter() + .map(|(key, g)| { + let n = g["n_nodes"].as_u64().unwrap() as usize; + let edges: Vec<(usize, usize, f64)> = g["edges"] + .as_array() + .unwrap() + .iter() + .map(|e| { + let e = e.as_array().unwrap(); + ( + e[0].as_u64().unwrap() as usize, + e[1].as_u64().unwrap() as usize, + e[2].as_f64().unwrap(), + ) + }) + .collect(); + (key.clone(), CSRNetwork::from_edges(n, &edges).unwrap()) + }) + .collect(); + + doc["cases"] + .as_array() + .expect("cases array") + .iter() + .map(|c| { + let resolution = c["resolution"].as_f64().unwrap(); + Case { + name: c["name"].as_str().unwrap().to_string(), + graph: graphs[c["graph"].as_str().unwrap()].clone(), + objective: match c["objective"].as_str().unwrap() { + "rb" => ObjectiveKind::Rb { resolution }, + "cpm" => ObjectiveKind::Cpm { resolution }, + other => panic!("unknown objective {other}"), + }, + reference_membership: c["reference_membership"] + .as_array() + .unwrap() + .iter() + .map(|v| v.as_u64().unwrap() as usize) + .collect(), + reference_n_clusters: c["reference_n_clusters"].as_u64().unwrap() as usize, + reference_quality: c["reference_quality"].as_f64().unwrap(), + reference_modularity: c["reference_modularity"].as_f64().unwrap(), + } + }) + .collect() +} + +fn quality_of(case: &Case, membership: &[usize]) -> f64 { + let partition = Partition::from_membership(&case.graph, membership); + match case.objective { + ObjectiveKind::Rb { resolution } => Rb::new(resolution).quality(&partition), + ObjectiveKind::Cpm { resolution } => Cpm::new(resolution).quality(&partition), + } +} + +fn nmi(a: &[usize], b: &[usize]) -> f64 { + let n = a.len() as f64; + let ka = a.iter().max().map_or(0, |m| m + 1); + let kb = b.iter().max().map_or(0, |m| m + 1); + let mut joint = vec![0.0f64; ka * kb]; + let (mut pa, mut pb) = (vec![0.0f64; ka], vec![0.0f64; kb]); + for i in 0..a.len() { + joint[a[i] * kb + b[i]] += 1.0; + pa[a[i]] += 1.0; + pb[b[i]] += 1.0; + } + let mut mi = 0.0; + for i in 0..ka { + for j in 0..kb { + let p = joint[i * kb + j] / n; + if p > 0.0 { + mi += p * (p / ((pa[i] / n) * (pb[j] / n))).ln(); + } + } + } + let h = |p: &[f64]| -> f64 { + -p.iter() + .filter(|&&x| x > 0.0) + .map(|&x| (x / n) * (x / n).ln()) + .sum::() + }; + let (ha, hb) = (h(&pa), h(&pb)); + if ha + hb == 0.0 { + 1.0 + } else { + 2.0 * mi / (ha + hb) + } +} + +/// Runs our algorithm with `n_iterations = 5`, matching how the fixtures were generated. +fn run(case: &Case, seed: u64) -> Vec { + let config = LeidenConfig { + objective: case.objective, + seed: Some(seed), + n_iterations: 5, + ..Default::default() + }; + leiden(&case.graph, &config).unwrap().into_labels() +} + +/// Best of `seeds` runs. Both are stochastic heuristics over the same landscape — +/// `leidenalg`'s own two fixture seeds differ by up to 3.5% on the hard instances, so one run +/// of ours against whichever run it recorded is noise. +fn run_best(case: &Case, seeds: u64) -> Vec { + (0..seeds) + .map(|s| run(case, 42 + s)) + .max_by(|a, b| { + quality_of(case, a) + .partial_cmp(&quality_of(case, b)) + .unwrap() + }) + .expect("seeds >= 1") +} + +#[test] +fn fixtures_are_present() { + let cases = load_cases(); + assert!(cases.len() >= 100, "only {} cases loaded", cases.len()); +} + +/// Our quality function must agree with `leidenalg`'s exactly, on the same membership. +/// +/// Pins the definitions — fails if a resolution is scaled wrong or a constant convention +/// drifts, no matter how good either optimizer is. +#[test] +fn quality_definitions_match_leidenalg() { + let mut failures = Vec::new(); + for case in load_cases() { + let ours = quality_of(&case, &case.reference_membership) * LEIDENALG_SCALE; + let tol = 1e-6 * case.reference_quality.abs().max(1.0); + if (ours - case.reference_quality).abs() > tol { + failures.push(format!( + "{}: ours*{LEIDENALG_SCALE} = {ours:.9}, leidenalg = {:.9}", + case.name, case.reference_quality + )); + } + } + assert!( + failures.is_empty(), + "{} quality mismatches:\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// Our modularity must agree with igraph's on the same membership. +/// +/// To `1e-7`, not to the last bit: adjacency is stored as `f32`, so a weight like `0.2531` +/// from the fixtures is rounded on the way in. Modularity is bounded by 1, and f32 epsilon is +/// 1.2e-7, so that is the best agreement the storage can support. The arithmetic itself is +/// f64 throughout. +#[test] +fn modularity_matches_igraph() { + let mut failures = Vec::new(); + for case in load_cases() { + // igraph's modularity is always the gamma=1 form regardless of the objective used. + let ours = modularity(&case.graph, &case.reference_membership, 1.0); + if (ours - case.reference_modularity).abs() > 1e-7 { + failures.push(format!( + "{}: ours = {ours:.9}, igraph = {:.9}", + case.name, case.reference_modularity + )); + } + } + assert!( + failures.is_empty(), + "{} modularity mismatches:\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// No single case may fall grossly short of `leidenalg`. +/// +/// The bound is deliberately loose — its job is to catch a systematic defect, like a +/// misscaled resolution, not to police heuristic noise. Aggregate parity is checked by +/// [`optimizer_is_not_systematically_worse`], which is the tight test. +/// +/// Known residual: high-resolution RB on the medium SBM sits ~6% below `leidenalg`, on an +/// instance where `leidenalg`'s own two seeds differ by 3.5%. +#[test] +fn no_case_falls_grossly_short() { + let mut failures = Vec::new(); + for case in load_cases() { + let labels = run_best(&case, 2); + let ours = quality_of(&case, &labels) * LEIDENALG_SCALE; + let slack = 0.08 * case.reference_quality.abs().max(1.0); + if ours < case.reference_quality - slack { + failures.push(format!( + "{}: ours = {ours:.6}, leidenalg = {:.6} ({:+.2}%)", + case.name, + case.reference_quality, + 100.0 * (ours - case.reference_quality) / case.reference_quality.abs().max(1e-12), + )); + } + } + assert!( + failures.is_empty(), + "{} cases below leidenalg:\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// Across the whole fixture set, a single run of ours must be level with `leidenalg`. +/// +/// At the time of writing this is -0.11%: 33 cases better, 107 equal, 20 worse. The bound +/// leaves room for heuristic noise while still failing loudly on a real regression. +#[test] +fn optimizer_is_not_systematically_worse() { + let cases = load_cases(); + let (mut wins, mut losses, mut ties) = (0, 0, 0); + let mut total_rel = 0.0; + + for case in &cases { + let labels = run(case, 42); + let ours = quality_of(case, &labels) * LEIDENALG_SCALE; + let scale = case.reference_quality.abs().max(1e-9); + let rel = (ours - case.reference_quality) / scale; + total_rel += rel; + if rel > 1e-9 { + wins += 1; + } else if rel < -1e-9 { + losses += 1; + } else { + ties += 1; + } + } + + let mean_rel = total_rel / cases.len() as f64; + println!( + "vs leidenalg over {} cases: {wins} better, {ties} equal, {losses} worse, mean {:+.4}%", + cases.len(), + 100.0 * mean_rel + ); + assert!( + mean_rel > -0.005, + "mean quality {:+.4}% below leidenalg across {} cases", + 100.0 * mean_rel, + cases.len() + ); +} + +/// Given the same budget `leidenalg` had, we should come out ahead on aggregate. +#[test] +fn two_seeds_beat_leidenalg_on_aggregate() { + let cases = load_cases(); + let (mut wins, mut losses) = (0, 0); + let mut total_rel = 0.0; + + for case in &cases { + let labels = run_best(case, 2); + let ours = quality_of(case, &labels) * LEIDENALG_SCALE; + let rel = (ours - case.reference_quality) / case.reference_quality.abs().max(1e-9); + total_rel += rel; + if rel > 1e-9 { + wins += 1; + } else if rel < -1e-9 { + losses += 1; + } + } + + let mean_rel = total_rel / cases.len() as f64; + println!( + "best-of-2 vs leidenalg: {wins} better, {losses} worse, mean {:+.4}%", + 100.0 * mean_rel + ); + assert!( + mean_rel > 0.0, + "mean {:+.4}%, expected to be ahead", + 100.0 * mean_rel + ); +} + +/// Where the answer is unambiguous, we find the *same* communities, not merely ones scoring +/// as well. +/// +/// Restricted on purpose. Agreement is only a meaningful signal when the instance has one +/// clearly right answer: `sbm_hard` (p_in 0.18 vs p_out 0.07) and resolutions far from an +/// instance's natural granularity admit many near-equal partitions, so two good optimizers +/// legitimately disagree there. Quality, checked above, is the right measure for those. +#[test] +fn partitions_agree_where_structure_is_unambiguous() { + let mut failures = Vec::new(); + let mut checked = 0; + + for case in load_cases() { + let strong_structure = + case.name.starts_with("sbm_easy") || case.name.starts_with("ring_of_cliques"); + let natural_resolution = matches!( + case.objective, + ObjectiveKind::Rb { resolution } if (0.5..=2.0).contains(&resolution) + ); + if !strong_structure || !natural_resolution { + continue; + } + // Degenerate references make NMI uninformative. + if case.reference_n_clusters <= 1 + || case.reference_n_clusters >= case.reference_membership.len() + { + continue; + } + + checked += 1; + let labels = run_best(&case, 2); + let score = nmi(&labels, &case.reference_membership); + if score < 0.85 { + failures.push(format!( + "{}: NMI {score:.3} (ours {} clusters, leidenalg {})", + case.name, + labels.iter().max().map_or(0, |m| m + 1), + case.reference_n_clusters + )); + } + } + + assert!(checked >= 20, "only {checked} cases matched the filter"); + assert!( + failures.is_empty(), + "{} of {checked} unambiguous cases disagree:\n{}", + failures.len(), + failures.join("\n") + ); +} + +/// Cluster counts should track `leidenalg`'s. This is the user-visible consequence of the +/// resolution parameter meaning the same thing in both libraries. +#[test] +fn cluster_counts_track_leidenalg() { + let mut failures = Vec::new(); + for case in load_cases() { + let labels = run_best(&case, 2); + let ours = labels.iter().max().map_or(0, |m| m + 1); + let theirs = case.reference_n_clusters; + // Allow a little slack for genuinely ambiguous granularity, but not a factor of two, + // which is exactly what the old resolution bug produced. + let tol = 2 + theirs / 5; + if ours.abs_diff(theirs) > tol { + failures.push(format!("{}: ours {ours}, leidenalg {theirs}", case.name)); + } + } + assert!( + failures.is_empty(), + "{} cases differ in cluster count:\n{}", + failures.len(), + failures.join("\n") + ); +} diff --git a/tools/export_h5ad.py b/tools/export_h5ad.py new file mode 100644 index 0000000..b08fa07 --- /dev/null +++ b/tools/export_h5ad.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Export a scanpy neighbourhood graph plus reference clusterings for the Rust test harness. + +Writes into ``: + +* ``indptr.bin`` / ``indices.bin`` / ``data.bin`` - the connectivities matrix as CSR, raw + little-endian (u64 / u32 / f32), so the Rust harness needs no extra dependency +* ``reference.json`` - scanpy's Leiden labels per resolution, plus author annotations, + cluster counts and wall times +* ``meta.json`` - provenance: dataset, versions, n_neighbors + +Usage: + python tools/export_h5ad.py --dataset pbmc3k --out

+ python tools/export_h5ad.py --h5ad path/to/data.h5ad --out + +The graph is exactly what `CSRNetwork::from_csr_parts` consumes, so the Rust side clusters +the *same* graph scanpy did - any difference in output is the algorithm, not the input. +""" + +import argparse +import json +import pathlib +import time + +import numpy as np +import scanpy as sc +import scipy.sparse as sp + +RESOLUTIONS = [0.1, 0.25, 0.5, 0.75, 1.0, 1.5, 2.0, 4.0] + + +def load(args): + """Returns (adata, description, annotation column or None).""" + if args.h5ad: + adata = sc.read_h5ad(args.h5ad) + return adata, f"h5ad:{args.h5ad}", args.annotation + + if args.dataset == "pbmc3k": + # Raw counts; we run the standard preprocessing ourselves so the graph is built the + # way a real pipeline would build it. + adata = sc.datasets.pbmc3k() + annotated = sc.datasets.pbmc3k_processed() + # pbmc3k_processed is filtered; carry its author annotations across by barcode. + adata = adata[adata.obs_names.isin(annotated.obs_names)].copy() + adata.obs["cell_type"] = annotated.obs.loc[adata.obs_names, "louvain"].values + return adata, "scanpy:pbmc3k", "cell_type" + + if args.dataset == "pbmc68k": + adata = sc.datasets.pbmc68k_reduced() + adata.obs["cell_type"] = adata.obs["bulk_labels"].values + return adata, "scanpy:pbmc68k_reduced", "cell_type" + + raise SystemExit(f"unknown dataset {args.dataset}") + + +def preprocess(adata, n_neighbors, n_pcs): + """Standard scanpy pipeline, skipping steps the data already has.""" + if "connectivities" in adata.obsp: + print(" reusing existing neighbourhood graph") + return adata + + if adata.X.min() >= 0 and adata.X.max() > 50: # looks like raw counts + print(" normalising + log1p + HVG + PCA") + sc.pp.filter_cells(adata, min_genes=200) + sc.pp.filter_genes(adata, min_cells=3) + sc.pp.normalize_total(adata, target_sum=1e4) + sc.pp.log1p(adata) + sc.pp.highly_variable_genes(adata, n_top_genes=2000) + adata = adata[:, adata.var.highly_variable].copy() + sc.pp.scale(adata, max_value=10) + + if "X_pca" not in adata.obsm: + sc.tl.pca(adata, n_comps=min(n_pcs, adata.n_vars - 1, adata.n_obs - 1)) + + print(f" building {n_neighbors}-NN graph") + sc.pp.neighbors(adata, n_neighbors=n_neighbors) + return adata + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--dataset", default="pbmc3k") + ap.add_argument("--h5ad") + ap.add_argument("--annotation", default=None) + ap.add_argument("--out", required=True) + ap.add_argument("--n-neighbors", type=int, default=15) + ap.add_argument("--n-pcs", type=int, default=50) + args = ap.parse_args() + + out = pathlib.Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + print("loading...") + adata, description, annotation = load(args) + adata = preprocess(adata, args.n_neighbors, args.n_pcs) + + conn = sp.csr_matrix(adata.obsp["connectivities"]) + conn.sort_indices() + n = conn.shape[0] + print(f" {n} cells, {conn.nnz} directed entries " + f"({conn.nnz / n:.1f} per cell), dtype {conn.data.dtype}") + + asym = abs(conn - conn.T).max() + print(f" max |A - A^T| = {asym:.3e} (must be ~0 for the Rust side to accept it)") + + conn.indptr.astype("/.edges "u v w" per line + /.truth ground-truth label per line (if any) +and a single results.json with leidenalg's membership, quality and wall time. +""" +import json, pathlib, random, sys, time + +import igraph as ig +import leidenalg as la +import networkx as nx + +OUT = pathlib.Path(sys.argv[1]) +OUT.mkdir(parents=True, exist_ok=True) + + +def write_case(name, n, edges, truth=None): + (OUT / f"{name}.edges").write_text("".join(f"{a} {b} 1.0\n" for a, b in edges)) + if truth is not None: + (OUT / f"{name}.truth").write_text("".join(f"{t}\n" for t in truth)) + return {"name": name, "n_nodes": n, "n_edges": len(edges)} + + +def lfr(n, mu, seed, avg_deg=15, max_deg=50, tau1=2.5, tau2=1.5, min_c=20, max_c=100): + """Lancichinetti-Fortunato-Radicchi benchmark: the standard test for community detection. + + `mu` is the mixing parameter - the fraction of each node's edges that go outside its + community. mu=0.1 is easy, mu=0.6+ is at or past the detectability limit.""" + g = nx.LFR_benchmark_graph( + n, tau1, tau2, mu, average_degree=avg_deg, max_degree=max_deg, + min_community=min_c, max_community=max_c, seed=seed, max_iters=500, + ) + g.remove_edges_from(nx.selfloop_edges(g)) + comms = {} + truth = [0] * n + for v, data in g.nodes(data=True): + key = frozenset(data["community"]) + if key not in comms: + comms[key] = len(comms) + truth[v] = comms[key] + return list(g.edges()), truth + + +def knn_blobs(n, blocks, k, seed): + """Points in 2D blobs joined to their k nearest neighbours - the shape of a + single-cell neighbourhood graph.""" + rng = random.Random(seed) + per = n // blocks + import math + centers = [(60 * math.cos(2 * math.pi * b / blocks), 60 * math.sin(2 * math.pi * b / blocks)) + for b in range(blocks)] + pts, truth = [], [] + for i in range(n): + b = min(i // per, blocks - 1) + c = centers[b] + pts.append((c[0] + rng.uniform(-8, 8), c[1] + rng.uniform(-8, 8))) + truth.append(b) + + cell = 4.0 + grid = {} + for i, p in enumerate(pts): + grid.setdefault((int(p[0] // cell), int(p[1] // cell)), []).append(i) + + edges = set() + for i, p in enumerate(pts): + gx, gy = int(p[0] // cell), int(p[1] // cell) + cand = [] + for dx in range(-2, 3): + for dy in range(-2, 3): + cand.extend(j for j in grid.get((gx + dx, gy + dy), ()) if j != i) + cand.sort(key=lambda j: (pts[i][0] - pts[j][0]) ** 2 + (pts[i][1] - pts[j][1]) ** 2) + for j in cand[:k]: + edges.add((i, j) if i < j else (j, i)) + return sorted(edges), truth + + +def run_leidenalg(n, edges, resolution, n_iterations, seed): + g = ig.Graph(n=n, edges=edges) + g.es["weight"] = [1.0] * len(edges) + t0 = time.perf_counter() + part = la.find_partition( + g, la.RBConfigurationVertexPartition, weights="weight", + resolution_parameter=resolution, seed=seed, n_iterations=n_iterations, + ) + elapsed = time.perf_counter() - t0 + return list(part.membership), float(part.quality()), elapsed + + +cases = [] + +# --- LFR: mixing parameter sweep, the standard benchmark ------------------------- +for mu in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]: + for seed in [1, 2]: + name = f"lfr_n2000_mu{mu}_s{seed}" + try: + edges, truth = lfr(2000, mu, seed) + except Exception as e: + print(f" skip {name}: {e}") + continue + meta = write_case(name, 2000, edges, truth) + memb, q, t = run_leidenalg(2000, edges, 1.0, 5, 42) + meta.update(kind="lfr", mu=mu, resolution=1.0, + reference_membership=memb, reference_quality=q, reference_seconds=t, + reference_n_clusters=len(set(memb)), truth_n_clusters=len(set(truth))) + cases.append(meta) + print(f" {name}: {len(edges)} edges, leidenalg {len(set(memb))} clusters in {t:.2f}s") + +# --- scale: kNN-shaped graphs at single-cell sizes -------------------------------- +for n in [10_000, 50_000, 200_000]: + name = f"knn_n{n}" + edges, truth = knn_blobs(n, 10, 15, 5) + meta = write_case(name, n, edges, truth) + memb, q, t = run_leidenalg(n, edges, 1.0, 2, 42) + meta.update(kind="knn", resolution=1.0, + reference_membership=memb, reference_quality=q, reference_seconds=t, + reference_n_clusters=len(set(memb)), truth_n_clusters=len(set(truth))) + cases.append(meta) + print(f" {name}: {len(edges)} edges, leidenalg {len(set(memb))} clusters in {t:.2f}s") + +(OUT / "results.json").write_text(json.dumps({"cases": cases})) +print(f"wrote {len(cases)} cases") diff --git a/tools/gen_fixtures.py b/tools/gen_fixtures.py new file mode 100644 index 0000000..2198343 --- /dev/null +++ b/tools/gen_fixtures.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Generate reference clusterings from `leidenalg` for the differential test suite. + +Run once; the generated JSON is committed, so CI never needs Python. + + uv venv --python 3.12 .refvenv + uv pip install --python .refvenv/bin/python leidenalg python-igraph + .refvenv/bin/python tools/gen_fixtures.py + +`leidenalg` wheels are unreliable on Python 3.14 — pin 3.12. + +Each fixture records the graph, the objective, and, for the partition leidenalg found: + +* ``reference_quality`` — leidenalg's own quality value. Our objective must agree with it + exactly (up to the documented factor of 2) *for the same membership*, which is what pins + the definitions rather than merely the outcome. +* ``reference_modularity`` — igraph's modularity of that membership, used to check that our + optimizer reaches partitions as good as theirs. +""" + +import json +import pathlib +import random + +import igraph as ig +import leidenalg as la + +OUT = pathlib.Path(__file__).resolve().parent.parent / "tests" / "fixtures" + +KARATE = [ + (0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6), (0, 7), (0, 8), (0, 10), (0, 11), + (0, 12), (0, 13), (0, 17), (0, 19), (0, 21), (0, 31), (1, 2), (1, 3), (1, 7), (1, 13), + (1, 17), (1, 19), (1, 21), (1, 30), (2, 3), (2, 7), (2, 8), (2, 9), (2, 13), (2, 27), + (2, 28), (2, 32), (3, 7), (3, 12), (3, 13), (4, 6), (4, 10), (5, 6), (5, 10), (5, 16), + (6, 16), (8, 30), (8, 32), (8, 33), (9, 33), (13, 33), (14, 32), (14, 33), (15, 32), + (15, 33), (18, 32), (18, 33), (19, 33), (20, 32), (20, 33), (22, 32), (22, 33), + (23, 25), (23, 27), (23, 29), (23, 32), (23, 33), (24, 25), (24, 27), (24, 31), + (25, 31), (26, 29), (26, 33), (27, 33), (28, 31), (28, 33), (29, 32), (29, 33), + (30, 32), (30, 33), (31, 32), (31, 33), (32, 33), +] + + +def sbm(n_per, blocks, p_in, p_out, seed): + rng = random.Random(seed) + n = n_per * blocks + truth = [i // n_per for i in range(n)] + edges = [ + (i, j) + for i in range(n) + for j in range(i + 1, n) + if rng.random() < (p_in if truth[i] == truth[j] else p_out) + ] + return n, edges, truth + + +def ring_of_cliques(n_cliques, clique_size): + """Cliques in a ring — the classic resolution-limit case, where RB and CPM disagree.""" + edges, n = [], n_cliques * clique_size + for c in range(n_cliques): + base = c * clique_size + edges += [ + (base + i, base + j) + for i in range(clique_size) + for j in range(i + 1, clique_size) + ] + edges.append((base, ((c + 1) % n_cliques) * clique_size)) + return n, edges + + +def weighted(edges, seed): + """Attach non-unit weights, so the fixtures also cover the weighted path.""" + rng = random.Random(seed) + return [round(0.25 + 3.5 * rng.random(), 4) for _ in edges] + + +def make_case(graph_key, n, edges, weights, kind, resolution, seed): + g = ig.Graph(n=n, edges=edges) + g.es["weight"] = weights + + cls = la.RBConfigurationVertexPartition if kind == "rb" else la.CPMVertexPartition + part = la.find_partition( + g, cls, weights="weight", resolution_parameter=resolution, seed=seed, n_iterations=5 + ) + membership = list(part.membership) + + return { + "name": f"{graph_key}/{kind}/res={resolution}/seed={seed}", + # Graphs are stored once under `graphs` and referenced by key: the same graph is + # reused across ~20 configurations, and inlining it made the fixture 15x larger. + "graph": graph_key, + "objective": kind, + "resolution": resolution, + "seed": seed, + "reference_membership": membership, + "reference_n_clusters": len(set(membership)), + "reference_quality": float(part.quality()), + "reference_modularity": float(g.modularity(membership, weights="weight")), + } + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + graphs = {} + + graphs["karate"] = (34, KARATE) + for tag, args in { + "sbm_easy": (30, 4, 0.40, 0.02, 1), + "sbm_medium": (40, 5, 0.25, 0.04, 2), + "sbm_hard": (50, 4, 0.18, 0.07, 3), + }.items(): + n, edges, _ = sbm(*args) + graphs[tag] = (n, edges) + graphs["ring_of_cliques"] = ring_of_cliques(12, 6) + + stored, cases = {}, [] + for gname, (n, edges) in graphs.items(): + for wtag, weights in ( + ("unit", [1.0] * len(edges)), + ("weighted", weighted(edges, 7)), + ): + graph_key = f"{gname}/{wtag}" + stored[graph_key] = { + "n_nodes": n, + "edges": [[int(a), int(b), float(w)] for (a, b), w in zip(edges, weights)], + } + for kind, resolutions in ( + ("rb", [0.25, 0.5, 1.0, 2.0, 4.0]), + ("cpm", [0.02, 0.05, 0.1]), + ): + for res in resolutions: + for seed in (1, 2): + cases.append( + make_case(graph_key, n, edges, weights, kind, res, seed) + ) + + path = OUT / "leidenalg_reference.json" + path.write_text(json.dumps({"graphs": stored, "cases": cases}, separators=(",", ":"))) + print(f"wrote {len(cases)} cases to {path}") + print(f"leidenalg {la.version}, igraph {ig.__version__}") + + +if __name__ == "__main__": + main()