From 093862019db836479a1f144f5cce4def9f2c96b0 Mon Sep 17 00:00:00 2001 From: Davy Duperron Date: Sun, 24 May 2026 21:20:58 +0200 Subject: [PATCH 1/5] feat: add PersistentHypergraph disk-backed graph --- Cargo.lock | 416 ++++++++++++++- Cargo.toml | 7 +- README.md | 31 ++ src/core/bi_hash_map.rs | 40 -- src/core/disk/hyperedges.rs | 196 +++++++ src/core/disk/mod.rs | 483 ++++++++++++++++++ src/core/disk/open.rs | 108 ++++ src/core/disk/vertices.rs | 135 +++++ src/core/errors.rs | 4 + src/core/hyperedges/add_hyperedge.rs | 45 +- src/core/hyperedges/add_hyperedge_index.rs | 38 -- src/core/hyperedges/clear_hyperedges.rs | 22 +- .../hyperedges/contract_hyperedge_vertices.rs | 53 +- src/core/hyperedges/get_hyperedge.rs | 26 - src/core/hyperedges/get_hyperedge_vertices.rs | 22 +- src/core/hyperedges/get_hyperedge_weight.rs | 19 +- src/core/hyperedges/get_hyperedges.rs | 26 - .../hyperedges/get_hyperedges_connecting.rs | 11 +- .../get_hyperedges_intersections.rs | 81 ++- src/core/hyperedges/get_internal_hyperedge.rs | 24 - .../hyperedges/get_internal_hyperedges.rs | 24 - src/core/hyperedges/join_hyperedges.rs | 13 +- src/core/hyperedges/mod.rs | 6 - src/core/hyperedges/remove_hyperedge.rs | 129 +---- src/core/hyperedges/reverse_hyperedge.rs | 10 +- .../hyperedges/update_hyperedge_vertices.rs | 121 ++--- .../hyperedges/update_hyperedge_weight.rs | 65 +-- src/core/iterator.rs | 80 +-- src/core/mod.rs | 104 ++-- src/core/shared.rs | 20 +- src/core/types.rs | 5 +- src/core/utils.rs | 23 - src/core/vertices/add_vertex.rs | 13 +- src/core/vertices/add_vertex_index.rs | 38 -- src/core/vertices/bfs.rs | 4 +- src/core/vertices/connected_components.rs | 4 +- src/core/vertices/contains_vertex.rs | 2 +- src/core/vertices/dfs.rs | 4 +- .../vertices/get_adjacent_vertices_from.rs | 10 +- src/core/vertices/get_adjacent_vertices_to.rs | 10 +- src/core/vertices/get_all_paths.rs | 13 +- src/core/vertices/get_dijkstra_connections.rs | 55 +- src/core/vertices/get_dijkstra_from.rs | 30 +- .../get_full_adjacent_vertices_from.rs | 14 +- .../vertices/get_full_adjacent_vertices_to.rs | 13 +- .../vertices/get_full_vertex_hyperedges.rs | 11 +- src/core/vertices/get_internal_vertex.rs | 24 - src/core/vertices/get_internal_vertices.rs | 27 - src/core/vertices/get_vertex.rs | 24 - src/core/vertices/get_vertex_degree_in.rs | 11 +- src/core/vertices/get_vertex_degree_out.rs | 11 +- src/core/vertices/get_vertex_hyperedges.rs | 21 +- src/core/vertices/get_vertex_index.rs | 5 +- src/core/vertices/get_vertex_weight.rs | 13 +- src/core/vertices/get_vertices.rs | 26 - src/core/vertices/is_reachable.rs | 8 +- src/core/vertices/mod.rs | 6 - src/core/vertices/remove_vertex.rs | 114 +---- .../vertices/strongly_connected_components.rs | 7 +- src/core/vertices/topological_sort.rs | 6 +- src/core/vertices/update_vertex_weight.rs | 19 +- src/lib.rs | 66 ++- tests/integration_disk.rs | 339 ++++++++++++ tests/integration_main.rs | 12 +- 64 files changed, 2246 insertions(+), 1101 deletions(-) delete mode 100644 src/core/bi_hash_map.rs create mode 100644 src/core/disk/hyperedges.rs create mode 100644 src/core/disk/mod.rs create mode 100644 src/core/disk/open.rs create mode 100644 src/core/disk/vertices.rs delete mode 100644 src/core/hyperedges/add_hyperedge_index.rs delete mode 100644 src/core/hyperedges/get_hyperedge.rs delete mode 100644 src/core/hyperedges/get_hyperedges.rs delete mode 100644 src/core/hyperedges/get_internal_hyperedge.rs delete mode 100644 src/core/hyperedges/get_internal_hyperedges.rs delete mode 100644 src/core/utils.rs delete mode 100644 src/core/vertices/add_vertex_index.rs delete mode 100644 src/core/vertices/get_internal_vertex.rs delete mode 100644 src/core/vertices/get_internal_vertices.rs delete mode 100644 src/core/vertices/get_vertex.rs delete mode 100644 src/core/vertices/get_vertices.rs create mode 100644 tests/integration_disk.rs diff --git a/Cargo.lock b/Cargo.lock index 1c21f34..c535854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -45,18 +45,51 @@ version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "byteview" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c53ba0f290bfc610084c05582d9c5d421662128fc69f4bf236707af6fd321b9" + [[package]] name = "cast" version = "0.3.0" @@ -131,6 +164,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "compare" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0095f6103c2a8b44acd6fd15960c801dafebf02e21940360833e0673f48ba7" + [[package]] name = "criterion" version = "0.8.2" @@ -166,6 +214,12 @@ dependencies = [ "itertools 0.13.0", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -185,6 +239,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-skiplist" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df29de440c58ca2cc6e587ec3d22347551a32435fbde9d2bff64e78a9ffa151b" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -197,24 +261,104 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fjall" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62b25b4d815ae178d7d9e4aa32ee59f072efd5431c736abede1e6ee13c8c453" +dependencies = [ + "byteorder-lite", + "byteview", + "dashmap", + "flume", + "log", + "lsm-tree", + "lz4_flex", + "tempfile", + "xxhash-rust", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "spin", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -262,23 +406,62 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + [[package]] name = "hypergraph" version = "3.0.0" dependencies = [ "ahash", "criterion", + "fjall", "indexmap", "itertools 0.14.0", + "postcard", + "quick_cache", "rayon", "serde", "serde_json", + "tempfile", "thiserror", ] @@ -289,12 +472,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", "rayon", "serde", "serde_core", ] +[[package]] +name = "interval-heap" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11274e5e8e89b8607cfedc2910b6626e998779b48a019151c7604d0adcb86ac6" +dependencies = [ + "compare", +] + [[package]] name = "itertools" version = "0.13.0" @@ -337,6 +529,58 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lsm-tree" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e447ac67ff6aef4ec07fc19e507b219336cbba90a697c0dbeb1bf51b91536b67" +dependencies = [ + "byteorder-lite", + "byteview", + "crossbeam-skiplist", + "enum_dispatch", + "interval-heap", + "log", + "lz4_flex", + "quick_cache", + "rustc-hash", + "self_cell", + "sfa", + "tempfile", + "varint-rs", + "xxhash-rust", +] + +[[package]] +name = "lz4_flex" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" +dependencies = [ + "twox-hash", +] + [[package]] name = "memchr" version = "2.8.0" @@ -374,6 +618,29 @@ dependencies = [ "winapi", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -408,6 +675,19 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -417,6 +697,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick_cache" +version = "0.6.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c821816e9b928e20e92ed59bb3ac4aab321d16ca2316871c9fe7ca739cd477" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quote" version = "1.0.45" @@ -452,6 +744,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -481,6 +782,34 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -496,6 +825,24 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -528,9 +875,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -539,6 +886,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sfa" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1296838937cab56cd6c4eeeb8718ec777383700c33f060e2869867bd01d1175" +dependencies = [ + "byteorder-lite", + "log", + "xxhash-rust", +] + [[package]] name = "shlex" version = "1.3.0" @@ -551,6 +909,27 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.117" @@ -562,6 +941,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -592,12 +984,24 @@ dependencies = [ "serde_json", ] +[[package]] +name = "twox-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "varint-rs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f54a172d0620933a27a4360d3db3e2ae0dd6cceae9730751a036bbf182c4b23" + [[package]] name = "version_check" version = "0.9.5" @@ -730,6 +1134,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index 5ba9bc0..e291301 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,18 +14,23 @@ version = "3.0.0" [features] serde = ["dep:serde", "indexmap/serde"] +persistence = ["serde", "dep:fjall", "dep:quick_cache", "dep:postcard"] [dependencies] ahash = "0.8.12" +postcard = { version = "1.1.3", features = ["alloc"], optional = true } +fjall = { version = "3.1.4", optional = true } indexmap = { version = "2.14.0", features = ["rayon"] } itertools = "0.14.0" +quick_cache = { version = "0.6.22", optional = true } rayon = "1.12.0" serde = { version = "1.0.228", features = ["derive"], optional = true } thiserror = "2.0.18" [dev-dependencies] criterion = "0.8.2" -serde_json = "1.0.149" +serde_json = "1.0.150" +tempfile = "3.27.0" [[bench]] name = "performance" diff --git a/README.md b/README.md index 9ac6a68..3eeb48e 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ And to compute: - Stable indexes for each hyperedge and each vertex — identity is the index, not the weight; duplicate weights are allowed on both sides - Parallelism (with Rayon) - Optional `serde` support (`features = ["serde"]` in `Cargo.toml`) +- Optional `persistence` support (`features = ["persistence"]` in `Cargo.toml`) ## 🛠️ Installation @@ -45,6 +46,36 @@ Add this to your `Cargo.toml` (replace _current_version_ with the [latest versio hypergraph = "current_version" ``` +To enable disk-backed persistent graphs: + +```toml +[dependencies] +hypergraph = { version = "current_version", features = ["persistence"] } +``` + +## 💾 Persistent graphs + +The `persistence` feature unlocks [`PersistentHypergraph`](https://docs.rs/hypergraph), a disk-backed variant built on an [LSM-tree](https://en.wikipedia.org/wiki/Log-structured_merge-tree) (via [fjall](https://github.com/fjall-rs/fjall)) with an in-memory hot-data cache. It supports graphs that exceed available RAM and survives process restarts without any manual serialization step. + +```rust +use std::sync::Arc; +use hypergraph::PersistentHypergraph; + +// Opens the database directory, or creates it if it doesn't exist. +let g = Arc::new(PersistentHypergraph::::open("/var/data/my-graph")?); + +// All write methods take &self — share freely across threads. +let g2 = Arc::clone(&g); +std::thread::spawn(move || { + g2.add_vertex(my_vertex)?; + Ok(()) +}); +``` + +Vertex and hyperedge types must implement `serde::Serialize + serde::DeserializeOwned` in addition to the usual trait bounds. + +A bounded LRU cache (via [quick-cache](https://github.com/arthurprs/quick-cache)) sits in front of the disk store, keeping hot vertex weights and hyperedges in memory. The default capacity is 10 000 entries per layer; use `PersistentHypergraph::open_with_capacity` to tune it for your workload. + ## ⚡️ Usage Please read the [documentation](https://docs.rs/hypergraph) to get started. diff --git a/src/core/bi_hash_map.rs b/src/core/bi_hash_map.rs deleted file mode 100644 index 4588897..0000000 --- a/src/core/bi_hash_map.rs +++ /dev/null @@ -1,40 +0,0 @@ -use std::{ - collections::HashMap, - fmt::Debug, - hash::Hash, -}; - -/// Bi-directional hashmap used to store the mapping between the internal -/// unstable indexes - generated by `IndexMap` and `IndexSet` - and the exposed -/// stable indexes. -#[derive(Clone, Debug)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub(crate) struct BiHashMap -where - Index: Copy + Debug + Eq + Hash, -{ - pub(crate) left: HashMap, - pub(crate) right: HashMap, -} - -impl BiHashMap -where - Index: Copy + Debug + Eq + Hash, -{ - /// Creates a new `BiHashMap` with no allocation. - pub(crate) fn new() -> BiHashMap { - Self { - left: HashMap::::with_capacity(0), - right: HashMap::::with_capacity(0), - } - } -} - -impl Default for BiHashMap -where - Index: Copy + Debug + Eq + Hash, -{ - fn default() -> Self { - BiHashMap::new() - } -} diff --git a/src/core/disk/hyperedges.rs b/src/core/disk/hyperedges.rs new file mode 100644 index 0000000..c18158e --- /dev/null +++ b/src/core/disk/hyperedges.rs @@ -0,0 +1,196 @@ +use std::sync::atomic::Ordering; + +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{HyperedgeIndex, HyperedgeTrait, VertexIndex, VertexTrait, errors::HypergraphError}; + +use super::PersistentHypergraph; + +impl PersistentHypergraph +where + V: VertexTrait + Serialize + DeserializeOwned, + HE: HyperedgeTrait + Serialize + DeserializeOwned, +{ + /// Adds a hyperedge connecting `vertices` with the given `weight` and + /// returns its stable [`HyperedgeIndex`]. + /// + /// Each unique vertex in `vertices` receives an O(1) back-reference insert + /// into the `vertex_refs` keyspace; no vertex weight data is modified. + /// + /// This method takes `&self` and is safe to call from multiple threads. + /// Each call atomically reserves a unique [`HyperedgeIndex`]. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeCreationNoVertices`] if `vertices` + /// is empty, [`HypergraphError::VertexIndexNotFound`] if any vertex does + /// not exist, or [`HypergraphError::StorageError`] on I/O failure. + pub fn add_hyperedge( + &self, + vertices: &[VertexIndex], + weight: HE, + ) -> Result> { + if vertices.is_empty() { + return Err(HypergraphError::HyperedgeCreationNoVertices(weight)); + } + + for &v in vertices { + self.load_vertex(v)?; + } + + #[allow(clippy::cast_possible_truncation)] + let he_idx = HyperedgeIndex( + self.hyperedges_next_idx.fetch_add(1, Ordering::Relaxed) as usize, + ); + self.hyperedges_count.fetch_add(1, Ordering::Relaxed); + + self.store_hyperedge(he_idx, vertices, weight)?; + + let mut unique_verts = vertices.to_vec(); + unique_verts.sort_unstable(); + unique_verts.dedup(); + + for v in unique_verts { + self.add_vertex_ref(v, he_idx)?; + } + + self.flush_meta()?; + Ok(he_idx) + } + + /// Returns the weight of the hyperedge at `hyperedge_index`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist, or [`HypergraphError::StorageError`] on I/O failure. + pub fn get_hyperedge_weight( + &self, + hyperedge_index: HyperedgeIndex, + ) -> Result> { + self.load_hyperedge(hyperedge_index).map(|arc| arc.1) + } + + /// Returns the ordered vertex list of the hyperedge at `hyperedge_index`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist, or [`HypergraphError::StorageError`] on I/O failure. + pub fn get_hyperedge_vertices( + &self, + hyperedge_index: HyperedgeIndex, + ) -> Result, HypergraphError> { + self.load_hyperedge(hyperedge_index) + .map(|arc| arc.0.clone()) + } + + /// Updates the weight of the hyperedge at `hyperedge_index`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist, [`HypergraphError::HyperedgeWeightUnchanged`] if + /// `weight` equals the current weight, or + /// [`HypergraphError::StorageError`] on I/O failure. + pub fn update_hyperedge_weight( + &self, + hyperedge_index: HyperedgeIndex, + weight: HE, + ) -> Result<(), HypergraphError> { + let entry = self.load_hyperedge(hyperedge_index)?; + if entry.1 == weight { + return Err(HypergraphError::HyperedgeWeightUnchanged { + index: hyperedge_index, + weight, + }); + } + self.store_hyperedge(hyperedge_index, &entry.0, weight) + } + + /// Replaces the vertex list of the hyperedge at `hyperedge_index`. + /// + /// Back-references are updated with O(1) inserts/deletes per changed + /// vertex: vertices added to the list receive a new `vertex_refs` entry; + /// vertices removed from the list have their entry deleted. No vertex + /// weight data is loaded or modified. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeUpdateNoVertices`] if `vertices` is + /// empty, [`HypergraphError::VertexIndexNotFound`] if any vertex does not + /// exist, [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist, or [`HypergraphError::HyperedgeVerticesUnchanged`] if + /// the new list is identical to the current one. + pub fn update_hyperedge_vertices( + &self, + hyperedge_index: HyperedgeIndex, + vertices: &[VertexIndex], + ) -> Result<(), HypergraphError> { + if vertices.is_empty() { + return Err(HypergraphError::HyperedgeUpdateNoVertices(hyperedge_index)); + } + + for &v in vertices { + self.load_vertex(v)?; + } + + let entry = self.load_hyperedge(hyperedge_index)?; + let prev_vertices = entry.0.clone(); + + if vertices == prev_vertices.as_slice() { + return Err(HypergraphError::HyperedgeVerticesUnchanged(hyperedge_index)); + } + + let mut prev_unique = prev_vertices.clone(); + prev_unique.sort_unstable(); + prev_unique.dedup(); + + let mut new_unique = vertices.to_vec(); + new_unique.sort_unstable(); + new_unique.dedup(); + + for &v in &new_unique { + if !prev_unique.contains(&v) { + self.add_vertex_ref(v, hyperedge_index)?; + } + } + + for &v in &prev_unique { + if !new_unique.contains(&v) { + self.remove_vertex_ref(v, hyperedge_index)?; + } + } + + self.store_hyperedge(hyperedge_index, vertices, entry.1) + } + + /// Removes the hyperedge at `hyperedge_index`. + /// + /// Each vertex that was part of the hyperedge receives an O(1) + /// back-reference delete in the `vertex_refs` keyspace; no vertex weight + /// data is modified. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist, or [`HypergraphError::StorageError`] on I/O failure. + pub fn remove_hyperedge( + &self, + hyperedge_index: HyperedgeIndex, + ) -> Result<(), HypergraphError> { + let vertices = self.get_hyperedge_vertices(hyperedge_index)?; + + let mut unique_verts = vertices; + unique_verts.sort_unstable(); + unique_verts.dedup(); + + for v in unique_verts { + self.remove_vertex_ref(v, hyperedge_index)?; + } + + self.delete_hyperedge(hyperedge_index)?; + self.hyperedges_count.fetch_sub(1, Ordering::Relaxed); + self.flush_meta() + } +} diff --git a/src/core/disk/mod.rs b/src/core/disk/mod.rs new file mode 100644 index 0000000..8aa762d --- /dev/null +++ b/src/core/disk/mod.rs @@ -0,0 +1,483 @@ +use std::{ + fmt, + marker::PhantomData, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; + +use fjall::Keyspace; +use quick_cache::sync::Cache; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + VertexIndex, + VertexTrait, + errors::HypergraphError, +}; + +mod hyperedges; +mod open; +mod vertices; + +// ────────────────────────────────────────────────────────────────────────────── +// Type aliases +// ────────────────────────────────────────────────────────────────────────────── + +/// Cached hyperedge entry: ordered vertex list + weight. +type HyperedgeArc = Arc<(Vec, HE)>; + +// ────────────────────────────────────────────────────────────────────────────── +// Constants +// ────────────────────────────────────────────────────────────────────────────── + +const META_VERTEX_IDX: &[u8] = b"vi"; +const META_VERTEX_COUNT: &[u8] = b"vc"; +const META_HYPEREDGE_IDX: &[u8] = b"hi"; +const META_HYPEREDGE_COUNT: &[u8] = b"hc"; + +pub(super) const DEFAULT_CACHE_CAPACITY: usize = 10_000; + +// ────────────────────────────────────────────────────────────────────────────── +// Key helpers +// ────────────────────────────────────────────────────────────────────────────── + +pub(super) fn decode_u64(bytes: &[u8]) -> u64 { + bytes + .get(..8) + .and_then(|b| b.try_into().ok()) + .map_or(0, u64::from_be_bytes) +} + +/// Encode a `VertexIndex` as a big-endian 8-byte key. +#[allow(clippy::cast_possible_truncation)] +pub(super) fn vertex_key(idx: VertexIndex) -> [u8; 8] { + (idx.0 as u64).to_be_bytes() +} + +/// Encode a `HyperedgeIndex` as a big-endian 8-byte key. +#[allow(clippy::cast_possible_truncation)] +pub(super) fn hyperedge_key(idx: HyperedgeIndex) -> [u8; 8] { + (idx.0 as u64).to_be_bytes() +} + +/// Encode a `(vertex, hyperedge)` back-reference as a 16-byte key. +/// +/// The first 8 bytes are the vertex index; the last 8 bytes are the hyperedge +/// index. Big-endian layout means all back-references for a given vertex are +/// contiguous in the keyspace, enabling an O(degree) prefix scan. +pub(super) fn vertex_ref_key(v: VertexIndex, he: HyperedgeIndex) -> [u8; 16] { + let mut key = [0u8; 16]; + key[..8].copy_from_slice(&vertex_key(v)); + key[8..].copy_from_slice(&hyperedge_key(he)); + key +} + +pub(super) fn key_to_vertex(key: &[u8]) -> Option { + key.get(..8) + .and_then(|b| b.try_into().ok()) + .map(|b: [u8; 8]| { + #[allow(clippy::cast_possible_truncation)] + VertexIndex(u64::from_be_bytes(b) as usize) + }) +} + +pub(super) fn key_to_hyperedge(key: &[u8]) -> Option { + key.get(..8) + .and_then(|b| b.try_into().ok()) + .map(|b: [u8; 8]| { + #[allow(clippy::cast_possible_truncation)] + HyperedgeIndex(u64::from_be_bytes(b) as usize) + }) +} + +pub(super) fn storage_err(e: E) -> HypergraphError +where + V: Copy + Eq, + HE: Copy + Eq, + E: fmt::Display, +{ + HypergraphError::StorageError(e.to_string()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Struct definition +// ────────────────────────────────────────────────────────────────────────────── + +/// A directed hypergraph persisted on disk via fjall (LSM-tree) with a +/// [`quick_cache`] hot-data layer. +/// +/// ## Larger-than-RAM support +/// +/// fjall is the primary store; the in-memory cache is bounded. Three separate +/// fjall keyspaces are used: +/// +/// | Keyspace | Key | Value | +/// |---|---|---| +/// | `vertices` | `vertex_idx (8 B)` | serialized vertex weight | +/// | `hyperedges` | `hyperedge_idx (8 B)` | serialized `(vertices, weight)` | +/// | `vertex_refs` | `vertex_idx (8 B) ‖ hyperedge_idx (8 B)` | empty | +/// +/// Back-references (which hyperedges include a vertex) are stored as individual +/// 16-byte keys in `vertex_refs` rather than as an inline list inside the +/// vertex record. This means: +/// +/// - **Vertex weight read**: single O(1) point lookup — never touches back-refs. +/// - **Adding/removing a back-reference**: single O(1) key insert/delete. +/// - **Getting all hyperedges for a vertex**: O(degree) prefix scan that streams +/// from disk one entry at a time, with no in-memory accumulation beyond the +/// returned `Vec`. +/// +/// High-degree "hub" vertices therefore impose no special memory cost. +/// +/// ## Thread safety +/// +/// `PersistentHypergraph` is `Send + Sync`. All write methods take `&self` and use +/// atomic counters internally, so the same instance can be wrapped in an `Arc` +/// and shared across threads without an external `Mutex`. +/// +/// Note that individual multi-step operations (e.g. `add_hyperedge`) are **not** +/// serializable with respect to concurrent writers: concurrent calls may +/// interleave. For full operation-level isolation wrap in a `Mutex`. +/// +/// ## Open or create +/// +/// ```ignore +/// use std::sync::Arc; +/// use hypergraph::PersistentHypergraph; +/// +/// let g = Arc::new(PersistentHypergraph::::open("/var/data/my-graph")?); +/// +/// let g2 = Arc::clone(&g); +/// std::thread::spawn(move || { g2.add_vertex(my_vertex)?; Ok(()) }); +/// ``` +pub struct PersistentHypergraph { + pub(super) db: fjall::Database, + /// Stores serialized vertex weights, keyed by `vertex_idx`. + pub(super) vertices_ks: Keyspace, + /// Stores serialized `(vertex_list, weight)` tuples, keyed by `hyperedge_idx`. + pub(super) hyperedges_ks: Keyspace, + /// Stores back-references as presence-only 16-byte keys + /// `vertex_idx ‖ hyperedge_idx`. No value payload. + pub(super) vertex_refs_ks: Keyspace, + pub(super) meta_ks: Keyspace, + /// Hot cache: `vertex_idx` → deserialized weight `V`. + pub(super) vertex_cache: Cache, + /// Hot cache: `hyperedge_idx` → `Arc<(Vec, HE)>`. + pub(super) hyperedge_cache: Cache>, + /// Monotonically increasing counter; never decrements. + pub(super) vertices_next_idx: AtomicU64, + /// Actual number of vertices currently in the graph. + pub(super) vertices_count: AtomicU64, + /// Monotonically increasing counter; never decrements. + pub(super) hyperedges_next_idx: AtomicU64, + /// Actual number of hyperedges currently in the graph. + pub(super) hyperedges_count: AtomicU64, + pub(super) _phantom: PhantomData<(V, HE)>, +} + +impl fmt::Debug for PersistentHypergraph +where + V: VertexTrait, + HE: HyperedgeTrait, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PersistentHypergraph") + .field("vertices", &self.vertices_count.load(Ordering::Relaxed)) + .field("hyperedges", &self.hyperedges_count.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ────────────────────────────────────────────────────────────────────────────── + +impl PersistentHypergraph +where + V: VertexTrait + Serialize + DeserializeOwned, + HE: HyperedgeTrait + Serialize + DeserializeOwned, +{ + /// Fetches the weight of a vertex from cache, falling back to disk. + pub(super) fn load_vertex(&self, idx: VertexIndex) -> Result> { + let raw_key = idx.0 as u64; + + if let Some(cached) = self.vertex_cache.get(&raw_key) { + return Ok(cached); + } + + let bytes = self + .vertices_ks + .get(vertex_key(idx)) + .map_err(storage_err)? + .ok_or(HypergraphError::VertexIndexNotFound(idx))?; + + let weight: V = postcard::from_bytes(&bytes).map_err(storage_err)?; + self.vertex_cache.insert(raw_key, weight); + Ok(weight) + } + + /// Serializes and stores a vertex weight, updating the cache. + pub(super) fn store_vertex( + &self, + idx: VertexIndex, + weight: V, + ) -> Result<(), HypergraphError> { + let bytes = postcard::to_allocvec(&weight).map_err(storage_err)?; + self.vertices_ks + .insert(vertex_key(idx), bytes.as_slice()) + .map_err(storage_err)?; + self.vertex_cache.insert(idx.0 as u64, weight); + Ok(()) + } + + /// Removes a vertex weight from disk and cache. + pub(super) fn delete_vertex(&self, idx: VertexIndex) -> Result<(), HypergraphError> { + self.vertices_ks + .remove(vertex_key(idx)) + .map_err(storage_err)?; + self.vertex_cache.remove(&(idx.0 as u64)); + Ok(()) + } + + /// Records that `he` includes `v` by inserting a 16-byte presence key. + pub(super) fn add_vertex_ref( + &self, + v: VertexIndex, + he: HyperedgeIndex, + ) -> Result<(), HypergraphError> { + self.vertex_refs_ks + .insert(vertex_ref_key(v, he), []) + .map_err(storage_err) + } + + /// Removes the back-reference `(v, he)`. + pub(super) fn remove_vertex_ref( + &self, + v: VertexIndex, + he: HyperedgeIndex, + ) -> Result<(), HypergraphError> { + self.vertex_refs_ks + .remove(vertex_ref_key(v, he)) + .map_err(storage_err) + } + + /// Returns all hyperedge indices that include `v` via a prefix scan. + /// + /// Streams one 16-byte key at a time from disk. Memory usage is O(degree) + /// for the returned `Vec`, not proportional to the entire back-ref keyspace. + pub(super) fn load_vertex_refs( + &self, + v: VertexIndex, + ) -> Result, HypergraphError> { + self.vertex_refs_ks + .prefix(vertex_key(v)) + .map(|guard| { + let (k, _) = guard.into_inner().map_err(storage_err)?; + let he_bytes: [u8; 8] = k + .get(8..16) + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| { + HypergraphError::StorageError( + "invalid vertex ref key length".into(), + ) + })?; + #[allow(clippy::cast_possible_truncation)] + Ok(HyperedgeIndex(u64::from_be_bytes(he_bytes) as usize)) + }) + .collect() + } + + /// Deletes all back-reference keys for `v` from the `vertex_refs` keyspace. + pub(super) fn delete_vertex_refs( + &self, + v: VertexIndex, + ) -> Result<(), HypergraphError> { + let keys: Vec<[u8; 16]> = self + .vertex_refs_ks + .prefix(vertex_key(v)) + .filter_map(|guard| { + guard + .into_inner() + .ok() + .and_then(|(k, _)| k.get(..16).and_then(|b| b.try_into().ok())) + }) + .collect(); + + for key in keys { + self.vertex_refs_ks.remove(key).map_err(storage_err)?; + } + Ok(()) + } + + pub(super) fn load_hyperedge( + &self, + idx: HyperedgeIndex, + ) -> Result, HypergraphError> { + let raw_key = idx.0 as u64; + + if let Some(cached) = self.hyperedge_cache.get(&raw_key) { + return Ok(cached); + } + + let bytes = self + .hyperedges_ks + .get(hyperedge_key(idx)) + .map_err(storage_err)? + .ok_or(HypergraphError::HyperedgeIndexNotFound(idx))?; + + let entry: (Vec, HE) = + postcard::from_bytes(&bytes).map_err(storage_err)?; + + let arc = Arc::new(entry); + self.hyperedge_cache.insert(raw_key, arc.clone()); + Ok(arc) + } + + pub(super) fn store_hyperedge( + &self, + idx: HyperedgeIndex, + vertices: &[VertexIndex], + weight: HE, + ) -> Result<(), HypergraphError> { + let bytes = postcard::to_allocvec(&(vertices, weight)).map_err(storage_err)?; + self.hyperedges_ks + .insert(hyperedge_key(idx), bytes.as_slice()) + .map_err(storage_err)?; + self.hyperedge_cache + .insert(idx.0 as u64, Arc::new((vertices.to_vec(), weight))); + Ok(()) + } + + pub(super) fn delete_hyperedge( + &self, + idx: HyperedgeIndex, + ) -> Result<(), HypergraphError> { + self.hyperedges_ks + .remove(hyperedge_key(idx)) + .map_err(storage_err)?; + self.hyperedge_cache.remove(&(idx.0 as u64)); + Ok(()) + } + + pub(super) fn flush_meta(&self) -> Result<(), HypergraphError> { + let vi = self.vertices_next_idx.load(Ordering::Relaxed); + let vc = self.vertices_count.load(Ordering::Relaxed); + let hi = self.hyperedges_next_idx.load(Ordering::Relaxed); + let hc = self.hyperedges_count.load(Ordering::Relaxed); + + let mut batch = self.db.batch(); + batch.insert(&self.meta_ks, META_VERTEX_IDX, vi.to_be_bytes().as_slice()); + batch.insert(&self.meta_ks, META_VERTEX_COUNT, vc.to_be_bytes().as_slice()); + batch.insert(&self.meta_ks, META_HYPEREDGE_IDX, hi.to_be_bytes().as_slice()); + batch.insert(&self.meta_ks, META_HYPEREDGE_COUNT, hc.to_be_bytes().as_slice()); + batch.commit().map_err(storage_err) + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Graph-level utilities +// ────────────────────────────────────────────────────────────────────────────── + +impl PersistentHypergraph +where + V: VertexTrait + Serialize + DeserializeOwned, + HE: HyperedgeTrait + Serialize + DeserializeOwned, +{ + /// Returns the number of vertices currently in the hypergraph. + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn count_vertices(&self) -> usize { + self.vertices_count.load(Ordering::Relaxed) as usize + } + + /// Returns the number of hyperedges currently in the hypergraph. + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn count_hyperedges(&self) -> usize { + self.hyperedges_count.load(Ordering::Relaxed) as usize + } + + /// Returns `true` if the hypergraph contains no vertices. + #[must_use] + pub fn is_empty(&self) -> bool { + self.vertices_count.load(Ordering::Relaxed) == 0 + } + + /// Flushes all pending writes to durable storage (fsync). + /// + /// Normal writes are already appended to the WAL and durable on crash; + /// this call additionally syncs the journal to the physical medium. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn persist(&self) -> Result<(), HypergraphError> { + self.db + .persist(fjall::PersistMode::SyncAll) + .map_err(storage_err) + } + + /// Clears all vertices and hyperedges from the graph. + /// + /// Also clears the `vertex_refs` keyspace and the in-memory caches. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn clear(&self) -> Result<(), HypergraphError> { + for ks in [&self.vertices_ks, &self.hyperedges_ks, &self.vertex_refs_ks] { + let keys: Vec> = ks + .iter() + .filter_map(|guard| guard.into_inner().ok().map(|(k, _)| k.to_vec())) + .collect(); + for k in keys { + ks.remove(k).map_err(storage_err)?; + } + } + + self.vertex_cache.clear(); + self.hyperedge_cache.clear(); + self.vertices_count.store(0, Ordering::Relaxed); + self.hyperedges_count.store(0, Ordering::Relaxed); + + self.flush_meta() + } + + /// Returns all vertex indices currently stored in the graph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn vertex_indices(&self) -> Result, HypergraphError> { + self.vertices_ks + .iter() + .map(|guard| { + let (k, _) = guard.into_inner().map_err(storage_err)?; + key_to_vertex(&k).ok_or_else(|| { + HypergraphError::StorageError("invalid vertex key in storage".into()) + }) + }) + .collect() + } + + /// Returns all hyperedge indices currently stored in the graph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn hyperedge_indices(&self) -> Result, HypergraphError> { + self.hyperedges_ks + .iter() + .map(|guard| { + let (k, _) = guard.into_inner().map_err(storage_err)?; + key_to_hyperedge(&k).ok_or_else(|| { + HypergraphError::StorageError("invalid hyperedge key in storage".into()) + }) + }) + .collect() + } +} diff --git a/src/core/disk/open.rs b/src/core/disk/open.rs new file mode 100644 index 0000000..0024fb2 --- /dev/null +++ b/src/core/disk/open.rs @@ -0,0 +1,108 @@ +use std::{marker::PhantomData, path::Path, sync::atomic::AtomicU64}; + +use fjall::{Database, KeyspaceCreateOptions}; +use quick_cache::sync::Cache; +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{HyperedgeTrait, VertexTrait, errors::HypergraphError}; + +use super::{ + DEFAULT_CACHE_CAPACITY, PersistentHypergraph, META_HYPEREDGE_COUNT, META_HYPEREDGE_IDX, + META_VERTEX_COUNT, META_VERTEX_IDX, decode_u64, storage_err, +}; + +impl PersistentHypergraph +where + V: VertexTrait + Serialize + DeserializeOwned, + HE: HyperedgeTrait + Serialize + DeserializeOwned, +{ + /// Opens (or creates) a persistent hypergraph at `path`. + /// + /// If `path` does not exist, an empty hypergraph is created. If it already + /// holds hypergraph data, that data is recovered automatically. + /// + /// The returned value is `Send + Sync` and can be wrapped in an `Arc` for + /// sharing across threads. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] if the database cannot be + /// opened or the metadata cannot be read. + pub fn open(path: impl AsRef) -> Result> { + let db = Database::builder(path.as_ref()) + .open() + .map_err(storage_err)?; + + let vertices_ks = db + .keyspace("vertices", KeyspaceCreateOptions::default) + .map_err(storage_err)?; + let hyperedges_ks = db + .keyspace("hyperedges", KeyspaceCreateOptions::default) + .map_err(storage_err)?; + let vertex_refs_ks = db + .keyspace("vertex_refs", KeyspaceCreateOptions::default) + .map_err(storage_err)?; + let meta_ks = db + .keyspace("meta", KeyspaceCreateOptions::default) + .map_err(storage_err)?; + + let vertices_next_idx = meta_ks + .get(META_VERTEX_IDX) + .map_err(storage_err)? + .as_deref() + .map_or(0, decode_u64); + + let vertices_count = meta_ks + .get(META_VERTEX_COUNT) + .map_err(storage_err)? + .as_deref() + .map_or(0, decode_u64); + + let hyperedges_next_idx = meta_ks + .get(META_HYPEREDGE_IDX) + .map_err(storage_err)? + .as_deref() + .map_or(0, decode_u64); + + let hyperedges_count = meta_ks + .get(META_HYPEREDGE_COUNT) + .map_err(storage_err)? + .as_deref() + .map_or(0, decode_u64); + + Ok(Self { + db, + vertices_ks, + hyperedges_ks, + vertex_refs_ks, + meta_ks, + vertex_cache: Cache::new(DEFAULT_CACHE_CAPACITY), + hyperedge_cache: Cache::new(DEFAULT_CACHE_CAPACITY), + vertices_next_idx: AtomicU64::new(vertices_next_idx), + vertices_count: AtomicU64::new(vertices_count), + hyperedges_next_idx: AtomicU64::new(hyperedges_next_idx), + hyperedges_count: AtomicU64::new(hyperedges_count), + _phantom: PhantomData, + }) + } + + /// Opens a persistent hypergraph with a custom hot-data cache capacity. + /// + /// `cache_capacity` controls how many entries each cache layer (vertex + /// weights and hyperedges) holds before evicting the least-recently-used + /// entries. The default when using [`open`](Self::open) is 10 000. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] if the database cannot be + /// opened. + pub fn open_with_capacity( + path: impl AsRef, + cache_capacity: usize, + ) -> Result> { + let mut graph = Self::open(path)?; + graph.vertex_cache = Cache::new(cache_capacity); + graph.hyperedge_cache = Cache::new(cache_capacity); + Ok(graph) + } +} diff --git a/src/core/disk/vertices.rs b/src/core/disk/vertices.rs new file mode 100644 index 0000000..9fb1226 --- /dev/null +++ b/src/core/disk/vertices.rs @@ -0,0 +1,135 @@ +use std::sync::atomic::Ordering; + +use serde::{Serialize, de::DeserializeOwned}; + +use crate::{HyperedgeIndex, HyperedgeTrait, VertexIndex, VertexTrait, errors::HypergraphError}; + +use super::PersistentHypergraph; + +impl PersistentHypergraph +where + V: VertexTrait + Serialize + DeserializeOwned, + HE: HyperedgeTrait + Serialize + DeserializeOwned, +{ + /// Adds a vertex and returns its stable [`VertexIndex`]. + /// + /// This method takes `&self` and is safe to call from multiple threads + /// concurrently. Each call atomically reserves a unique index. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn add_vertex(&self, weight: V) -> Result> { + #[allow(clippy::cast_possible_truncation)] + let idx = VertexIndex( + self.vertices_next_idx.fetch_add(1, Ordering::Relaxed) as usize, + ); + self.vertices_count.fetch_add(1, Ordering::Relaxed); + self.store_vertex(idx, weight)?; + self.flush_meta()?; + Ok(idx) + } + + /// Returns the weight of the vertex at `vertex_index`. + /// + /// The result is served from the in-memory cache when available, otherwise + /// a single point lookup is performed against the disk store. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `vertex_index` does + /// not exist, or [`HypergraphError::StorageError`] on I/O failure. + pub fn get_vertex_weight( + &self, + vertex_index: VertexIndex, + ) -> Result> { + self.load_vertex(vertex_index) + } + + /// Updates the weight of the vertex at `vertex_index`. + /// + /// Only the weight record is touched; back-references are stored separately + /// and are unaffected. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `vertex_index` does + /// not exist, [`HypergraphError::VertexWeightUnchanged`] if `weight` equals + /// the current weight, or [`HypergraphError::StorageError`] on I/O failure. + pub fn update_vertex_weight( + &self, + vertex_index: VertexIndex, + weight: V, + ) -> Result<(), HypergraphError> { + let current = self.load_vertex(vertex_index)?; + if current == weight { + return Err(HypergraphError::VertexWeightUnchanged { + index: vertex_index, + weight, + }); + } + self.store_vertex(vertex_index, weight) + } + + /// Returns the indices of all hyperedges that include `vertex_index`. + /// + /// Implemented as a prefix scan over the `vertex_refs` keyspace, streaming + /// one 16-byte key per hyperedge from disk. Memory usage is proportional + /// to the degree of the vertex, not to the total graph size. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `vertex_index` does + /// not exist, or [`HypergraphError::StorageError`] on I/O failure. + pub fn get_vertex_hyperedges( + &self, + vertex_index: VertexIndex, + ) -> Result, HypergraphError> { + self.load_vertex(vertex_index)?; + self.load_vertex_refs(vertex_index) + } + + /// Removes the vertex at `vertex_index`. + /// + /// Hyperedges that become empty after the removal are deleted. Hyperedges + /// with remaining vertices have the removed vertex stripped from their + /// vertex list. All back-references for this vertex are deleted via a + /// single prefix scan over the `vertex_refs` keyspace. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `vertex_index` does + /// not exist, or [`HypergraphError::StorageError`] on I/O failure. + pub fn remove_vertex(&self, vertex_index: VertexIndex) -> Result<(), HypergraphError> { + self.load_vertex(vertex_index)?; + let he_indices = self.load_vertex_refs(vertex_index)?; + + for he_idx in he_indices { + let vertices = self.get_hyperedge_vertices(he_idx)?; + + let unique_count = { + let mut tmp = vertices.clone(); + tmp.sort_unstable(); + tmp.dedup(); + tmp.len() + }; + + if unique_count == 1 { + // vertex_index is the sole unique member; delete the whole hyperedge. + self.delete_hyperedge(he_idx)?; + self.hyperedges_count.fetch_sub(1, Ordering::Relaxed); + } else { + let new_verts: Vec = + vertices.into_iter().filter(|&v| v != vertex_index).collect(); + let he_weight = self.load_hyperedge(he_idx)?.1; + self.store_hyperedge(he_idx, &new_verts, he_weight)?; + } + } + + // Remove all (vertex_index, *) entries from vertex_refs in one prefix scan. + self.delete_vertex_refs(vertex_index)?; + self.delete_vertex(vertex_index)?; + self.vertices_count.fetch_sub(1, Ordering::Relaxed); + self.flush_meta() + } +} diff --git a/src/core/errors.rs b/src/core/errors.rs index 2245af0..043828e 100644 --- a/src/core/errors.rs +++ b/src/core/errors.rs @@ -105,4 +105,8 @@ where /// Error when the hypergraph contains a cycle and a topological sort is requested. #[error("Hypergraph contains a cycle and cannot be topologically sorted")] HypergraphContainsCycle, + + /// Error from the persistent storage backend. + #[error("Storage error: {0}")] + StorageError(String), } diff --git a/src/core/hyperedges/add_hyperedge.rs b/src/core/hyperedges/add_hyperedge.rs index 82c1771..2460a79 100644 --- a/src/core/hyperedges/add_hyperedge.rs +++ b/src/core/hyperedges/add_hyperedge.rs @@ -1,10 +1,10 @@ use crate::{ HyperedgeIndex, - HyperedgeKey, HyperedgeTrait, Hypergraph, VertexIndex, VertexTrait, + core::types::AIndexSet, errors::HypergraphError, }; @@ -17,37 +17,48 @@ where /// Returns the stable index of the hyperedge. /// /// Duplicate weights are allowed — multiple hyperedges may carry the same - /// weight value. The unique key is the `(vertices, weight)` combination: if - /// an identical pair already exists the existing [`HyperedgeIndex`] is + /// weight value. The unique key is the `(vertices, weight)` combination: + /// if an identical pair already exists the existing [`HyperedgeIndex`] is /// returned without creating a duplicate entry. pub fn add_hyperedge( &mut self, vertices: Vec, weight: HE, ) -> Result> { - // If the provided vertices are empty, skip the update. if vertices.is_empty() { return Err(HypergraphError::HyperedgeCreationNoVertices(weight)); } - let internal_vertices = self.get_internal_vertices(vertices)?; + // Validate that all referenced vertices exist. + for &v in &vertices { + if !self.vertices.contains_key(&v) { + return Err(HypergraphError::VertexIndexNotFound(v)); + } + } - // We don't care about the second member of the tuple returned from - // the insertion since this is an infallible operation. - let (internal_index, _) = self + // Idempotent insertion: return the existing index if identical entry found. + if let Some((&existing, _)) = self .hyperedges - .insert_full(HyperedgeKey::new(internal_vertices.clone(), weight)); + .iter() + .find(|(_, (v, w))| v == &vertices && w == &weight) + { + return Ok(existing); + } + + let he_index = HyperedgeIndex(self.hyperedges_count); + self.hyperedges_count += 1; + + // Collect unique vertex refs so each vertex HE-set is updated once. + let unique_verts: AIndexSet = vertices.iter().copied().collect(); - // Update the vertices so that we keep directly track of the hyperedge. - for vertex in internal_vertices { - let (_, index_set) = self - .vertices - .get_mut(vertex) - .ok_or(HypergraphError::InternalVertexIndexNotFound(vertex))?; + self.hyperedges.insert(he_index, (vertices, weight)); - index_set.insert(internal_index); + for v in unique_verts { + if let Some((_, he_set)) = self.vertices.get_mut(&v) { + he_set.insert(he_index); + } } - Ok(self.add_hyperedge_index(internal_index)) + Ok(he_index) } } diff --git a/src/core/hyperedges/add_hyperedge_index.rs b/src/core/hyperedges/add_hyperedge_index.rs deleted file mode 100644 index f0c4d7e..0000000 --- a/src/core/hyperedges/add_hyperedge_index.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::{ - HyperedgeIndex, - HyperedgeTrait, - Hypergraph, - VertexTrait, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // This private method is infallible since adding the same hyperedge - // will return the existing index. - pub(crate) fn add_hyperedge_index(&mut self, internal_index: usize) -> HyperedgeIndex { - if let Some(hyperedge_index) = self.hyperedges_mapping.left.get(&internal_index) { - *hyperedge_index - } else { - let hyperedge_index = HyperedgeIndex(self.hyperedges_count); - - if self - .hyperedges_mapping - .left - .insert(internal_index, hyperedge_index) - .is_none() - { - // Update the counter only for the first insertion. - self.hyperedges_count += 1; - } - - self.hyperedges_mapping - .right - .insert(hyperedge_index, internal_index); - - hyperedge_index - } - } -} diff --git a/src/core/hyperedges/clear_hyperedges.rs b/src/core/hyperedges/clear_hyperedges.rs index d276674..30f2876 100644 --- a/src/core/hyperedges/clear_hyperedges.rs +++ b/src/core/hyperedges/clear_hyperedges.rs @@ -1,10 +1,7 @@ -use rayon::prelude::*; - use crate::{ HyperedgeTrait, Hypergraph, VertexTrait, - bi_hash_map::BiHashMap, errors::HypergraphError, }; @@ -13,22 +10,17 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Clears all the hyperedges from the hypergraph. + /// Removes all hyperedges from the hypergraph. + /// + /// Vertices are retained with their weights, but their hyperedge + /// back-reference sets are cleared. Always returns `Ok(())`. pub fn clear_hyperedges(&mut self) -> Result<(), HypergraphError> { - // Clear the set while keeping its capacity. self.hyperedges.clear(); - - // Reset the hyperedges mapping. - self.hyperedges_mapping = BiHashMap::default(); - - // Reset the hyperedges counter. self.hyperedges_count = 0; - // Update the vertices accordingly. - self.vertices - .par_iter_mut() - // Clear the sets while keeping their capacities. - .for_each(|(_, hyperedges)| hyperedges.clear()); + for (_, he_set) in self.vertices.values_mut() { + he_set.clear(); + } Ok(()) } diff --git a/src/core/hyperedges/contract_hyperedge_vertices.rs b/src/core/hyperedges/contract_hyperedge_vertices.rs index 3b31050..b36c8a1 100644 --- a/src/core/hyperedges/contract_hyperedge_vertices.rs +++ b/src/core/hyperedges/contract_hyperedge_vertices.rs @@ -6,7 +6,6 @@ use crate::{ Hypergraph, VertexIndex, VertexTrait, - core::utils::are_slices_equal, errors::HypergraphError, }; @@ -15,24 +14,34 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Contracts a set of the vertices of a hyperedge into one single vertex. - /// Returns the updated vertices. - /// Based on + /// Contracts `vertices` into `target` within `hyperedge_index`. + /// + /// Every occurrence of any vertex in `vertices` across all hyperedges that + /// touch those vertices is replaced by `target`. Consecutive duplicates + /// introduced by the substitution are collapsed. Returns the updated vertex + /// list of `hyperedge_index` after the contraction. + /// + /// See for the definition. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist, [`HypergraphError::HyperedgeInvalidContraction`] if + /// `target` is not in `vertices`, or + /// [`HypergraphError::HyperedgeVerticesIndexesNotFound`] if any of + /// `vertices` are not part of the hyperedge. pub fn contract_hyperedge_vertices( &mut self, hyperedge_index: HyperedgeIndex, vertices: Vec, target: VertexIndex, ) -> Result, HypergraphError> { - // Get all the vertices of the hyperedge. let hyperedge_vertices = self.get_hyperedge_vertices(hyperedge_index)?; let mut deduped_vertices = vertices; - deduped_vertices.sort_unstable(); deduped_vertices.dedup(); - // Check that the target is included in the deduped vertices. if !deduped_vertices.contains(&target) { return Err(HypergraphError::HyperedgeInvalidContraction { index: hyperedge_index, @@ -41,15 +50,12 @@ where }); } - // Get the vertices not found in the hyperedge. let vertices_not_found = deduped_vertices .iter() .filter(|index| !hyperedge_vertices.contains(index)) .copied() .collect::>(); - // Check that all the vertices - target included - are a subset of - // the current hyperedge's vertices. if !vertices_not_found.is_empty() { return Err(HypergraphError::HyperedgeVerticesIndexesNotFound { index: hyperedge_index, @@ -57,26 +63,18 @@ where }); } - // Store all the hyperedges which are going to change. - let mut all_hyperedges: Vec = vec![]; - - // Iterate over all the deduped vertices. + // Collect all hyperedges touching any of the contracting vertices. + let mut all_hyperedges: Vec = Vec::new(); for &vertex in &deduped_vertices { - // Safely get the hyperedges of the current vertex. let mut vertex_hyperedges = self.get_vertex_hyperedges(vertex)?; - - // Concatenate them to the global ones. all_hyperedges.append(&mut vertex_hyperedges); } - // Iterate over all the deduped hyperedges. for &hyperedge in all_hyperedges.iter().sorted().dedup() { - let hyperedge_vertices = self.get_hyperedge_vertices(hyperedge)?; + let he_vertices = self.get_hyperedge_vertices(hyperedge)?; - // Contract the vertices of the hyperedge. - let contraction = hyperedge_vertices + let contraction = he_vertices .iter() - // First remap each vertex to itself or to the target. .map(|vertex| { if deduped_vertices.contains(vertex) { target @@ -84,21 +82,14 @@ where *vertex } }) - // Then dedupe the resulting vector. .dedup() - .collect_vec(); + .collect::>(); - // Only update the hyperedge if necessary. - if !are_slices_equal( - &self.get_internal_vertices(&contraction)?, - &self.get_internal_vertices(hyperedge_vertices)?, - ) { - // Safely update the current hyperedge with the contraction. + if contraction != he_vertices { self.update_hyperedge_vertices(hyperedge, contraction)?; } } - // Return the contraction. self.get_hyperedge_vertices(hyperedge_index) } } diff --git a/src/core/hyperedges/get_hyperedge.rs b/src/core/hyperedges/get_hyperedge.rs deleted file mode 100644 index 869115c..0000000 --- a/src/core/hyperedges/get_hyperedge.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::{ - HyperedgeIndex, - HyperedgeTrait, - Hypergraph, - VertexTrait, - errors::HypergraphError, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // Private method to get the HyperedgeIndex matching an internal index. - pub(crate) fn get_hyperedge( - &self, - hyperedge_index: usize, - ) -> Result> { - match self.hyperedges_mapping.left.get(&hyperedge_index) { - Some(index) => Ok(*index), - None => Err(HypergraphError::InternalHyperedgeIndexNotFound( - hyperedge_index, - )), - } - } -} diff --git a/src/core/hyperedges/get_hyperedge_vertices.rs b/src/core/hyperedges/get_hyperedge_vertices.rs index 80b4f2a..5815032 100644 --- a/src/core/hyperedges/get_hyperedge_vertices.rs +++ b/src/core/hyperedges/get_hyperedge_vertices.rs @@ -1,6 +1,5 @@ use crate::{ HyperedgeIndex, - HyperedgeKey, HyperedgeTrait, Hypergraph, VertexIndex, @@ -13,17 +12,22 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the vertices of a hyperedge. + /// Returns the ordered vertex list of the hyperedge at `hyperedge_index`. + /// + /// The order reflects the direction of the hyperedge — i.e. the sequence in + /// which vertices were provided when the hyperedge was created or last updated. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist. pub fn get_hyperedge_vertices( &self, hyperedge_index: HyperedgeIndex, ) -> Result, HypergraphError> { - let internal_index = self.get_internal_hyperedge(hyperedge_index)?; - - let HyperedgeKey { vertices, .. } = self.hyperedges.get_index(internal_index).ok_or( - HypergraphError::InternalHyperedgeIndexNotFound(internal_index), - )?; - - self.get_vertices(vertices) + self.hyperedges + .get(&hyperedge_index) + .map(|(vertices, _)| vertices.clone()) + .ok_or(HypergraphError::HyperedgeIndexNotFound(hyperedge_index)) } } diff --git a/src/core/hyperedges/get_hyperedge_weight.rs b/src/core/hyperedges/get_hyperedge_weight.rs index df3b7f9..5e31ef9 100644 --- a/src/core/hyperedges/get_hyperedge_weight.rs +++ b/src/core/hyperedges/get_hyperedge_weight.rs @@ -11,18 +11,19 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the weight of a hyperedge from its index. + /// Returns a reference to the weight of the hyperedge at `hyperedge_index`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist. pub fn get_hyperedge_weight( &self, hyperedge_index: HyperedgeIndex, ) -> Result<&HE, HypergraphError> { - let internal_index = self.get_internal_hyperedge(hyperedge_index)?; - - let hyperedge_key = self - .hyperedges - .get_index(internal_index) - .ok_or(HypergraphError::InternalVertexIndexNotFound(internal_index))?; - - Ok(&**hyperedge_key) + self.hyperedges + .get(&hyperedge_index) + .map(|(_, weight)| weight) + .ok_or(HypergraphError::HyperedgeIndexNotFound(hyperedge_index)) } } diff --git a/src/core/hyperedges/get_hyperedges.rs b/src/core/hyperedges/get_hyperedges.rs deleted file mode 100644 index 295e8c6..0000000 --- a/src/core/hyperedges/get_hyperedges.rs +++ /dev/null @@ -1,26 +0,0 @@ -use rayon::prelude::*; - -use crate::{ - HyperedgeIndex, - HyperedgeTrait, - Hypergraph, - VertexTrait, - errors::HypergraphError, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // Private method to get a vector of HyperedgeIndex from a vector of internal indexes. - pub(crate) fn get_hyperedges( - &self, - hyperedges: &[usize], - ) -> Result, HypergraphError> { - hyperedges - .par_iter() - .map(|hyperedge_index| self.get_hyperedge(*hyperedge_index)) - .collect() - } -} diff --git a/src/core/hyperedges/get_hyperedges_connecting.rs b/src/core/hyperedges/get_hyperedges_connecting.rs index cab1855..4dff8c8 100644 --- a/src/core/hyperedges/get_hyperedges_connecting.rs +++ b/src/core/hyperedges/get_hyperedges_connecting.rs @@ -15,7 +15,16 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the hyperedges directly connecting a vertex to another. + /// Returns the indices of all hyperedges that contain a direct `from → to` + /// consecutive connection. + /// + /// A hyperedge qualifies when `from` and `to` appear as adjacent entries in + /// its vertex list (in that order). Supports self-loops when `from == to`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if either `from` or `to` + /// does not exist. pub fn get_hyperedges_connecting( &self, from: VertexIndex, diff --git a/src/core/hyperedges/get_hyperedges_intersections.rs b/src/core/hyperedges/get_hyperedges_intersections.rs index 674262b..429036a 100644 --- a/src/core/hyperedges/get_hyperedges_intersections.rs +++ b/src/core/hyperedges/get_hyperedges_intersections.rs @@ -1,12 +1,10 @@ -use itertools::Itertools; - use crate::{ HyperedgeIndex, - HyperedgeKey, HyperedgeTrait, Hypergraph, VertexIndex, VertexTrait, + core::types::AIndexSet, errors::HypergraphError, }; @@ -15,60 +13,45 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the intersections of a set of hyperedges as a vector of vertices. + /// Returns the vertices present in every hyperedge in `hyperedges`. + /// + /// The result is sorted by [`VertexIndex`] and deduplicated. An empty `Vec` + /// is returned when the hyperedges share no common vertices. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgesInvalidIntersections`] if fewer than + /// two hyperedge indices are provided, or + /// [`HypergraphError::HyperedgeIndexNotFound`] if any index does not exist. pub fn get_hyperedges_intersections( &self, - hyperedges: Vec, + hyperedges: &[HyperedgeIndex], ) -> Result, HypergraphError> { - // Keep track of the number of hyperedges. - let number_of_hyperedges = hyperedges.len(); + let n = hyperedges.len(); - // Early exit if less than two hyperedges are provided. - if number_of_hyperedges < 2 { + if n < 2 { return Err(HypergraphError::HyperedgesInvalidIntersections); } - // Get the internal vertices of the hyperedges and keep the eventual error. - let vertices = hyperedges - .into_iter() - .map(|hyperedge_index| { - self.get_internal_hyperedge(hyperedge_index) - .and_then(|internal_index| { - self.hyperedges - .get_index(internal_index) - .ok_or(HypergraphError::InternalHyperedgeIndexNotFound( - internal_index, - )) - .map(|HyperedgeKey { vertices, .. }| { - vertices.iter().unique().copied().collect_vec() - }) - }) + // Build a unique vertex set per hyperedge. + let vertex_sets = hyperedges + .iter() + .map(|&he_index| { + self.hyperedges + .get(&he_index) + .map(|(v, _)| v.iter().copied().collect::>()) + .ok_or(HypergraphError::HyperedgeIndexNotFound(he_index)) }) - .collect::>, HypergraphError>>(); + .collect::, _>>()?; + + // Intersection: vertices present in every hyperedge's set. + let mut result: Vec = vertex_sets[0] + .iter() + .filter(|v| vertex_sets[1..].iter().all(|s| s.contains(*v))) + .copied() + .collect(); - vertices.and_then(|vertices| { - self.get_vertices( - &vertices - .into_iter() - // Flatten and sort the vertices. - .flatten() - .sorted() - // Map the result to tuples where the second term is an arbitrary value. - // The goal is to group them by indexes. - .map(|index| (index, 0)) - .into_group_map() - .into_iter() - // Filter the groups having the same size as the hyperedge. - .filter_map(|(index, occurences)| { - if occurences.len() == number_of_hyperedges { - Some(index) - } else { - None - } - }) - .sorted() - .collect_vec(), - ) - }) + result.sort(); + Ok(result) } } diff --git a/src/core/hyperedges/get_internal_hyperedge.rs b/src/core/hyperedges/get_internal_hyperedge.rs deleted file mode 100644 index aff5722..0000000 --- a/src/core/hyperedges/get_internal_hyperedge.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::{ - HyperedgeIndex, - HyperedgeTrait, - Hypergraph, - VertexTrait, - errors::HypergraphError, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // Private method to get the internal hyperedge matching a HyperedgeIndex. - pub(crate) fn get_internal_hyperedge( - &self, - hyperedge_index: HyperedgeIndex, - ) -> Result> { - match self.hyperedges_mapping.right.get(&hyperedge_index) { - Some(index) => Ok(*index), - None => Err(HypergraphError::HyperedgeIndexNotFound(hyperedge_index)), - } - } -} diff --git a/src/core/hyperedges/get_internal_hyperedges.rs b/src/core/hyperedges/get_internal_hyperedges.rs deleted file mode 100644 index 2cb7849..0000000 --- a/src/core/hyperedges/get_internal_hyperedges.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::{ - HyperedgeIndex, - HyperedgeTrait, - Hypergraph, - VertexTrait, - errors::HypergraphError, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // Private method to get the internal hyperedges from a vector of HyperedgeIndex. - pub(crate) fn get_internal_hyperedges( - &self, - hyperedges: &[HyperedgeIndex], - ) -> Result, HypergraphError> { - hyperedges - .iter() - .map(|hyperedge_index| self.get_internal_hyperedge(*hyperedge_index)) - .collect() - } -} diff --git a/src/core/hyperedges/join_hyperedges.rs b/src/core/hyperedges/join_hyperedges.rs index 8d8886d..5c33cd2 100644 --- a/src/core/hyperedges/join_hyperedges.rs +++ b/src/core/hyperedges/join_hyperedges.rs @@ -12,8 +12,17 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Joins two or more hyperedges from the hypergraph into one single entity. - /// All the vertices are moved to the first hyperedge in the provided order. + /// Merges two or more hyperedges into the first one and removes the rest. + /// + /// The vertex lists of all provided hyperedges are concatenated (in the + /// order they appear in `hyperedges`) and assigned to `hyperedges[0]`. The + /// remaining hyperedges are then deleted from the graph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgesInvalidJoin`] if fewer than two + /// indices are provided, or [`HypergraphError::HyperedgeIndexNotFound`] if + /// any index does not exist. pub fn join_hyperedges( &mut self, hyperedges: &[HyperedgeIndex], diff --git a/src/core/hyperedges/mod.rs b/src/core/hyperedges/mod.rs index 637be45..1da3b7e 100644 --- a/src/core/hyperedges/mod.rs +++ b/src/core/hyperedges/mod.rs @@ -1,9 +1,3 @@ -pub(crate) mod add_hyperedge_index; -pub(crate) mod get_hyperedge; -pub(crate) mod get_hyperedges; -pub(crate) mod get_internal_hyperedge; -pub(crate) mod get_internal_hyperedges; - pub mod add_hyperedge; pub mod clear_hyperedges; pub mod contract_hyperedge_vertices; diff --git a/src/core/hyperedges/remove_hyperedge.rs b/src/core/hyperedges/remove_hyperedge.rs index a8767db..c5c7d5d 100644 --- a/src/core/hyperedges/remove_hyperedge.rs +++ b/src/core/hyperedges/remove_hyperedge.rs @@ -1,9 +1,9 @@ use crate::{ HyperedgeIndex, - HyperedgeKey, HyperedgeTrait, Hypergraph, VertexTrait, + core::types::AIndexSet, errors::HypergraphError, }; @@ -12,120 +12,33 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Removes a hyperedge by index. + /// Removes the hyperedge at `hyperedge_index` from the graph. + /// + /// Also removes the corresponding back-reference from every vertex that was + /// part of the hyperedge. Vertex weights and all other hyperedges are + /// unaffected. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist. pub fn remove_hyperedge( &mut self, hyperedge_index: HyperedgeIndex, ) -> Result<(), HypergraphError> { - let internal_index = self.get_internal_hyperedge(hyperedge_index)?; - - let HyperedgeKey { vertices, .. } = - self.hyperedges.get_index(internal_index).cloned().ok_or( - HypergraphError::InternalHyperedgeIndexNotFound(internal_index), - )?; - - // Find the last index. - let last_index = self.hyperedges.len() - 1; - - // Swap and remove by index. - self.hyperedges.swap_remove_index(internal_index); - - // Update the mapping for the removed hyperedge. - self.hyperedges_mapping.left.remove(&internal_index); - self.hyperedges_mapping.right.remove(&hyperedge_index); - - // Remove the hyperedge from the vertices. - for vertex in vertices { - match self.vertices.get_mut(vertex) { - Some((_, index_set)) => { - index_set.swap_remove(&internal_index); - } - None => return Err(HypergraphError::InternalVertexIndexNotFound(vertex)), - } - } - - // Given the following bi-mapping with three hyperedges, i.e. an - // initial set of hyperedges [0, 1, 2]. Let's assume in this example - // that the first hyperedge will be removed: - // - // left | Right - // --------------------------------------------------------- - // 0usize -> HyperedgeIndex(0) | HyperedgeIndex(0) -> 0usize - // 1usize -> HyperedgeIndex(1) | HyperedgeIndex(1) -> 1usize - // 2usize -> HyperedgeIndex(2) | HyperedgeIndex(2) -> 2usize - // - // In the previous steps, the current hyperedge has been already nuked. - // So we now have: - // - // left | Right - // --------------------------------------------------------- - // xxxxxxxxxxxxxxxxxxxxxxxxxxx | xxxxxxxxxxxxxxxxxxxxxxxxxxx - // 1usize -> HyperedgeIndex(1) | HyperedgeIndex(1) -> 1usize - // 2usize -> HyperedgeIndex(2) | HyperedgeIndex(2) -> 2usize - // - // The next step will be to insert the swapped index on the right: - // - // left | Right - // --------------------------------------------------------- - // xxxxxxxxxxxxxxxxxxxxxxxxxxx | xxxxxxxxxxxxxxxxxxxxxxxxxxx - // 1usize -> HyperedgeIndex(1) | HyperedgeIndex(1) -> 1usize - // 2usize -> HyperedgeIndex(2) | HyperedgeIndex(2) -> 0usize - // - // Now remove the index which no longer exists on the left: - // - // left | Right - // --------------------------------------------------------- - // xxxxxxxxxxxxxxxxxxxxxxxxxxx | xxxxxxxxxxxxxxxxxxxxxxxxxxx - // 1usize -> HyperedgeIndex(1) | HyperedgeIndex(1) -> 1usize - // xxxxxxxxxxxxxxxxxxxxxxxxxxx | HyperedgeIndex(2) -> 0usize - // - // Insert the swapped index on the left: - // - // left | Right - // --------------------------------------------------------- - // 0usize -> HyperedgeIndex(2) | xxxxxxxxxxxxxxxxxxxxxxxxxxx - // 1usize -> HyperedgeIndex(1) | HyperedgeIndex(1) -> 1usize - // xxxxxxxxxxxxxxxxxxxxxxxxxxx | HyperedgeIndex(2) -> 0usize - // - // If the index to remove wasn't the last one, the last hyperedge has - // been swapped in place of the removed one. Thus we need to update - // the mapping accordingly. - if internal_index != last_index { - // Get the index of the swapped hyperedge. - let swapped_hyperedge_index = self.get_hyperedge(last_index)?; - - // Proceed with the aforementioned operations. - self.hyperedges_mapping - .right - .insert(swapped_hyperedge_index, internal_index); - self.hyperedges_mapping.left.remove(&last_index); - self.hyperedges_mapping - .left - .insert(internal_index, swapped_hyperedge_index); - - // Get the vertices of the swapped hyperedge. - let HyperedgeKey { - vertices: swapped_vertices, - .. - } = self.hyperedges.get_index(internal_index).cloned().ok_or( - HypergraphError::InternalHyperedgeIndexNotFound(internal_index), - )?; - - // Update the impacted vertices accordingly. - for vertex in swapped_vertices { - match self.vertices.get_mut(vertex) { - Some((_, index_set)) => { - // Perform an insertion of the current hyperedge and a - // removal of the swapped one. - index_set.insert(internal_index); - index_set.swap_remove(&last_index); - } - None => return Err(HypergraphError::InternalVertexIndexNotFound(vertex)), - } + let (vertices, _) = self + .hyperedges + .swap_remove(&hyperedge_index) + .ok_or(HypergraphError::HyperedgeIndexNotFound(hyperedge_index))?; + + // Remove this hyperedge ref from each unique vertex. + let unique_verts: AIndexSet<_> = vertices.into_iter().collect(); + for v in unique_verts { + if let Some((_, he_set)) = self.vertices.get_mut(&v) { + he_set.swap_remove(&hyperedge_index); } } - // Return a unit. Ok(()) } } diff --git a/src/core/hyperedges/reverse_hyperedge.rs b/src/core/hyperedges/reverse_hyperedge.rs index b5a30b1..4b6b843 100644 --- a/src/core/hyperedges/reverse_hyperedge.rs +++ b/src/core/hyperedges/reverse_hyperedge.rs @@ -13,7 +13,15 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Reverses the vertices of a hyperedge by index. + /// Reverses the vertex order of the hyperedge at `hyperedge_index`. + /// + /// This inverts the direction of the hyperedge without changing which + /// vertices it connects. The weight is unchanged. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist. pub fn reverse_hyperedge( &mut self, hyperedge_index: HyperedgeIndex, diff --git a/src/core/hyperedges/update_hyperedge_vertices.rs b/src/core/hyperedges/update_hyperedge_vertices.rs index 67e1dbe..2e150eb 100644 --- a/src/core/hyperedges/update_hyperedge_vertices.rs +++ b/src/core/hyperedges/update_hyperedge_vertices.rs @@ -1,13 +1,10 @@ -use rayon::prelude::*; - use crate::{ HyperedgeIndex, - HyperedgeKey, HyperedgeTrait, Hypergraph, VertexIndex, VertexTrait, - core::utils::are_slices_equal, + core::types::AIndexSet, errors::HypergraphError, }; @@ -16,107 +13,65 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Updates the vertices of a hyperedge by index. + /// Replaces the vertex list of the hyperedge at `hyperedge_index` with `vertices`. + /// + /// Back-references on newly added vertices and removed vertices are updated + /// automatically. The weight of the hyperedge is unchanged. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeUpdateNoVertices`] if `vertices` is + /// empty, [`HypergraphError::VertexIndexNotFound`] if any vertex does not + /// exist, [`HypergraphError::HyperedgeIndexNotFound`] if `hyperedge_index` + /// does not exist, or [`HypergraphError::HyperedgeVerticesUnchanged`] if the + /// new list is identical to the current one. pub fn update_hyperedge_vertices( &mut self, hyperedge_index: HyperedgeIndex, vertices: Vec, ) -> Result<(), HypergraphError> { - // If the provided vertices are empty, skip the update. if vertices.is_empty() { return Err(HypergraphError::HyperedgeUpdateNoVertices(hyperedge_index)); } - let internal_index = self.get_internal_hyperedge(hyperedge_index)?; - - let internal_vertices = self.get_internal_vertices(vertices)?; + // Validate that all new vertices exist. + for &v in &vertices { + if !self.vertices.contains_key(&v) { + return Err(HypergraphError::VertexIndexNotFound(v)); + } + } - let HyperedgeKey { - vertices: previous_vertices, - weight, - } = self.hyperedges.get_index(internal_index).cloned().ok_or( - HypergraphError::InternalHyperedgeIndexNotFound(internal_index), - )?; + let previous_vertices = self + .hyperedges + .get(&hyperedge_index) + .map(|(v, _)| v.clone()) + .ok_or(HypergraphError::HyperedgeIndexNotFound(hyperedge_index))?; - // If the new vertices are the same as the old ones, skip the update. - if are_slices_equal(&internal_vertices, &previous_vertices) { + if vertices == previous_vertices { return Err(HypergraphError::HyperedgeVerticesUnchanged(hyperedge_index)); } - // Find the vertices which have been added. - let mut added = internal_vertices - .par_iter() - .fold_with(vec![], |mut acc: Vec, index| { - if !previous_vertices - .par_iter() - .any(|current_index| current_index == index) - { - acc.push(*index); - } - - acc - }) - .flatten() - .collect::>(); + let prev_unique: AIndexSet = previous_vertices.iter().copied().collect(); + let new_unique: AIndexSet = vertices.iter().copied().collect(); - added.par_sort_unstable(); - added.dedup(); - - // Find the vertices which have been removed. - let mut removed = previous_vertices - .into_par_iter() - .filter_map(|index| { - if internal_vertices - .par_iter() - .any(|current_index| index == *current_index) - { - None - } else { - Some(index) - } - }) - .collect::>(); - - removed.par_sort_unstable(); - removed.dedup(); - - // Update the added vertices. - for index in added { - match self.vertices.get_mut(index) { - Some((_, index_set)) => { - index_set.insert(internal_index); - } - None => return Err(HypergraphError::InternalVertexIndexNotFound(index)), + // Add hyperedge ref to newly included vertices. + for &v in &new_unique { + if !prev_unique.contains(&v) && let Some((_, he_set)) = self.vertices.get_mut(&v) { + he_set.insert(hyperedge_index); } } - // Update the removed vertices. - for index in removed { - match self.vertices.get_mut(index) { - Some((_, index_set)) => { - // This has an impact on the internal indexing for the set. - // However since this is not exposed to the user - i.e. no - // mapping is involved - we can safely perform the operation. - index_set.swap_remove_index(internal_index); - } - None => return Err(HypergraphError::InternalVertexIndexNotFound(index)), + // Remove hyperedge ref from vertices no longer included. + for &v in &prev_unique { + if !new_unique.contains(&v) && let Some((_, he_set)) = self.vertices.get_mut(&v) { + he_set.swap_remove(&hyperedge_index); } } - // Insert the new entry. - // Since we are not altering the weight, we can safely perform the - // operation without checking its output. - self.hyperedges.insert(HyperedgeKey { - vertices: internal_vertices, - weight, - }); - - // Swap and remove by index. - // Since we know that the internal index is correct, we can safely - // perform the operation without checking its output. - self.hyperedges.swap_remove_index(internal_index); + if let Some((v, _)) = self.hyperedges.get_mut(&hyperedge_index) { + *v = vertices; + } - // Return a unit. Ok(()) } } diff --git a/src/core/hyperedges/update_hyperedge_weight.rs b/src/core/hyperedges/update_hyperedge_weight.rs index 82626ba..8f5ac0b 100644 --- a/src/core/hyperedges/update_hyperedge_weight.rs +++ b/src/core/hyperedges/update_hyperedge_weight.rs @@ -1,6 +1,5 @@ use crate::{ HyperedgeIndex, - HyperedgeKey, HyperedgeTrait, Hypergraph, VertexTrait, @@ -22,70 +21,22 @@ where hyperedge_index: HyperedgeIndex, weight: HE, ) -> Result<(), HypergraphError> { - let internal_index = self.get_internal_hyperedge(hyperedge_index)?; + let (_, current_weight) = self + .hyperedges + .get(&hyperedge_index) + .ok_or(HypergraphError::HyperedgeIndexNotFound(hyperedge_index))?; - let HyperedgeKey { - vertices, - weight: previous_weight, - } = self.hyperedges.get_index(internal_index).ok_or( - HypergraphError::InternalHyperedgeIndexNotFound(internal_index), - )?; - - // Return an error if the new weight is the same as the previous one. - if weight == *previous_weight { + if weight == *current_weight { return Err(HypergraphError::HyperedgeWeightUnchanged { index: hyperedge_index, weight, }); } - // IndexMap doesn't allow holes by design, see: - // https://github.com/bluss/indexmap/issues/90#issuecomment-455381877 - // - // As a consequence, we have two options. Either we use shift_remove - // and it will result in an expensive regeneration of all the indexes - // in the map/set or we use swap_remove methods and deal with the fact - // that the last element will be swapped in place of the removed one - // and will thus get a new index. - // - // In our case, since we are inserting an entry upfront, it circumvents - // the aforementioned issue. - // - // First case: index alteration is avoided. - // - // Entry to remove - // | 1.Insert new entry - // | | - // v v - // [a, b, c] -> [a, b, c, d] -> [d, b, c, _] - // ^ ^ - // | | - // +--------+ - // 2.Swap and remove - // - // ----------------------------------------- - // - // Second case: no index alteration. - // - // Entry to remove - // | 1.Insert new entry - // | | - // v v - // [a, b, c] -> [a, b, c, d] -> [a, b, d, _] - // ^ ^ - // | | - // +--+ - // 2.Swap and remove - // - self.hyperedges - .insert(HyperedgeKey::new(vertices.clone(), weight)); - - // Swap and remove by index. - // Since we know that the internal index is correct, we can safely - // perform the operation without checking its output. - self.hyperedges.swap_remove_index(internal_index); + if let Some((_, w)) = self.hyperedges.get_mut(&hyperedge_index) { + *w = weight; + } - // Return a unit. Ok(()) } } diff --git a/src/core/iterator.rs b/src/core/iterator.rs index c9c4e22..4664776 100644 --- a/src/core/iterator.rs +++ b/src/core/iterator.rs @@ -1,11 +1,9 @@ use crate::{ HyperedgeIndex, - HyperedgeKey, HyperedgeTrait, Hypergraph, VertexIndex, VertexTrait, - errors::HypergraphError, }; impl IntoIterator for Hypergraph @@ -54,31 +52,13 @@ where /// Returns an iterator over all vertices as `(VertexIndex, &V)` pairs, in insertion order. #[must_use = "the iterator is lazy and must be consumed"] pub fn vertices_iter(&self) -> impl Iterator + '_ { - self.vertices - .iter() - .enumerate() - .filter_map(|(internal, (weight, _))| { - self.vertices_mapping - .left - .get(&internal) - .copied() - .map(|stable| (stable, weight)) - }) + self.vertices.iter().map(|(&idx, (weight, _))| (idx, weight)) } /// Returns an iterator over all hyperedges as `(HyperedgeIndex, &HE)` pairs, in insertion order. #[must_use = "the iterator is lazy and must be consumed"] pub fn hyperedges_iter(&self) -> impl Iterator + '_ { - self.hyperedges - .iter() - .enumerate() - .filter_map(|(internal, key)| { - self.hyperedges_mapping - .left - .get(&internal) - .copied() - .map(|stable| (stable, &**key)) - }) + self.hyperedges.iter().map(|(&idx, (_, weight))| (idx, weight)) } } @@ -101,24 +81,15 @@ where type Item = (HE, Vec); fn next(&mut self) -> Option { - match self.hypergraph.hyperedges.get_index(self.index) { - Some(HyperedgeKey { vertices, weight }) => { - if let Ok(indexes) = self.hypergraph.get_vertices(&vertices.clone()) { - indexes - .iter() - .map(|index| self.hypergraph.get_vertex_weight(*index)) - .collect::, HypergraphError>>() - .ok() - .map(|vertices_weights| { - self.index += 1; - (*weight, vertices_weights.into_iter().copied().collect()) - }) - } else { - None - } - } - None => None, - } + let (_, (vertices, weight)) = self.hypergraph.hyperedges.get_index(self.index)?; + self.index += 1; + + let vertex_weights: Option> = vertices + .iter() + .map(|v_idx| self.hypergraph.vertices.get(v_idx).map(|(w, _)| *w)) + .collect(); + + vertex_weights.map(|vw| (*weight, vw)) } } @@ -141,25 +112,14 @@ where type Item = (&'s HE, Vec<&'s V>); fn next(&mut self) -> Option { - match self.hypergraph.hyperedges.get_index(self.index) { - Some(HyperedgeKey { vertices, weight }) => { - let hypergraph = self.hypergraph; - - if let Ok(indexes) = hypergraph.get_vertices(vertices) { - indexes - .iter() - .map(|index| hypergraph.get_vertex_weight(*index)) - .collect::, HypergraphError>>() - .ok() - .map(|vertex_weights| { - self.index += 1; - (weight, vertex_weights) - }) - } else { - None - } - } - None => None, - } + let (_, (vertices, weight)) = self.hypergraph.hyperedges.get_index(self.index)?; + self.index += 1; + + let vertex_weights: Option> = vertices + .iter() + .map(|v_idx| self.hypergraph.vertices.get(v_idx).map(|(w, _)| w)) + .collect(); + + vertex_weights.map(|vw| (weight, vw)) } } diff --git a/src/core/mod.rs b/src/core/mod.rs index 3262bae..0036193 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,6 @@ -pub(crate) mod bi_hash_map; +#[cfg(feature = "persistence")] +#[doc(hidden)] +pub mod disk; #[doc(hidden)] pub mod errors; #[doc(hidden)] @@ -9,7 +11,6 @@ pub mod iterator; mod shared; #[doc(hidden)] mod types; -mod utils; #[doc(hidden)] pub mod vertices; @@ -21,11 +22,10 @@ use std::{ Result, }, hash::Hash, - ops::Deref, }; -use bi_hash_map::BiHashMap; use types::{ + AIndexMap, AIndexSet, ARandomState, }; @@ -39,46 +39,28 @@ pub use crate::core::iterator::{ HypergraphBorrowingIterator, HypergraphIterator, }; - -/// Shared Trait for the vertices. -/// Must be implemented to use the library. +#[cfg(feature = "persistence")] +pub use crate::core::disk::PersistentHypergraph; + +/// Trait bound required for vertex weights. +/// +/// Any type that implements `Copy + Debug + Display + Eq + Hash + Send + Sync` +/// satisfies this trait automatically via the blanket impl. You do not need to +/// implement it manually. pub trait VertexTrait: Copy + Debug + Display + Eq + Hash + Send + Sync {} impl VertexTrait for T where T: Copy + Debug + Display + Eq + Hash + Send + Sync {} -/// Shared Trait for the hyperedges. -/// Must be implemented to use the library. +/// Trait bound required for hyperedge weights. +/// +/// In addition to [`VertexTrait`], a hyperedge weight must implement +/// `Into` so that its value can be used as a numeric cost in +/// shortest-path algorithms. The blanket impl covers any type that already +/// satisfies both constraints. pub trait HyperedgeTrait: VertexTrait + Into {} impl HyperedgeTrait for T where T: VertexTrait + Into {} -/// A `HyperedgeKey` is a representation of both the vertices and the weight -/// of a hyperedge, used as a key in the hyperedges set. -/// In a non-simple hypergraph, since the same vertices can be shared by -/// different hyperedges, the weight is also included in the key to keep -/// it unique. -#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] -pub(crate) struct HyperedgeKey { - vertices: Vec, - weight: HE, -} - -impl HyperedgeKey { - /// Creates a new `HyperedgeKey` from the given vertices and weight. - pub(crate) fn new(vertices: Vec, weight: HE) -> HyperedgeKey { - Self { vertices, weight } - } -} - -impl Deref for HyperedgeKey { - type Target = HE; - - fn deref(&self) -> &HE { - &self.weight - } -} - /// A directed hypergraph composed of generic vertices and hyperedges. #[derive(Clone)] #[cfg_attr( @@ -86,31 +68,23 @@ impl Deref for HyperedgeKey { derive(serde::Serialize, serde::Deserialize), serde(bound( serialize = "V: serde::Serialize, HE: serde::Serialize", - deserialize = "V: serde::Deserialize<'de>, HE: serde::Deserialize<'de> + Eq + std::hash::Hash" + deserialize = "V: serde::Deserialize<'de>, HE: serde::Deserialize<'de>" )) )] pub struct Hypergraph { - /// Vertices are stored as a vec of `(weight, hyperedge-index-set)` pairs. - /// Position in the vec is the internal index. Weights are not required to - /// be unique — identity is the stable `VertexIndex`, not the weight. - vertices: Vec<(V, AIndexSet)>, - - /// Hyperedges are stored as a set whose unique keys are a combination of - /// vertices indexes and a weight. Two or more hyperedges can contain - /// the exact same vertices (non-simple hypergraph). - hyperedges: AIndexSet>, + /// Vertices keyed by their stable index. + /// Each entry holds the weight and the set of hyperedge indices that include this vertex. + pub(crate) vertices: AIndexMap)>, - /// Bi-directional map for hyperedges. - hyperedges_mapping: BiHashMap, + /// Hyperedges keyed by their stable index. + /// Each entry holds the ordered vertex list and the weight. + pub(crate) hyperedges: AIndexMap, HE)>, - /// Bi-directional map for vertices. - vertices_mapping: BiHashMap, + /// Monotonically increasing counter used to generate unique [`VertexIndex`] values. + pub(crate) vertices_count: usize, - /// Stable index generation counter for hyperedges. - hyperedges_count: usize, - - /// Stable index generation counter for vertices. - vertices_count: usize, + /// Monotonically increasing counter used to generate unique [`HyperedgeIndex`] values. + pub(crate) hyperedges_count: usize, } impl Debug for Hypergraph @@ -194,17 +168,13 @@ where self.vertices.is_empty() } - /// Clears the hypergraph. + /// Removes all vertices and hyperedges from the hypergraph. + /// + /// Both internal maps are emptied and the monotonic index counters are + /// reset to zero, so the next insertion will start from index `0` again. pub fn clear(&mut self) { - // Clear the hyperedges and vertices sets while keeping their capacities. self.hyperedges.clear(); self.vertices.clear(); - - // Reset the mappings. - self.hyperedges_mapping = BiHashMap::default(); - self.vertices_mapping = BiHashMap::default(); - - // Reset the counters. self.hyperedges_count = 0; self.vertices_count = 0; } @@ -219,12 +189,10 @@ where #[must_use] pub fn with_capacity(vertices: usize, hyperedges: usize) -> Self { Hypergraph { - hyperedges_count: 0, - hyperedges_mapping: BiHashMap::default(), - hyperedges: AIndexSet::with_capacity_and_hasher(hyperedges, ARandomState::default()), + vertices: AIndexMap::with_capacity_and_hasher(vertices, ARandomState::default()), + hyperedges: AIndexMap::with_capacity_and_hasher(hyperedges, ARandomState::default()), vertices_count: 0, - vertices_mapping: BiHashMap::default(), - vertices: Vec::with_capacity(vertices), + hyperedges_count: 0, } } } diff --git a/src/core/shared.rs b/src/core/shared.rs index c3be337..5fdfe1d 100644 --- a/src/core/shared.rs +++ b/src/core/shared.rs @@ -34,25 +34,19 @@ where &self, connections: &Connection, ) -> Result> { - let internal_index = self.get_internal_vertex(match connections { + let vertex_index = match connections { Connection::InAndOut(vertex_index, _) | Connection::In(vertex_index) | Connection::Out(vertex_index) => *vertex_index, - })?; + }; - let (_, hyperedges_index_set) = self - .vertices - .get(internal_index) - .ok_or(HypergraphError::InternalVertexIndexNotFound(internal_index))?; + let hyperedge_indices = self.get_vertex_hyperedges(vertex_index)?; - let hyperedges = - self.get_hyperedges(&hyperedges_index_set.iter().copied().collect_vec())?; - - let hyperedges_with_vertices = hyperedges + let hyperedges_with_vertices = hyperedge_indices .into_par_iter() - .map(|hyperedge_index| { - self.get_hyperedge_vertices(hyperedge_index) - .map(|vertices| (hyperedge_index, vertices)) + .map(|he_index| { + self.get_hyperedge_vertices(he_index) + .map(|vertices| (he_index, vertices)) }) .collect::)>, HypergraphError>>()?; diff --git a/src/core/types.rs b/src/core/types.rs index b878335..0a01564 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -1,5 +1,8 @@ use ahash::RandomState; -use indexmap::IndexSet; +use indexmap::{IndexMap, IndexSet}; + +/// Type alias to use `AHash` as a faster hasher for `IndexMap`. +pub(crate) type AIndexMap = IndexMap; /// Type alias to use `AHash` as a faster hasher for `IndexSet`. pub(crate) type AIndexSet = IndexSet; diff --git a/src/core/utils.rs b/src/core/utils.rs deleted file mode 100644 index 1219212..0000000 --- a/src/core/utils.rs +++ /dev/null @@ -1,23 +0,0 @@ -pub(crate) fn are_slices_equal(a: &[usize], b: &[usize]) -> bool { - a == b -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn check_matching() { - assert!(are_slices_equal(&[], &[])); - assert!(are_slices_equal(&[1], &[1])); - assert!(are_slices_equal(&[1, 2, 3], &[1, 2, 3])); - } - #[test] - fn check_not_matching() { - assert!(!are_slices_equal(&[], &[1])); - assert!(!are_slices_equal(&[1], &[])); - assert!(!are_slices_equal(&[1], &[2])); - assert!(!are_slices_equal(&[1, 2, 3], &[1, 2, 4])); - assert!(!are_slices_equal(&[1, 2, 3], &[1, 2, 3, 4])); - } -} diff --git a/src/core/vertices/add_vertex.rs b/src/core/vertices/add_vertex.rs index 0cd0211..85dc6cd 100644 --- a/src/core/vertices/add_vertex.rs +++ b/src/core/vertices/add_vertex.rs @@ -21,11 +21,12 @@ where /// Duplicate weights are allowed — vertex identity is the returned /// [`VertexIndex`], not the weight value. pub fn add_vertex(&mut self, weight: V) -> Result> { - let internal_index = self.vertices.len(); - self.vertices.push(( - weight, - AIndexSet::with_capacity_and_hasher(0, ARandomState::default()), - )); - Ok(self.add_vertex_index(internal_index)) + let index = VertexIndex(self.vertices_count); + self.vertices_count += 1; + self.vertices.insert( + index, + (weight, AIndexSet::with_capacity_and_hasher(0, ARandomState::default())), + ); + Ok(index) } } diff --git a/src/core/vertices/add_vertex_index.rs b/src/core/vertices/add_vertex_index.rs deleted file mode 100644 index 57f0146..0000000 --- a/src/core/vertices/add_vertex_index.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::{ - HyperedgeTrait, - Hypergraph, - VertexIndex, - VertexTrait, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // This private method is infallible since adding the same vertex - // will return the existing index. - pub(crate) fn add_vertex_index(&mut self, internal_index: usize) -> VertexIndex { - if let Some(vertex_index) = self.vertices_mapping.left.get(&internal_index) { - *vertex_index - } else { - let vertex_index = VertexIndex(self.vertices_count); - - if self - .vertices_mapping - .left - .insert(internal_index, vertex_index) - .is_none() - { - // Update the counter only for the first insertion. - self.vertices_count += 1; - } - - self.vertices_mapping - .right - .insert(vertex_index, internal_index); - - vertex_index - } - } -} diff --git a/src/core/vertices/bfs.rs b/src/core/vertices/bfs.rs index da70c20..9830d64 100644 --- a/src/core/vertices/bfs.rs +++ b/src/core/vertices/bfs.rs @@ -22,7 +22,9 @@ where /// /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not exist. pub fn get_bfs(&self, from: VertexIndex) -> Result, HypergraphError> { - self.get_internal_vertex(from)?; + if !self.vertices.contains_key(&from) { + return Err(HypergraphError::VertexIndexNotFound(from)); + } let mut visited: AHashSet = AHashSet::new(); let mut queue: VecDeque = VecDeque::new(); diff --git a/src/core/vertices/connected_components.rs b/src/core/vertices/connected_components.rs index 6b9a4d9..50919fd 100644 --- a/src/core/vertices/connected_components.rs +++ b/src/core/vertices/connected_components.rs @@ -24,8 +24,7 @@ where /// /// Returns an empty `Vec` for an empty hypergraph. pub fn connected_components(&self) -> Result>, HypergraphError> { - let mut all_vertices: Vec = - self.vertices_mapping.left.values().copied().collect(); + let mut all_vertices: Vec = self.vertices.keys().copied().collect(); all_vertices.sort(); let mut visited: AHashSet = AHashSet::new(); @@ -45,7 +44,6 @@ where while let Some(current) = queue.pop_front() { component.push(current); - // Treat edges as undirected: follow both outgoing and incoming. for neighbor in self.get_adjacent_vertices_from(current)? { if visited.insert(neighbor) { queue.push_back(neighbor); diff --git a/src/core/vertices/contains_vertex.rs b/src/core/vertices/contains_vertex.rs index 9741f22..819907f 100644 --- a/src/core/vertices/contains_vertex.rs +++ b/src/core/vertices/contains_vertex.rs @@ -12,6 +12,6 @@ where /// Returns `true` if at least one vertex with the given weight exists. #[must_use] pub fn contains_vertex(&self, weight: V) -> bool { - self.vertices.iter().any(|(w, _)| *w == weight) + self.vertices.values().any(|(w, _)| *w == weight) } } diff --git a/src/core/vertices/dfs.rs b/src/core/vertices/dfs.rs index eca2f21..0ed543f 100644 --- a/src/core/vertices/dfs.rs +++ b/src/core/vertices/dfs.rs @@ -20,7 +20,9 @@ where /// /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not exist. pub fn get_dfs(&self, from: VertexIndex) -> Result, HypergraphError> { - self.get_internal_vertex(from)?; + if !self.vertices.contains_key(&from) { + return Err(HypergraphError::VertexIndexNotFound(from)); + } let mut visited: AHashSet = AHashSet::new(); let mut stack: Vec = vec![from]; diff --git a/src/core/vertices/get_adjacent_vertices_from.rs b/src/core/vertices/get_adjacent_vertices_from.rs index 6659d31..290fffd 100644 --- a/src/core/vertices/get_adjacent_vertices_from.rs +++ b/src/core/vertices/get_adjacent_vertices_from.rs @@ -14,7 +14,15 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the list of all vertices connected from a given vertex. + /// Returns the unique set of vertices directly reachable from `from` via a + /// directed hyperedge (i.e. vertices that follow `from` in some hyperedge's + /// vertex list). + /// + /// The result is sorted by [`VertexIndex`] and deduplicated. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not exist. pub fn get_adjacent_vertices_from( &self, from: VertexIndex, diff --git a/src/core/vertices/get_adjacent_vertices_to.rs b/src/core/vertices/get_adjacent_vertices_to.rs index 57d57e4..48d016d 100644 --- a/src/core/vertices/get_adjacent_vertices_to.rs +++ b/src/core/vertices/get_adjacent_vertices_to.rs @@ -14,7 +14,15 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the list of all vertices connected to a given vertex. + /// Returns the unique set of vertices that have a directed hyperedge leading + /// into `to` (i.e. vertices that immediately precede `to` in some hyperedge's + /// vertex list). + /// + /// The result is sorted by [`VertexIndex`] and deduplicated. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `to` does not exist. pub fn get_adjacent_vertices_to( &self, to: VertexIndex, diff --git a/src/core/vertices/get_all_paths.rs b/src/core/vertices/get_all_paths.rs index 479f6de..6a7596f 100644 --- a/src/core/vertices/get_all_paths.rs +++ b/src/core/vertices/get_all_paths.rs @@ -29,8 +29,12 @@ where from: VertexIndex, to: VertexIndex, ) -> Result>, HypergraphError> { - self.get_internal_vertex(from)?; - self.get_internal_vertex(to)?; + if !self.vertices.contains_key(&from) { + return Err(HypergraphError::VertexIndexNotFound(from)); + } + if !self.vertices.contains_key(&to) { + return Err(HypergraphError::VertexIndexNotFound(to)); + } if from == to { return Ok(vec![vec![from]]); @@ -41,8 +45,6 @@ where let mut visited: AHashSet = AHashSet::from([from]); // Each frame: (current vertex, its neighbors, next-neighbor index). - // Pushing a frame means we have already added `current` to `current_path` - // and `visited`. Popping a frame undoes both. let mut stack: Vec<(VertexIndex, Vec, usize)> = vec![(from, self.get_adjacent_vertices_from(from)?, 0)]; @@ -51,7 +53,6 @@ where let current = *current; if *idx >= neighbors.len() { - // All neighbors of `current` explored — backtrack. stack.pop(); current_path.pop(); visited.remove(¤t); @@ -69,8 +70,6 @@ where let mut path = current_path.clone(); path.push(to); all_paths.push(path); - // Do not push `to` onto the stack: paths beyond the destination - // are not simple paths to `to`. continue; } diff --git a/src/core/vertices/get_dijkstra_connections.rs b/src/core/vertices/get_dijkstra_connections.rs index a6ba5e9..7cf7a77 100644 --- a/src/core/vertices/get_dijkstra_connections.rs +++ b/src/core/vertices/get_dijkstra_connections.rs @@ -18,16 +18,16 @@ use crate::{ #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Visitor { distance: usize, - index: usize, + index: VertexIndex, } impl Visitor { - fn new(distance: usize, index: usize) -> Self { + fn new(distance: usize, index: VertexIndex) -> Self { Self { distance, index } } } -// Use a custom implementation of Ord as we want a min-heap BinaryHeap. +// Custom Ord for a min-heap by distance, with VertexIndex as tiebreaker. impl Ord for Visitor { fn cmp(&self, other: &Visitor) -> Ordering { other @@ -53,50 +53,45 @@ where HE: HyperedgeTrait, { fn dijkstra_impl(&self, from: VertexIndex, to: VertexIndex) -> DijkstraResult { - let internal_from = self.get_internal_vertex(from)?; - let internal_to = self.get_internal_vertex(to)?; + if !self.vertices.contains_key(&from) { + return Err(HypergraphError::VertexIndexNotFound(from)); + } + if !self.vertices.contains_key(&to) { + return Err(HypergraphError::VertexIndexNotFound(to)); + } - let mut distances: AHashMap = AHashMap::new(); - // Maps each internal vertex index to its (predecessor, hyperedge used to arrive). - let mut predecessors: AHashMap)> = AHashMap::new(); + let mut distances: AHashMap = AHashMap::new(); + let mut predecessors: AHashMap)> = + AHashMap::new(); let mut to_traverse = BinaryHeap::new(); - distances.insert(internal_from, 0); - to_traverse.push(Visitor::new(0, internal_from)); + distances.insert(from, 0); + to_traverse.push(Visitor::new(0, from)); while let Some(Visitor { distance, index }) = to_traverse.pop() { - if index == internal_to { - // Walk the predecessor chain from destination back to source, - // then reverse to get source-to-destination order. - let path = successors(Some(internal_to), |¤t| { - (current != internal_from).then(|| predecessors[¤t].0) + if index == to { + let path = successors(Some(to), |¤t| { + (current != from).then(|| predecessors[¤t].0) }) .collect::>() .into_iter() .rev() - .map(|internal| { - Ok(( - self.get_vertex(internal)?, - predecessors.get(&internal).and_then(|&(_, he)| he), - )) + .map(|v| { + let he = predecessors.get(&v).and_then(|&(_, he)| he); + Ok((v, he)) }) .collect::, HypergraphError>>()?; return Ok((distance, path)); } - // Skip stale heap entries. if distance > distances[&index] { continue; } - let mapped_index = self.get_vertex(index)?; - let neighbors = self.get_full_adjacent_vertices_from(mapped_index)?; + let neighbors = self.get_full_adjacent_vertices_from(index)?; for (vertex_index, hyperedge_indexes) in neighbors { - let internal_neighbor = self.get_internal_vertex(vertex_index)?; - - // Find the minimum-cost hyperedge to this neighbor. let mut min_cost = usize::MAX; let mut best_hyperedge: Option = None; @@ -114,13 +109,13 @@ where let next_distance = distance + min_cost; let is_shorter = distances - .get(&internal_neighbor) + .get(&vertex_index) .is_none_or(|¤t| next_distance < current); if is_shorter { - distances.insert(internal_neighbor, next_distance); - predecessors.insert(internal_neighbor, (index, best_hyperedge)); - to_traverse.push(Visitor::new(next_distance, internal_neighbor)); + distances.insert(vertex_index, next_distance); + predecessors.insert(vertex_index, (index, best_hyperedge)); + to_traverse.push(Visitor::new(next_distance, vertex_index)); } } } diff --git a/src/core/vertices/get_dijkstra_from.rs b/src/core/vertices/get_dijkstra_from.rs index 25d60f3..d75cee2 100644 --- a/src/core/vertices/get_dijkstra_from.rs +++ b/src/core/vertices/get_dijkstra_from.rs @@ -16,11 +16,11 @@ use crate::{ #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Visitor { distance: usize, - index: usize, + index: VertexIndex, } impl Visitor { - fn new(distance: usize, index: usize) -> Self { + fn new(distance: usize, index: VertexIndex) -> Self { Self { distance, index } } } @@ -56,25 +56,24 @@ where &self, from: VertexIndex, ) -> Result, HypergraphError> { - let internal_from = self.get_internal_vertex(from)?; + if !self.vertices.contains_key(&from) { + return Err(HypergraphError::VertexIndexNotFound(from)); + } - let mut distances: AHashMap = AHashMap::new(); + let mut distances: AHashMap = AHashMap::new(); let mut to_traverse = BinaryHeap::new(); - distances.insert(internal_from, 0); - to_traverse.push(Visitor::new(0, internal_from)); + distances.insert(from, 0); + to_traverse.push(Visitor::new(0, from)); while let Some(Visitor { distance, index }) = to_traverse.pop() { if distance > distances[&index] { continue; } - let mapped_index = self.get_vertex(index)?; - let neighbors = self.get_full_adjacent_vertices_from(mapped_index)?; + let neighbors = self.get_full_adjacent_vertices_from(index)?; for (vertex_index, hyperedge_indexes) in neighbors { - let internal_neighbor = self.get_internal_vertex(vertex_index)?; - let mut min_cost = usize::MAX; for hyperedge_index in hyperedge_indexes { let cost: usize = self @@ -88,19 +87,16 @@ where let next_distance = distance + min_cost; let is_shorter = distances - .get(&internal_neighbor) + .get(&vertex_index) .is_none_or(|¤t| next_distance < current); if is_shorter { - distances.insert(internal_neighbor, next_distance); - to_traverse.push(Visitor::new(next_distance, internal_neighbor)); + distances.insert(vertex_index, next_distance); + to_traverse.push(Visitor::new(next_distance, vertex_index)); } } } - distances - .into_iter() - .map(|(internal, dist)| self.get_vertex(internal).map(|stable| (stable, dist))) - .collect() + Ok(distances) } } diff --git a/src/core/vertices/get_full_adjacent_vertices_from.rs b/src/core/vertices/get_full_adjacent_vertices_from.rs index 2bd417e..4dc9298 100644 --- a/src/core/vertices/get_full_adjacent_vertices_from.rs +++ b/src/core/vertices/get_full_adjacent_vertices_from.rs @@ -20,8 +20,18 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the list of all vertices connected from a given vertex as tuples - /// of the form (`VertexIndex`, `Vec`). + /// Returns all vertices directly reachable from `from`, grouped with the + /// hyperedges through which they are reached. + /// + /// Each element of the result is `(neighbor, hyperedges)` where `hyperedges` + /// lists every hyperedge that connects `from` to `neighbor`. Use this + /// over [`get_adjacent_vertices_from`](Self::get_adjacent_vertices_from) when + /// you also need to know which hyperedges carry each connection (e.g. for + /// Dijkstra). + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not exist. pub fn get_full_adjacent_vertices_from( &self, from: VertexIndex, diff --git a/src/core/vertices/get_full_adjacent_vertices_to.rs b/src/core/vertices/get_full_adjacent_vertices_to.rs index 5fe574e..2529c56 100644 --- a/src/core/vertices/get_full_adjacent_vertices_to.rs +++ b/src/core/vertices/get_full_adjacent_vertices_to.rs @@ -20,8 +20,17 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the list of all vertices connected to a given vertex as tuples of - /// the form (`VertexIndex`, `Vec`). + /// Returns all vertices that have a directed connection into `to`, grouped + /// with the hyperedges through which they reach it. + /// + /// Each element of the result is `(predecessor, hyperedges)` where + /// `hyperedges` lists every hyperedge that connects `predecessor` to `to`. + /// This is the incoming-edge counterpart of + /// [`get_full_adjacent_vertices_from`](Self::get_full_adjacent_vertices_from). + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `to` does not exist. pub fn get_full_adjacent_vertices_to( &self, to: VertexIndex, diff --git a/src/core/vertices/get_full_vertex_hyperedges.rs b/src/core/vertices/get_full_vertex_hyperedges.rs index 7336ba5..ac6ae36 100644 --- a/src/core/vertices/get_full_vertex_hyperedges.rs +++ b/src/core/vertices/get_full_vertex_hyperedges.rs @@ -13,7 +13,16 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the hyperedges of a vertex as a vector of vectors of `VertexIndex`. + /// Returns the vertex list of every hyperedge that includes `vertex_index`. + /// + /// Each element of the outer `Vec` is the ordered vertex list of one + /// hyperedge, in the same order as returned by + /// [`get_vertex_hyperedges`](Self::get_vertex_hyperedges). + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `vertex_index` does + /// not exist. pub fn get_full_vertex_hyperedges( &self, vertex_index: VertexIndex, diff --git a/src/core/vertices/get_internal_vertex.rs b/src/core/vertices/get_internal_vertex.rs deleted file mode 100644 index 4acd7fe..0000000 --- a/src/core/vertices/get_internal_vertex.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::{ - HyperedgeTrait, - Hypergraph, - VertexIndex, - VertexTrait, - errors::HypergraphError, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // Private method to get the internal vertex matching a VertexIndex. - pub(crate) fn get_internal_vertex( - &self, - vertex_index: VertexIndex, - ) -> Result> { - match self.vertices_mapping.right.get(&vertex_index) { - Some(index) => Ok(*index), - None => Err(HypergraphError::VertexIndexNotFound(vertex_index)), - } - } -} diff --git a/src/core/vertices/get_internal_vertices.rs b/src/core/vertices/get_internal_vertices.rs deleted file mode 100644 index 1e3bad7..0000000 --- a/src/core/vertices/get_internal_vertices.rs +++ /dev/null @@ -1,27 +0,0 @@ -use rayon::prelude::*; - -use crate::{ - HyperedgeTrait, - Hypergraph, - VertexIndex, - VertexTrait, - errors::HypergraphError, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // Private method to get the internal vertices from a vector of VertexIndex. - pub(crate) fn get_internal_vertices>>( - &self, - vertices: R, - ) -> Result, HypergraphError> { - vertices - .as_ref() - .par_iter() - .map(|vertex_index| self.get_internal_vertex(*vertex_index)) - .collect() - } -} diff --git a/src/core/vertices/get_vertex.rs b/src/core/vertices/get_vertex.rs deleted file mode 100644 index ce97b06..0000000 --- a/src/core/vertices/get_vertex.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::{ - HyperedgeTrait, - Hypergraph, - VertexIndex, - VertexTrait, - errors::HypergraphError, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // Private method to get the VertexIndex matching an internal index. - pub(crate) fn get_vertex( - &self, - vertex_index: usize, - ) -> Result> { - match self.vertices_mapping.left.get(&vertex_index) { - Some(index) => Ok(*index), - None => Err(HypergraphError::InternalVertexIndexNotFound(vertex_index)), - } - } -} diff --git a/src/core/vertices/get_vertex_degree_in.rs b/src/core/vertices/get_vertex_degree_in.rs index cbdfe89..b8d3553 100644 --- a/src/core/vertices/get_vertex_degree_in.rs +++ b/src/core/vertices/get_vertex_degree_in.rs @@ -12,8 +12,15 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the in-degree of a vertex. - /// + /// Returns the in-degree of the vertex at `to`. + /// + /// The in-degree is the number of directed connections that arrive at `to` + /// across all hyperedges (counting each `predecessor → to` pair once per + /// hyperedge). See . + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `to` does not exist. pub fn get_vertex_degree_in(&self, to: VertexIndex) -> Result> { let results = self.get_connections(&Connection::Out(to))?; diff --git a/src/core/vertices/get_vertex_degree_out.rs b/src/core/vertices/get_vertex_degree_out.rs index b067a75..77e4ba6 100644 --- a/src/core/vertices/get_vertex_degree_out.rs +++ b/src/core/vertices/get_vertex_degree_out.rs @@ -12,8 +12,15 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the out-degree of a vertex. - /// + /// Returns the out-degree of the vertex at `from`. + /// + /// The out-degree is the number of directed connections that leave `from` + /// across all hyperedges (counting each `from → successor` pair once per + /// hyperedge). See . + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not exist. pub fn get_vertex_degree_out( &self, from: VertexIndex, diff --git a/src/core/vertices/get_vertex_hyperedges.rs b/src/core/vertices/get_vertex_hyperedges.rs index e743f9b..4d7e83b 100644 --- a/src/core/vertices/get_vertex_hyperedges.rs +++ b/src/core/vertices/get_vertex_hyperedges.rs @@ -1,5 +1,3 @@ -use itertools::Itertools; - use crate::{ HyperedgeIndex, HyperedgeTrait, @@ -14,18 +12,19 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the hyperedges of a vertex as a vector of `HyperedgeIndex`. + /// Returns the indices of all hyperedges that include `vertex_index`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `vertex_index` does + /// not exist. pub fn get_vertex_hyperedges( &self, vertex_index: VertexIndex, ) -> Result, HypergraphError> { - let internal_index = self.get_internal_vertex(vertex_index)?; - - let (_, hyperedges_index_set) = self - .vertices - .get(internal_index) - .ok_or(HypergraphError::InternalVertexIndexNotFound(internal_index))?; - - self.get_hyperedges(&hyperedges_index_set.iter().copied().collect_vec()) + self.vertices + .get(&vertex_index) + .map(|(_, he_set)| he_set.iter().copied().collect()) + .ok_or(HypergraphError::VertexIndexNotFound(vertex_index)) } } diff --git a/src/core/vertices/get_vertex_index.rs b/src/core/vertices/get_vertex_index.rs index 4ebd3b1..89b1d45 100644 --- a/src/core/vertices/get_vertex_index.rs +++ b/src/core/vertices/get_vertex_index.rs @@ -20,10 +20,7 @@ where pub fn get_vertex_index(&self, weight: V) -> Vec { self.vertices .iter() - .enumerate() - .filter_map(|(internal, (w, _))| { - (*w == weight).then(|| self.vertices_mapping.left.get(&internal).copied())? - }) + .filter_map(|(&idx, (w, _))| (*w == weight).then_some(idx)) .collect() } } diff --git a/src/core/vertices/get_vertex_weight.rs b/src/core/vertices/get_vertex_weight.rs index 45bb635..2129695 100644 --- a/src/core/vertices/get_vertex_weight.rs +++ b/src/core/vertices/get_vertex_weight.rs @@ -11,16 +11,19 @@ where V: VertexTrait, HE: HyperedgeTrait, { - /// Gets the weight of a vertex from its index. + /// Returns a reference to the weight of the vertex at `vertex_index`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `vertex_index` does + /// not exist. pub fn get_vertex_weight( &self, vertex_index: VertexIndex, ) -> Result<&V, HypergraphError> { - let internal_index = self.get_internal_vertex(vertex_index)?; - self.vertices - .get(internal_index) + .get(&vertex_index) .map(|(weight, _)| weight) - .ok_or(HypergraphError::InternalVertexIndexNotFound(internal_index)) + .ok_or(HypergraphError::VertexIndexNotFound(vertex_index)) } } diff --git a/src/core/vertices/get_vertices.rs b/src/core/vertices/get_vertices.rs deleted file mode 100644 index 4a72cba..0000000 --- a/src/core/vertices/get_vertices.rs +++ /dev/null @@ -1,26 +0,0 @@ -use rayon::prelude::*; - -use crate::{ - HyperedgeTrait, - Hypergraph, - VertexIndex, - VertexTrait, - errors::HypergraphError, -}; - -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - // Private method to get a vector of VertexIndex from a vector of internal indexes. - pub(crate) fn get_vertices( - &self, - vertices: &[usize], - ) -> Result, HypergraphError> { - vertices - .par_iter() - .map(|vertex_index| self.get_vertex(*vertex_index)) - .collect() - } -} diff --git a/src/core/vertices/is_reachable.rs b/src/core/vertices/is_reachable.rs index 0a66d81..d8abc3a 100644 --- a/src/core/vertices/is_reachable.rs +++ b/src/core/vertices/is_reachable.rs @@ -25,8 +25,12 @@ where from: VertexIndex, to: VertexIndex, ) -> Result> { - self.get_internal_vertex(from)?; - self.get_internal_vertex(to)?; + if !self.vertices.contains_key(&from) { + return Err(HypergraphError::VertexIndexNotFound(from)); + } + if !self.vertices.contains_key(&to) { + return Err(HypergraphError::VertexIndexNotFound(to)); + } if from == to { return Ok(true); diff --git a/src/core/vertices/mod.rs b/src/core/vertices/mod.rs index 484fb1c..b5cafb4 100644 --- a/src/core/vertices/mod.rs +++ b/src/core/vertices/mod.rs @@ -1,9 +1,3 @@ -pub(crate) mod add_vertex_index; -pub(crate) mod get_internal_vertex; -pub(crate) mod get_internal_vertices; -pub(crate) mod get_vertex; -pub(crate) mod get_vertices; - pub mod add_vertex; pub mod bfs; pub mod connected_components; diff --git a/src/core/vertices/remove_vertex.rs b/src/core/vertices/remove_vertex.rs index 69489cb..7f99dd7 100644 --- a/src/core/vertices/remove_vertex.rs +++ b/src/core/vertices/remove_vertex.rs @@ -1,5 +1,4 @@ use crate::{ - HyperedgeKey, HyperedgeTrait, Hypergraph, VertexIndex, @@ -13,110 +12,41 @@ where HE: HyperedgeTrait, { /// Removes a vertex by index. + /// + /// All hyperedges that contain only this vertex are removed. Hyperedges + /// that contain other vertices are updated with the vertex filtered out. pub fn remove_vertex( &mut self, vertex_index: VertexIndex, ) -> Result<(), HypergraphError> { - let internal_index = self.get_internal_vertex(vertex_index)?; + // Collect the hyperedge indices upfront before any mutation. + let he_indices = self.get_vertex_hyperedges(vertex_index)?; - // Get the hyperedges of the vertex. - let hyperedges = - self.get_internal_hyperedges(&self.get_vertex_hyperedges(vertex_index)?)?; - - // Remove the vertex from the hyperedges which contain it. - for hyperedge in hyperedges { - let HyperedgeKey { vertices, .. } = self + for he_index in he_indices { + let vertices = self .hyperedges - .get_index(hyperedge) - .cloned() - .ok_or(HypergraphError::InternalHyperedgeIndexNotFound(hyperedge))?; - - let hyperedge_index = self.get_hyperedge(hyperedge)?; + .get(&he_index) + .map(|(v, _)| v.clone()) + .ok_or(HypergraphError::HyperedgeIndexNotFound(he_index))?; - // Get the unique vertices, i.e. check for self-loops. - let mut unique_vertices = vertices.clone(); + // Determine if this vertex is the sole unique vertex in the hyperedge. + let mut unique_verts = vertices.clone(); + unique_verts.sort_unstable(); + unique_verts.dedup(); - unique_vertices.sort_unstable(); - unique_vertices.dedup(); - - // Remove the hyperedge if the vertex is the only one present. - if unique_vertices.len() == 1 { - self.remove_hyperedge(hyperedge_index)?; + if unique_verts.len() == 1 { + self.remove_hyperedge(he_index)?; } else { - // Otherwise update the hyperedge with the updated vertices. - let updated_vertices = self.get_vertices( - &vertices - .into_iter() - .filter(|vertex| *vertex != internal_index) - .collect::>(), - )?; - - self.update_hyperedge_vertices(hyperedge_index, updated_vertices)?; + let updated: Vec = + vertices.into_iter().filter(|&v| v != vertex_index).collect(); + self.update_hyperedge_vertices(he_index, updated)?; } } - // Find the last index. - let last_index = self.vertices.len() - 1; - - // Swap and remove by index. - self.vertices.swap_remove(internal_index); - - // Update the mapping for the removed vertex. - self.vertices_mapping.left.remove(&internal_index); - self.vertices_mapping.right.remove(&vertex_index); - - // If the index to remove wasn't the last one, the last vertex has - // been swapped in place of the removed one. See the remove_hyperedge - // method for more details about the internals. - if internal_index != last_index { - // Get the index of the swapped vertex. - let swapped_vertex_index = self.get_vertex(last_index)?; - - // Proceed with the aforementioned operations. - self.vertices_mapping - .right - .insert(swapped_vertex_index, internal_index); - self.vertices_mapping.left.remove(&last_index); - self.vertices_mapping - .left - .insert(internal_index, swapped_vertex_index); - - let stale_hyperedges = - self.get_internal_hyperedges(&self.get_vertex_hyperedges(swapped_vertex_index)?)?; - - // Update the impacted hyperedges accordingly. - for hyperedge in stale_hyperedges { - let HyperedgeKey { vertices, weight } = self - .hyperedges - .get_index(hyperedge) - .ok_or(HypergraphError::InternalHyperedgeIndexNotFound(hyperedge))?; - - let updated_vertices = vertices - .iter() - .map(|vertex| { - // Remap the vertex if this is the swapped one. - if *vertex == last_index { - internal_index - } else { - *vertex - } - }) - .collect(); - - // Insert the new entry with the updated vertices. - // Since we are not altering the weight, we can safely perform - // the operation without checking its output. - self.hyperedges - .insert(HyperedgeKey::new(updated_vertices, *weight)); - - // Swap and remove by index. - // Since we know that the hyperedge index is correct, we can - // safely perform the operation without checking its output. - self.hyperedges.swap_remove_index(hyperedge); - } - } + self.vertices + .swap_remove(&vertex_index) + .ok_or(HypergraphError::VertexIndexNotFound(vertex_index))?; - // Return a unit. Ok(()) } } diff --git a/src/core/vertices/strongly_connected_components.rs b/src/core/vertices/strongly_connected_components.rs index 602d6b5..bc0d670 100644 --- a/src/core/vertices/strongly_connected_components.rs +++ b/src/core/vertices/strongly_connected_components.rs @@ -25,8 +25,7 @@ where pub fn strongly_connected_components( &self, ) -> Result>, HypergraphError> { - let mut all_vertices: Vec = - self.vertices_mapping.left.values().copied().collect(); + let mut all_vertices: Vec = self.vertices.keys().copied().collect(); all_vertices.sort(); // Phase 1 — iterative DFS on the original graph; record finish order. @@ -38,8 +37,6 @@ where continue; } - // Each stack entry: (vertex, exiting). - // Push (v, false) to enter v, then (v, true) to record finish. let mut stack: Vec<(VertexIndex, bool)> = vec![(start, false)]; while let Some((v, exiting)) = stack.pop() { @@ -60,8 +57,6 @@ where } // Phase 2 — iterative DFS on the transposed graph in reverse finish order. - // The transposed graph is traversed by following incoming edges via - // `get_adjacent_vertices_to`. let mut visited2: AHashSet = AHashSet::new(); let mut sccs: Vec> = Vec::new(); diff --git a/src/core/vertices/topological_sort.rs b/src/core/vertices/topological_sort.rs index b96e1b8..5eaad6e 100644 --- a/src/core/vertices/topological_sort.rs +++ b/src/core/vertices/topological_sort.rs @@ -26,12 +26,9 @@ where /// Returns [`HypergraphError::HypergraphContainsCycle`] if the hypergraph /// contains a cycle. pub fn topological_sort(&self) -> Result, HypergraphError> { - // Collect all stable vertex indexes. - let all_vertices: Vec = self.vertices_mapping.left.values().copied().collect(); - + let all_vertices: Vec = self.vertices.keys().copied().collect(); let vertex_count = all_vertices.len(); - // Compute in-degree for each vertex (number of directed edges arriving at it). let mut in_degree: AHashMap = all_vertices.iter().map(|&v| (v, 0)).collect(); @@ -41,7 +38,6 @@ where } } - // Seed the min-heap with all zero-in-degree vertices. let mut heap: BinaryHeap> = in_degree .iter() .filter_map(|(&v, °)| (deg == 0).then_some(Reverse(v))) diff --git a/src/core/vertices/update_vertex_weight.rs b/src/core/vertices/update_vertex_weight.rs index 7facefd..48cd3d0 100644 --- a/src/core/vertices/update_vertex_weight.rs +++ b/src/core/vertices/update_vertex_weight.rs @@ -21,26 +21,21 @@ where vertex_index: VertexIndex, weight: V, ) -> Result<(), HypergraphError> { - let internal_index = self.get_internal_vertex(vertex_index)?; - - let (previous_weight, index_set) = self + let (current_weight, _) = self .vertices - .get(internal_index) - .map(|(w, s)| (*w, s.clone())) - .ok_or(HypergraphError::InternalVertexIndexNotFound(internal_index))?; + .get(&vertex_index) + .ok_or(HypergraphError::VertexIndexNotFound(vertex_index))?; - if weight == previous_weight { + if weight == *current_weight { return Err(HypergraphError::VertexWeightUnchanged { index: vertex_index, weight, }); } - // Append the new entry at the end, then swap-remove the old position so - // that the new entry lands at `internal_index`. The BiHashMap mapping is - // unchanged — `internal_index` still maps to `vertex_index`. - self.vertices.push((weight, index_set)); - self.vertices.swap_remove(internal_index); + if let Some((w, _)) = self.vertices.get_mut(&vertex_index) { + *w = weight; + } Ok(()) } diff --git a/src/lib.rs b/src/lib.rs index fe3371c..3994b69 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,70 @@ //! - Filtered views: `retain_vertices`, `retain_hyperedges` //! - Optional **`serde`** feature for serialization/deserialization support //! (enable with `features = ["serde"]` in `Cargo.toml`) +//! - Optional **`persistence`** feature for disk-backed graphs larger than RAM +//! (enable with `features = ["persistence"]` in `Cargo.toml`) +//! +//! ## Persistent disk-backed graphs +//! +//! Enable the `persistence` feature to unlock [`PersistentHypergraph`], a variant +//! backed by an [LSM-tree](https://en.wikipedia.org/wiki/Log-structured_merge-tree) +//! (via [fjall](https://github.com/fjall-rs/fjall)) with an in-memory hot-data +//! cache. It supports graphs that exceed available RAM and survives process +//! restarts without any manual serialization step. +//! +//! ```toml +//! # Cargo.toml +//! [dependencies] +//! hypergraph = { version = "*", features = ["persistence"] } +//! ``` +//! +//! ### Vertex and hyperedge types +//! +//! In addition to the usual [`VertexTrait`] / [`HyperedgeTrait`] bounds, both +//! types must implement `serde::Serialize + serde::DeserializeOwned` so they +//! can be encoded to disk. +//! +//! ### Opening a graph +//! +//! [`PersistentHypergraph::open`] creates the database directory if it does not +//! exist, or recovers all data if it does. +//! +//! ```ignore +//! use std::sync::Arc; +//! use hypergraph::PersistentHypergraph; +//! +//! // Open (or create) a persistent graph. +//! let g = Arc::new(PersistentHypergraph::::open("/var/data/my-graph")?); +//! +//! // All write methods take &self, so the Arc can be shared across threads. +//! let g2 = Arc::clone(&g); +//! std::thread::spawn(move || -> anyhow::Result<()> { +//! g2.add_vertex(my_vertex)?; +//! Ok(()) +//! }); +//! ``` +//! +//! ### Thread safety +//! +//! [`PersistentHypergraph`] is `Send + Sync`. All write methods take `&self` and use +//! atomic counters internally, so the same instance can be shared across threads +//! via an `Arc` without an external `Mutex`. +//! +//! > **Note**: individual multi-step operations such as `add_hyperedge` are not +//! > serializable with respect to concurrent writers. If full operation-level +//! > isolation is required, wrap the `Arc` in a `Mutex`. +//! +//! ### Persistence +//! +//! Writes are appended to the WAL immediately and are durable on process crash. +//! Call [`PersistentHypergraph::persist`] to additionally fsync to the physical +//! medium when you need a hard durability guarantee. +//! +//! ### Cache capacity +//! +//! By default the hot-data cache holds up to 10 000 entries per layer (vertices +//! and hyperedges). Use [`PersistentHypergraph::open_with_capacity`] to tune this for +//! your workload. //! //! ## Example //! @@ -137,7 +201,7 @@ //! assert_eq!(graph.get_full_vertex_hyperedges(VertexIndex(0)), Ok(vec![vec![faarooq, ava, ghanda], vec![faarooq, ava, ghanda], vec![ewan, ava, bianca]])); //! //! // Get the intersection of some hyperedges. -//! assert_eq!(graph.get_hyperedges_intersections(vec![second_relation, third_relation]), Ok(vec![ava])); +//! assert_eq!(graph.get_hyperedges_intersections(&[second_relation, third_relation]), Ok(vec![ava])); //! //! // Find a hyperedge containing a connection between two vertices. //! assert_eq!(graph.get_hyperedges_connecting(bianca, bianca), Ok(vec![fifth_relation])); diff --git a/tests/integration_disk.rs b/tests/integration_disk.rs new file mode 100644 index 0000000..d8ebcef --- /dev/null +++ b/tests/integration_disk.rs @@ -0,0 +1,339 @@ +//! Integration tests for the `persistence` feature and `PersistentHypergraph`. + +#![deny(unsafe_code, nonstandard_style)] +#![allow(missing_docs)] + +#[cfg(feature = "persistence")] +mod disk_tests { + use std::fmt::{Display, Formatter, Result}; + + use hypergraph::{PersistentHypergraph, HyperedgeIndex, VertexIndex}; + + // ────────────────────────────────────────────────────────────────────── + // Minimal vertex / hyperedge types + // ────────────────────────────────────────────────────────────────────── + + #[derive( + Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize, + )] + struct V(u32); + + impl Display for V { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + write!(f, "V({})", self.0) + } + } + + #[derive( + Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize, + )] + struct HE(u32); + + impl Display for HE { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + write!(f, "HE({})", self.0) + } + } + + impl From for usize { + fn from(HE(cost): HE) -> Self { + cost as usize + } + } + + fn open_temp() -> (PersistentHypergraph, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir"); + let g = PersistentHypergraph::open(dir.path()).expect("open disk graph"); + (g, dir) + } + + // ────────────────────────────────────────────────────────────────────── + // Tests + // ────────────────────────────────────────────────────────────────────── + + #[test] + fn empty_graph() { + let (g, _dir) = open_temp(); + assert!(g.is_empty()); + assert_eq!(g.count_vertices(), 0); + assert_eq!(g.count_hyperedges(), 0); + } + + #[test] + fn add_and_get_vertices() { + let (mut g, _dir) = open_temp(); + + let v0 = g.add_vertex(V(10)).expect("add v0"); + let v1 = g.add_vertex(V(20)).expect("add v1"); + let v2 = g.add_vertex(V(30)).expect("add v2"); + + assert_eq!(v0, VertexIndex(0)); + assert_eq!(v1, VertexIndex(1)); + assert_eq!(v2, VertexIndex(2)); + + assert_eq!(g.count_vertices(), 3); + assert_eq!(g.get_vertex_weight(v0).expect("v0"), V(10)); + assert_eq!(g.get_vertex_weight(v1).expect("v1"), V(20)); + assert_eq!(g.get_vertex_weight(v2).expect("v2"), V(30)); + } + + #[test] + fn update_vertex_weight() { + let (mut g, _dir) = open_temp(); + let v0 = g.add_vertex(V(1)).expect("add"); + + g.update_vertex_weight(v0, V(99)).expect("update"); + assert_eq!(g.get_vertex_weight(v0).expect("get"), V(99)); + } + + #[test] + fn update_vertex_weight_unchanged_returns_error() { + let (mut g, _dir) = open_temp(); + let v0 = g.add_vertex(V(42)).expect("add"); + assert!(g.update_vertex_weight(v0, V(42)).is_err()); + } + + #[test] + fn add_and_get_hyperedges() { + let (mut g, _dir) = open_temp(); + + let v0 = g.add_vertex(V(0)).expect("v0"); + let v1 = g.add_vertex(V(1)).expect("v1"); + let v2 = g.add_vertex(V(2)).expect("v2"); + + let he0 = g.add_hyperedge(&[v0, v1, v2], HE(5)).expect("he0"); + let he1 = g.add_hyperedge(&[v1, v2], HE(3)).expect("he1"); + + assert_eq!(he0, HyperedgeIndex(0)); + assert_eq!(he1, HyperedgeIndex(1)); + + assert_eq!(g.count_hyperedges(), 2); + assert_eq!(g.get_hyperedge_weight(he0).expect("he0 weight"), HE(5)); + assert_eq!( + g.get_hyperedge_vertices(he0).expect("he0 verts"), + vec![v0, v1, v2] + ); + assert_eq!( + g.get_hyperedge_vertices(he1).expect("he1 verts"), + vec![v1, v2] + ); + } + + #[test] + fn vertex_hyperedge_refs_are_maintained() { + let (mut g, _dir) = open_temp(); + + let v0 = g.add_vertex(V(0)).expect("v0"); + let v1 = g.add_vertex(V(1)).expect("v1"); + + let he0 = g.add_hyperedge(&[v0, v1], HE(1)).expect("he0"); + let he1 = g.add_hyperedge(&[v0], HE(2)).expect("he1"); + + let v0_hes = g.get_vertex_hyperedges(v0).expect("v0 hes"); + assert!(v0_hes.contains(&he0)); + assert!(v0_hes.contains(&he1)); + + let v1_hes = g.get_vertex_hyperedges(v1).expect("v1 hes"); + assert!(v1_hes.contains(&he0)); + assert!(!v1_hes.contains(&he1)); + } + + #[test] + fn remove_hyperedge_cleans_vertex_refs() { + let (mut g, _dir) = open_temp(); + + let v0 = g.add_vertex(V(0)).expect("v0"); + let v1 = g.add_vertex(V(1)).expect("v1"); + let he0 = g.add_hyperedge(&[v0, v1], HE(1)).expect("he0"); + + g.remove_hyperedge(he0).expect("remove"); + + assert_eq!(g.count_hyperedges(), 0); + assert!(g.get_vertex_hyperedges(v0).expect("v0 hes").is_empty()); + assert!(g.get_vertex_hyperedges(v1).expect("v1 hes").is_empty()); + } + + #[test] + fn remove_vertex_also_removes_solo_hyperedges() { + let (mut g, _dir) = open_temp(); + + let v0 = g.add_vertex(V(0)).expect("v0"); + let v1 = g.add_vertex(V(1)).expect("v1"); + let _he_solo = g.add_hyperedge(&[v0], HE(1)).expect("solo"); + let _he_shared = g.add_hyperedge(&[v0, v1], HE(2)).expect("shared"); + + g.remove_vertex(v0).expect("remove v0"); + + assert_eq!(g.count_vertices(), 1); + // Solo HE must be gone; shared HE now has only v1. + assert_eq!(g.count_hyperedges(), 1); + let remaining = g.hyperedge_indices().expect("indices"); + let verts = g.get_hyperedge_vertices(remaining[0]).expect("verts"); + assert_eq!(verts, vec![v1]); + } + + #[test] + fn update_hyperedge_vertices() { + let (mut g, _dir) = open_temp(); + + let v0 = g.add_vertex(V(0)).expect("v0"); + let v1 = g.add_vertex(V(1)).expect("v1"); + let v2 = g.add_vertex(V(2)).expect("v2"); + let he0 = g.add_hyperedge(&[v0, v1], HE(1)).expect("he0"); + + g.update_hyperedge_vertices(he0, &[v0, v2]).expect("update"); + assert_eq!( + g.get_hyperedge_vertices(he0).expect("verts"), + vec![v0, v2] + ); + + // v1 should no longer reference he0. + assert!(g.get_vertex_hyperedges(v1).expect("v1 hes").is_empty()); + // v2 should now reference he0. + assert!(g.get_vertex_hyperedges(v2).expect("v2 hes").contains(&he0)); + } + + #[test] + fn update_hyperedge_weight() { + let (mut g, _dir) = open_temp(); + let v0 = g.add_vertex(V(0)).expect("v0"); + let he0 = g.add_hyperedge(&[v0], HE(1)).expect("he0"); + + g.update_hyperedge_weight(he0, HE(99)).expect("update"); + assert_eq!(g.get_hyperedge_weight(he0).expect("weight"), HE(99)); + } + + #[test] + fn clear_empties_graph() { + let (mut g, _dir) = open_temp(); + + let v0 = g.add_vertex(V(0)).expect("v0"); + g.add_hyperedge(&[v0], HE(1)).expect("he"); + + g.clear().expect("clear"); + + assert_eq!(g.count_vertices(), 0); + assert_eq!(g.count_hyperedges(), 0); + assert!(g.vertex_indices().expect("vis").is_empty()); + } + + #[test] + fn persistence_survives_reopen() { + let dir = tempfile::tempdir().expect("temp dir"); + + // Write data. + { + let mut g: PersistentHypergraph = + PersistentHypergraph::open(dir.path()).expect("open"); + let v0 = g.add_vertex(V(7)).expect("v0"); + let v1 = g.add_vertex(V(8)).expect("v1"); + g.add_hyperedge(&[v0, v1], HE(42)).expect("he"); + g.persist().expect("persist"); + } + + // Re-open and verify data is present. + { + let g: PersistentHypergraph = + PersistentHypergraph::open(dir.path()).expect("reopen"); + assert_eq!(g.count_vertices(), 2); + assert_eq!(g.count_hyperedges(), 1); + assert_eq!(g.get_vertex_weight(VertexIndex(0)).expect("v0"), V(7)); + assert_eq!(g.get_vertex_weight(VertexIndex(1)).expect("v1"), V(8)); + assert_eq!( + g.get_hyperedge_weight(HyperedgeIndex(0)).expect("he0"), + HE(42) + ); + } + } + + #[test] + fn not_found_errors() { + let (g, _dir) = open_temp(); + assert!(g.get_vertex_weight(VertexIndex(999)).is_err()); + assert!(g.get_hyperedge_weight(HyperedgeIndex(999)).is_err()); + } + + #[test] + fn debug_impl() { + let (g, _dir) = open_temp(); + let repr = format!("{g:?}"); + assert!(repr.contains("PersistentHypergraph")); + } + + #[test] + fn concurrent_vertex_adds_produce_unique_indices() { + use std::sync::Arc; + + let dir = tempfile::tempdir().expect("temp dir"); + let g = Arc::new(PersistentHypergraph::::open(dir.path()).expect("open")); + + const THREADS: usize = 8; + const PER_THREAD: usize = 25; + + let handles: Vec<_> = (0..THREADS) + .map(|t| { + let g = Arc::clone(&g); + std::thread::spawn(move || { + (0..PER_THREAD) + .map(|i| { + g.add_vertex(V((t * PER_THREAD + i) as u32)) + .expect("add_vertex") + }) + .collect::>() + }) + }) + .collect(); + + let mut all_indices: Vec = handles + .into_iter() + .flat_map(|h| h.join().expect("thread panicked")) + .collect(); + + let total = THREADS * PER_THREAD; + assert_eq!(all_indices.len(), total); + all_indices.sort_unstable(); + all_indices.dedup(); + assert_eq!(all_indices.len(), total, "duplicate vertex indices detected"); + assert_eq!(g.count_vertices(), total); + } + + #[test] + fn concurrent_hyperedge_adds_produce_unique_indices() { + use std::sync::Arc; + + let dir = tempfile::tempdir().expect("temp dir"); + let g = Arc::new(PersistentHypergraph::::open(dir.path()).expect("open")); + + let v0 = g.add_vertex(V(0)).expect("v0"); + let v1 = g.add_vertex(V(1)).expect("v1"); + + const THREADS: usize = 8; + const PER_THREAD: usize = 25; + + let handles: Vec<_> = (0..THREADS) + .map(|t| { + let g = Arc::clone(&g); + std::thread::spawn(move || { + (0..PER_THREAD) + .map(|i| { + g.add_hyperedge(&[v0, v1], HE((t * PER_THREAD + i) as u32)) + .expect("add_hyperedge") + }) + .collect::>() + }) + }) + .collect(); + + let mut all_indices: Vec = handles + .into_iter() + .flat_map(|h| h.join().expect("thread panicked")) + .collect(); + + let total = THREADS * PER_THREAD; + assert_eq!(all_indices.len(), total); + all_indices.sort_unstable(); + all_indices.dedup(); + assert_eq!(all_indices.len(), total, "duplicate hyperedge indices detected"); + assert_eq!(g.count_hyperedges(), total); + } +} diff --git a/tests/integration_main.rs b/tests/integration_main.rs index bd305f9..5a4e521 100644 --- a/tests/integration_main.rs +++ b/tests/integration_main.rs @@ -325,12 +325,12 @@ fn integration_main() { // Check hyperedges intersections. assert_eq!( - graph.get_hyperedges_intersections(vec![HyperedgeIndex(0), HyperedgeIndex(2)]), + graph.get_hyperedges_intersections(&[HyperedgeIndex(0), HyperedgeIndex(2)]), Ok(vec![VertexIndex(0), VertexIndex(3)]), "should get two intersections" ); assert_eq!( - graph.get_hyperedges_intersections(vec![ + graph.get_hyperedges_intersections(&[ HyperedgeIndex(0), HyperedgeIndex(2), HyperedgeIndex(3) @@ -339,22 +339,22 @@ fn integration_main() { "should get one intersection" ); assert_eq!( - graph.get_hyperedges_intersections(vec![HyperedgeIndex(0), HyperedgeIndex(0),]), + graph.get_hyperedges_intersections(&[HyperedgeIndex(0), HyperedgeIndex(0),]), Ok(vec![VertexIndex(0), VertexIndex(1), VertexIndex(3)]), "should return all the vertices of a hyperedge intersecting itself" ); assert_eq!( - graph.get_hyperedges_intersections(vec![]), + graph.get_hyperedges_intersections(&[]), Err(HypergraphError::HyperedgesInvalidIntersections), "should fail since computing the intersections of less than two hyperedges is not possible" ); assert_eq!( - graph.get_hyperedges_intersections(vec![HyperedgeIndex(0)]), + graph.get_hyperedges_intersections(&[HyperedgeIndex(0)]), Err(HypergraphError::HyperedgesInvalidIntersections), "should fail since computing the intersections of less than two hyperedges is not possible" ); assert_eq!( - graph.get_hyperedges_intersections(vec![HyperedgeIndex(5), HyperedgeIndex(6)]), + graph.get_hyperedges_intersections(&[HyperedgeIndex(5), HyperedgeIndex(6)]), Err(HypergraphError::HyperedgeIndexNotFound(HyperedgeIndex(5))), "should be out-of-bound and return an explicit error" ); From 7340c3f22f6a65dd9296fc42de76f2a9bd6b12df Mon Sep 17 00:00:00 2001 From: Davy Duperron Date: Sun, 24 May 2026 23:31:15 +0200 Subject: [PATCH 2/5] chore: switch to pedantic-only clippy and fix lints --- Cargo.toml | 3 +- benches/performance.rs | 16 ++++----- tests/integration_contraction.rs | 2 ++ tests/integration_dijkstra.rs | 2 ++ tests/integration_disk.rs | 55 ++++++++++++++----------------- tests/integration_iterator.rs | 2 ++ tests/integration_join.rs | 2 ++ tests/integration_main.rs | 2 ++ tests/integration_new_features.rs | 37 +++++++++++---------- 9 files changed, 62 insertions(+), 59 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e291301..f65eca7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,5 +45,4 @@ unreachable_pub = "warn" unsafe_code = "deny" [lints.clippy] -all = "deny" -pedantic = "deny" +pedantic = { level = "deny", priority = -1 } diff --git a/benches/performance.rs b/benches/performance.rs index 614c31a..b9c9cb2 100644 --- a/benches/performance.rs +++ b/benches/performance.rs @@ -1,4 +1,3 @@ -#![deny(unsafe_code, nonstandard_style)] #![allow(missing_docs)] use std::fmt::{ @@ -68,23 +67,24 @@ fn criterion_benchmark(criterion: &mut Criterion) { let graph = build_graph(); criterion.bench_function("get-hyperedge-vertices", |bencher| { - bencher.iter(|| graph.get_hyperedge_vertices(HyperedgeIndex(VERTICES / 2))) + bencher.iter(|| graph.get_hyperedge_vertices(HyperedgeIndex(VERTICES / 2))); }); criterion.bench_function("get-hyperedges-connecting", |bencher| { bencher.iter(|| { graph .get_hyperedges_connecting(VertexIndex(VERTICES / 2), VertexIndex(VERTICES / 2 + 1)) - }) + }); }); criterion.bench_function("get-hyperedges-intersections", |bencher| { - bencher - .iter(|| graph.get_hyperedges_intersections((0..10).map(HyperedgeIndex).collect_vec())) + bencher.iter(|| { + graph.get_hyperedges_intersections(&(0..10).map(HyperedgeIndex).collect_vec()) + }); }); criterion.bench_function("dijkstra", |bencher| { - bencher.iter(|| graph.get_dijkstra_connections(VertexIndex(0), VertexIndex(VERTICES - 1))) + bencher.iter(|| graph.get_dijkstra_connections(VertexIndex(0), VertexIndex(VERTICES - 1))); }); criterion.bench_function("remove-vertex", |bencher| { @@ -92,7 +92,7 @@ fn criterion_benchmark(criterion: &mut Criterion) { build_graph, |mut g| g.remove_vertex(VertexIndex(VERTICES - 1)), BatchSize::LargeInput, - ) + ); }); criterion.bench_function("remove-hyperedge", |bencher| { @@ -100,7 +100,7 @@ fn criterion_benchmark(criterion: &mut Criterion) { build_graph, |mut g| g.remove_hyperedge(HyperedgeIndex(VERTICES - 2)), BatchSize::LargeInput, - ) + ); }); } diff --git a/tests/integration_contraction.rs b/tests/integration_contraction.rs index 16e81c8..26afece 100644 --- a/tests/integration_contraction.rs +++ b/tests/integration_contraction.rs @@ -1,5 +1,7 @@ //! Integration tests. +#![allow(clippy::many_single_char_names)] + mod common; use common::{ diff --git a/tests/integration_dijkstra.rs b/tests/integration_dijkstra.rs index 029a1f3..a94ab17 100644 --- a/tests/integration_dijkstra.rs +++ b/tests/integration_dijkstra.rs @@ -1,5 +1,7 @@ //! Integration tests. +#![allow(clippy::many_single_char_names)] + mod common; use common::{ diff --git a/tests/integration_disk.rs b/tests/integration_disk.rs index d8ebcef..282aef6 100644 --- a/tests/integration_disk.rs +++ b/tests/integration_disk.rs @@ -1,13 +1,13 @@ //! Integration tests for the `persistence` feature and `PersistentHypergraph`. -#![deny(unsafe_code, nonstandard_style)] -#![allow(missing_docs)] - #[cfg(feature = "persistence")] mod disk_tests { - use std::fmt::{Display, Formatter, Result}; + use std::{ + fmt::{Display, Formatter, Result}, + sync::Arc, + }; - use hypergraph::{PersistentHypergraph, HyperedgeIndex, VertexIndex}; + use hypergraph::{HyperedgeIndex, PersistentHypergraph, VertexIndex}; // ────────────────────────────────────────────────────────────────────── // Minimal vertex / hyperedge types @@ -61,7 +61,7 @@ mod disk_tests { #[test] fn add_and_get_vertices() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(10)).expect("add v0"); let v1 = g.add_vertex(V(20)).expect("add v1"); @@ -79,7 +79,7 @@ mod disk_tests { #[test] fn update_vertex_weight() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(1)).expect("add"); g.update_vertex_weight(v0, V(99)).expect("update"); @@ -88,14 +88,14 @@ mod disk_tests { #[test] fn update_vertex_weight_unchanged_returns_error() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(42)).expect("add"); assert!(g.update_vertex_weight(v0, V(42)).is_err()); } #[test] fn add_and_get_hyperedges() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(0)).expect("v0"); let v1 = g.add_vertex(V(1)).expect("v1"); @@ -121,7 +121,7 @@ mod disk_tests { #[test] fn vertex_hyperedge_refs_are_maintained() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(0)).expect("v0"); let v1 = g.add_vertex(V(1)).expect("v1"); @@ -140,7 +140,7 @@ mod disk_tests { #[test] fn remove_hyperedge_cleans_vertex_refs() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(0)).expect("v0"); let v1 = g.add_vertex(V(1)).expect("v1"); @@ -155,7 +155,7 @@ mod disk_tests { #[test] fn remove_vertex_also_removes_solo_hyperedges() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(0)).expect("v0"); let v1 = g.add_vertex(V(1)).expect("v1"); @@ -174,7 +174,7 @@ mod disk_tests { #[test] fn update_hyperedge_vertices() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(0)).expect("v0"); let v1 = g.add_vertex(V(1)).expect("v1"); @@ -195,7 +195,7 @@ mod disk_tests { #[test] fn update_hyperedge_weight() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(0)).expect("v0"); let he0 = g.add_hyperedge(&[v0], HE(1)).expect("he0"); @@ -205,7 +205,7 @@ mod disk_tests { #[test] fn clear_empties_graph() { - let (mut g, _dir) = open_temp(); + let (g, _dir) = open_temp(); let v0 = g.add_vertex(V(0)).expect("v0"); g.add_hyperedge(&[v0], HE(1)).expect("he"); @@ -223,7 +223,7 @@ mod disk_tests { // Write data. { - let mut g: PersistentHypergraph = + let g: PersistentHypergraph = PersistentHypergraph::open(dir.path()).expect("open"); let v0 = g.add_vertex(V(7)).expect("v0"); let v1 = g.add_vertex(V(8)).expect("v1"); @@ -262,23 +262,18 @@ mod disk_tests { #[test] fn concurrent_vertex_adds_produce_unique_indices() { - use std::sync::Arc; + const THREADS: u32 = 8; + const PER_THREAD: u32 = 25; let dir = tempfile::tempdir().expect("temp dir"); let g = Arc::new(PersistentHypergraph::::open(dir.path()).expect("open")); - const THREADS: usize = 8; - const PER_THREAD: usize = 25; - let handles: Vec<_> = (0..THREADS) .map(|t| { let g = Arc::clone(&g); std::thread::spawn(move || { (0..PER_THREAD) - .map(|i| { - g.add_vertex(V((t * PER_THREAD + i) as u32)) - .expect("add_vertex") - }) + .map(|i| g.add_vertex(V(t * PER_THREAD + i)).expect("add_vertex")) .collect::>() }) }) @@ -289,7 +284,7 @@ mod disk_tests { .flat_map(|h| h.join().expect("thread panicked")) .collect(); - let total = THREADS * PER_THREAD; + let total = (THREADS * PER_THREAD) as usize; assert_eq!(all_indices.len(), total); all_indices.sort_unstable(); all_indices.dedup(); @@ -299,7 +294,8 @@ mod disk_tests { #[test] fn concurrent_hyperedge_adds_produce_unique_indices() { - use std::sync::Arc; + const THREADS: u32 = 8; + const PER_THREAD: u32 = 25; let dir = tempfile::tempdir().expect("temp dir"); let g = Arc::new(PersistentHypergraph::::open(dir.path()).expect("open")); @@ -307,16 +303,13 @@ mod disk_tests { let v0 = g.add_vertex(V(0)).expect("v0"); let v1 = g.add_vertex(V(1)).expect("v1"); - const THREADS: usize = 8; - const PER_THREAD: usize = 25; - let handles: Vec<_> = (0..THREADS) .map(|t| { let g = Arc::clone(&g); std::thread::spawn(move || { (0..PER_THREAD) .map(|i| { - g.add_hyperedge(&[v0, v1], HE((t * PER_THREAD + i) as u32)) + g.add_hyperedge(&[v0, v1], HE(t * PER_THREAD + i)) .expect("add_hyperedge") }) .collect::>() @@ -329,7 +322,7 @@ mod disk_tests { .flat_map(|h| h.join().expect("thread panicked")) .collect(); - let total = THREADS * PER_THREAD; + let total = (THREADS * PER_THREAD) as usize; assert_eq!(all_indices.len(), total); all_indices.sort_unstable(); all_indices.dedup(); diff --git a/tests/integration_iterator.rs b/tests/integration_iterator.rs index 7d562db..17dcd16 100644 --- a/tests/integration_iterator.rs +++ b/tests/integration_iterator.rs @@ -1,5 +1,7 @@ //! Integration tests. +#![allow(clippy::many_single_char_names)] + mod common; use common::{ diff --git a/tests/integration_join.rs b/tests/integration_join.rs index 804241c..c93e005 100644 --- a/tests/integration_join.rs +++ b/tests/integration_join.rs @@ -1,5 +1,7 @@ //! Integration tests. +#![allow(clippy::many_single_char_names)] + mod common; use common::{ diff --git a/tests/integration_main.rs b/tests/integration_main.rs index 5a4e521..9d8846f 100644 --- a/tests/integration_main.rs +++ b/tests/integration_main.rs @@ -1,5 +1,7 @@ //! Integration tests. +#![allow(clippy::similar_names, clippy::too_many_lines)] + mod common; use common::{ diff --git a/tests/integration_new_features.rs b/tests/integration_new_features.rs index d3e2de3..d28c165 100644 --- a/tests/integration_new_features.rs +++ b/tests/integration_new_features.rs @@ -1,11 +1,13 @@ //! Integration tests for features added in the modernization pass: -//! is_empty, Clone, vertices_iter, hyperedges_iter, IntoIterator for &Hypergraph, -//! BFS, DFS, is_reachable, topological_sort, get_dijkstra_connections_with_cost, -//! contains_vertex, get_vertex_index, is_acyclic, Display, connected_components, -//! get_dijkstra_from. +//! [`Hypergraph::is_empty`], [`Clone`], [`Hypergraph::vertices_iter`], +//! [`Hypergraph::hyperedges_iter`], [`IntoIterator`] for `&Hypergraph`, BFS, DFS, +//! [`Hypergraph::is_reachable`], [`Hypergraph::topological_sort`], +//! [`Hypergraph::get_dijkstra_connections_with_cost`], +//! [`Hypergraph::contains_vertex`], [`Hypergraph::get_vertex_index`], +//! [`Hypergraph::is_acyclic`], [`Display`], [`Hypergraph::connected_components`], +//! [`Hypergraph::get_dijkstra_from`]. -#![deny(unsafe_code, nonstandard_style)] -#![allow(missing_docs)] +#![allow(clippy::many_single_char_names, missing_docs)] mod common; @@ -133,7 +135,7 @@ fn ref_into_iterator() { #[test] fn bfs_from_source() { - let (g, a, b, c, _, ..) = build_graph(); + let (g, a, b, c, ..) = build_graph(); let bfs = g.get_bfs(a).unwrap(); // a must come first; b and c must both appear. assert_eq!(bfs[0], a); @@ -160,7 +162,7 @@ fn bfs_invalid_vertex() { #[test] fn dfs_from_source() { - let (g, a, b, c, _, ..) = build_graph(); + let (g, a, b, c, ..) = build_graph(); let dfs = g.get_dfs(a).unwrap(); assert_eq!(dfs[0], a); assert!(dfs.contains(&b)); @@ -186,26 +188,26 @@ fn dfs_invalid_vertex() { #[test] fn is_reachable_self() { let (g, a, ..) = build_graph(); - assert_eq!(g.is_reachable(a, a).unwrap(), true); + assert!(g.is_reachable(a, a).unwrap()); } #[test] fn is_reachable_direct_edge() { let (g, a, b, ..) = build_graph(); - assert_eq!(g.is_reachable(a, b).unwrap(), true); + assert!(g.is_reachable(a, b).unwrap()); } #[test] fn is_reachable_transitive() { let (g, a, _, c, ..) = build_graph(); - assert_eq!(g.is_reachable(a, c).unwrap(), true); + assert!(g.is_reachable(a, c).unwrap()); } #[test] fn is_reachable_false() { let (g, a, _, _, d, ..) = build_graph(); // d has no incoming edges, only a has outgoing ones. - assert_eq!(g.is_reachable(d, a).unwrap(), false); + assert!(!g.is_reachable(d, a).unwrap()); } #[test] @@ -366,10 +368,10 @@ fn is_acyclic_empty() { fn display_contains_vertex_weights() { let (g, ..) = build_graph(); let s = g.to_string(); - assert!(s.contains("a"), "missing vertex a in: {s}"); - assert!(s.contains("b"), "missing vertex b in: {s}"); - assert!(s.contains("c"), "missing vertex c in: {s}"); - assert!(s.contains("d"), "missing vertex d in: {s}"); + assert!(s.contains('a'), "missing vertex a in: {s}"); + assert!(s.contains('b'), "missing vertex b in: {s}"); + assert!(s.contains('c'), "missing vertex c in: {s}"); + assert!(s.contains('d'), "missing vertex d in: {s}"); } #[test] @@ -529,8 +531,7 @@ fn scc_dag_all_singletons() { for v in [a, b, c, d] { assert!( sccs.iter().any(|s| s == &vec![v]), - "expected singleton SCC for {:?}", - v + "expected singleton SCC for {v:?}" ); } } From 07d6f5f66d67375cb9eea0a5aa7f2ef24e019263 Mon Sep 17 00:00:00 2001 From: Davy Duperron Date: Mon, 25 May 2026 10:59:06 +0200 Subject: [PATCH 3/5] refactor: enforce mod.rs-only-declarations structure --- CLAUDE.md | 18 + rustfmt.toml | 2 +- src/core/disk/graph.rs | 126 +++++ src/core/disk/helpers.rs | 289 +++++++++++ src/core/disk/hyperedges.rs | 19 +- src/core/disk/mod.rs | 483 +----------------- src/core/disk/open.rs | 35 +- src/core/disk/types.rs | 116 +++++ src/core/disk/vertices.rs | 24 +- .../hyperedges/update_hyperedge_vertices.rs | 8 +- src/core/hypergraph.rs | 152 ++++++ src/core/iterator.rs | 8 +- src/core/mod.rs | 204 +------- src/core/traits.rs | 26 + src/core/types.rs | 5 +- src/core/vertices/add_vertex.rs | 5 +- src/core/vertices/remove_vertex.rs | 6 +- tests/integration_disk.rs | 37 +- 18 files changed, 856 insertions(+), 707 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/core/disk/graph.rs create mode 100644 src/core/disk/helpers.rs create mode 100644 src/core/disk/types.rs create mode 100644 src/core/hypergraph.rs create mode 100644 src/core/traits.rs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f96c71 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,18 @@ +# hypergraph + +## After every code change + +Run both of these before considering a task done: + +```bash +cargo fmt +cargo clippy --all-targets --all-features +cargo nextest run +``` + +## Module structure + +`mod.rs` files must only contain module declarations (`mod foo;`) and re-exports +(`pub use`). All type definitions, trait impls, and method implementations +belong in dedicated subfiles — one logical group per file (e.g. `types.rs`, +`helpers.rs`, `graph.rs`, `vertices.rs`, `hyperedges.rs`). diff --git a/rustfmt.toml b/rustfmt.toml index 676fe38..98ba7f0 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -2,5 +2,5 @@ edition = "2024" group_imports = "StdExternalCrate" imports_granularity = "Crate" imports_layout="Vertical" -use_field_init_shorthand = true style_edition = "2024" +use_field_init_shorthand = true diff --git a/src/core/disk/graph.rs b/src/core/disk/graph.rs new file mode 100644 index 0000000..82313b7 --- /dev/null +++ b/src/core/disk/graph.rs @@ -0,0 +1,126 @@ +use std::sync::atomic::Ordering; + +use serde::{ + Serialize, + de::DeserializeOwned, +}; + +use super::{ + PersistentHypergraph, + helpers::{ + key_to_hyperedge, + key_to_vertex, + storage_err, + }, +}; +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + VertexIndex, + VertexTrait, + errors::HypergraphError, +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Graph-level utilities +// ────────────────────────────────────────────────────────────────────────────── + +impl PersistentHypergraph +where + V: VertexTrait + Serialize + DeserializeOwned, + HE: HyperedgeTrait + Serialize + DeserializeOwned, +{ + /// Returns the number of vertices currently in the hypergraph. + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn count_vertices(&self) -> usize { + self.vertices_count.load(Ordering::Relaxed) as usize + } + + /// Returns the number of hyperedges currently in the hypergraph. + #[must_use] + #[allow(clippy::cast_possible_truncation)] + pub fn count_hyperedges(&self) -> usize { + self.hyperedges_count.load(Ordering::Relaxed) as usize + } + + /// Returns `true` if the hypergraph contains no vertices. + #[must_use] + pub fn is_empty(&self) -> bool { + self.vertices_count.load(Ordering::Relaxed) == 0 + } + + /// Flushes all pending writes to durable storage (fsync). + /// + /// Normal writes are already appended to the WAL and durable on crash; + /// this call additionally syncs the journal to the physical medium. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn persist(&self) -> Result<(), HypergraphError> { + self.db + .persist(fjall::PersistMode::SyncAll) + .map_err(storage_err) + } + + /// Clears all vertices and hyperedges from the graph. + /// + /// Also clears the `vertex_refs` keyspace and the in-memory caches. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn clear(&self) -> Result<(), HypergraphError> { + for ks in [&self.vertices_ks, &self.hyperedges_ks, &self.vertex_refs_ks] { + let keys: Vec> = ks + .iter() + .filter_map(|guard| guard.into_inner().ok().map(|(k, _)| k.to_vec())) + .collect(); + for k in keys { + ks.remove(k).map_err(storage_err)?; + } + } + + self.vertex_cache.clear(); + self.hyperedge_cache.clear(); + self.vertices_count.store(0, Ordering::Relaxed); + self.hyperedges_count.store(0, Ordering::Relaxed); + + self.flush_meta() + } + + /// Returns all vertex indices currently stored in the graph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn vertex_indices(&self) -> Result, HypergraphError> { + self.vertices_ks + .iter() + .map(|guard| { + let (k, _) = guard.into_inner().map_err(storage_err)?; + key_to_vertex(&k).ok_or_else(|| { + HypergraphError::StorageError("invalid vertex key in storage".into()) + }) + }) + .collect() + } + + /// Returns all hyperedge indices currently stored in the graph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure. + pub fn hyperedge_indices(&self) -> Result, HypergraphError> { + self.hyperedges_ks + .iter() + .map(|guard| { + let (k, _) = guard.into_inner().map_err(storage_err)?; + key_to_hyperedge(&k).ok_or_else(|| { + HypergraphError::StorageError("invalid hyperedge key in storage".into()) + }) + }) + .collect() + } +} diff --git a/src/core/disk/helpers.rs b/src/core/disk/helpers.rs new file mode 100644 index 0000000..804b3bd --- /dev/null +++ b/src/core/disk/helpers.rs @@ -0,0 +1,289 @@ +use std::{ + fmt, + sync::{ + Arc, + atomic::Ordering, + }, +}; + +use serde::{ + Serialize, + de::DeserializeOwned, +}; + +use super::types::{ + HyperedgeArc, + PersistentHypergraph, +}; +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + VertexIndex, + VertexTrait, + errors::HypergraphError, +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Constants +// ────────────────────────────────────────────────────────────────────────────── + +pub(super) const META_VERTEX_IDX: &[u8] = b"vi"; +pub(super) const META_VERTEX_COUNT: &[u8] = b"vc"; +pub(super) const META_HYPEREDGE_IDX: &[u8] = b"hi"; +pub(super) const META_HYPEREDGE_COUNT: &[u8] = b"hc"; + +pub(super) const DEFAULT_CACHE_CAPACITY: usize = 10_000; + +// ────────────────────────────────────────────────────────────────────────────── +// Key helpers +// ────────────────────────────────────────────────────────────────────────────── + +pub(super) fn decode_u64(bytes: &[u8]) -> u64 { + bytes + .get(..8) + .and_then(|b| b.try_into().ok()) + .map_or(0, u64::from_be_bytes) +} + +/// Encode a `VertexIndex` as a big-endian 8-byte key. +#[allow(clippy::cast_possible_truncation)] +pub(super) fn vertex_key(idx: VertexIndex) -> [u8; 8] { + (idx.0 as u64).to_be_bytes() +} + +/// Encode a `HyperedgeIndex` as a big-endian 8-byte key. +#[allow(clippy::cast_possible_truncation)] +pub(super) fn hyperedge_key(idx: HyperedgeIndex) -> [u8; 8] { + (idx.0 as u64).to_be_bytes() +} + +/// Encode a `(vertex, hyperedge)` back-reference as a 16-byte key. +/// +/// The first 8 bytes are the vertex index; the last 8 bytes are the hyperedge +/// index. Big-endian layout means all back-references for a given vertex are +/// contiguous in the keyspace, enabling an O(degree) prefix scan. +pub(super) fn vertex_ref_key(v: VertexIndex, he: HyperedgeIndex) -> [u8; 16] { + let mut key = [0u8; 16]; + key[..8].copy_from_slice(&vertex_key(v)); + key[8..].copy_from_slice(&hyperedge_key(he)); + key +} + +pub(super) fn key_to_vertex(key: &[u8]) -> Option { + key.get(..8) + .and_then(|b| b.try_into().ok()) + .map(|b: [u8; 8]| { + #[allow(clippy::cast_possible_truncation)] + VertexIndex(u64::from_be_bytes(b) as usize) + }) +} + +pub(super) fn key_to_hyperedge(key: &[u8]) -> Option { + key.get(..8) + .and_then(|b| b.try_into().ok()) + .map(|b: [u8; 8]| { + #[allow(clippy::cast_possible_truncation)] + HyperedgeIndex(u64::from_be_bytes(b) as usize) + }) +} + +pub(super) fn storage_err(e: E) -> HypergraphError +where + V: Copy + Eq, + HE: Copy + Eq, + E: fmt::Display, +{ + HypergraphError::StorageError(e.to_string()) +} + +// ────────────────────────────────────────────────────────────────────────────── +// Internal helpers +// ────────────────────────────────────────────────────────────────────────────── + +impl PersistentHypergraph +where + V: VertexTrait + Serialize + DeserializeOwned, + HE: HyperedgeTrait + Serialize + DeserializeOwned, +{ + /// Fetches the weight of a vertex from cache, falling back to disk. + pub(super) fn load_vertex(&self, idx: VertexIndex) -> Result> { + let raw_key = idx.0 as u64; + + if let Some(cached) = self.vertex_cache.get(&raw_key) { + return Ok(cached); + } + + let bytes = self + .vertices_ks + .get(vertex_key(idx)) + .map_err(storage_err)? + .ok_or(HypergraphError::VertexIndexNotFound(idx))?; + + let weight: V = postcard::from_bytes(&bytes).map_err(storage_err)?; + self.vertex_cache.insert(raw_key, weight); + Ok(weight) + } + + /// Serializes and stores a vertex weight, updating the cache. + pub(super) fn store_vertex( + &self, + idx: VertexIndex, + weight: V, + ) -> Result<(), HypergraphError> { + let bytes = postcard::to_allocvec(&weight).map_err(storage_err)?; + self.vertices_ks + .insert(vertex_key(idx), bytes.as_slice()) + .map_err(storage_err)?; + self.vertex_cache.insert(idx.0 as u64, weight); + Ok(()) + } + + /// Removes a vertex weight from disk and cache. + pub(super) fn delete_vertex(&self, idx: VertexIndex) -> Result<(), HypergraphError> { + self.vertices_ks + .remove(vertex_key(idx)) + .map_err(storage_err)?; + self.vertex_cache.remove(&(idx.0 as u64)); + Ok(()) + } + + /// Records that `he` includes `v` by inserting a 16-byte presence key. + pub(super) fn add_vertex_ref( + &self, + v: VertexIndex, + he: HyperedgeIndex, + ) -> Result<(), HypergraphError> { + self.vertex_refs_ks + .insert(vertex_ref_key(v, he), []) + .map_err(storage_err) + } + + /// Removes the back-reference `(v, he)`. + pub(super) fn remove_vertex_ref( + &self, + v: VertexIndex, + he: HyperedgeIndex, + ) -> Result<(), HypergraphError> { + self.vertex_refs_ks + .remove(vertex_ref_key(v, he)) + .map_err(storage_err) + } + + /// Returns all hyperedge indices that include `v` via a prefix scan. + /// + /// Streams one 16-byte key at a time from disk. Memory usage is O(degree) + /// for the returned `Vec`, not proportional to the entire back-ref keyspace. + pub(super) fn load_vertex_refs( + &self, + v: VertexIndex, + ) -> Result, HypergraphError> { + self.vertex_refs_ks + .prefix(vertex_key(v)) + .map(|guard| { + let (k, _) = guard.into_inner().map_err(storage_err)?; + let he_bytes: [u8; 8] = + k.get(8..16) + .and_then(|b| b.try_into().ok()) + .ok_or_else(|| { + HypergraphError::StorageError("invalid vertex ref key length".into()) + })?; + #[allow(clippy::cast_possible_truncation)] + Ok(HyperedgeIndex(u64::from_be_bytes(he_bytes) as usize)) + }) + .collect() + } + + /// Deletes all back-reference keys for `v` from the `vertex_refs` keyspace. + pub(super) fn delete_vertex_refs(&self, v: VertexIndex) -> Result<(), HypergraphError> { + let keys: Vec<[u8; 16]> = self + .vertex_refs_ks + .prefix(vertex_key(v)) + .filter_map(|guard| { + guard + .into_inner() + .ok() + .and_then(|(k, _)| k.get(..16).and_then(|b| b.try_into().ok())) + }) + .collect(); + + for key in keys { + self.vertex_refs_ks.remove(key).map_err(storage_err)?; + } + Ok(()) + } + + pub(super) fn load_hyperedge( + &self, + idx: HyperedgeIndex, + ) -> Result, HypergraphError> { + let raw_key = idx.0 as u64; + + if let Some(cached) = self.hyperedge_cache.get(&raw_key) { + return Ok(cached); + } + + let bytes = self + .hyperedges_ks + .get(hyperedge_key(idx)) + .map_err(storage_err)? + .ok_or(HypergraphError::HyperedgeIndexNotFound(idx))?; + + let entry: (Vec, HE) = postcard::from_bytes(&bytes).map_err(storage_err)?; + + let arc = Arc::new(entry); + self.hyperedge_cache.insert(raw_key, arc.clone()); + Ok(arc) + } + + pub(super) fn store_hyperedge( + &self, + idx: HyperedgeIndex, + vertices: &[VertexIndex], + weight: HE, + ) -> Result<(), HypergraphError> { + let bytes = postcard::to_allocvec(&(vertices, weight)).map_err(storage_err)?; + self.hyperedges_ks + .insert(hyperedge_key(idx), bytes.as_slice()) + .map_err(storage_err)?; + self.hyperedge_cache + .insert(idx.0 as u64, Arc::new((vertices.to_vec(), weight))); + Ok(()) + } + + pub(super) fn delete_hyperedge( + &self, + idx: HyperedgeIndex, + ) -> Result<(), HypergraphError> { + self.hyperedges_ks + .remove(hyperedge_key(idx)) + .map_err(storage_err)?; + self.hyperedge_cache.remove(&(idx.0 as u64)); + Ok(()) + } + + pub(super) fn flush_meta(&self) -> Result<(), HypergraphError> { + let vi = self.vertices_next_idx.load(Ordering::Relaxed); + let vc = self.vertices_count.load(Ordering::Relaxed); + let hi = self.hyperedges_next_idx.load(Ordering::Relaxed); + let hc = self.hyperedges_count.load(Ordering::Relaxed); + + let mut batch = self.db.batch(); + batch.insert(&self.meta_ks, META_VERTEX_IDX, vi.to_be_bytes().as_slice()); + batch.insert( + &self.meta_ks, + META_VERTEX_COUNT, + vc.to_be_bytes().as_slice(), + ); + batch.insert( + &self.meta_ks, + META_HYPEREDGE_IDX, + hi.to_be_bytes().as_slice(), + ); + batch.insert( + &self.meta_ks, + META_HYPEREDGE_COUNT, + hc.to_be_bytes().as_slice(), + ); + batch.commit().map_err(storage_err) + } +} diff --git a/src/core/disk/hyperedges.rs b/src/core/disk/hyperedges.rs index c18158e..4c90e47 100644 --- a/src/core/disk/hyperedges.rs +++ b/src/core/disk/hyperedges.rs @@ -1,10 +1,18 @@ use std::sync::atomic::Ordering; -use serde::{Serialize, de::DeserializeOwned}; - -use crate::{HyperedgeIndex, HyperedgeTrait, VertexIndex, VertexTrait, errors::HypergraphError}; +use serde::{ + Serialize, + de::DeserializeOwned, +}; use super::PersistentHypergraph; +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + VertexIndex, + VertexTrait, + errors::HypergraphError, +}; impl PersistentHypergraph where @@ -39,9 +47,8 @@ where } #[allow(clippy::cast_possible_truncation)] - let he_idx = HyperedgeIndex( - self.hyperedges_next_idx.fetch_add(1, Ordering::Relaxed) as usize, - ); + let he_idx = + HyperedgeIndex(self.hyperedges_next_idx.fetch_add(1, Ordering::Relaxed) as usize); self.hyperedges_count.fetch_add(1, Ordering::Relaxed); self.store_hyperedge(he_idx, vertices, weight)?; diff --git a/src/core/disk/mod.rs b/src/core/disk/mod.rs index 8aa762d..d95737f 100644 --- a/src/core/disk/mod.rs +++ b/src/core/disk/mod.rs @@ -1,483 +1,8 @@ -use std::{ - fmt, - marker::PhantomData, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, - }, -}; - -use fjall::Keyspace; -use quick_cache::sync::Cache; -use serde::{Serialize, de::DeserializeOwned}; - -use crate::{ - HyperedgeIndex, - HyperedgeTrait, - VertexIndex, - VertexTrait, - errors::HypergraphError, -}; - +mod graph; +mod helpers; mod hyperedges; mod open; +mod types; mod vertices; -// ────────────────────────────────────────────────────────────────────────────── -// Type aliases -// ────────────────────────────────────────────────────────────────────────────── - -/// Cached hyperedge entry: ordered vertex list + weight. -type HyperedgeArc = Arc<(Vec, HE)>; - -// ────────────────────────────────────────────────────────────────────────────── -// Constants -// ────────────────────────────────────────────────────────────────────────────── - -const META_VERTEX_IDX: &[u8] = b"vi"; -const META_VERTEX_COUNT: &[u8] = b"vc"; -const META_HYPEREDGE_IDX: &[u8] = b"hi"; -const META_HYPEREDGE_COUNT: &[u8] = b"hc"; - -pub(super) const DEFAULT_CACHE_CAPACITY: usize = 10_000; - -// ────────────────────────────────────────────────────────────────────────────── -// Key helpers -// ────────────────────────────────────────────────────────────────────────────── - -pub(super) fn decode_u64(bytes: &[u8]) -> u64 { - bytes - .get(..8) - .and_then(|b| b.try_into().ok()) - .map_or(0, u64::from_be_bytes) -} - -/// Encode a `VertexIndex` as a big-endian 8-byte key. -#[allow(clippy::cast_possible_truncation)] -pub(super) fn vertex_key(idx: VertexIndex) -> [u8; 8] { - (idx.0 as u64).to_be_bytes() -} - -/// Encode a `HyperedgeIndex` as a big-endian 8-byte key. -#[allow(clippy::cast_possible_truncation)] -pub(super) fn hyperedge_key(idx: HyperedgeIndex) -> [u8; 8] { - (idx.0 as u64).to_be_bytes() -} - -/// Encode a `(vertex, hyperedge)` back-reference as a 16-byte key. -/// -/// The first 8 bytes are the vertex index; the last 8 bytes are the hyperedge -/// index. Big-endian layout means all back-references for a given vertex are -/// contiguous in the keyspace, enabling an O(degree) prefix scan. -pub(super) fn vertex_ref_key(v: VertexIndex, he: HyperedgeIndex) -> [u8; 16] { - let mut key = [0u8; 16]; - key[..8].copy_from_slice(&vertex_key(v)); - key[8..].copy_from_slice(&hyperedge_key(he)); - key -} - -pub(super) fn key_to_vertex(key: &[u8]) -> Option { - key.get(..8) - .and_then(|b| b.try_into().ok()) - .map(|b: [u8; 8]| { - #[allow(clippy::cast_possible_truncation)] - VertexIndex(u64::from_be_bytes(b) as usize) - }) -} - -pub(super) fn key_to_hyperedge(key: &[u8]) -> Option { - key.get(..8) - .and_then(|b| b.try_into().ok()) - .map(|b: [u8; 8]| { - #[allow(clippy::cast_possible_truncation)] - HyperedgeIndex(u64::from_be_bytes(b) as usize) - }) -} - -pub(super) fn storage_err(e: E) -> HypergraphError -where - V: Copy + Eq, - HE: Copy + Eq, - E: fmt::Display, -{ - HypergraphError::StorageError(e.to_string()) -} - -// ────────────────────────────────────────────────────────────────────────────── -// Struct definition -// ────────────────────────────────────────────────────────────────────────────── - -/// A directed hypergraph persisted on disk via fjall (LSM-tree) with a -/// [`quick_cache`] hot-data layer. -/// -/// ## Larger-than-RAM support -/// -/// fjall is the primary store; the in-memory cache is bounded. Three separate -/// fjall keyspaces are used: -/// -/// | Keyspace | Key | Value | -/// |---|---|---| -/// | `vertices` | `vertex_idx (8 B)` | serialized vertex weight | -/// | `hyperedges` | `hyperedge_idx (8 B)` | serialized `(vertices, weight)` | -/// | `vertex_refs` | `vertex_idx (8 B) ‖ hyperedge_idx (8 B)` | empty | -/// -/// Back-references (which hyperedges include a vertex) are stored as individual -/// 16-byte keys in `vertex_refs` rather than as an inline list inside the -/// vertex record. This means: -/// -/// - **Vertex weight read**: single O(1) point lookup — never touches back-refs. -/// - **Adding/removing a back-reference**: single O(1) key insert/delete. -/// - **Getting all hyperedges for a vertex**: O(degree) prefix scan that streams -/// from disk one entry at a time, with no in-memory accumulation beyond the -/// returned `Vec`. -/// -/// High-degree "hub" vertices therefore impose no special memory cost. -/// -/// ## Thread safety -/// -/// `PersistentHypergraph` is `Send + Sync`. All write methods take `&self` and use -/// atomic counters internally, so the same instance can be wrapped in an `Arc` -/// and shared across threads without an external `Mutex`. -/// -/// Note that individual multi-step operations (e.g. `add_hyperedge`) are **not** -/// serializable with respect to concurrent writers: concurrent calls may -/// interleave. For full operation-level isolation wrap in a `Mutex`. -/// -/// ## Open or create -/// -/// ```ignore -/// use std::sync::Arc; -/// use hypergraph::PersistentHypergraph; -/// -/// let g = Arc::new(PersistentHypergraph::::open("/var/data/my-graph")?); -/// -/// let g2 = Arc::clone(&g); -/// std::thread::spawn(move || { g2.add_vertex(my_vertex)?; Ok(()) }); -/// ``` -pub struct PersistentHypergraph { - pub(super) db: fjall::Database, - /// Stores serialized vertex weights, keyed by `vertex_idx`. - pub(super) vertices_ks: Keyspace, - /// Stores serialized `(vertex_list, weight)` tuples, keyed by `hyperedge_idx`. - pub(super) hyperedges_ks: Keyspace, - /// Stores back-references as presence-only 16-byte keys - /// `vertex_idx ‖ hyperedge_idx`. No value payload. - pub(super) vertex_refs_ks: Keyspace, - pub(super) meta_ks: Keyspace, - /// Hot cache: `vertex_idx` → deserialized weight `V`. - pub(super) vertex_cache: Cache, - /// Hot cache: `hyperedge_idx` → `Arc<(Vec, HE)>`. - pub(super) hyperedge_cache: Cache>, - /// Monotonically increasing counter; never decrements. - pub(super) vertices_next_idx: AtomicU64, - /// Actual number of vertices currently in the graph. - pub(super) vertices_count: AtomicU64, - /// Monotonically increasing counter; never decrements. - pub(super) hyperedges_next_idx: AtomicU64, - /// Actual number of hyperedges currently in the graph. - pub(super) hyperedges_count: AtomicU64, - pub(super) _phantom: PhantomData<(V, HE)>, -} - -impl fmt::Debug for PersistentHypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PersistentHypergraph") - .field("vertices", &self.vertices_count.load(Ordering::Relaxed)) - .field("hyperedges", &self.hyperedges_count.load(Ordering::Relaxed)) - .finish_non_exhaustive() - } -} - -// ────────────────────────────────────────────────────────────────────────────── -// Internal helpers -// ────────────────────────────────────────────────────────────────────────────── - -impl PersistentHypergraph -where - V: VertexTrait + Serialize + DeserializeOwned, - HE: HyperedgeTrait + Serialize + DeserializeOwned, -{ - /// Fetches the weight of a vertex from cache, falling back to disk. - pub(super) fn load_vertex(&self, idx: VertexIndex) -> Result> { - let raw_key = idx.0 as u64; - - if let Some(cached) = self.vertex_cache.get(&raw_key) { - return Ok(cached); - } - - let bytes = self - .vertices_ks - .get(vertex_key(idx)) - .map_err(storage_err)? - .ok_or(HypergraphError::VertexIndexNotFound(idx))?; - - let weight: V = postcard::from_bytes(&bytes).map_err(storage_err)?; - self.vertex_cache.insert(raw_key, weight); - Ok(weight) - } - - /// Serializes and stores a vertex weight, updating the cache. - pub(super) fn store_vertex( - &self, - idx: VertexIndex, - weight: V, - ) -> Result<(), HypergraphError> { - let bytes = postcard::to_allocvec(&weight).map_err(storage_err)?; - self.vertices_ks - .insert(vertex_key(idx), bytes.as_slice()) - .map_err(storage_err)?; - self.vertex_cache.insert(idx.0 as u64, weight); - Ok(()) - } - - /// Removes a vertex weight from disk and cache. - pub(super) fn delete_vertex(&self, idx: VertexIndex) -> Result<(), HypergraphError> { - self.vertices_ks - .remove(vertex_key(idx)) - .map_err(storage_err)?; - self.vertex_cache.remove(&(idx.0 as u64)); - Ok(()) - } - - /// Records that `he` includes `v` by inserting a 16-byte presence key. - pub(super) fn add_vertex_ref( - &self, - v: VertexIndex, - he: HyperedgeIndex, - ) -> Result<(), HypergraphError> { - self.vertex_refs_ks - .insert(vertex_ref_key(v, he), []) - .map_err(storage_err) - } - - /// Removes the back-reference `(v, he)`. - pub(super) fn remove_vertex_ref( - &self, - v: VertexIndex, - he: HyperedgeIndex, - ) -> Result<(), HypergraphError> { - self.vertex_refs_ks - .remove(vertex_ref_key(v, he)) - .map_err(storage_err) - } - - /// Returns all hyperedge indices that include `v` via a prefix scan. - /// - /// Streams one 16-byte key at a time from disk. Memory usage is O(degree) - /// for the returned `Vec`, not proportional to the entire back-ref keyspace. - pub(super) fn load_vertex_refs( - &self, - v: VertexIndex, - ) -> Result, HypergraphError> { - self.vertex_refs_ks - .prefix(vertex_key(v)) - .map(|guard| { - let (k, _) = guard.into_inner().map_err(storage_err)?; - let he_bytes: [u8; 8] = k - .get(8..16) - .and_then(|b| b.try_into().ok()) - .ok_or_else(|| { - HypergraphError::StorageError( - "invalid vertex ref key length".into(), - ) - })?; - #[allow(clippy::cast_possible_truncation)] - Ok(HyperedgeIndex(u64::from_be_bytes(he_bytes) as usize)) - }) - .collect() - } - - /// Deletes all back-reference keys for `v` from the `vertex_refs` keyspace. - pub(super) fn delete_vertex_refs( - &self, - v: VertexIndex, - ) -> Result<(), HypergraphError> { - let keys: Vec<[u8; 16]> = self - .vertex_refs_ks - .prefix(vertex_key(v)) - .filter_map(|guard| { - guard - .into_inner() - .ok() - .and_then(|(k, _)| k.get(..16).and_then(|b| b.try_into().ok())) - }) - .collect(); - - for key in keys { - self.vertex_refs_ks.remove(key).map_err(storage_err)?; - } - Ok(()) - } - - pub(super) fn load_hyperedge( - &self, - idx: HyperedgeIndex, - ) -> Result, HypergraphError> { - let raw_key = idx.0 as u64; - - if let Some(cached) = self.hyperedge_cache.get(&raw_key) { - return Ok(cached); - } - - let bytes = self - .hyperedges_ks - .get(hyperedge_key(idx)) - .map_err(storage_err)? - .ok_or(HypergraphError::HyperedgeIndexNotFound(idx))?; - - let entry: (Vec, HE) = - postcard::from_bytes(&bytes).map_err(storage_err)?; - - let arc = Arc::new(entry); - self.hyperedge_cache.insert(raw_key, arc.clone()); - Ok(arc) - } - - pub(super) fn store_hyperedge( - &self, - idx: HyperedgeIndex, - vertices: &[VertexIndex], - weight: HE, - ) -> Result<(), HypergraphError> { - let bytes = postcard::to_allocvec(&(vertices, weight)).map_err(storage_err)?; - self.hyperedges_ks - .insert(hyperedge_key(idx), bytes.as_slice()) - .map_err(storage_err)?; - self.hyperedge_cache - .insert(idx.0 as u64, Arc::new((vertices.to_vec(), weight))); - Ok(()) - } - - pub(super) fn delete_hyperedge( - &self, - idx: HyperedgeIndex, - ) -> Result<(), HypergraphError> { - self.hyperedges_ks - .remove(hyperedge_key(idx)) - .map_err(storage_err)?; - self.hyperedge_cache.remove(&(idx.0 as u64)); - Ok(()) - } - - pub(super) fn flush_meta(&self) -> Result<(), HypergraphError> { - let vi = self.vertices_next_idx.load(Ordering::Relaxed); - let vc = self.vertices_count.load(Ordering::Relaxed); - let hi = self.hyperedges_next_idx.load(Ordering::Relaxed); - let hc = self.hyperedges_count.load(Ordering::Relaxed); - - let mut batch = self.db.batch(); - batch.insert(&self.meta_ks, META_VERTEX_IDX, vi.to_be_bytes().as_slice()); - batch.insert(&self.meta_ks, META_VERTEX_COUNT, vc.to_be_bytes().as_slice()); - batch.insert(&self.meta_ks, META_HYPEREDGE_IDX, hi.to_be_bytes().as_slice()); - batch.insert(&self.meta_ks, META_HYPEREDGE_COUNT, hc.to_be_bytes().as_slice()); - batch.commit().map_err(storage_err) - } -} - -// ────────────────────────────────────────────────────────────────────────────── -// Graph-level utilities -// ────────────────────────────────────────────────────────────────────────────── - -impl PersistentHypergraph -where - V: VertexTrait + Serialize + DeserializeOwned, - HE: HyperedgeTrait + Serialize + DeserializeOwned, -{ - /// Returns the number of vertices currently in the hypergraph. - #[must_use] - #[allow(clippy::cast_possible_truncation)] - pub fn count_vertices(&self) -> usize { - self.vertices_count.load(Ordering::Relaxed) as usize - } - - /// Returns the number of hyperedges currently in the hypergraph. - #[must_use] - #[allow(clippy::cast_possible_truncation)] - pub fn count_hyperedges(&self) -> usize { - self.hyperedges_count.load(Ordering::Relaxed) as usize - } - - /// Returns `true` if the hypergraph contains no vertices. - #[must_use] - pub fn is_empty(&self) -> bool { - self.vertices_count.load(Ordering::Relaxed) == 0 - } - - /// Flushes all pending writes to durable storage (fsync). - /// - /// Normal writes are already appended to the WAL and durable on crash; - /// this call additionally syncs the journal to the physical medium. - /// - /// # Errors - /// - /// Returns [`HypergraphError::StorageError`] on I/O failure. - pub fn persist(&self) -> Result<(), HypergraphError> { - self.db - .persist(fjall::PersistMode::SyncAll) - .map_err(storage_err) - } - - /// Clears all vertices and hyperedges from the graph. - /// - /// Also clears the `vertex_refs` keyspace and the in-memory caches. - /// - /// # Errors - /// - /// Returns [`HypergraphError::StorageError`] on I/O failure. - pub fn clear(&self) -> Result<(), HypergraphError> { - for ks in [&self.vertices_ks, &self.hyperedges_ks, &self.vertex_refs_ks] { - let keys: Vec> = ks - .iter() - .filter_map(|guard| guard.into_inner().ok().map(|(k, _)| k.to_vec())) - .collect(); - for k in keys { - ks.remove(k).map_err(storage_err)?; - } - } - - self.vertex_cache.clear(); - self.hyperedge_cache.clear(); - self.vertices_count.store(0, Ordering::Relaxed); - self.hyperedges_count.store(0, Ordering::Relaxed); - - self.flush_meta() - } - - /// Returns all vertex indices currently stored in the graph. - /// - /// # Errors - /// - /// Returns [`HypergraphError::StorageError`] on I/O failure. - pub fn vertex_indices(&self) -> Result, HypergraphError> { - self.vertices_ks - .iter() - .map(|guard| { - let (k, _) = guard.into_inner().map_err(storage_err)?; - key_to_vertex(&k).ok_or_else(|| { - HypergraphError::StorageError("invalid vertex key in storage".into()) - }) - }) - .collect() - } - - /// Returns all hyperedge indices currently stored in the graph. - /// - /// # Errors - /// - /// Returns [`HypergraphError::StorageError`] on I/O failure. - pub fn hyperedge_indices(&self) -> Result, HypergraphError> { - self.hyperedges_ks - .iter() - .map(|guard| { - let (k, _) = guard.into_inner().map_err(storage_err)?; - key_to_hyperedge(&k).ok_or_else(|| { - HypergraphError::StorageError("invalid hyperedge key in storage".into()) - }) - }) - .collect() - } -} +pub use types::PersistentHypergraph; diff --git a/src/core/disk/open.rs b/src/core/disk/open.rs index 0024fb2..c378c24 100644 --- a/src/core/disk/open.rs +++ b/src/core/disk/open.rs @@ -1,14 +1,35 @@ -use std::{marker::PhantomData, path::Path, sync::atomic::AtomicU64}; +use std::{ + marker::PhantomData, + path::Path, + sync::atomic::AtomicU64, +}; -use fjall::{Database, KeyspaceCreateOptions}; +use fjall::{ + Database, + KeyspaceCreateOptions, +}; use quick_cache::sync::Cache; -use serde::{Serialize, de::DeserializeOwned}; - -use crate::{HyperedgeTrait, VertexTrait, errors::HypergraphError}; +use serde::{ + Serialize, + de::DeserializeOwned, +}; use super::{ - DEFAULT_CACHE_CAPACITY, PersistentHypergraph, META_HYPEREDGE_COUNT, META_HYPEREDGE_IDX, - META_VERTEX_COUNT, META_VERTEX_IDX, decode_u64, storage_err, + PersistentHypergraph, + helpers::{ + DEFAULT_CACHE_CAPACITY, + META_HYPEREDGE_COUNT, + META_HYPEREDGE_IDX, + META_VERTEX_COUNT, + META_VERTEX_IDX, + decode_u64, + storage_err, + }, +}; +use crate::{ + HyperedgeTrait, + VertexTrait, + errors::HypergraphError, }; impl PersistentHypergraph diff --git a/src/core/disk/types.rs b/src/core/disk/types.rs new file mode 100644 index 0000000..e338a25 --- /dev/null +++ b/src/core/disk/types.rs @@ -0,0 +1,116 @@ +use std::{ + fmt, + marker::PhantomData, + sync::{ + Arc, + atomic::{ + AtomicU64, + Ordering, + }, + }, +}; + +use fjall::Keyspace; +use quick_cache::sync::Cache; + +use crate::{ + HyperedgeTrait, + VertexIndex, + VertexTrait, +}; + +// ────────────────────────────────────────────────────────────────────────────── +// Type aliases +// ────────────────────────────────────────────────────────────────────────────── + +/// Cached hyperedge entry: ordered vertex list + weight. +pub(super) type HyperedgeArc = Arc<(Vec, HE)>; + +// ────────────────────────────────────────────────────────────────────────────── +// Struct definition +// ────────────────────────────────────────────────────────────────────────────── + +/// A directed hypergraph persisted on disk via fjall (LSM-tree) with a +/// [`quick_cache`] hot-data layer. +/// +/// ## Larger-than-RAM support +/// +/// fjall is the primary store; the in-memory cache is bounded. Three separate +/// fjall keyspaces are used: +/// +/// | Keyspace | Key | Value | +/// |---|---|---| +/// | `vertices` | `vertex_idx (8 B)` | serialized vertex weight | +/// | `hyperedges` | `hyperedge_idx (8 B)` | serialized `(vertices, weight)` | +/// | `vertex_refs` | `vertex_idx (8 B) ‖ hyperedge_idx (8 B)` | empty | +/// +/// Back-references (which hyperedges include a vertex) are stored as individual +/// 16-byte keys in `vertex_refs` rather than as an inline list inside the +/// vertex record. This means: +/// +/// - **Vertex weight read**: single O(1) point lookup — never touches back-refs. +/// - **Adding/removing a back-reference**: single O(1) key insert/delete. +/// - **Getting all hyperedges for a vertex**: O(degree) prefix scan that streams +/// from disk one entry at a time, with no in-memory accumulation beyond the +/// returned `Vec`. +/// +/// High-degree "hub" vertices therefore impose no special memory cost. +/// +/// ## Thread safety +/// +/// `PersistentHypergraph` is `Send + Sync`. All write methods take `&self` and use +/// atomic counters internally, so the same instance can be wrapped in an `Arc` +/// and shared across threads without an external `Mutex`. +/// +/// Note that individual multi-step operations (e.g. `add_hyperedge`) are **not** +/// serializable with respect to concurrent writers: concurrent calls may +/// interleave. For full operation-level isolation wrap in a `Mutex`. +/// +/// ## Open or create +/// +/// ```ignore +/// use std::sync::Arc; +/// use hypergraph::PersistentHypergraph; +/// +/// let g = Arc::new(PersistentHypergraph::::open("/var/data/my-graph")?); +/// +/// let g2 = Arc::clone(&g); +/// std::thread::spawn(move || { g2.add_vertex(my_vertex)?; Ok(()) }); +/// ``` +pub struct PersistentHypergraph { + pub(super) db: fjall::Database, + /// Stores serialized vertex weights, keyed by `vertex_idx`. + pub(super) vertices_ks: Keyspace, + /// Stores serialized `(vertex_list, weight)` tuples, keyed by `hyperedge_idx`. + pub(super) hyperedges_ks: Keyspace, + /// Stores back-references as presence-only 16-byte keys + /// `vertex_idx ‖ hyperedge_idx`. No value payload. + pub(super) vertex_refs_ks: Keyspace, + pub(super) meta_ks: Keyspace, + /// Hot cache: `vertex_idx` → deserialized weight `V`. + pub(super) vertex_cache: Cache, + /// Hot cache: `hyperedge_idx` → `Arc<(Vec, HE)>`. + pub(super) hyperedge_cache: Cache>, + /// Monotonically increasing counter; never decrements. + pub(super) vertices_next_idx: AtomicU64, + /// Actual number of vertices currently in the graph. + pub(super) vertices_count: AtomicU64, + /// Monotonically increasing counter; never decrements. + pub(super) hyperedges_next_idx: AtomicU64, + /// Actual number of hyperedges currently in the graph. + pub(super) hyperedges_count: AtomicU64, + pub(super) _phantom: PhantomData<(V, HE)>, +} + +impl fmt::Debug for PersistentHypergraph +where + V: VertexTrait, + HE: HyperedgeTrait, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PersistentHypergraph") + .field("vertices", &self.vertices_count.load(Ordering::Relaxed)) + .field("hyperedges", &self.hyperedges_count.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} diff --git a/src/core/disk/vertices.rs b/src/core/disk/vertices.rs index 9fb1226..7d57567 100644 --- a/src/core/disk/vertices.rs +++ b/src/core/disk/vertices.rs @@ -1,10 +1,18 @@ use std::sync::atomic::Ordering; -use serde::{Serialize, de::DeserializeOwned}; - -use crate::{HyperedgeIndex, HyperedgeTrait, VertexIndex, VertexTrait, errors::HypergraphError}; +use serde::{ + Serialize, + de::DeserializeOwned, +}; use super::PersistentHypergraph; +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + VertexIndex, + VertexTrait, + errors::HypergraphError, +}; impl PersistentHypergraph where @@ -21,9 +29,7 @@ where /// Returns [`HypergraphError::StorageError`] on I/O failure. pub fn add_vertex(&self, weight: V) -> Result> { #[allow(clippy::cast_possible_truncation)] - let idx = VertexIndex( - self.vertices_next_idx.fetch_add(1, Ordering::Relaxed) as usize, - ); + let idx = VertexIndex(self.vertices_next_idx.fetch_add(1, Ordering::Relaxed) as usize); self.vertices_count.fetch_add(1, Ordering::Relaxed); self.store_vertex(idx, weight)?; self.flush_meta()?; @@ -119,8 +125,10 @@ where self.delete_hyperedge(he_idx)?; self.hyperedges_count.fetch_sub(1, Ordering::Relaxed); } else { - let new_verts: Vec = - vertices.into_iter().filter(|&v| v != vertex_index).collect(); + let new_verts: Vec = vertices + .into_iter() + .filter(|&v| v != vertex_index) + .collect(); let he_weight = self.load_hyperedge(he_idx)?.1; self.store_hyperedge(he_idx, &new_verts, he_weight)?; } diff --git a/src/core/hyperedges/update_hyperedge_vertices.rs b/src/core/hyperedges/update_hyperedge_vertices.rs index 2e150eb..54d80a1 100644 --- a/src/core/hyperedges/update_hyperedge_vertices.rs +++ b/src/core/hyperedges/update_hyperedge_vertices.rs @@ -56,14 +56,18 @@ where // Add hyperedge ref to newly included vertices. for &v in &new_unique { - if !prev_unique.contains(&v) && let Some((_, he_set)) = self.vertices.get_mut(&v) { + if !prev_unique.contains(&v) + && let Some((_, he_set)) = self.vertices.get_mut(&v) + { he_set.insert(hyperedge_index); } } // Remove hyperedge ref from vertices no longer included. for &v in &prev_unique { - if !new_unique.contains(&v) && let Some((_, he_set)) = self.vertices.get_mut(&v) { + if !new_unique.contains(&v) + && let Some((_, he_set)) = self.vertices.get_mut(&v) + { he_set.swap_remove(&hyperedge_index); } } diff --git a/src/core/hypergraph.rs b/src/core/hypergraph.rs new file mode 100644 index 0000000..19c2d31 --- /dev/null +++ b/src/core/hypergraph.rs @@ -0,0 +1,152 @@ +use std::fmt::{ + Debug, + Display, + Formatter, + Result, +}; + +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + VertexIndex, + VertexTrait, + core::types::{ + AIndexMap, + ARandomState, + }, +}; + +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(bound( + serialize = "V: serde::Serialize, HE: serde::Serialize", + deserialize = "V: serde::Deserialize<'de>, HE: serde::Deserialize<'de>" + )) +)] +/// A directed hypergraph composed of generic vertices and hyperedges. +#[derive(Clone)] +pub struct Hypergraph { + /// Vertices keyed by their stable index. + /// Each entry holds the weight and the set of hyperedge indices that include this vertex. + pub(crate) vertices: AIndexMap)>, + + /// Hyperedges keyed by their stable index. + /// Each entry holds the ordered vertex list and the weight. + pub(crate) hyperedges: AIndexMap, HE)>, + + /// Monotonically increasing counter used to generate unique [`VertexIndex`] values. + pub(crate) vertices_count: usize, + + /// Monotonically increasing counter used to generate unique [`HyperedgeIndex`] values. + pub(crate) hyperedges_count: usize, +} + +impl Debug for Hypergraph +where + V: Debug, + HE: Debug, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + f.debug_struct("Hypergraph") + .field("vertices", &self.vertices) + .field("hyperedges", &self.hyperedges) + .finish_non_exhaustive() + } +} + +impl Display for Hypergraph +where + V: VertexTrait, + HE: HyperedgeTrait, +{ + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let mut vertices: Vec<(VertexIndex, &V)> = self.vertices_iter().collect(); + vertices.sort_by_key(|(idx, _)| *idx); + + write!(f, "Hypergraph {{ vertices: [")?; + for (i, (idx, weight)) in vertices.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}: {}", idx.0, weight)?; + } + + write!(f, "], hyperedges: [")?; + let mut hyperedges: Vec<(HyperedgeIndex, &HE)> = self.hyperedges_iter().collect(); + hyperedges.sort_by_key(|(idx, _)| *idx); + + for (i, (idx, weight)) in hyperedges.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}: {} [", idx.0, weight)?; + if let Ok(vertex_indexes) = self.get_hyperedge_vertices(*idx) { + for (j, v_idx) in vertex_indexes.iter().enumerate() { + if j > 0 { + write!(f, " → ")?; + } + if let Ok(v_weight) = self.get_vertex_weight(*v_idx) { + write!(f, "{v_weight}")?; + } + } + } + write!(f, "]")?; + } + + write!(f, "] }}") + } +} + +impl Default for Hypergraph +where + V: VertexTrait, + HE: HyperedgeTrait, +{ + fn default() -> Self { + Hypergraph::new() + } +} + +impl Hypergraph +where + V: VertexTrait, + HE: HyperedgeTrait, +{ + /// Returns `true` if the hypergraph contains no vertices. + /// + /// Because hyperedges require at least one vertex to exist, an empty vertex + /// set implies an empty hyperedge set as well. + #[must_use] + pub fn is_empty(&self) -> bool { + self.vertices.is_empty() + } + + /// Removes all vertices and hyperedges from the hypergraph. + /// + /// Both internal maps are emptied and the monotonic index counters are + /// reset to zero, so the next insertion will start from index `0` again. + pub fn clear(&mut self) { + self.hyperedges.clear(); + self.vertices.clear(); + self.hyperedges_count = 0; + self.vertices_count = 0; + } + + /// Creates a new hypergraph with no allocation. + #[must_use] + pub fn new() -> Self { + Hypergraph::with_capacity(0, 0) + } + + /// Creates a new hypergraph with the specified capacity. + #[must_use] + pub fn with_capacity(vertices: usize, hyperedges: usize) -> Self { + Hypergraph { + vertices: AIndexMap::with_capacity_and_hasher(vertices, ARandomState::default()), + hyperedges: AIndexMap::with_capacity_and_hasher(hyperedges, ARandomState::default()), + vertices_count: 0, + hyperedges_count: 0, + } + } +} diff --git a/src/core/iterator.rs b/src/core/iterator.rs index 4664776..1a0af9a 100644 --- a/src/core/iterator.rs +++ b/src/core/iterator.rs @@ -52,13 +52,17 @@ where /// Returns an iterator over all vertices as `(VertexIndex, &V)` pairs, in insertion order. #[must_use = "the iterator is lazy and must be consumed"] pub fn vertices_iter(&self) -> impl Iterator + '_ { - self.vertices.iter().map(|(&idx, (weight, _))| (idx, weight)) + self.vertices + .iter() + .map(|(&idx, (weight, _))| (idx, weight)) } /// Returns an iterator over all hyperedges as `(HyperedgeIndex, &HE)` pairs, in insertion order. #[must_use = "the iterator is lazy and must be consumed"] pub fn hyperedges_iter(&self) -> impl Iterator + '_ { - self.hyperedges.iter().map(|(&idx, (_, weight))| (idx, weight)) + self.hyperedges + .iter() + .map(|(&idx, (_, weight))| (idx, weight)) } } diff --git a/src/core/mod.rs b/src/core/mod.rs index 0036193..9c02ba5 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -5,194 +5,30 @@ pub mod disk; pub mod errors; #[doc(hidden)] pub mod hyperedges; -mod indexes; +pub(crate) mod hypergraph; +pub(crate) mod indexes; #[doc(hidden)] pub mod iterator; -mod shared; -#[doc(hidden)] -mod types; +pub(crate) mod shared; +pub(crate) mod traits; +pub(crate) mod types; #[doc(hidden)] pub mod vertices; -use std::{ - fmt::{ - Debug, - Display, - Formatter, - Result, - }, - hash::Hash, -}; - -use types::{ - AIndexMap, - AIndexSet, - ARandomState, -}; - -// Reexport indexes at this level. -pub use crate::core::indexes::{ - HyperedgeIndex, - VertexIndex, -}; -pub use crate::core::iterator::{ - HypergraphBorrowingIterator, - HypergraphIterator, -}; #[cfg(feature = "persistence")] pub use crate::core::disk::PersistentHypergraph; - -/// Trait bound required for vertex weights. -/// -/// Any type that implements `Copy + Debug + Display + Eq + Hash + Send + Sync` -/// satisfies this trait automatically via the blanket impl. You do not need to -/// implement it manually. -pub trait VertexTrait: Copy + Debug + Display + Eq + Hash + Send + Sync {} - -impl VertexTrait for T where T: Copy + Debug + Display + Eq + Hash + Send + Sync {} - -/// Trait bound required for hyperedge weights. -/// -/// In addition to [`VertexTrait`], a hyperedge weight must implement -/// `Into` so that its value can be used as a numeric cost in -/// shortest-path algorithms. The blanket impl covers any type that already -/// satisfies both constraints. -pub trait HyperedgeTrait: VertexTrait + Into {} - -impl HyperedgeTrait for T where T: VertexTrait + Into {} - -/// A directed hypergraph composed of generic vertices and hyperedges. -#[derive(Clone)] -#[cfg_attr( - feature = "serde", - derive(serde::Serialize, serde::Deserialize), - serde(bound( - serialize = "V: serde::Serialize, HE: serde::Serialize", - deserialize = "V: serde::Deserialize<'de>, HE: serde::Deserialize<'de>" - )) -)] -pub struct Hypergraph { - /// Vertices keyed by their stable index. - /// Each entry holds the weight and the set of hyperedge indices that include this vertex. - pub(crate) vertices: AIndexMap)>, - - /// Hyperedges keyed by their stable index. - /// Each entry holds the ordered vertex list and the weight. - pub(crate) hyperedges: AIndexMap, HE)>, - - /// Monotonically increasing counter used to generate unique [`VertexIndex`] values. - pub(crate) vertices_count: usize, - - /// Monotonically increasing counter used to generate unique [`HyperedgeIndex`] values. - pub(crate) hyperedges_count: usize, -} - -impl Debug for Hypergraph -where - V: Debug, - HE: Debug, -{ - fn fmt(&self, f: &mut Formatter<'_>) -> Result { - f.debug_struct("Hypergraph") - .field("vertices", &self.vertices) - .field("hyperedges", &self.hyperedges) - .finish_non_exhaustive() - } -} - -impl Display for Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - fn fmt(&self, f: &mut Formatter<'_>) -> Result { - let mut vertices: Vec<(VertexIndex, &V)> = self.vertices_iter().collect(); - vertices.sort_by_key(|(idx, _)| *idx); - - write!(f, "Hypergraph {{ vertices: [")?; - for (i, (idx, weight)) in vertices.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - write!(f, "{}: {}", idx.0, weight)?; - } - - write!(f, "], hyperedges: [")?; - let mut hyperedges: Vec<(HyperedgeIndex, &HE)> = self.hyperedges_iter().collect(); - hyperedges.sort_by_key(|(idx, _)| *idx); - - for (i, (idx, weight)) in hyperedges.iter().enumerate() { - if i > 0 { - write!(f, ", ")?; - } - write!(f, "{}: {} [", idx.0, weight)?; - if let Ok(vertex_indexes) = self.get_hyperedge_vertices(*idx) { - for (j, v_idx) in vertex_indexes.iter().enumerate() { - if j > 0 { - write!(f, " → ")?; - } - if let Ok(v_weight) = self.get_vertex_weight(*v_idx) { - write!(f, "{v_weight}")?; - } - } - } - write!(f, "]")?; - } - - write!(f, "] }}") - } -} - -impl Default for Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - fn default() -> Self { - Hypergraph::new() - } -} - -/// Hypergraph implementations. -impl Hypergraph -where - V: VertexTrait, - HE: HyperedgeTrait, -{ - /// Returns `true` if the hypergraph contains no vertices. - /// - /// Because hyperedges require at least one vertex to exist, an empty vertex - /// set implies an empty hyperedge set as well. - #[must_use] - pub fn is_empty(&self) -> bool { - self.vertices.is_empty() - } - - /// Removes all vertices and hyperedges from the hypergraph. - /// - /// Both internal maps are emptied and the monotonic index counters are - /// reset to zero, so the next insertion will start from index `0` again. - pub fn clear(&mut self) { - self.hyperedges.clear(); - self.vertices.clear(); - self.hyperedges_count = 0; - self.vertices_count = 0; - } - - /// Creates a new hypergraph with no allocation. - #[must_use] - pub fn new() -> Self { - Hypergraph::with_capacity(0, 0) - } - - /// Creates a new hypergraph with the specified capacity. - #[must_use] - pub fn with_capacity(vertices: usize, hyperedges: usize) -> Self { - Hypergraph { - vertices: AIndexMap::with_capacity_and_hasher(vertices, ARandomState::default()), - hyperedges: AIndexMap::with_capacity_and_hasher(hyperedges, ARandomState::default()), - vertices_count: 0, - hyperedges_count: 0, - } - } -} +pub use crate::core::{ + hypergraph::Hypergraph, + indexes::{ + HyperedgeIndex, + VertexIndex, + }, + iterator::{ + HypergraphBorrowingIterator, + HypergraphIterator, + }, + traits::{ + HyperedgeTrait, + VertexTrait, + }, +}; diff --git a/src/core/traits.rs b/src/core/traits.rs new file mode 100644 index 0000000..38a7e8b --- /dev/null +++ b/src/core/traits.rs @@ -0,0 +1,26 @@ +use std::{ + fmt::{ + Debug, + Display, + }, + hash::Hash, +}; + +/// Trait bound required for vertex weights. +/// +/// Any type that implements `Copy + Debug + Display + Eq + Hash + Send + Sync` +/// satisfies this trait automatically via the blanket impl. You do not need to +/// implement it manually. +pub trait VertexTrait: Copy + Debug + Display + Eq + Hash + Send + Sync {} + +impl VertexTrait for T where T: Copy + Debug + Display + Eq + Hash + Send + Sync {} + +/// Trait bound required for hyperedge weights. +/// +/// In addition to [`VertexTrait`], a hyperedge weight must implement +/// `Into` so that its value can be used as a numeric cost in +/// shortest-path algorithms. The blanket impl covers any type that already +/// satisfies both constraints. +pub trait HyperedgeTrait: VertexTrait + Into {} + +impl HyperedgeTrait for T where T: VertexTrait + Into {} diff --git a/src/core/types.rs b/src/core/types.rs index 0a01564..783b726 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -1,5 +1,8 @@ use ahash::RandomState; -use indexmap::{IndexMap, IndexSet}; +use indexmap::{ + IndexMap, + IndexSet, +}; /// Type alias to use `AHash` as a faster hasher for `IndexMap`. pub(crate) type AIndexMap = IndexMap; diff --git a/src/core/vertices/add_vertex.rs b/src/core/vertices/add_vertex.rs index 85dc6cd..236be5e 100644 --- a/src/core/vertices/add_vertex.rs +++ b/src/core/vertices/add_vertex.rs @@ -25,7 +25,10 @@ where self.vertices_count += 1; self.vertices.insert( index, - (weight, AIndexSet::with_capacity_and_hasher(0, ARandomState::default())), + ( + weight, + AIndexSet::with_capacity_and_hasher(0, ARandomState::default()), + ), ); Ok(index) } diff --git a/src/core/vertices/remove_vertex.rs b/src/core/vertices/remove_vertex.rs index 7f99dd7..2808802 100644 --- a/src/core/vertices/remove_vertex.rs +++ b/src/core/vertices/remove_vertex.rs @@ -37,8 +37,10 @@ where if unique_verts.len() == 1 { self.remove_hyperedge(he_index)?; } else { - let updated: Vec = - vertices.into_iter().filter(|&v| v != vertex_index).collect(); + let updated: Vec = vertices + .into_iter() + .filter(|&v| v != vertex_index) + .collect(); self.update_hyperedge_vertices(he_index, updated)?; } } diff --git a/tests/integration_disk.rs b/tests/integration_disk.rs index 282aef6..598b331 100644 --- a/tests/integration_disk.rs +++ b/tests/integration_disk.rs @@ -3,19 +3,25 @@ #[cfg(feature = "persistence")] mod disk_tests { use std::{ - fmt::{Display, Formatter, Result}, + fmt::{ + Display, + Formatter, + Result, + }, sync::Arc, }; - use hypergraph::{HyperedgeIndex, PersistentHypergraph, VertexIndex}; + use hypergraph::{ + HyperedgeIndex, + PersistentHypergraph, + VertexIndex, + }; // ────────────────────────────────────────────────────────────────────── // Minimal vertex / hyperedge types // ────────────────────────────────────────────────────────────────────── - #[derive( - Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize, - )] + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)] struct V(u32); impl Display for V { @@ -24,9 +30,7 @@ mod disk_tests { } } - #[derive( - Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize, - )] + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Serialize, serde::Deserialize)] struct HE(u32); impl Display for HE { @@ -182,10 +186,7 @@ mod disk_tests { let he0 = g.add_hyperedge(&[v0, v1], HE(1)).expect("he0"); g.update_hyperedge_vertices(he0, &[v0, v2]).expect("update"); - assert_eq!( - g.get_hyperedge_vertices(he0).expect("verts"), - vec![v0, v2] - ); + assert_eq!(g.get_hyperedge_vertices(he0).expect("verts"), vec![v0, v2]); // v1 should no longer reference he0. assert!(g.get_vertex_hyperedges(v1).expect("v1 hes").is_empty()); @@ -288,7 +289,11 @@ mod disk_tests { assert_eq!(all_indices.len(), total); all_indices.sort_unstable(); all_indices.dedup(); - assert_eq!(all_indices.len(), total, "duplicate vertex indices detected"); + assert_eq!( + all_indices.len(), + total, + "duplicate vertex indices detected" + ); assert_eq!(g.count_vertices(), total); } @@ -326,7 +331,11 @@ mod disk_tests { assert_eq!(all_indices.len(), total); all_indices.sort_unstable(); all_indices.dedup(); - assert_eq!(all_indices.len(), total, "duplicate hyperedge indices detected"); + assert_eq!( + all_indices.len(), + total, + "duplicate hyperedge indices detected" + ); assert_eq!(g.count_hyperedges(), total); } } From 1ae9e4ad363fbc1eccaf502c5e53bbe5694553d0 Mon Sep 17 00:00:00 2001 From: Davy Duperron Date: Mon, 25 May 2026 14:27:12 +0200 Subject: [PATCH 4/5] feat: add HypergraphQuery trait, unit tests, and code quality fixes --- CLAUDE.md | 25 + src/core/disk/graph.rs | 74 +- src/core/disk/helpers.rs | 104 +- src/core/disk/hyperedges.rs | 96 ++ src/core/disk/open.rs | 83 +- src/core/disk/types.rs | 8 - src/core/disk/vertices.rs | 70 ++ src/core/errors.rs | 8 +- src/core/hyperedges/add_hyperedge.rs | 45 + src/core/hyperedges/clear_hyperedges.rs | 20 + .../hyperedges/contract_hyperedge_vertices.rs | 28 + src/core/hyperedges/count_hyperedges.rs | 26 + .../hyperedges/find_hyperedges_by_weight.rs | 24 + src/core/hyperedges/get_hyperedge_vertices.rs | 25 + src/core/hyperedges/get_hyperedge_weight.rs | 25 + .../hyperedges/get_hyperedges_connecting.rs | 24 + .../get_hyperedges_intersections.rs | 26 + src/core/hyperedges/join_hyperedges.rs | 26 + src/core/hyperedges/mod.rs | 30 +- src/core/hyperedges/remove_hyperedge.rs | 26 + src/core/hyperedges/retain_hyperedges.rs | 26 + src/core/hyperedges/reverse_hyperedge.rs | 49 +- .../hyperedges/update_hyperedge_vertices.rs | 42 + .../hyperedges/update_hyperedge_weight.rs | 32 + src/core/hypergraph.rs | 50 + src/core/indexes.rs | 38 + src/core/mod.rs | 4 + src/core/query/hypergraph_impl.rs | 165 ++++ src/core/query/mod.rs | 6 + src/core/query/persistent_impl.rs | 167 ++++ src/core/query/trait_def.rs | 892 ++++++++++++++++++ src/core/shared.rs | 68 ++ src/core/test_support.rs | 109 +++ src/core/vertices/add_vertex.rs | 29 + src/core/vertices/bfs.rs | 27 + src/core/vertices/connected_components.rs | 30 + src/core/vertices/contains_vertex.rs | 24 + src/core/vertices/count_vertices.rs | 25 + src/core/vertices/dfs.rs | 27 + .../vertices/get_adjacent_vertices_from.rs | 27 + src/core/vertices/get_adjacent_vertices_to.rs | 26 + src/core/vertices/get_all_paths.rs | 34 + src/core/vertices/get_dijkstra_connections.rs | 61 +- src/core/vertices/get_dijkstra_from.rs | 62 +- .../get_full_adjacent_vertices_from.rs | 28 + .../vertices/get_full_adjacent_vertices_to.rs | 28 + .../vertices/get_full_vertex_hyperedges.rs | 36 +- src/core/vertices/get_vertex_degree_in.rs | 31 + src/core/vertices/get_vertex_degree_out.rs | 31 + src/core/vertices/get_vertex_hyperedges.rs | 27 + src/core/vertices/get_vertex_index.rs | 34 + src/core/vertices/get_vertex_weight.rs | 25 + src/core/vertices/is_acyclic.rs | 28 + src/core/vertices/is_reachable.rs | 38 + src/core/vertices/mod.rs | 54 +- src/core/vertices/remove_vertex.rs | 32 + src/core/vertices/retain_vertices.rs | 29 + .../vertices/strongly_connected_components.rs | 33 + src/core/vertices/subgraph.rs | 28 + src/core/vertices/topological_sort.rs | 31 + src/core/vertices/update_vertex_weight.rs | 33 + tests/integration_contraction.rs | 2 +- tests/integration_join.rs | 2 +- tests/integration_query_trait.rs | 433 +++++++++ 64 files changed, 3628 insertions(+), 168 deletions(-) create mode 100644 src/core/query/hypergraph_impl.rs create mode 100644 src/core/query/mod.rs create mode 100644 src/core/query/persistent_impl.rs create mode 100644 src/core/query/trait_def.rs create mode 100644 src/core/test_support.rs create mode 100644 tests/integration_query_trait.rs diff --git a/CLAUDE.md b/CLAUDE.md index 8f96c71..ac8e15a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,3 +16,28 @@ cargo nextest run (`pub use`). All type definitions, trait impls, and method implementations belong in dedicated subfiles — one logical group per file (e.g. `types.rs`, `helpers.rs`, `graph.rs`, `vertices.rs`, `hyperedges.rs`). + +## Comments + +Do not use ASCII ruler/banner comments as section dividers, e.g.: + +```rust +// ────────────────────────────────────────────────────────────────────────────── +// Section title +// ────────────────────────────────────────────────────────────────────────────── +``` + +or inline section labels such as: + +```rust +// ── Shortest paths ─────────────────────────────────────────────────────────── +``` + +Instead, document public functions and methods with idiomatic Rust doc comments +(`///`). Include `# Errors`, `# Panics`, and `# Returns` sections where +relevant. Every public method in a `pub` API module must have a doc comment. + +## Testing + +Every public function and method must have at least one test that exercises it +directly. diff --git a/src/core/disk/graph.rs b/src/core/disk/graph.rs index 82313b7..39bdc6f 100644 --- a/src/core/disk/graph.rs +++ b/src/core/disk/graph.rs @@ -21,10 +21,6 @@ use crate::{ errors::HypergraphError, }; -// ────────────────────────────────────────────────────────────────────────────── -// Graph-level utilities -// ────────────────────────────────────────────────────────────────────────────── - impl PersistentHypergraph where V: VertexTrait + Serialize + DeserializeOwned, @@ -86,6 +82,8 @@ where self.hyperedge_cache.clear(); self.vertices_count.store(0, Ordering::Relaxed); self.hyperedges_count.store(0, Ordering::Relaxed); + self.vertices_next_idx.store(0, Ordering::Relaxed); + self.hyperedges_next_idx.store(0, Ordering::Relaxed); self.flush_meta() } @@ -124,3 +122,71 @@ where .collect() } } + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use crate::core::test_support::disk::{ + EP, + WP, + build_persistent, + }; + + #[test] + fn count_vertices_and_hyperedges() { + let dir = tempdir().unwrap(); + let (g, _, _) = build_persistent(dir.path()); + assert_eq!(g.count_vertices(), 4); + assert_eq!(g.count_hyperedges(), 3); + } + + #[test] + fn is_empty_false_after_add() { + let dir = tempdir().unwrap(); + let (g, _, _) = build_persistent(dir.path()); + assert!(!g.is_empty()); + } + + #[test] + fn is_empty_true_for_new_graph() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(g.is_empty()); + } + + #[test] + fn vertex_indices_returns_all() { + let dir = tempdir().unwrap(); + let (g, [v0, v1, v2, v3], _) = build_persistent(dir.path()); + let mut got = g.vertex_indices().unwrap(); + got.sort(); + assert_eq!(got, vec![v0, v1, v2, v3]); + } + + #[test] + fn hyperedge_indices_returns_all() { + let dir = tempdir().unwrap(); + let (g, _, [e0, e1, e2]) = build_persistent(dir.path()); + let mut got = g.hyperedge_indices().unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); + } + + #[test] + fn clear_resets_everything() { + let dir = tempdir().unwrap(); + let (g, _, _) = build_persistent(dir.path()); + g.clear().unwrap(); + assert_eq!(g.count_vertices(), 0); + assert_eq!(g.count_hyperedges(), 0); + assert!(g.is_empty()); + } + + #[test] + fn persist_does_not_error() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(g.persist().is_ok()); + } +} diff --git a/src/core/disk/helpers.rs b/src/core/disk/helpers.rs index 804b3bd..6f0928c 100644 --- a/src/core/disk/helpers.rs +++ b/src/core/disk/helpers.rs @@ -23,10 +23,6 @@ use crate::{ errors::HypergraphError, }; -// ────────────────────────────────────────────────────────────────────────────── -// Constants -// ────────────────────────────────────────────────────────────────────────────── - pub(super) const META_VERTEX_IDX: &[u8] = b"vi"; pub(super) const META_VERTEX_COUNT: &[u8] = b"vc"; pub(super) const META_HYPEREDGE_IDX: &[u8] = b"hi"; @@ -34,10 +30,6 @@ pub(super) const META_HYPEREDGE_COUNT: &[u8] = b"hc"; pub(super) const DEFAULT_CACHE_CAPACITY: usize = 10_000; -// ────────────────────────────────────────────────────────────────────────────── -// Key helpers -// ────────────────────────────────────────────────────────────────────────────── - pub(super) fn decode_u64(bytes: &[u8]) -> u64 { bytes .get(..8) @@ -96,10 +88,6 @@ where HypergraphError::StorageError(e.to_string()) } -// ────────────────────────────────────────────────────────────────────────────── -// Internal helpers -// ────────────────────────────────────────────────────────────────────────────── - impl PersistentHypergraph where V: VertexTrait + Serialize + DeserializeOwned, @@ -198,13 +186,13 @@ where let keys: Vec<[u8; 16]> = self .vertex_refs_ks .prefix(vertex_key(v)) - .filter_map(|guard| { - guard - .into_inner() - .ok() - .and_then(|(k, _)| k.get(..16).and_then(|b| b.try_into().ok())) + .map(|guard| { + let (k, _) = guard.into_inner().map_err(storage_err)?; + k.get(..16).and_then(|b| b.try_into().ok()).ok_or_else(|| { + HypergraphError::StorageError("invalid vertex ref key length".into()) + }) }) - .collect(); + .collect::, _>>()?; for key in keys { self.vertex_refs_ks.remove(key).map_err(storage_err)?; @@ -287,3 +275,83 @@ where batch.commit().map_err(storage_err) } } + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::{ + decode_u64, + hyperedge_key, + key_to_hyperedge, + key_to_vertex, + vertex_key, + vertex_ref_key, + }; + use crate::{ + HyperedgeIndex, + VertexIndex, + core::test_support::disk::{ + EP, + WP, + build_persistent, + }, + }; + + #[test] + fn decode_u64_roundtrip() { + let n: u64 = 0xDEAD_BEEF_1234_5678; + assert_eq!(decode_u64(&n.to_be_bytes()), n); + } + + #[test] + fn decode_u64_short_slice_returns_zero() { + assert_eq!(decode_u64(&[1, 2, 3]), 0); + } + + #[test] + fn vertex_key_roundtrip() { + let idx = VertexIndex(42); + assert_eq!(key_to_vertex(&vertex_key(idx)), Some(idx)); + } + + #[test] + fn hyperedge_key_roundtrip() { + let idx = HyperedgeIndex(7); + assert_eq!(key_to_hyperedge(&hyperedge_key(idx)), Some(idx)); + } + + #[test] + fn vertex_ref_key_encodes_both_indices() { + let v = VertexIndex(1); + let he = HyperedgeIndex(2); + let key = vertex_ref_key(v, he); + assert_eq!(key_to_vertex(&key[..8]), Some(v)); + assert_eq!(key_to_hyperedge(&key[8..]), Some(he)); + } + + #[test] + fn load_and_store_vertex() { + let dir = tempdir().unwrap(); + let (g, [v0, _v1, _v2, _v3], _) = build_persistent(dir.path()); + assert_eq!(g.load_vertex(v0).unwrap(), WP(0)); + } + + #[test] + fn load_vertex_refs_returns_hyperedge_indices() { + let dir = tempdir().unwrap(); + let (g, [_v0, v1, _v2, _v3], [e0, e1, e2]) = build_persistent(dir.path()); + let mut got = g.load_vertex_refs(v1).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); + } + + #[test] + fn load_hyperedge_returns_arc() { + let dir = tempdir().unwrap(); + let (g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build_persistent(dir.path()); + let arc = g.load_hyperedge(e0).unwrap(); + assert_eq!(arc.0, vec![v0, v1]); + assert_eq!(arc.1, EP(1)); + } +} diff --git a/src/core/disk/hyperedges.rs b/src/core/disk/hyperedges.rs index 4c90e47..39e15f7 100644 --- a/src/core/disk/hyperedges.rs +++ b/src/core/disk/hyperedges.rs @@ -201,3 +201,99 @@ where self.flush_meta() } } + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use crate::{ + HyperedgeIndex, + VertexIndex, + core::test_support::disk::{ + EP, + WP, + build_persistent, + }, + }; + + #[test] + fn add_hyperedge_returns_index() { + let dir = tempdir().unwrap(); + let (g, [v0, v1, _v2, _v3], _) = build_persistent(dir.path()); + let e = g.add_hyperedge(&[v0, v1], EP(99)).unwrap(); + assert!(e.0 >= 3); // already have 3 edges from build_persistent + } + + #[test] + fn add_hyperedge_empty_vertices_returns_error() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(g.add_hyperedge(&[], EP(1)).is_err()); + } + + #[test] + fn add_hyperedge_missing_vertex_returns_error() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(g.add_hyperedge(&[VertexIndex(99)], EP(1)).is_err()); + } + + #[test] + fn get_hyperedge_weight() { + let dir = tempdir().unwrap(); + let (g, _, [e0, _e1, _e2]) = build_persistent(dir.path()); + assert_eq!(g.get_hyperedge_weight(e0).unwrap(), EP(1)); + } + + #[test] + fn get_hyperedge_weight_not_found() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(g.get_hyperedge_weight(HyperedgeIndex(99)).is_err()); + } + + #[test] + fn get_hyperedge_vertices() { + let dir = tempdir().unwrap(); + let (g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build_persistent(dir.path()); + assert_eq!(g.get_hyperedge_vertices(e0).unwrap(), vec![v0, v1]); + } + + #[test] + fn update_hyperedge_weight() { + let dir = tempdir().unwrap(); + let (g, _, [e0, _e1, _e2]) = build_persistent(dir.path()); + g.update_hyperedge_weight(e0, EP(99)).unwrap(); + assert_eq!(g.get_hyperedge_weight(e0).unwrap(), EP(99)); + } + + #[test] + fn update_hyperedge_weight_unchanged_returns_error() { + let dir = tempdir().unwrap(); + let (g, _, [e0, _e1, _e2]) = build_persistent(dir.path()); + assert!(g.update_hyperedge_weight(e0, EP(1)).is_err()); + } + + #[test] + fn update_hyperedge_vertices() { + let dir = tempdir().unwrap(); + let (g, [v0, _v1, v2, _v3], [e0, _e1, _e2]) = build_persistent(dir.path()); + g.update_hyperedge_vertices(e0, &[v0, v2]).unwrap(); + assert_eq!(g.get_hyperedge_vertices(e0).unwrap(), vec![v0, v2]); + } + + #[test] + fn remove_hyperedge_decrements_count() { + let dir = tempdir().unwrap(); + let (g, _, [e0, _e1, _e2]) = build_persistent(dir.path()); + g.remove_hyperedge(e0).unwrap(); + assert_eq!(g.count_hyperedges(), 2); + } + + #[test] + fn remove_hyperedge_not_found_returns_error() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(g.remove_hyperedge(HyperedgeIndex(99)).is_err()); + } +} diff --git a/src/core/disk/open.rs b/src/core/disk/open.rs index c378c24..6bbfb71 100644 --- a/src/core/disk/open.rs +++ b/src/core/disk/open.rs @@ -50,6 +50,23 @@ where /// Returns [`HypergraphError::StorageError`] if the database cannot be /// opened or the metadata cannot be read. pub fn open(path: impl AsRef) -> Result> { + Self::open_with_capacity(path, DEFAULT_CACHE_CAPACITY) + } + + /// Opens a persistent hypergraph with a custom hot-data cache capacity. + /// + /// `cache_capacity` controls how many entries each cache layer (vertex + /// weights and hyperedges) holds before evicting the least-recently-used + /// entries. The default when using [`open`](Self::open) is 10 000. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] if the database cannot be + /// opened. + pub fn open_with_capacity( + path: impl AsRef, + cache_capacity: usize, + ) -> Result> { let db = Database::builder(path.as_ref()) .open() .map_err(storage_err)?; @@ -97,8 +114,8 @@ where hyperedges_ks, vertex_refs_ks, meta_ks, - vertex_cache: Cache::new(DEFAULT_CACHE_CAPACITY), - hyperedge_cache: Cache::new(DEFAULT_CACHE_CAPACITY), + vertex_cache: Cache::new(cache_capacity), + hyperedge_cache: Cache::new(cache_capacity), vertices_next_idx: AtomicU64::new(vertices_next_idx), vertices_count: AtomicU64::new(vertices_count), hyperedges_next_idx: AtomicU64::new(hyperedges_next_idx), @@ -106,24 +123,50 @@ where _phantom: PhantomData, }) } +} - /// Opens a persistent hypergraph with a custom hot-data cache capacity. - /// - /// `cache_capacity` controls how many entries each cache layer (vertex - /// weights and hyperedges) holds before evicting the least-recently-used - /// entries. The default when using [`open`](Self::open) is 10 000. - /// - /// # Errors - /// - /// Returns [`HypergraphError::StorageError`] if the database cannot be - /// opened. - pub fn open_with_capacity( - path: impl AsRef, - cache_capacity: usize, - ) -> Result> { - let mut graph = Self::open(path)?; - graph.vertex_cache = Cache::new(cache_capacity); - graph.hyperedge_cache = Cache::new(cache_capacity); - Ok(graph) +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use crate::core::test_support::disk::{ + EP, + WP, + build_persistent, + }; + + #[test] + fn open_creates_empty_graph() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert_eq!(g.count_vertices(), 0); + assert_eq!(g.count_hyperedges(), 0); + assert!(g.is_empty()); + } + + #[test] + fn open_with_capacity_creates_empty_graph() { + let dir = tempdir().unwrap(); + let g = + crate::core::disk::PersistentHypergraph::::open_with_capacity(dir.path(), 100) + .unwrap(); + assert_eq!(g.count_vertices(), 0); + } + + #[test] + fn reopen_recovers_data() { + let dir = tempdir().unwrap(); + { + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + g.add_vertex(WP(1)).unwrap(); + g.add_vertex(WP(2)).unwrap(); + } + let g2 = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert_eq!(g2.count_vertices(), 2); + } + + // Silence unused import warning for build_persistent when only some tests use it + fn _use_build_persistent(dir: &std::path::Path) { + let _ = build_persistent(dir); } } diff --git a/src/core/disk/types.rs b/src/core/disk/types.rs index e338a25..5c359e7 100644 --- a/src/core/disk/types.rs +++ b/src/core/disk/types.rs @@ -19,17 +19,9 @@ use crate::{ VertexTrait, }; -// ────────────────────────────────────────────────────────────────────────────── -// Type aliases -// ────────────────────────────────────────────────────────────────────────────── - /// Cached hyperedge entry: ordered vertex list + weight. pub(super) type HyperedgeArc = Arc<(Vec, HE)>; -// ────────────────────────────────────────────────────────────────────────────── -// Struct definition -// ────────────────────────────────────────────────────────────────────────────── - /// A directed hypergraph persisted on disk via fjall (LSM-tree) with a /// [`quick_cache`] hot-data layer. /// diff --git a/src/core/disk/vertices.rs b/src/core/disk/vertices.rs index 7d57567..8aa7a40 100644 --- a/src/core/disk/vertices.rs +++ b/src/core/disk/vertices.rs @@ -141,3 +141,73 @@ where self.flush_meta() } } + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use crate::{ + VertexIndex, + core::test_support::disk::{ + EP, + WP, + build_persistent, + }, + }; + + #[test] + fn add_vertex_returns_sequential_indices() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + let i0 = g.add_vertex(WP(0)).unwrap(); + let i1 = g.add_vertex(WP(1)).unwrap(); + assert_eq!(i0, VertexIndex(0)); + assert_eq!(i1, VertexIndex(1)); + } + + #[test] + fn get_vertex_weight_returns_value() { + let dir = tempdir().unwrap(); + let (g, [v0, _v1, _v2, _v3], _) = build_persistent(dir.path()); + assert_eq!(g.get_vertex_weight(v0).unwrap(), WP(0)); + } + + #[test] + fn get_vertex_weight_not_found() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(g.get_vertex_weight(VertexIndex(99)).is_err()); + } + + #[test] + fn update_vertex_weight() { + let dir = tempdir().unwrap(); + let (g, [v0, _v1, _v2, _v3], _) = build_persistent(dir.path()); + g.update_vertex_weight(v0, WP(99)).unwrap(); + assert_eq!(g.get_vertex_weight(v0).unwrap(), WP(99)); + } + + #[test] + fn update_vertex_weight_unchanged_returns_error() { + let dir = tempdir().unwrap(); + let (g, [v0, _v1, _v2, _v3], _) = build_persistent(dir.path()); + assert!(g.update_vertex_weight(v0, WP(0)).is_err()); + } + + #[test] + fn get_vertex_hyperedges_returns_indices() { + let dir = tempdir().unwrap(); + let (g, [_v0, v1, _v2, _v3], [e0, e1, e2]) = build_persistent(dir.path()); + let mut got = g.get_vertex_hyperedges(v1).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); + } + + #[test] + fn remove_vertex_decrements_count() { + let dir = tempdir().unwrap(); + let (g, [v0, _v1, _v2, _v3], _) = build_persistent(dir.path()); + g.remove_vertex(v0).unwrap(); + assert_eq!(g.count_vertices(), 3); + } +} diff --git a/src/core/errors.rs b/src/core/errors.rs index 043828e..7edf862 100644 --- a/src/core/errors.rs +++ b/src/core/errors.rs @@ -32,12 +32,12 @@ where #[error("HyperedgeIndex {0} vertices are unchanged (no-op)")] HyperedgeVerticesUnchanged(HyperedgeIndex), - /// Error when a hyperedge is updated with no vertices. - #[error("HyperedgeIndex {0} vertices are missing")] + /// Error when a hyperedge cannot be created because no vertices were provided. + #[error("Hyperedge with weight {0} cannot be created: no vertices provided")] HyperedgeCreationNoVertices(HE), - /// Error when a hyperedge is updated with no vertices. - #[error("HyperedgeIndex {0} vertices are missing")] + /// Error when a hyperedge cannot be updated because no vertices were provided. + #[error("HyperedgeIndex {0} cannot be updated: no vertices provided")] HyperedgeUpdateNoVertices(HyperedgeIndex), /// Error when a hyperedge doesn't contain some vertices. diff --git a/src/core/hyperedges/add_hyperedge.rs b/src/core/hyperedges/add_hyperedge.rs index 2460a79..9891bb8 100644 --- a/src/core/hyperedges/add_hyperedge.rs +++ b/src/core/hyperedges/add_hyperedge.rs @@ -62,3 +62,48 @@ where Ok(he_index) } } + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn returns_sequential_indices() { + let mut g: Hypergraph = Hypergraph::new(); + let v = g.add_vertex(W(0)).unwrap(); + let e0 = g.add_hyperedge(vec![v], E(1)).unwrap(); + let e1 = g.add_hyperedge(vec![v], E(2)).unwrap(); + assert_eq!(e0, HyperedgeIndex(0)); + assert_eq!(e1, HyperedgeIndex(1)); + } + + #[test] + fn empty_vertices_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + assert!(g.add_hyperedge(vec![], E(1)).is_err()); + } + + #[test] + fn missing_vertex_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + assert!(g.add_hyperedge(vec![VertexIndex(99)], E(1)).is_err()); + } + + #[test] + fn identical_entry_is_idempotent() { + let mut g: Hypergraph = Hypergraph::new(); + let v = g.add_vertex(W(0)).unwrap(); + let e0 = g.add_hyperedge(vec![v], E(1)).unwrap(); + let e1 = g.add_hyperedge(vec![v], E(1)).unwrap(); + assert_eq!(e0, e1); + assert_eq!(g.count_hyperedges(), 1); + } +} diff --git a/src/core/hyperedges/clear_hyperedges.rs b/src/core/hyperedges/clear_hyperedges.rs index 30f2876..aa85037 100644 --- a/src/core/hyperedges/clear_hyperedges.rs +++ b/src/core/hyperedges/clear_hyperedges.rs @@ -25,3 +25,23 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn clears_all_hyperedges_keeps_vertices() { + let (mut g, _, _) = build(); + g.clear_hyperedges().unwrap(); + assert_eq!(g.count_hyperedges(), 0); + assert_eq!(g.count_vertices(), 4); + } +} diff --git a/src/core/hyperedges/contract_hyperedge_vertices.rs b/src/core/hyperedges/contract_hyperedge_vertices.rs index b36c8a1..95ecfe2 100644 --- a/src/core/hyperedges/contract_hyperedge_vertices.rs +++ b/src/core/hyperedges/contract_hyperedge_vertices.rs @@ -93,3 +93,31 @@ where self.get_hyperedge_vertices(hyperedge_index) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn contracts_vertices() { + let (mut g, [v0, v1, v2, _v3], [e0, _e1, _e2]) = build(); + // e0 = [v0, v1]; contract v0 and v1 into v0 + let result = g.contract_hyperedge_vertices(e0, vec![v0, v1], v0).unwrap(); + // both v0 and v1 map to v0; consecutive duplicates deduped → [v0] + assert_eq!(result, vec![v0]); + let _ = v2; + } + + #[test] + fn target_not_in_set_returns_error() { + let (mut g, [v0, v1, v2, _v3], [e0, _e1, _e2]) = build(); + assert!(g.contract_hyperedge_vertices(e0, vec![v0, v1], v2).is_err()); + } +} diff --git a/src/core/hyperedges/count_hyperedges.rs b/src/core/hyperedges/count_hyperedges.rs index 1dd9932..9a62434 100644 --- a/src/core/hyperedges/count_hyperedges.rs +++ b/src/core/hyperedges/count_hyperedges.rs @@ -15,3 +15,29 @@ where self.hyperedges.len() } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn empty_graph_is_zero() { + let g: Hypergraph = Hypergraph::new(); + assert_eq!(g.count_hyperedges(), 0); + } + + #[test] + fn after_additions() { + let mut g: Hypergraph = Hypergraph::new(); + let v = g.add_vertex(W(0)).unwrap(); + g.add_hyperedge(vec![v], E(1)).unwrap(); + g.add_hyperedge(vec![v], E(2)).unwrap(); + assert_eq!(g.count_hyperedges(), 2); + } +} diff --git a/src/core/hyperedges/find_hyperedges_by_weight.rs b/src/core/hyperedges/find_hyperedges_by_weight.rs index 09fb03a..0789850 100644 --- a/src/core/hyperedges/find_hyperedges_by_weight.rs +++ b/src/core/hyperedges/find_hyperedges_by_weight.rs @@ -21,3 +21,27 @@ where .collect() } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn finds_by_weight() { + let (g, _, [e0, _e1, _e2]) = build(); + assert_eq!(g.find_hyperedges_by_weight(E(1)), vec![e0]); + } + + #[test] + fn returns_empty_for_missing() { + let (g, _, _) = build(); + assert!(g.find_hyperedges_by_weight(E(99)).is_empty()); + } +} diff --git a/src/core/hyperedges/get_hyperedge_vertices.rs b/src/core/hyperedges/get_hyperedge_vertices.rs index 5815032..5704d95 100644 --- a/src/core/hyperedges/get_hyperedge_vertices.rs +++ b/src/core/hyperedges/get_hyperedge_vertices.rs @@ -31,3 +31,28 @@ where .ok_or(HypergraphError::HyperedgeIndexNotFound(hyperedge_index)) } } + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_vertex_list() { + let (g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build(); + assert_eq!(g.get_hyperedge_vertices(e0).unwrap(), vec![v0, v1]); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_hyperedge_vertices(HyperedgeIndex(99)).is_err()); + } +} diff --git a/src/core/hyperedges/get_hyperedge_weight.rs b/src/core/hyperedges/get_hyperedge_weight.rs index 5e31ef9..d57d8b2 100644 --- a/src/core/hyperedges/get_hyperedge_weight.rs +++ b/src/core/hyperedges/get_hyperedge_weight.rs @@ -27,3 +27,28 @@ where .ok_or(HypergraphError::HyperedgeIndexNotFound(hyperedge_index)) } } + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_weight() { + let (g, _, [e0, _e1, _e2]) = build(); + assert_eq!(g.get_hyperedge_weight(e0).unwrap(), &E(1)); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_hyperedge_weight(HyperedgeIndex(99)).is_err()); + } +} diff --git a/src/core/hyperedges/get_hyperedges_connecting.rs b/src/core/hyperedges/get_hyperedges_connecting.rs index 4dff8c8..5370d5c 100644 --- a/src/core/hyperedges/get_hyperedges_connecting.rs +++ b/src/core/hyperedges/get_hyperedges_connecting.rs @@ -38,3 +38,27 @@ where .collect()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn finds_connecting_hyperedge() { + let (g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build(); + assert_eq!(g.get_hyperedges_connecting(v0, v1).unwrap(), vec![e0]); + } + + #[test] + fn returns_empty_when_no_direct_connection() { + let (g, [v0, _v1, v2, _v3], _) = build(); + assert!(g.get_hyperedges_connecting(v0, v2).unwrap().is_empty()); + } +} diff --git a/src/core/hyperedges/get_hyperedges_intersections.rs b/src/core/hyperedges/get_hyperedges_intersections.rs index 429036a..8798602 100644 --- a/src/core/hyperedges/get_hyperedges_intersections.rs +++ b/src/core/hyperedges/get_hyperedges_intersections.rs @@ -55,3 +55,29 @@ where Ok(result) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_common_vertices() { + let (g, [_v0, v1, _v2, _v3], [e0, e1, e2]) = build(); + let mut got = g.get_hyperedges_intersections(&[e0, e1, e2]).unwrap(); + got.sort(); + assert_eq!(got, vec![v1]); + } + + #[test] + fn too_few_hyperedges_returns_error() { + let (g, _, [e0, _e1, _e2]) = build(); + assert!(g.get_hyperedges_intersections(&[e0]).is_err()); + } +} diff --git a/src/core/hyperedges/join_hyperedges.rs b/src/core/hyperedges/join_hyperedges.rs index 5c33cd2..812fe11 100644 --- a/src/core/hyperedges/join_hyperedges.rs +++ b/src/core/hyperedges/join_hyperedges.rs @@ -60,3 +60,29 @@ where } } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn joins_two_hyperedges() { + let (mut g, [v0, v1, v2, _v3], [e0, e1, _e2]) = build(); + g.join_hyperedges(&[e0, e1]).unwrap(); + assert_eq!(g.count_hyperedges(), 2); // e0+e1 merged, e2 remains + assert_eq!(g.get_hyperedge_vertices(e0).unwrap(), vec![v0, v1, v1, v2]); + } + + #[test] + fn too_few_hyperedges_returns_error() { + let (mut g, _, [e0, _e1, _e2]) = build(); + assert!(g.join_hyperedges(&[e0]).is_err()); + } +} diff --git a/src/core/hyperedges/mod.rs b/src/core/hyperedges/mod.rs index 1da3b7e..b6e011c 100644 --- a/src/core/hyperedges/mod.rs +++ b/src/core/hyperedges/mod.rs @@ -1,15 +1,15 @@ -pub mod add_hyperedge; -pub mod clear_hyperedges; -pub mod contract_hyperedge_vertices; -pub mod count_hyperedges; -pub mod find_hyperedges_by_weight; -pub mod get_hyperedge_vertices; -pub mod get_hyperedge_weight; -pub mod get_hyperedges_connecting; -pub mod get_hyperedges_intersections; -pub mod join_hyperedges; -pub mod remove_hyperedge; -pub mod retain_hyperedges; -pub mod reverse_hyperedge; -pub mod update_hyperedge_vertices; -pub mod update_hyperedge_weight; +pub(crate) mod add_hyperedge; +pub(crate) mod clear_hyperedges; +pub(crate) mod contract_hyperedge_vertices; +pub(crate) mod count_hyperedges; +pub(crate) mod find_hyperedges_by_weight; +pub(crate) mod get_hyperedge_vertices; +pub(crate) mod get_hyperedge_weight; +pub(crate) mod get_hyperedges_connecting; +pub(crate) mod get_hyperedges_intersections; +pub(crate) mod join_hyperedges; +pub(crate) mod remove_hyperedge; +pub(crate) mod retain_hyperedges; +pub(crate) mod reverse_hyperedge; +pub(crate) mod update_hyperedge_vertices; +pub(crate) mod update_hyperedge_weight; diff --git a/src/core/hyperedges/remove_hyperedge.rs b/src/core/hyperedges/remove_hyperedge.rs index c5c7d5d..7a2491d 100644 --- a/src/core/hyperedges/remove_hyperedge.rs +++ b/src/core/hyperedges/remove_hyperedge.rs @@ -42,3 +42,29 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn removes_hyperedge() { + let (mut g, _, [e0, _e1, _e2]) = build(); + g.remove_hyperedge(e0).unwrap(); + assert_eq!(g.count_hyperedges(), 2); + } + + #[test] + fn not_found_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + assert!(g.remove_hyperedge(HyperedgeIndex(99)).is_err()); + } +} diff --git a/src/core/hyperedges/retain_hyperedges.rs b/src/core/hyperedges/retain_hyperedges.rs index 9002a2a..ce321bf 100644 --- a/src/core/hyperedges/retain_hyperedges.rs +++ b/src/core/hyperedges/retain_hyperedges.rs @@ -31,3 +31,29 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn retains_matching_removes_rest() { + let (mut g, _, [e0, _e1, _e2]) = build(); + g.retain_hyperedges(|idx, _| idx == e0).unwrap(); + assert_eq!(g.count_hyperedges(), 1); + } + + #[test] + fn keep_all_leaves_graph_intact() { + let (mut g, _, _) = build(); + g.retain_hyperedges(|_, _| true).unwrap(); + assert_eq!(g.count_hyperedges(), 3); + } +} diff --git a/src/core/hyperedges/reverse_hyperedge.rs b/src/core/hyperedges/reverse_hyperedge.rs index 4b6b843..63deec0 100644 --- a/src/core/hyperedges/reverse_hyperedge.rs +++ b/src/core/hyperedges/reverse_hyperedge.rs @@ -1,5 +1,3 @@ -use rayon::prelude::*; - use crate::{ HyperedgeIndex, HyperedgeTrait, @@ -16,7 +14,9 @@ where /// Reverses the vertex order of the hyperedge at `hyperedge_index`. /// /// This inverts the direction of the hyperedge without changing which - /// vertices it connects. The weight is unchanged. + /// vertices it connects. The weight is unchanged. Palindromic vertex + /// lists (where reversing yields the same sequence) are treated as a + /// no-op and return `Ok(())`. /// /// # Errors /// @@ -27,7 +27,48 @@ where hyperedge_index: HyperedgeIndex, ) -> Result<(), HypergraphError> { let vertices = self.get_hyperedge_vertices(hyperedge_index)?; + let reversed: Vec<_> = vertices.iter().copied().rev().collect(); + + if reversed == vertices { + return Ok(()); + } + + self.update_hyperedge_vertices(hyperedge_index, reversed) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn reverses_vertex_list() { + let (mut g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build(); + g.reverse_hyperedge(e0).unwrap(); + assert_eq!(g.get_hyperedge_vertices(e0).unwrap(), vec![v1, v0]); + } + + #[test] + fn palindrome_is_noop() { + let mut g: Hypergraph = Hypergraph::new(); + let a = g.add_vertex(W(0)).unwrap(); + let b = g.add_vertex(W(1)).unwrap(); + let e = g.add_hyperedge(vec![a, b, a], E(1)).unwrap(); + g.reverse_hyperedge(e).unwrap(); + assert_eq!(g.get_hyperedge_vertices(e).unwrap(), vec![a, b, a]); + } - self.update_hyperedge_vertices(hyperedge_index, vertices.into_par_iter().rev().collect()) + #[test] + fn not_found_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + assert!(g.reverse_hyperedge(HyperedgeIndex(99)).is_err()); } } diff --git a/src/core/hyperedges/update_hyperedge_vertices.rs b/src/core/hyperedges/update_hyperedge_vertices.rs index 54d80a1..c917328 100644 --- a/src/core/hyperedges/update_hyperedge_vertices.rs +++ b/src/core/hyperedges/update_hyperedge_vertices.rs @@ -79,3 +79,45 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn updates_vertex_list() { + let (mut g, [v0, _v1, v2, _v3], [e0, _e1, _e2]) = build(); + g.update_hyperedge_vertices(e0, vec![v0, v2]).unwrap(); + assert_eq!(g.get_hyperedge_vertices(e0).unwrap(), vec![v0, v2]); + } + + #[test] + fn empty_vertices_returns_error() { + let (mut g, _, [e0, _e1, _e2]) = build(); + assert!(g.update_hyperedge_vertices(e0, vec![]).is_err()); + } + + #[test] + fn unchanged_vertices_returns_error() { + let (mut g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build(); + assert!(g.update_hyperedge_vertices(e0, vec![v0, v1]).is_err()); + } + + #[test] + fn not_found_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + let v = g.add_vertex(W(0)).unwrap(); + assert!( + g.update_hyperedge_vertices(HyperedgeIndex(99), vec![v]) + .is_err() + ); + } +} diff --git a/src/core/hyperedges/update_hyperedge_weight.rs b/src/core/hyperedges/update_hyperedge_weight.rs index 8f5ac0b..260d9a5 100644 --- a/src/core/hyperedges/update_hyperedge_weight.rs +++ b/src/core/hyperedges/update_hyperedge_weight.rs @@ -40,3 +40,35 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn updates_weight() { + let (mut g, _, [e0, _e1, _e2]) = build(); + g.update_hyperedge_weight(e0, E(99)).unwrap(); + assert_eq!(g.get_hyperedge_weight(e0).unwrap(), &E(99)); + } + + #[test] + fn unchanged_weight_returns_error() { + let (mut g, _, [e0, _e1, _e2]) = build(); + assert!(g.update_hyperedge_weight(e0, E(1)).is_err()); + } + + #[test] + fn not_found_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + assert!(g.update_hyperedge_weight(HyperedgeIndex(99), E(1)).is_err()); + } +} diff --git a/src/core/hypergraph.rs b/src/core/hypergraph.rs index 19c2d31..4ae337d 100644 --- a/src/core/hypergraph.rs +++ b/src/core/hypergraph.rs @@ -150,3 +150,53 @@ where } } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn new_is_empty() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.is_empty()); + assert_eq!(g.count_vertices(), 0); + assert_eq!(g.count_hyperedges(), 0); + } + + #[test] + fn with_capacity_is_empty() { + let g: Hypergraph = Hypergraph::with_capacity(10, 10); + assert!(g.is_empty()); + } + + #[test] + fn is_empty_false_after_add() { + let mut g: Hypergraph = Hypergraph::new(); + g.add_vertex(W(0)).unwrap(); + assert!(!g.is_empty()); + } + + #[test] + fn clear_empties_everything() { + let (mut g, _, _) = build(); + g.clear(); + assert!(g.is_empty()); + assert_eq!(g.count_hyperedges(), 0); + // Counters reset — next insertion starts at 0 + let idx = g.add_vertex(W(0)).unwrap(); + assert_eq!(idx.0, 0); + } + + #[test] + fn default_is_empty() { + let g: Hypergraph = Hypergraph::default(); + assert!(g.is_empty()); + } +} diff --git a/src/core/indexes.rs b/src/core/indexes.rs index dede338..6354f53 100644 --- a/src/core/indexes.rs +++ b/src/core/indexes.rs @@ -41,3 +41,41 @@ impl From for HyperedgeIndex { HyperedgeIndex(index) } } + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + VertexIndex, + }; + + #[test] + fn vertex_index_display() { + assert_eq!(VertexIndex(7).to_string(), "7"); + } + + #[test] + fn hyperedge_index_display() { + assert_eq!(HyperedgeIndex(42).to_string(), "42"); + } + + #[test] + fn vertex_index_from_usize() { + assert_eq!(VertexIndex::from(3usize), VertexIndex(3)); + } + + #[test] + fn hyperedge_index_from_usize() { + assert_eq!(HyperedgeIndex::from(9usize), HyperedgeIndex(9)); + } + + #[test] + fn vertex_index_ordering() { + assert!(VertexIndex(0) < VertexIndex(1)); + } + + #[test] + fn hyperedge_index_ordering() { + assert!(HyperedgeIndex(2) > HyperedgeIndex(1)); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 9c02ba5..b19db77 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,7 +9,10 @@ pub(crate) mod hypergraph; pub(crate) mod indexes; #[doc(hidden)] pub mod iterator; +pub mod query; pub(crate) mod shared; +#[cfg(test)] +pub(crate) mod test_support; pub(crate) mod traits; pub(crate) mod types; #[doc(hidden)] @@ -27,6 +30,7 @@ pub use crate::core::{ HypergraphBorrowingIterator, HypergraphIterator, }, + query::HypergraphQuery, traits::{ HyperedgeTrait, VertexTrait, diff --git a/src/core/query/hypergraph_impl.rs b/src/core/query/hypergraph_impl.rs new file mode 100644 index 0000000..5acd72d --- /dev/null +++ b/src/core/query/hypergraph_impl.rs @@ -0,0 +1,165 @@ +use super::trait_def::HypergraphQuery; +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + Hypergraph, + VertexIndex, + VertexTrait, + errors::HypergraphError, +}; + +impl HypergraphQuery for Hypergraph +where + V: VertexTrait, + HE: HyperedgeTrait, +{ + fn count_vertices(&self) -> usize { + self.vertices.len() + } + + fn count_hyperedges(&self) -> usize { + self.hyperedges.len() + } + + fn is_empty(&self) -> bool { + self.vertices.is_empty() + } + + fn vertex_indices(&self) -> Result, HypergraphError> { + Ok(self.vertices.keys().copied().collect()) + } + + fn hyperedge_indices(&self) -> Result, HypergraphError> { + Ok(self.hyperedges.keys().copied().collect()) + } + + fn get_vertex_weight(&self, idx: VertexIndex) -> Result> { + self.vertices + .get(&idx) + .map(|(w, _)| *w) + .ok_or(HypergraphError::VertexIndexNotFound(idx)) + } + + fn get_hyperedge_weight(&self, idx: HyperedgeIndex) -> Result> { + self.hyperedges + .get(&idx) + .map(|(_, w)| *w) + .ok_or(HypergraphError::HyperedgeIndexNotFound(idx)) + } + + fn get_vertex_hyperedges( + &self, + idx: VertexIndex, + ) -> Result, HypergraphError> { + self.vertices + .get(&idx) + .map(|(_, he_set)| he_set.iter().copied().collect()) + .ok_or(HypergraphError::VertexIndexNotFound(idx)) + } + + fn get_hyperedge_vertices( + &self, + idx: HyperedgeIndex, + ) -> Result, HypergraphError> { + self.hyperedges + .get(&idx) + .map(|(v, _)| v.clone()) + .ok_or(HypergraphError::HyperedgeIndexNotFound(idx)) + } +} + +#[cfg(test)] +mod tests { + use crate::{ + HyperedgeIndex, + Hypergraph, + HypergraphQuery, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn count_vertices() { + let (g, _, _) = build(); + assert_eq!(HypergraphQuery::count_vertices(&g), 4); + } + + #[test] + fn count_hyperedges() { + let (g, _, _) = build(); + assert_eq!(HypergraphQuery::count_hyperedges(&g), 3); + } + + #[test] + fn is_empty_false() { + let (g, _, _) = build(); + assert!(!HypergraphQuery::is_empty(&g)); + } + + #[test] + fn is_empty_true() { + let g: Hypergraph = Hypergraph::new(); + assert!(HypergraphQuery::is_empty(&g)); + } + + #[test] + fn vertex_indices() { + let (g, [v0, v1, v2, v3], _) = build(); + let mut got = HypergraphQuery::vertex_indices(&g).unwrap(); + got.sort(); + assert_eq!(got, vec![v0, v1, v2, v3]); + } + + #[test] + fn hyperedge_indices() { + let (g, _, [e0, e1, e2]) = build(); + let mut got = HypergraphQuery::hyperedge_indices(&g).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); + } + + #[test] + fn get_vertex_weight() { + let (g, [v0, _v1, _v2, _v3], _) = build(); + assert_eq!(HypergraphQuery::get_vertex_weight(&g, v0).unwrap(), W(0)); + } + + #[test] + fn get_vertex_weight_not_found() { + let g: Hypergraph = Hypergraph::new(); + assert!(HypergraphQuery::get_vertex_weight(&g, VertexIndex(99)).is_err()); + } + + #[test] + fn get_hyperedge_weight() { + let (g, _, [e0, _e1, _e2]) = build(); + assert_eq!(HypergraphQuery::get_hyperedge_weight(&g, e0).unwrap(), E(1)); + } + + #[test] + fn get_hyperedge_weight_not_found() { + let g: Hypergraph = Hypergraph::new(); + assert!(HypergraphQuery::get_hyperedge_weight(&g, HyperedgeIndex(99)).is_err()); + } + + #[test] + fn get_vertex_hyperedges() { + let (g, [_v0, v1, _v2, _v3], [e0, e1, e2]) = build(); + let mut got = HypergraphQuery::get_vertex_hyperedges(&g, v1).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); + } + + #[test] + fn get_hyperedge_vertices() { + let (g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build(); + assert_eq!( + HypergraphQuery::get_hyperedge_vertices(&g, e0).unwrap(), + vec![v0, v1] + ); + } +} diff --git a/src/core/query/mod.rs b/src/core/query/mod.rs new file mode 100644 index 0000000..c9ef6a6 --- /dev/null +++ b/src/core/query/mod.rs @@ -0,0 +1,6 @@ +mod hypergraph_impl; +#[cfg(feature = "persistence")] +mod persistent_impl; +mod trait_def; + +pub use trait_def::HypergraphQuery; diff --git a/src/core/query/persistent_impl.rs b/src/core/query/persistent_impl.rs new file mode 100644 index 0000000..5c60528 --- /dev/null +++ b/src/core/query/persistent_impl.rs @@ -0,0 +1,167 @@ +use serde::{ + Serialize, + de::DeserializeOwned, +}; + +use super::trait_def::HypergraphQuery; +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + VertexIndex, + VertexTrait, + core::disk::PersistentHypergraph, + errors::HypergraphError, +}; + +impl HypergraphQuery for PersistentHypergraph +where + V: VertexTrait + Serialize + DeserializeOwned, + HE: HyperedgeTrait + Serialize + DeserializeOwned, +{ + fn count_vertices(&self) -> usize { + PersistentHypergraph::count_vertices(self) + } + + fn count_hyperedges(&self) -> usize { + PersistentHypergraph::count_hyperedges(self) + } + + fn is_empty(&self) -> bool { + PersistentHypergraph::is_empty(self) + } + + fn vertex_indices(&self) -> Result, HypergraphError> { + PersistentHypergraph::vertex_indices(self) + } + + fn hyperedge_indices(&self) -> Result, HypergraphError> { + PersistentHypergraph::hyperedge_indices(self) + } + + fn get_vertex_weight(&self, idx: VertexIndex) -> Result> { + PersistentHypergraph::get_vertex_weight(self, idx) + } + + fn get_hyperedge_weight(&self, idx: HyperedgeIndex) -> Result> { + PersistentHypergraph::get_hyperedge_weight(self, idx) + } + + fn get_vertex_hyperedges( + &self, + idx: VertexIndex, + ) -> Result, HypergraphError> { + PersistentHypergraph::get_vertex_hyperedges(self, idx) + } + + fn get_hyperedge_vertices( + &self, + idx: HyperedgeIndex, + ) -> Result, HypergraphError> { + PersistentHypergraph::get_hyperedge_vertices(self, idx) + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use crate::{ + HyperedgeIndex, + HypergraphQuery, + VertexIndex, + core::test_support::disk::{ + EP, + WP, + build_persistent, + }, + }; + + #[test] + fn count_vertices() { + let dir = tempdir().unwrap(); + let (g, _, _) = build_persistent(dir.path()); + assert_eq!(HypergraphQuery::count_vertices(&g), 4); + } + + #[test] + fn count_hyperedges() { + let dir = tempdir().unwrap(); + let (g, _, _) = build_persistent(dir.path()); + assert_eq!(HypergraphQuery::count_hyperedges(&g), 3); + } + + #[test] + fn is_empty_false() { + let dir = tempdir().unwrap(); + let (g, _, _) = build_persistent(dir.path()); + assert!(!HypergraphQuery::is_empty(&g)); + } + + #[test] + fn vertex_indices() { + let dir = tempdir().unwrap(); + let (g, [v0, v1, v2, v3], _) = build_persistent(dir.path()); + let mut got = HypergraphQuery::vertex_indices(&g).unwrap(); + got.sort(); + assert_eq!(got, vec![v0, v1, v2, v3]); + } + + #[test] + fn hyperedge_indices() { + let dir = tempdir().unwrap(); + let (g, _, [e0, e1, e2]) = build_persistent(dir.path()); + let mut got = HypergraphQuery::hyperedge_indices(&g).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); + } + + #[test] + fn get_vertex_weight() { + let dir = tempdir().unwrap(); + let (g, [v0, _v1, _v2, _v3], _) = build_persistent(dir.path()); + assert_eq!(HypergraphQuery::get_vertex_weight(&g, v0).unwrap(), WP(0)); + } + + #[test] + fn get_vertex_weight_not_found() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(HypergraphQuery::get_vertex_weight(&g, VertexIndex(99)).is_err()); + } + + #[test] + fn get_hyperedge_weight() { + let dir = tempdir().unwrap(); + let (g, _, [e0, _e1, _e2]) = build_persistent(dir.path()); + assert_eq!( + HypergraphQuery::get_hyperedge_weight(&g, e0).unwrap(), + EP(1) + ); + } + + #[test] + fn get_hyperedge_weight_not_found() { + let dir = tempdir().unwrap(); + let g = crate::core::disk::PersistentHypergraph::::open(dir.path()).unwrap(); + assert!(HypergraphQuery::get_hyperedge_weight(&g, HyperedgeIndex(99)).is_err()); + } + + #[test] + fn get_vertex_hyperedges() { + let dir = tempdir().unwrap(); + let (g, [_v0, v1, _v2, _v3], [e0, e1, e2]) = build_persistent(dir.path()); + let mut got = HypergraphQuery::get_vertex_hyperedges(&g, v1).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); + } + + #[test] + fn get_hyperedge_vertices() { + let dir = tempdir().unwrap(); + let (g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build_persistent(dir.path()); + assert_eq!( + HypergraphQuery::get_hyperedge_vertices(&g, e0).unwrap(), + vec![v0, v1] + ); + } +} diff --git a/src/core/query/trait_def.rs b/src/core/query/trait_def.rs new file mode 100644 index 0000000..6336551 --- /dev/null +++ b/src/core/query/trait_def.rs @@ -0,0 +1,892 @@ +use std::collections::{ + BinaryHeap, + VecDeque, +}; + +use ahash::{ + AHashMap, + AHashSet, +}; + +use crate::{ + HyperedgeIndex, + HyperedgeTrait, + VertexIndex, + VertexTrait, + core::shared::Visitor, + errors::HypergraphError, +}; + +type DijkstraPath = + Result<(usize, Vec<(VertexIndex, Option)>), HypergraphError>; + +fn dijkstra_pair(graph: &Q, from: VertexIndex, to: VertexIndex) -> DijkstraPath +where + V: VertexTrait, + HE: HyperedgeTrait, + Q: HypergraphQuery + ?Sized, +{ + graph.get_vertex_weight(from)?; + graph.get_vertex_weight(to)?; + + let mut distances: AHashMap = AHashMap::new(); + let mut predecessors: AHashMap)> = + AHashMap::new(); + let mut heap = BinaryHeap::new(); + + distances.insert(from, 0); + heap.push(Visitor::new(0, from)); + + while let Some(Visitor { distance, index }) = heap.pop() { + if index == to { + let mut path = Vec::new(); + let mut cur = to; + while cur != from { + let (prev, he) = predecessors[&cur]; + path.push((cur, he)); + cur = prev; + } + path.push((from, None)); + path.reverse(); + return Ok((distance, path)); + } + + if distance > distances[&index] { + continue; + } + + for (neighbor, he_indices) in graph.get_full_adjacent_vertices_from(index)? { + let mut min_cost = usize::MAX; + let mut best_he: Option = None; + for he_idx in he_indices { + let cost: usize = graph.get_hyperedge_weight(he_idx)?.into(); + if cost < min_cost { + min_cost = cost; + best_he = Some(he_idx); + } + } + let next = distance + min_cost; + if distances.get(&neighbor).is_none_or(|&d| next < d) { + distances.insert(neighbor, next); + predecessors.insert(neighbor, (index, best_he)); + heap.push(Visitor::new(next, neighbor)); + } + } + } + + Ok((0, vec![])) +} + +fn dijkstra_from( + graph: &Q, + from: VertexIndex, +) -> Result, HypergraphError> +where + V: VertexTrait, + HE: HyperedgeTrait, + Q: HypergraphQuery + ?Sized, +{ + graph.get_vertex_weight(from)?; + + let mut distances: AHashMap = AHashMap::new(); + let mut heap = BinaryHeap::new(); + + distances.insert(from, 0); + heap.push(Visitor::new(0, from)); + + while let Some(Visitor { distance, index }) = heap.pop() { + if distance > distances[&index] { + continue; + } + + for (neighbor, he_indices) in graph.get_full_adjacent_vertices_from(index)? { + let mut min_cost = usize::MAX; + for he_idx in he_indices { + let cost: usize = graph.get_hyperedge_weight(he_idx)?.into(); + if cost < min_cost { + min_cost = cost; + } + } + let next = distance + min_cost; + if distances.get(&neighbor).is_none_or(|&d| next < d) { + distances.insert(neighbor, next); + heap.push(Visitor::new(next, neighbor)); + } + } + } + + Ok(distances) +} + +/// Shared read/query interface for [`Hypergraph`](crate::Hypergraph) and +/// [`PersistentHypergraph`](crate::PersistentHypergraph). +/// +/// Implement the nine required primitive methods; every graph algorithm is +/// provided as a default built on top of those primitives. Concrete types may +/// override any default with a more efficient implementation. +pub trait HypergraphQuery +where + V: VertexTrait, + HE: HyperedgeTrait, +{ + /// Returns the number of vertices in the hypergraph. + fn count_vertices(&self) -> usize; + + /// Returns the number of hyperedges in the hypergraph. + fn count_hyperedges(&self) -> usize; + + /// Returns `true` if the hypergraph contains no vertices. + fn is_empty(&self) -> bool; + + /// Returns the stable index of every vertex currently in the hypergraph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure (persistent + /// backend only; in-memory always returns `Ok`). + fn vertex_indices(&self) -> Result, HypergraphError>; + + /// Returns the stable index of every hyperedge currently in the hypergraph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure (persistent + /// backend only; in-memory always returns `Ok`). + fn hyperedge_indices(&self) -> Result, HypergraphError>; + + /// Returns the weight of the vertex at `idx`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `idx` does not + /// exist, or [`HypergraphError::StorageError`] on I/O failure. + fn get_vertex_weight(&self, idx: VertexIndex) -> Result>; + + /// Returns the weight of the hyperedge at `idx`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `idx` does not + /// exist, or [`HypergraphError::StorageError`] on I/O failure. + fn get_hyperedge_weight(&self, idx: HyperedgeIndex) -> Result>; + + /// Returns the indices of all hyperedges that include `idx`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `idx` does not + /// exist, or [`HypergraphError::StorageError`] on I/O failure. + fn get_vertex_hyperedges( + &self, + idx: VertexIndex, + ) -> Result, HypergraphError>; + + /// Returns the ordered vertex list of the hyperedge at `idx`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgeIndexNotFound`] if `idx` does not + /// exist, or [`HypergraphError::StorageError`] on I/O failure. + fn get_hyperedge_vertices( + &self, + idx: HyperedgeIndex, + ) -> Result, HypergraphError>; + + /// Returns the unique set of vertices directly reachable from `from` via a + /// directed hyperedge. + /// + /// A vertex `b` is adjacent from `a` when `a` and `b` appear as consecutive + /// entries (in that order) in some hyperedge's vertex list. The result is + /// sorted by [`VertexIndex`] and deduplicated. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not + /// exist. + fn get_adjacent_vertices_from( + &self, + from: VertexIndex, + ) -> Result, HypergraphError> { + let he_indices = self.get_vertex_hyperedges(from)?; + let mut neighbors: Vec = Vec::new(); + for he_idx in he_indices { + let vertices = self.get_hyperedge_vertices(he_idx)?; + for w in vertices.windows(2) { + if w[0] == from && !neighbors.contains(&w[1]) { + neighbors.push(w[1]); + } + } + } + neighbors.sort(); + Ok(neighbors) + } + + /// Returns the unique set of vertices that have a directed connection + /// leading into `to`. + /// + /// A vertex `a` is adjacent to `b` when `a` and `b` appear as consecutive + /// entries (in that order) in some hyperedge's vertex list. The result is + /// sorted by [`VertexIndex`] and deduplicated. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `to` does not + /// exist. + fn get_adjacent_vertices_to( + &self, + to: VertexIndex, + ) -> Result, HypergraphError> { + let he_indices = self.get_vertex_hyperedges(to)?; + let mut predecessors: Vec = Vec::new(); + for he_idx in he_indices { + let vertices = self.get_hyperedge_vertices(he_idx)?; + for w in vertices.windows(2) { + if w[1] == to && !predecessors.contains(&w[0]) { + predecessors.push(w[0]); + } + } + } + predecessors.sort(); + Ok(predecessors) + } + + /// Returns all vertices directly reachable from `from`, each grouped with + /// the hyperedges through which they are reached. + /// + /// Each element is `(neighbor, hyperedge_indices)`. Use this over + /// [`get_adjacent_vertices_from`](Self::get_adjacent_vertices_from) when + /// you also need the carrying hyperedges (e.g. for Dijkstra). + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not + /// exist. + #[allow(clippy::type_complexity)] + fn get_full_adjacent_vertices_from( + &self, + from: VertexIndex, + ) -> Result)>, HypergraphError> { + let he_indices = self.get_vertex_hyperedges(from)?; + let mut map: AHashMap> = AHashMap::new(); + for he_idx in he_indices { + let vertices = self.get_hyperedge_vertices(he_idx)?; + for w in vertices.windows(2) { + if w[0] == from { + map.entry(w[1]).or_default().push(he_idx); + } + } + } + Ok(map.into_iter().collect()) + } + + /// Returns all vertices that have a directed connection into `to`, each + /// grouped with the hyperedges through which they reach it. + /// + /// The incoming-edge counterpart of + /// [`get_full_adjacent_vertices_from`](Self::get_full_adjacent_vertices_from). + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `to` does not + /// exist. + #[allow(clippy::type_complexity)] + fn get_full_adjacent_vertices_to( + &self, + to: VertexIndex, + ) -> Result)>, HypergraphError> { + let he_indices = self.get_vertex_hyperedges(to)?; + let mut map: AHashMap> = AHashMap::new(); + for he_idx in he_indices { + let vertices = self.get_hyperedge_vertices(he_idx)?; + for w in vertices.windows(2) { + if w[1] == to { + map.entry(w[0]).or_default().push(he_idx); + } + } + } + Ok(map.into_iter().collect()) + } + + /// Returns the in-degree of `to`. + /// + /// Counts the number of directed `predecessor → to` consecutive pairs + /// across all hyperedges (one count per matching pair per hyperedge). + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `to` does not + /// exist. + fn get_vertex_degree_in(&self, to: VertexIndex) -> Result> { + let he_indices = self.get_vertex_hyperedges(to)?; + let mut count = 0; + for he_idx in he_indices { + let vertices = self.get_hyperedge_vertices(he_idx)?; + for w in vertices.windows(2) { + if w[1] == to { + count += 1; + } + } + } + Ok(count) + } + + /// Returns the out-degree of `from`. + /// + /// Counts the number of directed `from → successor` consecutive pairs + /// across all hyperedges (one count per matching pair per hyperedge). + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not + /// exist. + fn get_vertex_degree_out(&self, from: VertexIndex) -> Result> { + let he_indices = self.get_vertex_hyperedges(from)?; + let mut count = 0; + for he_idx in he_indices { + let vertices = self.get_hyperedge_vertices(he_idx)?; + for w in vertices.windows(2) { + if w[0] == from { + count += 1; + } + } + } + Ok(count) + } + + /// Returns the indices of all hyperedges that contain a direct `from → to` + /// consecutive connection. + /// + /// A hyperedge qualifies when `from` and `to` appear as adjacent entries + /// in its vertex list (in that order). Supports self-loops when + /// `from == to`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not + /// exist. + fn get_hyperedges_connecting( + &self, + from: VertexIndex, + to: VertexIndex, + ) -> Result, HypergraphError> { + let he_indices = self.get_vertex_hyperedges(from)?; + let mut result = Vec::new(); + for he_idx in he_indices { + let vertices = self.get_hyperedge_vertices(he_idx)?; + for w in vertices.windows(2) { + if w[0] == from && w[1] == to { + result.push(he_idx); + } + } + } + Ok(result) + } + + /// Returns the vertices present in every hyperedge in `hyperedges`. + /// + /// The result is sorted by [`VertexIndex`] and deduplicated. Returns an + /// empty `Vec` when the hyperedges share no common vertices. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HyperedgesInvalidIntersections`] if fewer + /// than two indices are provided, or + /// [`HypergraphError::HyperedgeIndexNotFound`] if any index does not + /// exist. + fn get_hyperedges_intersections( + &self, + hyperedges: &[HyperedgeIndex], + ) -> Result, HypergraphError> { + if hyperedges.len() < 2 { + return Err(HypergraphError::HyperedgesInvalidIntersections); + } + let vertex_sets: Vec> = hyperedges + .iter() + .map(|&he_idx| self.get_hyperedge_vertices(he_idx)) + .collect::, _>>()?; + + let mut result: Vec = vertex_sets[0] + .iter() + .filter(|v| vertex_sets[1..].iter().all(|s| s.contains(v))) + .copied() + .collect(); + result.sort(); + result.dedup(); + Ok(result) + } + + /// Returns the stable indices of all hyperedges whose weight equals + /// `weight`. + /// + /// Multiple hyperedges may share the same weight. Returns an empty `Vec` + /// if no match is found. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure (persistent + /// backend only). + fn find_hyperedges_by_weight( + &self, + weight: HE, + ) -> Result, HypergraphError> { + let mut result = Vec::new(); + for idx in self.hyperedge_indices()? { + if self.get_hyperedge_weight(idx)? == weight { + result.push(idx); + } + } + Ok(result) + } + + /// Returns the vertex list of every hyperedge that includes `v`. + /// + /// Each element of the outer `Vec` is the ordered vertex list of one + /// hyperedge, in the same order as returned by + /// [`get_vertex_hyperedges`](Self::get_vertex_hyperedges). + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `v` does not + /// exist. + fn get_full_vertex_hyperedges( + &self, + v: VertexIndex, + ) -> Result>, HypergraphError> { + self.get_vertex_hyperedges(v)? + .into_iter() + .map(|he_idx| self.get_hyperedge_vertices(he_idx)) + .collect() + } + + /// Returns `true` if at least one vertex with the given `weight` exists. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure (persistent + /// backend only). + fn contains_vertex(&self, weight: V) -> Result> { + for idx in self.vertex_indices()? { + if self.get_vertex_weight(idx)? == weight { + return Ok(true); + } + } + Ok(false) + } + + /// Returns the stable indices of all vertices whose weight equals `weight`. + /// + /// Because vertex weights are not required to be unique, multiple indices + /// may be returned. Returns an empty `Vec` if no match is found. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure (persistent + /// backend only). + fn get_vertex_index(&self, weight: V) -> Result, HypergraphError> { + let mut result = Vec::new(); + for idx in self.vertex_indices()? { + if self.get_vertex_weight(idx)? == weight { + result.push(idx); + } + } + Ok(result) + } + + /// Returns the vertices reachable from `from` in breadth-first order. + /// + /// The starting vertex is always the first element of the result. Only + /// vertices reachable via directed hyperedges are included. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not + /// exist. + fn get_bfs(&self, from: VertexIndex) -> Result, HypergraphError> { + self.get_vertex_weight(from)?; + + let mut visited: AHashSet = AHashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + let mut result: Vec = Vec::new(); + + visited.insert(from); + queue.push_back(from); + + while let Some(current) = queue.pop_front() { + result.push(current); + for neighbor in self.get_adjacent_vertices_from(current)? { + if visited.insert(neighbor) { + queue.push_back(neighbor); + } + } + } + + Ok(result) + } + + /// Returns the vertices reachable from `from` in depth-first order. + /// + /// The starting vertex is always the first element of the result. Only + /// vertices reachable via directed hyperedges are included. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not + /// exist. + fn get_dfs(&self, from: VertexIndex) -> Result, HypergraphError> { + self.get_vertex_weight(from)?; + + let mut visited: AHashSet = AHashSet::new(); + let mut stack: Vec = vec![from]; + let mut result: Vec = Vec::new(); + + while let Some(current) = stack.pop() { + if visited.insert(current) { + result.push(current); + let neighbors = self.get_adjacent_vertices_from(current)?; + for neighbor in neighbors.into_iter().rev() { + if !visited.contains(&neighbor) { + stack.push(neighbor); + } + } + } + } + + Ok(result) + } + + /// Returns `true` if `to` is reachable from `from` via directed + /// hyperedges. A vertex is always reachable from itself. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if either index does + /// not exist. + fn is_reachable( + &self, + from: VertexIndex, + to: VertexIndex, + ) -> Result> { + self.get_vertex_weight(from)?; + self.get_vertex_weight(to)?; + + if from == to { + return Ok(true); + } + + let mut visited: AHashSet = AHashSet::new(); + let mut queue: VecDeque = VecDeque::new(); + + visited.insert(from); + queue.push_back(from); + + while let Some(current) = queue.pop_front() { + for neighbor in self.get_adjacent_vertices_from(current)? { + if neighbor == to { + return Ok(true); + } + if visited.insert(neighbor) { + queue.push_back(neighbor); + } + } + } + + Ok(false) + } + + /// Returns `true` if the hypergraph contains no directed cycles. + /// + /// Implemented as a topological sort: returns `false` when + /// [`topological_sort`](Self::topological_sort) would fail. + fn is_acyclic(&self) -> bool { + self.topological_sort().is_ok() + } + + /// Returns all simple paths (no repeated vertices) from `from` to `to`. + /// + /// Each path is a `Vec` that includes both endpoints. When + /// `from == to` the result is `vec![vec![from]]`. Paths are emitted in + /// DFS discovery order and are not sorted. + /// + /// **Warning**: the number of simple paths can grow exponentially with + /// graph size. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if either index does + /// not exist. + fn get_all_paths( + &self, + from: VertexIndex, + to: VertexIndex, + ) -> Result>, HypergraphError> { + self.get_vertex_weight(from)?; + self.get_vertex_weight(to)?; + + if from == to { + return Ok(vec![vec![from]]); + } + + let mut all_paths: Vec> = Vec::new(); + let mut current_path: Vec = vec![from]; + let mut visited: AHashSet = AHashSet::from([from]); + let mut stack: Vec<(VertexIndex, Vec, usize)> = + vec![(from, self.get_adjacent_vertices_from(from)?, 0)]; + + while let Some(frame) = stack.last_mut() { + let (current, neighbors, idx) = frame; + let current = *current; + + if *idx >= neighbors.len() { + stack.pop(); + current_path.pop(); + visited.remove(¤t); + continue; + } + + let next = neighbors[*idx]; + *idx += 1; + + if visited.contains(&next) { + continue; + } + + if next == to { + let mut path = current_path.clone(); + path.push(to); + all_paths.push(path); + continue; + } + + visited.insert(next); + current_path.push(next); + let next_neighbors = self.get_adjacent_vertices_from(next)?; + stack.push((next, next_neighbors, 0)); + } + + Ok(all_paths) + } + + /// Returns a topological ordering of all vertices using Kahn's algorithm. + /// + /// When multiple vertices are ready at the same step, the one with the + /// smallest [`VertexIndex`] is chosen, giving a deterministic result. + /// + /// # Errors + /// + /// Returns [`HypergraphError::HypergraphContainsCycle`] if the hypergraph + /// contains a cycle. + fn topological_sort(&self) -> Result, HypergraphError> { + use std::{ + cmp::Reverse, + collections::BinaryHeap, + }; + + let all_vertices = self.vertex_indices()?; + let vertex_count = all_vertices.len(); + + let mut in_degree: AHashMap = + all_vertices.iter().map(|&v| (v, 0)).collect(); + + for &v in &all_vertices { + for neighbor in self.get_adjacent_vertices_from(v)? { + *in_degree.entry(neighbor).or_insert(0) += 1; + } + } + + let mut heap: BinaryHeap> = in_degree + .iter() + .filter_map(|(&v, °)| (deg == 0).then_some(Reverse(v))) + .collect(); + + let mut result: Vec = Vec::with_capacity(vertex_count); + + while let Some(Reverse(current)) = heap.pop() { + result.push(current); + for neighbor in self.get_adjacent_vertices_from(current)? { + let deg = in_degree.entry(neighbor).or_insert(0); + *deg -= 1; + if *deg == 0 { + heap.push(Reverse(neighbor)); + } + } + } + + if result.len() == vertex_count { + Ok(result) + } else { + Err(HypergraphError::HypergraphContainsCycle) + } + } + + /// Returns the strongly connected components (SCCs) of the hypergraph + /// using Kosaraju's algorithm. + /// + /// Each SCC is a sorted `Vec` of mutually reachable vertices. + /// A vertex with no edges forms its own single-element SCC. The order of + /// the outer `Vec` follows reverse finish order from the first DFS pass. + /// Returns an empty `Vec` for an empty hypergraph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure (persistent + /// backend only). + fn strongly_connected_components( + &self, + ) -> Result>, HypergraphError> { + let mut all_vertices = self.vertex_indices()?; + all_vertices.sort(); + + let mut visited: AHashSet = AHashSet::new(); + let mut finish_order: Vec = Vec::new(); + + for &start in &all_vertices { + if visited.contains(&start) { + continue; + } + let mut stack: Vec<(VertexIndex, bool)> = vec![(start, false)]; + while let Some((v, exiting)) = stack.pop() { + if exiting { + finish_order.push(v); + continue; + } + if !visited.insert(v) { + continue; + } + stack.push((v, true)); + for neighbor in self.get_adjacent_vertices_from(v)? { + if !visited.contains(&neighbor) { + stack.push((neighbor, false)); + } + } + } + } + + let mut visited2: AHashSet = AHashSet::new(); + let mut sccs: Vec> = Vec::new(); + + for &start in finish_order.iter().rev() { + if visited2.contains(&start) { + continue; + } + let mut scc: Vec = Vec::new(); + let mut stack: Vec = vec![start]; + visited2.insert(start); + while let Some(v) = stack.pop() { + scc.push(v); + for predecessor in self.get_adjacent_vertices_to(v)? { + if visited2.insert(predecessor) { + stack.push(predecessor); + } + } + } + scc.sort(); + sccs.push(scc); + } + + Ok(sccs) + } + + /// Returns the weakly connected components of the hypergraph. + /// + /// Each component is a sorted `Vec` of vertices mutually + /// reachable when edge direction is ignored. Isolated vertices form their + /// own single-element component. The outer `Vec` is sorted by the smallest + /// index in each component, giving a deterministic result. Returns an + /// empty `Vec` for an empty hypergraph. + /// + /// # Errors + /// + /// Returns [`HypergraphError::StorageError`] on I/O failure (persistent + /// backend only). + fn connected_components(&self) -> Result>, HypergraphError> { + let mut all_vertices = self.vertex_indices()?; + all_vertices.sort(); + + let mut visited: AHashSet = AHashSet::new(); + let mut components: Vec> = Vec::new(); + + for start in all_vertices { + if visited.contains(&start) { + continue; + } + let mut component: Vec = Vec::new(); + let mut queue: VecDeque = VecDeque::new(); + visited.insert(start); + queue.push_back(start); + while let Some(current) = queue.pop_front() { + component.push(current); + for neighbor in self.get_adjacent_vertices_from(current)? { + if visited.insert(neighbor) { + queue.push_back(neighbor); + } + } + for neighbor in self.get_adjacent_vertices_to(current)? { + if visited.insert(neighbor) { + queue.push_back(neighbor); + } + } + } + component.sort(); + components.push(component); + } + + Ok(components) + } + + /// Gets the cheapest path between two vertices as a vector of + /// `(VertexIndex, Option)` tuples. + /// + /// The first element always carries `None` as no hyperedge has been + /// traversed to reach the starting vertex. When no path exists, returns + /// an empty `Vec`. Uses Dijkstra's algorithm. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if either vertex does + /// not exist. + #[allow(clippy::type_complexity)] + fn get_dijkstra_connections( + &self, + from: VertexIndex, + to: VertexIndex, + ) -> Result)>, HypergraphError> { + dijkstra_pair(self, from, to).map(|(_, path)| path) + } + + /// Gets the cheapest path between two vertices together with the total + /// cost. + /// + /// Returns `(total_cost, path)` where `path` uses the same format as + /// [`get_dijkstra_connections`](Self::get_dijkstra_connections). When no + /// path exists, returns `(0, [])`. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if either vertex does + /// not exist. + #[allow(clippy::type_complexity)] + fn get_dijkstra_connections_with_cost( + &self, + from: VertexIndex, + to: VertexIndex, + ) -> Result<(usize, Vec<(VertexIndex, Option)>), HypergraphError> { + dijkstra_pair(self, from, to) + } + + /// Returns the minimum cost to reach every vertex reachable from `from`. + /// + /// The result is a map of `VertexIndex → cost`. The source vertex itself + /// is always included with cost `0`. Vertices not reachable from `from` + /// are absent from the map. + /// + /// # Errors + /// + /// Returns [`HypergraphError::VertexIndexNotFound`] if `from` does not + /// exist. + fn get_dijkstra_from( + &self, + from: VertexIndex, + ) -> Result, HypergraphError> { + dijkstra_from(self, from) + } +} diff --git a/src/core/shared.rs b/src/core/shared.rs index 5fdfe1d..997be1e 100644 --- a/src/core/shared.rs +++ b/src/core/shared.rs @@ -1,3 +1,5 @@ +use std::cmp::Ordering; + use itertools::Itertools; use rayon::prelude::*; @@ -10,6 +12,34 @@ use crate::{ errors::HypergraphError, }; +/// Min-heap entry used by Dijkstra implementations. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Visitor { + pub(crate) distance: usize, + pub(crate) index: VertexIndex, +} + +impl Visitor { + pub(crate) fn new(distance: usize, index: VertexIndex) -> Self { + Self { distance, index } + } +} + +impl Ord for Visitor { + fn cmp(&self, other: &Visitor) -> Ordering { + other + .distance + .cmp(&self.distance) + .then_with(|| self.index.cmp(&other.index)) + } +} + +impl PartialOrd for Visitor { + fn partial_cmp(&self, other: &Visitor) -> Option { + Some(self.cmp(other)) + } +} + /// Enumeration of the different types of connection. /// Only used as a guard argument for the `get_connections` method. pub(crate) enum Connection { @@ -88,3 +118,41 @@ where Ok(results) } } + +#[cfg(test)] +mod tests { + use std::collections::BinaryHeap; + + use crate::{ + VertexIndex, + core::shared::Visitor, + }; + + #[test] + fn new_sets_fields() { + let v = Visitor::new(5, VertexIndex(3)); + assert_eq!(v.distance, 5); + assert_eq!(v.index, VertexIndex(3)); + } + + #[test] + fn min_heap_pops_smallest_distance_first() { + let mut heap = BinaryHeap::new(); + heap.push(Visitor::new(10, VertexIndex(0))); + heap.push(Visitor::new(1, VertexIndex(1))); + heap.push(Visitor::new(5, VertexIndex(2))); + assert_eq!(heap.pop().unwrap().distance, 1); + assert_eq!(heap.pop().unwrap().distance, 5); + assert_eq!(heap.pop().unwrap().distance, 10); + } + + #[test] + fn equal_distance_breaks_tie_by_larger_index_first() { + let mut heap = BinaryHeap::new(); + heap.push(Visitor::new(3, VertexIndex(2))); + heap.push(Visitor::new(3, VertexIndex(5))); + // tie-break is self.index.cmp(&other.index) — not reversed — so + // the larger index wins in the underlying max-heap and pops first. + assert_eq!(heap.pop().unwrap().index, VertexIndex(5)); + } +} diff --git a/src/core/test_support.rs b/src/core/test_support.rs new file mode 100644 index 0000000..6cdd5a1 --- /dev/null +++ b/src/core/test_support.rs @@ -0,0 +1,109 @@ +use std::fmt; + +use crate::{ + HyperedgeIndex, + Hypergraph, + VertexIndex, +}; + +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)] +pub(crate) struct W(pub(crate) u8); + +impl fmt::Display for W { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "W{}", self.0) + } +} + +#[derive(Debug, Copy, Clone, Hash, Eq, PartialEq)] +pub(crate) struct E(pub(crate) usize); + +impl fmt::Display for E { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "E{}", self.0) + } +} + +impl From for usize { + fn from(e: E) -> usize { + e.0 + } +} + +/// Standard acyclic test graph: +/// ```text +/// v0 -[e0,cost=1]-> v1 -[e1,cost=2]-> v2 +/// | +/// [e2,cost=3] +/// | +/// v3 +/// ``` +pub(crate) fn build() -> (Hypergraph, [VertexIndex; 4], [HyperedgeIndex; 3]) { + let mut g: Hypergraph = Hypergraph::new(); + let v0 = g.add_vertex(W(0)).unwrap(); + let v1 = g.add_vertex(W(1)).unwrap(); + let v2 = g.add_vertex(W(2)).unwrap(); + let v3 = g.add_vertex(W(3)).unwrap(); + let e0 = g.add_hyperedge(vec![v0, v1], E(1)).unwrap(); + let e1 = g.add_hyperedge(vec![v1, v2], E(2)).unwrap(); + let e2 = g.add_hyperedge(vec![v1, v3], E(3)).unwrap(); + (g, [v0, v1, v2, v3], [e0, e1, e2]) +} + +#[cfg(feature = "persistence")] +pub(crate) mod disk { + use std::path::Path; + + use serde::{ + Deserialize, + Serialize, + }; + + use crate::{ + HyperedgeIndex, + VertexIndex, + core::disk::PersistentHypergraph, + }; + + #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)] + pub(crate) struct WP(pub(crate) u8); + + impl std::fmt::Display for WP { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "WP{}", self.0) + } + } + + #[derive(Debug, Copy, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)] + pub(crate) struct EP(pub(crate) usize); + + impl std::fmt::Display for EP { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "EP{}", self.0) + } + } + + impl From for usize { + fn from(e: EP) -> usize { + e.0 + } + } + + pub(crate) fn build_persistent( + dir: &Path, + ) -> ( + PersistentHypergraph, + [VertexIndex; 4], + [HyperedgeIndex; 3], + ) { + let g = PersistentHypergraph::::open(dir).unwrap(); + let v0 = g.add_vertex(WP(0)).unwrap(); + let v1 = g.add_vertex(WP(1)).unwrap(); + let v2 = g.add_vertex(WP(2)).unwrap(); + let v3 = g.add_vertex(WP(3)).unwrap(); + let e0 = g.add_hyperedge(&[v0, v1], EP(1)).unwrap(); + let e1 = g.add_hyperedge(&[v1, v2], EP(2)).unwrap(); + let e2 = g.add_hyperedge(&[v1, v3], EP(3)).unwrap(); + (g, [v0, v1, v2, v3], [e0, e1, e2]) + } +} diff --git a/src/core/vertices/add_vertex.rs b/src/core/vertices/add_vertex.rs index 236be5e..02e80c4 100644 --- a/src/core/vertices/add_vertex.rs +++ b/src/core/vertices/add_vertex.rs @@ -33,3 +33,32 @@ where Ok(index) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn returns_sequential_indices() { + let mut g: Hypergraph = Hypergraph::new(); + assert_eq!(g.add_vertex(W(0)).unwrap(), VertexIndex(0)); + assert_eq!(g.add_vertex(W(1)).unwrap(), VertexIndex(1)); + assert_eq!(g.add_vertex(W(2)).unwrap(), VertexIndex(2)); + } + + #[test] + fn duplicate_weights_allowed() { + let mut g: Hypergraph = Hypergraph::new(); + let a = g.add_vertex(W(7)).unwrap(); + let b = g.add_vertex(W(7)).unwrap(); + assert_ne!(a, b); + assert_eq!(g.count_vertices(), 2); + } +} diff --git a/src/core/vertices/bfs.rs b/src/core/vertices/bfs.rs index 9830d64..b8a9d9a 100644 --- a/src/core/vertices/bfs.rs +++ b/src/core/vertices/bfs.rs @@ -46,3 +46,30 @@ where Ok(result) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn starts_at_from() { + let (g, [v0, v1, _v2, _v3], _) = build(); + let result = g.get_bfs(v0).unwrap(); + assert_eq!(result[0], v0); + assert_eq!(result[1], v1); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_bfs(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/connected_components.rs b/src/core/vertices/connected_components.rs index 50919fd..26e9fce 100644 --- a/src/core/vertices/connected_components.rs +++ b/src/core/vertices/connected_components.rs @@ -63,3 +63,33 @@ where Ok(components) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn two_disconnected_components() { + let mut g: Hypergraph = Hypergraph::new(); + let a = g.add_vertex(W(0)).unwrap(); + let b = g.add_vertex(W(1)).unwrap(); + let c = g.add_vertex(W(2)).unwrap(); + let d = g.add_vertex(W(3)).unwrap(); + g.add_hyperedge(vec![a, b], E(1)).unwrap(); + g.add_hyperedge(vec![c, d], E(1)).unwrap(); + let components = g.connected_components().unwrap(); + assert_eq!(components.len(), 2); + } + + #[test] + fn empty_graph_returns_empty_vec() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.connected_components().unwrap().is_empty()); + } +} diff --git a/src/core/vertices/contains_vertex.rs b/src/core/vertices/contains_vertex.rs index 819907f..bbf17d5 100644 --- a/src/core/vertices/contains_vertex.rs +++ b/src/core/vertices/contains_vertex.rs @@ -15,3 +15,27 @@ where self.vertices.values().any(|(w, _)| *w == weight) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn finds_existing() { + let mut g: Hypergraph = Hypergraph::new(); + g.add_vertex(W(3)).unwrap(); + assert!(g.contains_vertex(W(3))); + } + + #[test] + fn returns_false_for_missing() { + let g: Hypergraph = Hypergraph::new(); + assert!(!g.contains_vertex(W(99))); + } +} diff --git a/src/core/vertices/count_vertices.rs b/src/core/vertices/count_vertices.rs index 18ce05e..9f5c133 100644 --- a/src/core/vertices/count_vertices.rs +++ b/src/core/vertices/count_vertices.rs @@ -15,3 +15,28 @@ where self.vertices.len() } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn empty_graph_is_zero() { + let g: Hypergraph = Hypergraph::new(); + assert_eq!(g.count_vertices(), 0); + } + + #[test] + fn after_additions() { + let mut g: Hypergraph = Hypergraph::new(); + g.add_vertex(W(0)).unwrap(); + g.add_vertex(W(1)).unwrap(); + assert_eq!(g.count_vertices(), 2); + } +} diff --git a/src/core/vertices/dfs.rs b/src/core/vertices/dfs.rs index 0ed543f..372b122 100644 --- a/src/core/vertices/dfs.rs +++ b/src/core/vertices/dfs.rs @@ -44,3 +44,30 @@ where Ok(result) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn starts_at_from() { + let (g, [v0, v1, _v2, _v3], _) = build(); + let result = g.get_dfs(v0).unwrap(); + assert_eq!(result[0], v0); + assert_eq!(result[1], v1); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_dfs(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_adjacent_vertices_from.rs b/src/core/vertices/get_adjacent_vertices_from.rs index 290fffd..528cb9c 100644 --- a/src/core/vertices/get_adjacent_vertices_from.rs +++ b/src/core/vertices/get_adjacent_vertices_from.rs @@ -42,3 +42,30 @@ where Ok(results) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_direct_neighbors() { + let (g, [_v0, v1, v2, v3], _) = build(); + let mut got = g.get_adjacent_vertices_from(v1).unwrap(); + got.sort(); + assert_eq!(got, vec![v2, v3]); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_adjacent_vertices_from(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_adjacent_vertices_to.rs b/src/core/vertices/get_adjacent_vertices_to.rs index 48d016d..6502651 100644 --- a/src/core/vertices/get_adjacent_vertices_to.rs +++ b/src/core/vertices/get_adjacent_vertices_to.rs @@ -42,3 +42,29 @@ where Ok(results) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_predecessors() { + let (g, [v0, v1, _v2, _v3], _) = build(); + let got = g.get_adjacent_vertices_to(v1).unwrap(); + assert_eq!(got, vec![v0]); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_adjacent_vertices_to(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_all_paths.rs b/src/core/vertices/get_all_paths.rs index 6a7596f..a359af0 100644 --- a/src/core/vertices/get_all_paths.rs +++ b/src/core/vertices/get_all_paths.rs @@ -82,3 +82,37 @@ where Ok(all_paths) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn finds_path_between_vertices() { + let (g, [v0, _v1, v2, _v3], _) = build(); + let paths = g.get_all_paths(v0, v2).unwrap(); + assert!(!paths.is_empty()); + assert_eq!(paths[0][0], v0); + assert_eq!(*paths[0].last().unwrap(), v2); + } + + #[test] + fn same_vertex_returns_singleton() { + let (g, [v0, _v1, _v2, _v3], _) = build(); + assert_eq!(g.get_all_paths(v0, v0).unwrap(), vec![vec![v0]]); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_all_paths(VertexIndex(0), VertexIndex(1)).is_err()); + } +} diff --git a/src/core/vertices/get_dijkstra_connections.rs b/src/core/vertices/get_dijkstra_connections.rs index 7cf7a77..caa5370 100644 --- a/src/core/vertices/get_dijkstra_connections.rs +++ b/src/core/vertices/get_dijkstra_connections.rs @@ -1,5 +1,4 @@ use std::{ - cmp::Ordering, collections::BinaryHeap, iter::successors, }; @@ -12,37 +11,10 @@ use crate::{ Hypergraph, VertexIndex, VertexTrait, + core::shared::Visitor, errors::HypergraphError, }; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct Visitor { - distance: usize, - index: VertexIndex, -} - -impl Visitor { - fn new(distance: usize, index: VertexIndex) -> Self { - Self { distance, index } - } -} - -// Custom Ord for a min-heap by distance, with VertexIndex as tiebreaker. -impl Ord for Visitor { - fn cmp(&self, other: &Visitor) -> Ordering { - other - .distance - .cmp(&self.distance) - .then_with(|| self.index.cmp(&other.index)) - } -} - -impl PartialOrd for Visitor { - fn partial_cmp(&self, other: &Visitor) -> Option { - Some(self.cmp(other)) - } -} - type DijkstraResult = Result<(usize, Vec<(VertexIndex, Option)>), HypergraphError>; @@ -151,3 +123,34 @@ where self.dijkstra_impl(from, to) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn finds_shortest_path() { + let (g, [v0, _v1, v2, _v3], [e0, e1, _e2]) = build(); + assert_eq!( + g.get_dijkstra_connections(v0, v2).unwrap(), + vec![(v0, None), (_v1, Some(e0)), (v2, Some(e1))] + ); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!( + g.get_dijkstra_connections(VertexIndex(0), VertexIndex(1)) + .is_err() + ); + } +} diff --git a/src/core/vertices/get_dijkstra_from.rs b/src/core/vertices/get_dijkstra_from.rs index d75cee2..54e4de7 100644 --- a/src/core/vertices/get_dijkstra_from.rs +++ b/src/core/vertices/get_dijkstra_from.rs @@ -1,7 +1,4 @@ -use std::{ - cmp::Ordering, - collections::BinaryHeap, -}; +use std::collections::BinaryHeap; use ahash::AHashMap; @@ -10,36 +7,10 @@ use crate::{ Hypergraph, VertexIndex, VertexTrait, + core::shared::Visitor, errors::HypergraphError, }; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct Visitor { - distance: usize, - index: VertexIndex, -} - -impl Visitor { - fn new(distance: usize, index: VertexIndex) -> Self { - Self { distance, index } - } -} - -impl Ord for Visitor { - fn cmp(&self, other: &Visitor) -> Ordering { - other - .distance - .cmp(&self.distance) - .then_with(|| self.index.cmp(&other.index)) - } -} - -impl PartialOrd for Visitor { - fn partial_cmp(&self, other: &Visitor) -> Option { - Some(self.cmp(other)) - } -} - impl Hypergraph where V: VertexTrait, @@ -100,3 +71,32 @@ where Ok(distances) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_distances_from_source() { + let (g, [v0, v1, v2, v3], _) = build(); + let dist = g.get_dijkstra_from(v0).unwrap(); + assert_eq!(dist[&v0], 0); + assert_eq!(dist[&v1], 1); // cost of e0 + assert_eq!(dist[&v2], 3); // e0(1) + e1(2) + assert_eq!(dist[&v3], 4); // e0(1) + e2(3) + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_dijkstra_from(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_full_adjacent_vertices_from.rs b/src/core/vertices/get_full_adjacent_vertices_from.rs index 4dc9298..479cbe1 100644 --- a/src/core/vertices/get_full_adjacent_vertices_from.rs +++ b/src/core/vertices/get_full_adjacent_vertices_from.rs @@ -55,3 +55,31 @@ where .collect_vec()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_neighbor_with_hyperedges() { + let (g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build(); + let got = g.get_full_adjacent_vertices_from(v0).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].0, v1); + assert_eq!(got[0].1, vec![e0]); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_full_adjacent_vertices_from(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_full_adjacent_vertices_to.rs b/src/core/vertices/get_full_adjacent_vertices_to.rs index 2529c56..02c165c 100644 --- a/src/core/vertices/get_full_adjacent_vertices_to.rs +++ b/src/core/vertices/get_full_adjacent_vertices_to.rs @@ -54,3 +54,31 @@ where .collect_vec()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_predecessor_with_hyperedges() { + let (g, [v0, v1, _v2, _v3], [e0, _e1, _e2]) = build(); + let got = g.get_full_adjacent_vertices_to(v1).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0].0, v0); + assert_eq!(got[0].1, vec![e0]); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_full_adjacent_vertices_to(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_full_vertex_hyperedges.rs b/src/core/vertices/get_full_vertex_hyperedges.rs index ac6ae36..859dd55 100644 --- a/src/core/vertices/get_full_vertex_hyperedges.rs +++ b/src/core/vertices/get_full_vertex_hyperedges.rs @@ -27,11 +27,35 @@ where &self, vertex_index: VertexIndex, ) -> Result>, HypergraphError> { - self.get_vertex_hyperedges(vertex_index).map(|hyperedges| { - hyperedges - .into_par_iter() - .flat_map(|hyperedge_index| self.get_hyperedge_vertices(hyperedge_index)) - .collect() - }) + self.get_vertex_hyperedges(vertex_index)? + .into_par_iter() + .map(|he_idx| self.get_hyperedge_vertices(he_idx)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_vertex_lists() { + let (g, [v0, v1, _v2, _v3], _) = build(); + let got = g.get_full_vertex_hyperedges(v0).unwrap(); + assert_eq!(got, vec![vec![v0, v1]]); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_full_vertex_hyperedges(VertexIndex(99)).is_err()); } } diff --git a/src/core/vertices/get_vertex_degree_in.rs b/src/core/vertices/get_vertex_degree_in.rs index b8d3553..f871150 100644 --- a/src/core/vertices/get_vertex_degree_in.rs +++ b/src/core/vertices/get_vertex_degree_in.rs @@ -27,3 +27,34 @@ where Ok(results.len()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn counts_incoming_edges() { + let (g, [_v0, v1, _v2, _v3], _) = build(); + assert_eq!(g.get_vertex_degree_in(v1).unwrap(), 1); + } + + #[test] + fn source_vertex_has_zero_in_degree() { + let (g, [v0, _v1, _v2, _v3], _) = build(); + assert_eq!(g.get_vertex_degree_in(v0).unwrap(), 0); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_vertex_degree_in(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_vertex_degree_out.rs b/src/core/vertices/get_vertex_degree_out.rs index 77e4ba6..25803aa 100644 --- a/src/core/vertices/get_vertex_degree_out.rs +++ b/src/core/vertices/get_vertex_degree_out.rs @@ -30,3 +30,34 @@ where Ok(results.len()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn counts_outgoing_edges() { + let (g, [_v0, v1, _v2, _v3], _) = build(); + assert_eq!(g.get_vertex_degree_out(v1).unwrap(), 2); + } + + #[test] + fn sink_vertex_has_zero_out_degree() { + let (g, [_v0, _v1, v2, _v3], _) = build(); + assert_eq!(g.get_vertex_degree_out(v2).unwrap(), 0); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_vertex_degree_out(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_vertex_hyperedges.rs b/src/core/vertices/get_vertex_hyperedges.rs index 4d7e83b..5bc704f 100644 --- a/src/core/vertices/get_vertex_hyperedges.rs +++ b/src/core/vertices/get_vertex_hyperedges.rs @@ -28,3 +28,30 @@ where .ok_or(HypergraphError::VertexIndexNotFound(vertex_index)) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn returns_hyperedge_indices() { + let (g, [_v0, v1, _v2, _v3], [e0, e1, e2]) = build(); + let mut got = g.get_vertex_hyperedges(v1).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_vertex_hyperedges(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/get_vertex_index.rs b/src/core/vertices/get_vertex_index.rs index 89b1d45..b3b1cf8 100644 --- a/src/core/vertices/get_vertex_index.rs +++ b/src/core/vertices/get_vertex_index.rs @@ -24,3 +24,37 @@ where .collect() } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn returns_indices_for_weight() { + let mut g: Hypergraph = Hypergraph::new(); + let i = g.add_vertex(W(5)).unwrap(); + assert_eq!(g.get_vertex_index(W(5)), vec![i]); + } + + #[test] + fn returns_empty_for_missing() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_vertex_index(W(99)).is_empty()); + } + + #[test] + fn returns_multiple_for_duplicate_weights() { + let mut g: Hypergraph = Hypergraph::new(); + let a = g.add_vertex(W(1)).unwrap(); + let b = g.add_vertex(W(1)).unwrap(); + let mut got = g.get_vertex_index(W(1)); + got.sort(); + assert_eq!(got, vec![a, b]); + } +} diff --git a/src/core/vertices/get_vertex_weight.rs b/src/core/vertices/get_vertex_weight.rs index 2129695..e6d9d75 100644 --- a/src/core/vertices/get_vertex_weight.rs +++ b/src/core/vertices/get_vertex_weight.rs @@ -27,3 +27,28 @@ where .ok_or(HypergraphError::VertexIndexNotFound(vertex_index)) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn returns_weight() { + let mut g: Hypergraph = Hypergraph::new(); + let idx = g.add_vertex(W(5)).unwrap(); + assert_eq!(g.get_vertex_weight(idx).unwrap(), &W(5)); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.get_vertex_weight(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/is_acyclic.rs b/src/core/vertices/is_acyclic.rs index ec9f0a6..bbf7626 100644 --- a/src/core/vertices/is_acyclic.rs +++ b/src/core/vertices/is_acyclic.rs @@ -19,3 +19,31 @@ where self.topological_sort().is_ok() } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn dag_is_acyclic() { + let (g, _, _) = build(); + assert!(g.is_acyclic()); + } + + #[test] + fn cyclic_graph_is_not_acyclic() { + let mut g: Hypergraph = Hypergraph::new(); + let a = g.add_vertex(W(0)).unwrap(); + let b = g.add_vertex(W(1)).unwrap(); + g.add_hyperedge(vec![a, b], E(1)).unwrap(); + g.add_hyperedge(vec![b, a], E(1)).unwrap(); + assert!(!g.is_acyclic()); + } +} diff --git a/src/core/vertices/is_reachable.rs b/src/core/vertices/is_reachable.rs index d8abc3a..55115e9 100644 --- a/src/core/vertices/is_reachable.rs +++ b/src/core/vertices/is_reachable.rs @@ -56,3 +56,41 @@ where Ok(false) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn reachable_via_path() { + let (g, [v0, _v1, v2, _v3], _) = build(); + assert!(g.is_reachable(v0, v2).unwrap()); + } + + #[test] + fn self_is_reachable() { + let (g, [v0, _v1, _v2, _v3], _) = build(); + assert!(g.is_reachable(v0, v0).unwrap()); + } + + #[test] + fn unreachable_returns_false() { + let (g, [v0, _v1, _v2, v3], _) = build(); + // v3 has no outgoing edges + assert!(!g.is_reachable(v3, v0).unwrap()); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.is_reachable(VertexIndex(0), VertexIndex(1)).is_err()); + } +} diff --git a/src/core/vertices/mod.rs b/src/core/vertices/mod.rs index b5cafb4..f5031a1 100644 --- a/src/core/vertices/mod.rs +++ b/src/core/vertices/mod.rs @@ -1,27 +1,27 @@ -pub mod add_vertex; -pub mod bfs; -pub mod connected_components; -pub mod contains_vertex; -pub mod count_vertices; -pub mod dfs; -pub mod get_adjacent_vertices_from; -pub mod get_adjacent_vertices_to; -pub mod get_all_paths; -pub mod get_dijkstra_connections; -pub mod get_dijkstra_from; -pub mod get_full_adjacent_vertices_from; -pub mod get_full_adjacent_vertices_to; -pub mod get_full_vertex_hyperedges; -pub mod get_vertex_degree_in; -pub mod get_vertex_degree_out; -pub mod get_vertex_hyperedges; -pub mod get_vertex_index; -pub mod get_vertex_weight; -pub mod is_acyclic; -pub mod is_reachable; -pub mod remove_vertex; -pub mod retain_vertices; -pub mod strongly_connected_components; -pub mod subgraph; -pub mod topological_sort; -pub mod update_vertex_weight; +pub(crate) mod add_vertex; +pub(crate) mod bfs; +pub(crate) mod connected_components; +pub(crate) mod contains_vertex; +pub(crate) mod count_vertices; +pub(crate) mod dfs; +pub(crate) mod get_adjacent_vertices_from; +pub(crate) mod get_adjacent_vertices_to; +pub(crate) mod get_all_paths; +pub(crate) mod get_dijkstra_connections; +pub(crate) mod get_dijkstra_from; +pub(crate) mod get_full_adjacent_vertices_from; +pub(crate) mod get_full_adjacent_vertices_to; +pub(crate) mod get_full_vertex_hyperedges; +pub(crate) mod get_vertex_degree_in; +pub(crate) mod get_vertex_degree_out; +pub(crate) mod get_vertex_hyperedges; +pub(crate) mod get_vertex_index; +pub(crate) mod get_vertex_weight; +pub(crate) mod is_acyclic; +pub(crate) mod is_reachable; +pub(crate) mod remove_vertex; +pub(crate) mod retain_vertices; +pub(crate) mod strongly_connected_components; +pub(crate) mod subgraph; +pub(crate) mod topological_sort; +pub(crate) mod update_vertex_weight; diff --git a/src/core/vertices/remove_vertex.rs b/src/core/vertices/remove_vertex.rs index 2808802..0b1a866 100644 --- a/src/core/vertices/remove_vertex.rs +++ b/src/core/vertices/remove_vertex.rs @@ -52,3 +52,35 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn removes_vertex_and_fixes_hyperedges() { + let (mut g, [v0, v1, v2, v3], [e0, e1, e2]) = build(); + // v0 only appears in e0; removing it should drop e0 (sole unique member? no: e0 has v0,v1) + // e0 = [v0, v1]: v0 removed → e0 becomes [v1], a unary + g.remove_vertex(v0).unwrap(); + assert_eq!(g.count_vertices(), 3); + // e1 and e2 are untouched (don't contain v0) + assert_eq!(g.get_hyperedge_vertices(e1).unwrap(), vec![v1, v2]); + assert_eq!(g.get_hyperedge_vertices(e2).unwrap(), vec![v1, v3]); + let _ = (e0,); // silence unused warning + } + + #[test] + fn not_found_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + assert!(g.remove_vertex(VertexIndex(99)).is_err()); + } +} diff --git a/src/core/vertices/retain_vertices.rs b/src/core/vertices/retain_vertices.rs index 99bb15c..1e662fa 100644 --- a/src/core/vertices/retain_vertices.rs +++ b/src/core/vertices/retain_vertices.rs @@ -32,3 +32,32 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn retains_matching_removes_rest() { + let (mut g, [v0, v1, v2, v3], _) = build(); + // keep only v1 and v2 + g.retain_vertices(|idx, _| idx == v1 || idx == v2).unwrap(); + assert_eq!(g.count_vertices(), 2); + let _ = (v0, v3); // silence unused warnings + } + + #[test] + fn keep_all_leaves_graph_intact() { + let (mut g, _, _) = build(); + let before = g.count_vertices(); + g.retain_vertices(|_, _| true).unwrap(); + assert_eq!(g.count_vertices(), before); + } +} diff --git a/src/core/vertices/strongly_connected_components.rs b/src/core/vertices/strongly_connected_components.rs index bc0d670..b33f6d8 100644 --- a/src/core/vertices/strongly_connected_components.rs +++ b/src/core/vertices/strongly_connected_components.rs @@ -85,3 +85,36 @@ where Ok(sccs) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn detects_scc_in_cyclic_graph() { + let mut g: Hypergraph = Hypergraph::new(); + let a = g.add_vertex(W(0)).unwrap(); + let b = g.add_vertex(W(1)).unwrap(); + let c = g.add_vertex(W(2)).unwrap(); + g.add_hyperedge(vec![a, b], E(1)).unwrap(); + g.add_hyperedge(vec![b, c], E(1)).unwrap(); + g.add_hyperedge(vec![c, a], E(1)).unwrap(); + let sccs = g.strongly_connected_components().unwrap(); + let mut big = vec![a, b, c]; + big.sort(); + assert!(sccs.contains(&big)); + } + + #[test] + fn dag_has_singleton_sccs() { + let (g, _, _) = super::super::super::test_support::build(); + let sccs = g.strongly_connected_components().unwrap(); + assert!(sccs.iter().all(|scc| scc.len() == 1)); + } +} diff --git a/src/core/vertices/subgraph.rs b/src/core/vertices/subgraph.rs index df44629..8d52c89 100644 --- a/src/core/vertices/subgraph.rs +++ b/src/core/vertices/subgraph.rs @@ -63,3 +63,31 @@ where Ok(sub) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn induced_subgraph_has_correct_vertices_and_edges() { + let (g, [v0, v1, v2, _v3], _) = build(); + let sub = g.subgraph(&[v0, v1, v2]).unwrap(); + assert_eq!(sub.count_vertices(), 3); + // e0 = [v0,v1] and e1 = [v1,v2] are both fully within {v0,v1,v2} + assert_eq!(sub.count_hyperedges(), 2); + } + + #[test] + fn not_found_returns_error() { + let g: Hypergraph = Hypergraph::new(); + assert!(g.subgraph(&[VertexIndex(99)]).is_err()); + } +} diff --git a/src/core/vertices/topological_sort.rs b/src/core/vertices/topological_sort.rs index 5eaad6e..d4b89af 100644 --- a/src/core/vertices/topological_sort.rs +++ b/src/core/vertices/topological_sort.rs @@ -64,3 +64,34 @@ where } } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + core::test_support::{ + E, + W, + build, + }, + }; + + #[test] + fn dag_produces_valid_order() { + let (g, [v0, v1, _v2, _v3], _) = build(); + let order = g.topological_sort().unwrap(); + let pos0 = order.iter().position(|&v| v == v0).unwrap(); + let pos1 = order.iter().position(|&v| v == v1).unwrap(); + assert!(pos0 < pos1); + } + + #[test] + fn cyclic_graph_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + let a = g.add_vertex(W(0)).unwrap(); + let b = g.add_vertex(W(1)).unwrap(); + g.add_hyperedge(vec![a, b], E(1)).unwrap(); + g.add_hyperedge(vec![b, a], E(1)).unwrap(); + assert!(g.topological_sort().is_err()); + } +} diff --git a/src/core/vertices/update_vertex_weight.rs b/src/core/vertices/update_vertex_weight.rs index 48cd3d0..fdb3e4c 100644 --- a/src/core/vertices/update_vertex_weight.rs +++ b/src/core/vertices/update_vertex_weight.rs @@ -40,3 +40,36 @@ where Ok(()) } } + +#[cfg(test)] +mod tests { + use crate::{ + Hypergraph, + VertexIndex, + core::test_support::{ + E, + W, + }, + }; + + #[test] + fn updates_weight() { + let mut g: Hypergraph = Hypergraph::new(); + let idx = g.add_vertex(W(1)).unwrap(); + g.update_vertex_weight(idx, W(2)).unwrap(); + assert_eq!(g.get_vertex_weight(idx).unwrap(), &W(2)); + } + + #[test] + fn unchanged_weight_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + let idx = g.add_vertex(W(1)).unwrap(); + assert!(g.update_vertex_weight(idx, W(1)).is_err()); + } + + #[test] + fn not_found_returns_error() { + let mut g: Hypergraph = Hypergraph::new(); + assert!(g.update_vertex_weight(VertexIndex(99), W(1)).is_err()); + } +} diff --git a/tests/integration_contraction.rs b/tests/integration_contraction.rs index 26afece..62034b0 100644 --- a/tests/integration_contraction.rs +++ b/tests/integration_contraction.rs @@ -16,7 +16,7 @@ use hypergraph::{ }; #[test] -fn integration_contration() { +fn integration_contraction() { // Create a new hypergraph. let mut graph = Hypergraph::::new(); diff --git a/tests/integration_join.rs b/tests/integration_join.rs index c93e005..ac211bc 100644 --- a/tests/integration_join.rs +++ b/tests/integration_join.rs @@ -14,7 +14,7 @@ use hypergraph::{ }; #[test] -fn integration_contration() { +fn integration_contraction() { // Create a new hypergraph. let mut graph = Hypergraph::::new(); diff --git a/tests/integration_query_trait.rs b/tests/integration_query_trait.rs new file mode 100644 index 0000000..9232418 --- /dev/null +++ b/tests/integration_query_trait.rs @@ -0,0 +1,433 @@ +//! Integration tests for `HypergraphQuery` — exercises every trait method via +//! explicit trait dispatch so the implementations in `hypergraph_impl` and +//! `persistent_impl` are both covered (the latter through feature-gating). + +#![allow(clippy::many_single_char_names)] + +mod common; + +use common::{ + Hyperedge, + Vertex, +}; +use hypergraph::{ + HyperedgeIndex, + Hypergraph, + HypergraphQuery, + VertexIndex, +}; + +/// Builds a small acyclic directed hypergraph for most tests: +/// +/// ```text +/// a --[e0,cost=1]--> b --[e1,cost=2]--> c +/// | +/// [e2,cost=3] +/// | +/// v +/// d +/// ``` +/// +/// Vertex indices are in insertion order (0=a, 1=b, 2=c, 3=d). +/// Hyperedge indices: e0=0, e1=1, e2=2. +fn build_acyclic() -> ( + Hypergraph, Hyperedge<'static>>, + [VertexIndex; 4], + [HyperedgeIndex; 3], +) { + let mut g = Hypergraph::::new(); + let a = g.add_vertex(Vertex::new("a")).unwrap(); + let b = g.add_vertex(Vertex::new("b")).unwrap(); + let c = g.add_vertex(Vertex::new("c")).unwrap(); + let d = g.add_vertex(Vertex::new("d")).unwrap(); + let e0 = g + .add_hyperedge(vec![a, b], Hyperedge::new("e0", 1)) + .unwrap(); + let e1 = g + .add_hyperedge(vec![b, c], Hyperedge::new("e1", 2)) + .unwrap(); + let e2 = g + .add_hyperedge(vec![b, d], Hyperedge::new("e2", 3)) + .unwrap(); + (g, [a, b, c, d], [e0, e1, e2]) +} + +/// Builds a cyclic graph: a→b→c→a, with d isolated (but present). +fn build_cyclic() -> ( + Hypergraph, Hyperedge<'static>>, + [VertexIndex; 4], + [HyperedgeIndex; 3], +) { + let mut g = Hypergraph::::new(); + let a = g.add_vertex(Vertex::new("a")).unwrap(); + let b = g.add_vertex(Vertex::new("b")).unwrap(); + let c = g.add_vertex(Vertex::new("c")).unwrap(); + let d = g.add_vertex(Vertex::new("d")).unwrap(); + let e0 = g + .add_hyperedge(vec![a, b], Hyperedge::new("e0", 1)) + .unwrap(); + let e1 = g + .add_hyperedge(vec![b, c], Hyperedge::new("e1", 1)) + .unwrap(); + let e2 = g + .add_hyperedge(vec![c, a], Hyperedge::new("e2", 1)) + .unwrap(); + (g, [a, b, c, d], [e0, e1, e2]) +} + +#[test] +fn query_count_vertices() { + let (g, _, _) = build_acyclic(); + assert_eq!(HypergraphQuery::count_vertices(&g), 4); +} + +#[test] +fn query_count_hyperedges() { + let (g, _, _) = build_acyclic(); + assert_eq!(HypergraphQuery::count_hyperedges(&g), 3); +} + +#[test] +fn query_is_empty_false() { + let (g, _, _) = build_acyclic(); + assert!(!HypergraphQuery::is_empty(&g)); +} + +#[test] +fn query_is_empty_true() { + let g = Hypergraph::::new(); + assert!(HypergraphQuery::is_empty(&g)); +} + +#[test] +fn query_vertex_indices() { + let (g, [a, b, c, d], _) = build_acyclic(); + let mut got = HypergraphQuery::vertex_indices(&g).unwrap(); + got.sort(); + assert_eq!(got, vec![a, b, c, d]); +} + +#[test] +fn query_hyperedge_indices() { + let (g, _, [e0, e1, e2]) = build_acyclic(); + let mut got = HypergraphQuery::hyperedge_indices(&g).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); +} + +#[test] +fn query_get_vertex_weight() { + let (g, [a, _, _, _], _) = build_acyclic(); + assert_eq!( + HypergraphQuery::get_vertex_weight(&g, a).unwrap(), + Vertex::new("a") + ); +} + +#[test] +fn query_get_vertex_weight_not_found() { + let (g, _, _) = build_acyclic(); + let missing = VertexIndex(999); + assert!(HypergraphQuery::get_vertex_weight(&g, missing).is_err()); +} + +#[test] +fn query_get_hyperedge_weight() { + let (g, _, [e0, _, _]) = build_acyclic(); + assert_eq!( + HypergraphQuery::get_hyperedge_weight(&g, e0).unwrap(), + Hyperedge::new("e0", 1) + ); +} + +#[test] +fn query_get_hyperedge_weight_not_found() { + let (g, _, _) = build_acyclic(); + let missing = HyperedgeIndex(999); + assert!(HypergraphQuery::get_hyperedge_weight(&g, missing).is_err()); +} + +#[test] +fn query_get_vertex_hyperedges() { + let (g, [_, b, _, _], [e0, e1, e2]) = build_acyclic(); + let mut got = HypergraphQuery::get_vertex_hyperedges(&g, b).unwrap(); + got.sort(); + assert_eq!(got, vec![e0, e1, e2]); +} + +#[test] +fn query_get_hyperedge_vertices() { + let (g, [a, b, _, _], [e0, _, _]) = build_acyclic(); + assert_eq!( + HypergraphQuery::get_hyperedge_vertices(&g, e0).unwrap(), + vec![a, b] + ); +} + +#[test] +fn query_get_adjacent_vertices_from() { + let (g, [_, b, c, d], _) = build_acyclic(); + let mut got = HypergraphQuery::get_adjacent_vertices_from(&g, b).unwrap(); + got.sort(); + assert_eq!(got, vec![c, d]); +} + +#[test] +fn query_get_adjacent_vertices_to() { + let (g, [a, b, _, _], _) = build_acyclic(); + let got = HypergraphQuery::get_adjacent_vertices_to(&g, b).unwrap(); + assert_eq!(got, vec![a]); +} + +#[test] +fn query_get_full_adjacent_vertices_from() { + let (g, [a, b, _, _], [e0, _, _]) = build_acyclic(); + let got = HypergraphQuery::get_full_adjacent_vertices_from(&g, a).unwrap(); + assert_eq!(got.len(), 1); + let (neighbor, hes) = &got[0]; + assert_eq!(*neighbor, b); + assert_eq!(hes, &[e0]); +} + +#[test] +fn query_get_full_adjacent_vertices_to() { + let (g, [a, b, _, _], [e0, _, _]) = build_acyclic(); + let got = HypergraphQuery::get_full_adjacent_vertices_to(&g, b).unwrap(); + assert_eq!(got.len(), 1); + let (predecessor, hes) = &got[0]; + assert_eq!(*predecessor, a); + assert_eq!(hes, &[e0]); +} + +#[test] +fn query_get_vertex_degree_in() { + let (g, [_, b, _, _], _) = build_acyclic(); + assert_eq!(HypergraphQuery::get_vertex_degree_in(&g, b).unwrap(), 1); +} + +#[test] +fn query_get_vertex_degree_out() { + let (g, [_, b, _, _], _) = build_acyclic(); + assert_eq!(HypergraphQuery::get_vertex_degree_out(&g, b).unwrap(), 2); +} + +#[test] +fn query_get_hyperedges_connecting() { + let (g, [a, b, _, _], [e0, _, _]) = build_acyclic(); + assert_eq!( + HypergraphQuery::get_hyperedges_connecting(&g, a, b).unwrap(), + vec![e0] + ); +} + +#[test] +fn query_get_hyperedges_connecting_none() { + let (g, [a, _, c, _], _) = build_acyclic(); + assert_eq!( + HypergraphQuery::get_hyperedges_connecting(&g, a, c).unwrap(), + vec![] + ); +} + +#[test] +fn query_get_hyperedges_intersections() { + let (g, [_, b, _, _], [e0, e1, e2]) = build_acyclic(); + let mut got = HypergraphQuery::get_hyperedges_intersections(&g, &[e0, e1, e2]).unwrap(); + got.sort(); + assert_eq!(got, vec![b]); +} + +#[test] +fn query_get_hyperedges_intersections_too_few() { + let (g, _, [e0, _, _]) = build_acyclic(); + assert!(HypergraphQuery::get_hyperedges_intersections(&g, &[e0]).is_err()); +} + +#[test] +fn query_find_hyperedges_by_weight() { + let (g, _, [e0, _, _]) = build_acyclic(); + assert_eq!( + HypergraphQuery::find_hyperedges_by_weight(&g, Hyperedge::new("e0", 1)).unwrap(), + vec![e0] + ); +} + +#[test] +fn query_get_full_vertex_hyperedges() { + let (g, [a, b, _, _], _) = build_acyclic(); + let got = HypergraphQuery::get_full_vertex_hyperedges(&g, a).unwrap(); + assert_eq!(got, vec![vec![a, b]]); +} + +#[test] +fn query_contains_vertex_true() { + let (g, _, _) = build_acyclic(); + assert!(HypergraphQuery::contains_vertex(&g, Vertex::new("a")).unwrap()); +} + +#[test] +fn query_contains_vertex_false() { + let (g, _, _) = build_acyclic(); + assert!(!HypergraphQuery::contains_vertex(&g, Vertex::new("z")).unwrap()); +} + +#[test] +fn query_get_vertex_index() { + let (g, [a, _, _, _], _) = build_acyclic(); + assert_eq!( + HypergraphQuery::get_vertex_index(&g, Vertex::new("a")).unwrap(), + vec![a] + ); +} + +#[test] +fn query_get_bfs() { + let (g, [a, b, c, d], _) = build_acyclic(); + let got = HypergraphQuery::get_bfs(&g, a).unwrap(); + assert_eq!(got[0], a); + assert_eq!(got[1], b); + let rest: Vec<_> = got[2..].to_vec(); + assert!(rest.contains(&c)); + assert!(rest.contains(&d)); +} + +#[test] +fn query_get_dfs() { + let (g, [a, b, _, _], _) = build_acyclic(); + let got = HypergraphQuery::get_dfs(&g, a).unwrap(); + assert_eq!(got[0], a); + assert_eq!(got[1], b); +} + +#[test] +fn query_is_reachable_true() { + let (g, [a, _, c, _], _) = build_acyclic(); + assert!(HypergraphQuery::is_reachable(&g, a, c).unwrap()); +} + +#[test] +fn query_is_reachable_self() { + let (g, [a, _, _, _], _) = build_acyclic(); + assert!(HypergraphQuery::is_reachable(&g, a, a).unwrap()); +} + +#[test] +fn query_is_reachable_false() { + let (g, [a, _, _, d], _) = build_acyclic(); + // d has no outgoing edges — a is not reachable from d + assert!(!HypergraphQuery::is_reachable(&g, d, a).unwrap()); +} + +#[test] +fn query_is_acyclic_true() { + let (g, _, _) = build_acyclic(); + assert!(HypergraphQuery::is_acyclic(&g)); +} + +#[test] +fn query_is_acyclic_false() { + let (g, _, _) = build_cyclic(); + assert!(!HypergraphQuery::is_acyclic(&g)); +} + +#[test] +fn query_get_all_paths() { + let (g, [a, _, c, _], _) = build_acyclic(); + let got = HypergraphQuery::get_all_paths(&g, a, c).unwrap(); + assert_eq!(got.len(), 1); + assert_eq!(got[0][0], a); + assert_eq!(*got[0].last().unwrap(), c); +} + +#[test] +fn query_get_all_paths_same_vertex() { + let (g, [a, _, _, _], _) = build_acyclic(); + assert_eq!( + HypergraphQuery::get_all_paths(&g, a, a).unwrap(), + vec![vec![a]] + ); +} + +#[test] +fn query_topological_sort_acyclic() { + let (g, [a, b, _, _], _) = build_acyclic(); + let order = HypergraphQuery::topological_sort(&g).unwrap(); + let pos_a = order.iter().position(|&v| v == a).unwrap(); + let pos_b = order.iter().position(|&v| v == b).unwrap(); + assert!(pos_a < pos_b, "a must come before b in topological order"); +} + +#[test] +fn query_topological_sort_cyclic_errors() { + let (g, _, _) = build_cyclic(); + assert!(HypergraphQuery::topological_sort(&g).is_err()); +} + +#[test] +fn query_strongly_connected_components() { + let (g, [a, b, c, d], _) = build_cyclic(); + let sccs = HypergraphQuery::strongly_connected_components(&g).unwrap(); + let big_scc: Vec<_> = { + let mut v = vec![a, b, c]; + v.sort(); + v + }; + let small_scc = vec![d]; + assert!(sccs.contains(&big_scc)); + assert!(sccs.contains(&small_scc)); +} + +#[test] +fn query_connected_components() { + // Build a graph with two disconnected components. + let mut g = Hypergraph::::new(); + let a = g.add_vertex(Vertex::new("a")).unwrap(); + let b = g.add_vertex(Vertex::new("b")).unwrap(); + let c = g.add_vertex(Vertex::new("c")).unwrap(); + let d = g.add_vertex(Vertex::new("d")).unwrap(); + g.add_hyperedge(vec![a, b], Hyperedge::new("e0", 1)) + .unwrap(); + g.add_hyperedge(vec![c, d], Hyperedge::new("e1", 1)) + .unwrap(); + + let components = HypergraphQuery::connected_components(&g).unwrap(); + assert_eq!(components.len(), 2); + let first: Vec<_> = { + let mut v = vec![a, b]; + v.sort(); + v + }; + let second: Vec<_> = { + let mut v = vec![c, d]; + v.sort(); + v + }; + assert!(components.contains(&first)); + assert!(components.contains(&second)); +} + +#[test] +fn query_get_dijkstra_connections() { + let (g, [a, b, c, _], [e0, e1, _]) = build_acyclic(); + let path = HypergraphQuery::get_dijkstra_connections(&g, a, c).unwrap(); + assert_eq!(path, vec![(a, None), (b, Some(e0)), (c, Some(e1))]); +} + +#[test] +fn query_get_dijkstra_connections_with_cost() { + let (g, [a, _, c, _], _) = build_acyclic(); + let (cost, path) = HypergraphQuery::get_dijkstra_connections_with_cost(&g, a, c).unwrap(); + assert_eq!(cost, 3); // e0(1) + e1(2) + assert_eq!(path[0].0, a); + assert_eq!(path.last().unwrap().0, c); +} + +#[test] +fn query_get_dijkstra_from() { + let (g, [a, b, c, d], _) = build_acyclic(); + let distances = HypergraphQuery::get_dijkstra_from(&g, a).unwrap(); + assert_eq!(distances[&a], 0); + assert_eq!(distances[&b], 1); + assert_eq!(distances[&c], 3); // a→b(1) + b→c(2) + assert_eq!(distances[&d], 4); // a→b(1) + b→d(3) +} From fd83a7be0223711b1add83bc0d9be480a72f691e Mon Sep 17 00:00:00 2001 From: Davy Duperron Date: Mon, 25 May 2026 14:33:39 +0200 Subject: [PATCH 5/5] docs: mention HypergraphQuery trait in README and crate-level docs --- README.md | 2 ++ src/lib.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/README.md b/README.md index 3eeb48e..31f9a41 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ And to compute: - Shortest paths: **Dijkstra** point-to-point and single-source - Structural analysis: strongly connected components, weakly connected components, all simple paths, subgraph extraction, cycle detection - Filtered views: `retain_vertices`, `retain_hyperedges` +- Generic query interface: `HypergraphQuery` trait works over both `Hypergraph` and `PersistentHypergraph` ## ⚗️ Implementation @@ -34,6 +35,7 @@ And to compute: - Proper error handling - Stable indexes for each hyperedge and each vertex — identity is the index, not the weight; duplicate weights are allowed on both sides - Parallelism (with Rayon) +- `HypergraphQuery` trait — implement 9 primitives to get all graph algorithms for free; use it for generic functions and trait objects that work with either backend - Optional `serde` support (`features = ["serde"]` in `Cargo.toml`) - Optional `persistence` support (`features = ["persistence"]` in `Cargo.toml`) diff --git a/src/lib.rs b/src/lib.rs index 3994b69..f75a066 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,8 @@ //! - Shortest paths: Dijkstra point-to-point and single-source //! - Structural analysis: strongly connected components, weakly connected components, subgraph extraction, cycle detection //! - Filtered views: `retain_vertices`, `retain_hyperedges` +//! - Generic query interface: [`HypergraphQuery`] trait — implement 9 primitives to get all +//! graph algorithms for free; write generic code that works with either backend //! - Optional **`serde`** feature for serialization/deserialization support //! (enable with `features = ["serde"]` in `Cargo.toml`) //! - Optional **`persistence`** feature for disk-backed graphs larger than RAM