From 7807ed66f625ec1a0fa4da1ecdadee924f986137 Mon Sep 17 00:00:00 2001 From: jenya Date: Wed, 30 Sep 2026 17:43:23 +0300 Subject: [PATCH 1/4] Library API: portable core crate with CLI and WASM adapters Cargo workspace: - eigentrust (root): the library, rlib, builds on every target, no wasm gating - eigentrust-cli (cli/): the `eigentrust` binary, uses only the public API - eigentrust-wasm (wasm/): JS bindings, the only cdylib, publish = false Public API, all re-exported from lib.rs, every module private: - eigentrust(local_trust, pre_trust) and eigentrust_with_options(.., &options) - TrustEdge { from, to, weight }, PreTrust { peer, weight }, From tuple impls - EigenTrustOptions: alpha, epsilon, max_iterations; private fields, builder, validated before any work - TrustScores: scores, get, ranking, iterations, residual - EigenTrustError (#[non_exhaustive]) and Input; no String errors - csv feature: csv::Network::from_csv with its own Error / ErrorKind - parallel feature: rayon; default features: none Behavior and numbers are unchanged: CLI output is byte-identical to the previous version on 10 reference inputs (up to 5M lines), WASM output is byte-identical, CLI is ~15% faster, WASM the same. - crate docs with input rules, convergence and examples; missing_docs on - examples/basic.rs, examples/csv.rs - tests/api.rs and tests/csv.rs use the crate like an external consumer - README Rust example runs as a doc test - crates.io metadata, include list, docs.rs config; Cargo.lock committed - removed the unused sparse helpers (CSC, merge, sorting utils) and the unwired distrust functions; their history stays in git --- .gitignore | 2 - Cargo.lock | 298 +++++++++++++++---- Cargo.toml | 61 ++-- build.sh | 11 +- cli/Cargo.toml | 21 ++ cli/LICENSE-APACHE | 1 + cli/LICENSE-MIT | 1 + {src => cli/src}/main.rs | 40 +-- examples/basic.rs | 26 ++ examples/csv.rs | 17 ++ src/algorithm/matrix.rs | 105 +++++++ src/algorithm/mod.rs | 128 +++++++++ src/algorithm/power.rs | 202 +++++++++++++ src/basic/eigentrust.rs | 600 --------------------------------------- src/basic/engine.rs | 134 --------- src/basic/input.rs | 212 -------------- src/basic/localtrust.rs | 149 ---------- src/basic/mod.rs | 6 - src/basic/trustvector.rs | 118 -------- src/basic/util.rs | 54 ---- src/csv.rs | 395 ++++++++++++++++++++++++++ src/error.rs | 145 ++++++---- src/lib.rs | 211 +++++++++----- src/options.rs | 108 +++++++ src/scores.rs | 62 ++++ src/sparse/entry.rs | 310 -------------------- src/sparse/matrix.rs | 591 -------------------------------------- src/sparse/mod.rs | 4 - src/sparse/util.rs | 91 ------ src/sparse/vector.rs | 245 ---------------- src/types.rs | 64 +++++ tests/api.rs | 325 +++++++++++++++++++++ tests/csv.rs | 110 +++++++ wasm/Cargo.toml | 32 +++ wasm/src/bindings.rs | 34 +++ wasm/src/convert.rs | 71 +++++ wasm/src/lib.rs | 15 + 37 files changed, 2245 insertions(+), 2754 deletions(-) create mode 100644 cli/Cargo.toml create mode 120000 cli/LICENSE-APACHE create mode 120000 cli/LICENSE-MIT rename {src => cli/src}/main.rs (55%) create mode 100644 examples/basic.rs create mode 100644 examples/csv.rs create mode 100644 src/algorithm/matrix.rs create mode 100644 src/algorithm/mod.rs create mode 100644 src/algorithm/power.rs delete mode 100644 src/basic/eigentrust.rs delete mode 100644 src/basic/engine.rs delete mode 100644 src/basic/input.rs delete mode 100644 src/basic/localtrust.rs delete mode 100644 src/basic/mod.rs delete mode 100644 src/basic/trustvector.rs delete mode 100644 src/basic/util.rs create mode 100644 src/csv.rs create mode 100644 src/options.rs create mode 100644 src/scores.rs delete mode 100644 src/sparse/entry.rs delete mode 100644 src/sparse/matrix.rs delete mode 100644 src/sparse/mod.rs delete mode 100644 src/sparse/util.rs delete mode 100644 src/sparse/vector.rs create mode 100644 src/types.rs create mode 100644 tests/api.rs create mode 100644 tests/csv.rs create mode 100644 wasm/Cargo.toml create mode 100644 wasm/src/bindings.rs create mode 100644 wasm/src/convert.rs create mode 100644 wasm/src/lib.rs diff --git a/.gitignore b/.gitignore index 8daf6d3..8240dcf 100644 --- a/.gitignore +++ b/.gitignore @@ -19,9 +19,7 @@ firebase.json debug/ target/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock index b99bdc3..5bf2f3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,56 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "async-trait" version = "0.1.92" @@ -19,7 +69,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -28,6 +78,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bumpalo" version = "3.16.0" @@ -56,6 +112,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -73,7 +135,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" dependencies = [ "log", - "wasm-bindgen", "web-sys", ] @@ -132,17 +193,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + [[package]] name = "eigentrust" -version = "0.1.0" +version = "0.2.0" dependencies = [ - "console_error_panic_hook", - "console_log", "csv", - "env_logger", "log", "rayon", - "serde", +] + +[[package]] +name = "eigentrust-cli" +version = "0.2.0" +dependencies = [ + "csv", + "eigentrust", + "env_logger", +] + +[[package]] +name = "eigentrust-wasm" +version = "0.2.0" +dependencies = [ + "console_error_panic_hook", + "console_log", + "eigentrust", + "log", "serde_json", "wasm-bindgen", "wasm-bindgen-rayon", @@ -156,16 +263,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" [[package]] -name = "env_logger" -version = "0.10.2" +name = "env_filter" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ - "humantime", - "is-terminal", "log", "regex", - "termcolor", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", ] [[package]] @@ -199,33 +316,53 @@ dependencies = [ ] [[package]] -name = "hermit-abi" -version = "0.4.0" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "humantime" -version = "2.1.0" +name = "itoa" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" [[package]] -name = "is-terminal" -version = "0.4.13" +name = "jiff" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" dependencies = [ - "hermit-abi", - "libc", - "windows-sys 0.52.0", + "defmt", + "jiff-core", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", ] [[package]] -name = "itoa" -version = "1.0.11" +name = "jiff-core" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "js-sys" @@ -238,12 +375,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "libc" -version = "0.2.158" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439" - [[package]] name = "libm" version = "0.2.16" @@ -252,9 +383,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "log" -version = "0.4.22" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "memchr" @@ -297,6 +428,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -309,6 +446,21 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -320,9 +472,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.37" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -351,9 +503,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.10.6" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -363,9 +515,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -374,9 +526,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.4" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustversion" @@ -426,7 +578,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -453,6 +605,17 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.6" @@ -465,12 +628,23 @@ dependencies = [ ] [[package]] -name = "termcolor" -version = "1.4.1" +name = "thiserror" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "winapi-util", + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -488,6 +662,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "walkdir" version = "2.5.0" @@ -541,7 +721,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.6", "wasm-bindgen-shared", ] @@ -596,7 +776,7 @@ checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -635,22 +815,28 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" -version = "0.52.0" +version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-targets", + "windows-link", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cc63a15..da1b21d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,43 +1,50 @@ +[workspace] +members = ["cli", "wasm"] +resolver = "2" + [package] name = "eigentrust" -version = "0.1.0" +version = "0.2.0" edition = "2021" authors = ["Jenya "] license = "MIT OR Apache-2.0" -description = "Fast EigenTrust reputation algorithm: global trust scores for peer-to-peer networks and social graphs, native and WebAssembly" +description = "EigenTrust reputation algorithm: global trust scores for peer-to-peer networks and social graphs, with Sybil resistance" +repository = "https://github.com/hypnagonia/eigentrust-rust" homepage = "https://eigentrust.jenyadoesapps.com" +documentation = "https://docs.rs/eigentrust" readme = "README.md" -keywords = ["eigentrust", "reputation", "trust", "sybil", "wasm"] -categories = ["algorithms", "wasm", "science"] -repository = "https://github.com/hypnagonia/eigentrust-rust" +keywords = ["eigentrust", "reputation", "trust", "sybil", "pagerank"] +categories = ["algorithms", "mathematics", "science"] +include = [ + "/src/**/*.rs", + "/tests/**/*.rs", + "/examples/**/*.rs", + "/example/*.csv", + "/README.md", + "/LICENSE-MIT", + "/LICENSE-APACHE", +] [features] -# Multithreaded WASM (wasm-bindgen-rayon). Needs nightly, +atomics and a cross-origin -# isolated page, see build.sh -parallel = ["dep:wasm-bindgen-rayon", "dep:rayon"] - -[target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = "0.2.100" -console_error_panic_hook = "0.1" -console_log = { version = "1.0", features = ["color"]} -wasm-bindgen-rayon = { version = "1.3", optional = true } -rayon = { version = "1.8", optional = true } - -[target.'cfg(target_arch = "wasm32")'.dev-dependencies] -wasm-bindgen-test = "0.3" - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -env_logger = "0.10" -rayon = "1.8" +default = [] +## Read named peers from CSV (`eigentrust::csv`). +csv = ["dep:csv"] +## Multithreaded power iteration with rayon. On wasm32 this needs a threaded build, +## see the eigentrust-wasm crate. +parallel = ["dep:rayon"] [dependencies] -csv = "1.3" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +csv = { version = "1.3", optional = true } log = "0.4" +rayon = { version = "1.8", optional = true } + +[[example]] +name = "csv" +required-features = ["csv"] -[lib] -crate-type = ["cdylib"] +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] [profile.release] lto = true diff --git a/build.sh b/build.sh index 28ffa90..4b903d2 100755 --- a/build.sh +++ b/build.sh @@ -1,18 +1,19 @@ #!/bin/sh -# Builds the WASM packages and copies them into the demo. +# Builds the WebAssembly packages from wasm/ and copies them into the demo. # pkg/ single-threaded, stable toolchain, works everywhere # pkg-parallel/ multithreaded (rayon on Web Workers), nightly + atomics, # needs a cross-origin isolated page (COOP/COEP headers, see demo/vercel.json) set -e +cd "$(dirname "$0")" -wasm-pack build --target web --release +wasm-pack build wasm --target web --release --out-dir ../pkg --out-name eigentrust RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals' \ - rustup run nightly wasm-pack build --target web --release --out-dir pkg-parallel \ - -- --features parallel -Z build-std=panic_abort,std + rustup run nightly wasm-pack build wasm --target web --release \ + --out-dir ../pkg-parallel --out-name eigentrust \ + -- --features threads -Z build-std=panic_abort,std rm -rf demo/pkg demo/pkg-parallel -mkdir -p demo cp -r pkg demo/pkg cp -r pkg-parallel demo/pkg-parallel # wasm-bindgen-rayon imports its package as a directory ('../../..'), which only a bundler resolves diff --git a/cli/Cargo.toml b/cli/Cargo.toml new file mode 100644 index 0000000..e150c6a --- /dev/null +++ b/cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "eigentrust-cli" +version = "0.2.0" +edition = "2021" +authors = ["Jenya "] +license = "MIT OR Apache-2.0" +description = "Command-line EigenTrust: rank peers from CSV trust statements" +repository = "https://github.com/hypnagonia/eigentrust-rust" +homepage = "https://eigentrust.jenyadoesapps.com" +readme = "../README.md" +keywords = ["eigentrust", "reputation", "trust", "cli", "csv"] +categories = ["command-line-utilities", "algorithms"] + +[[bin]] +name = "eigentrust" +path = "src/main.rs" + +[dependencies] +eigentrust = { version = "0.2.0", path = "..", features = ["csv", "parallel"] } +csv = "1.3" +env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } diff --git a/cli/LICENSE-APACHE b/cli/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/cli/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/cli/LICENSE-MIT b/cli/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/cli/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/src/main.rs b/cli/src/main.rs similarity index 55% rename from src/main.rs rename to cli/src/main.rs index fcce6d2..c02fb22 100644 --- a/src/main.rs +++ b/cli/src/main.rs @@ -1,18 +1,22 @@ +//! eigentrust [alpha] +//! +//! Prints `peer,score` for every peer with a non-zero score, highest first. + +use eigentrust::csv::Network; +use eigentrust::EigenTrustOptions; use std::env; use std::fs; use std::io; use std::process::ExitCode; -use crate::basic::engine::calculate_from_csv; -use crate::basic::util::init_logger; -pub mod basic; -pub mod error; -pub mod sparse; - const USAGE: &str = "usage: eigentrust [alpha]"; fn main() -> ExitCode { - init_logger(); + // RUST_LOG overrides, e.g. RUST_LOG=trace + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp_millis() + .init(); + match run(env::args().skip(1).collect()) { Ok(()) => ExitCode::SUCCESS, Err(message) => { @@ -23,13 +27,13 @@ fn main() -> ExitCode { } fn run(args: Vec) -> Result<(), String> { - let (localtrust_path, pretrust_path, alpha) = match args.as_slice() { - [lt, pt] => (lt, pt, None), + let (localtrust_path, pretrust_path, options) = match args.as_slice() { + [lt, pt] => (lt, pt, EigenTrustOptions::default()), [lt, pt, alpha] => { let alpha = alpha .parse::() .map_err(|_| format!("alpha {:?} is not a number\n{}", alpha, USAGE))?; - (lt, pt, Some(alpha)) + (lt, pt, EigenTrustOptions::default().with_alpha(alpha)) } _ => return Err(USAGE.to_string()), }; @@ -37,17 +41,17 @@ fn run(args: Vec) -> Result<(), String> { let read = |path: &String| { fs::read_to_string(path).map_err(|e| format!("cannot read {}: {}", path, e)) }; - let localtrust_csv = read(localtrust_path)?; - let pretrust_csv = read(pretrust_path)?; - - let result = - calculate_from_csv(&localtrust_csv, &pretrust_csv, alpha).map_err(|e| e.to_string())?; + let network = Network::from_csv(&read(localtrust_path)?, &read(pretrust_path)?) + .map_err(|e| e.to_string())?; + let result = network.eigentrust(&options).map_err(|e| e.to_string())?; - // proper CSV so peer ids with commas or quotes round-trip + // proper CSV so peer names with commas or quotes round-trip let write = || -> csv::Result<()> { let mut out = csv::Writer::from_writer(io::stdout().lock()); - for (name, score) in &result { - out.write_record([name.as_str(), &score.to_string()])?; + for (name, score) in network.ranking(&result) { + if score > 0.0 { + out.write_record([name, &score.to_string()])?; + } } out.flush()?; Ok(()) diff --git a/examples/basic.rs b/examples/basic.rs new file mode 100644 index 0000000..f9a9e96 --- /dev/null +++ b/examples/basic.rs @@ -0,0 +1,26 @@ +//! cargo run --example basic + +use eigentrust::{eigentrust, PreTrust, TrustEdge}; + +fn main() -> Result<(), Box> { + let names = ["alice", "bob", "carol", "mallory"]; + + // who trusts whom, and how much (weights are relative per truster) + let local_trust = [ + TrustEdge::new(0, 1, 3.0), // alice -> bob + TrustEdge::new(0, 2, 1.0), // alice -> carol + TrustEdge::new(1, 2, 2.0), // bob -> carol + TrustEdge::new(2, 0, 1.0), // carol -> alice + TrustEdge::new(3, 3, 9.0), // mallory only vouches for herself + ]; + // alice is trusted from the start + let pre_trust = [PreTrust::new(0, 1.0)]; + + let result = eigentrust(local_trust, pre_trust)?; + + for (peer, score) in result.ranking() { + println!("{:<8} {:.4}", names[peer], score); + } + println!("converged after {} iterations", result.iterations()); + Ok(()) +} diff --git a/examples/csv.rs b/examples/csv.rs new file mode 100644 index 0000000..2ab6a3f --- /dev/null +++ b/examples/csv.rs @@ -0,0 +1,17 @@ +//! cargo run --example csv --features csv + +use eigentrust::csv::Network; +use eigentrust::EigenTrustOptions; + +fn main() -> Result<(), Box> { + let local_trust = "from,to,weight\nalice,bob,3\nalice,carol,1\nbob,carol,2\ncarol,alice,1\n"; + let pre_trust = "peer\nalice\n"; + + let network = Network::from_csv(local_trust, pre_trust)?; + let result = network.eigentrust(&EigenTrustOptions::default().with_alpha(0.2))?; + + for (name, score) in network.ranking(&result) { + println!("{:<8} {:.4}", name, score); + } + Ok(()) +} diff --git a/src/algorithm/matrix.rs b/src/algorithm/matrix.rs new file mode 100644 index 0000000..d82848a --- /dev/null +++ b/src/algorithm/matrix.rs @@ -0,0 +1,105 @@ +// Compressed sparse rows: `rows[i]` holds the non-zero entries of row i, sorted by column. + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Entry { + pub(crate) index: usize, + pub(crate) value: f64, +} + +impl Entry { + pub(crate) fn new(index: usize, value: f64) -> Self { + Entry { index, value } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct CsrMatrix { + pub(crate) rows: Vec>, +} + +impl CsrMatrix { + // Sorts each row by column. Repeated columns keep the last value in input order and + // zero values are dropped. + pub(crate) fn from_rows(mut rows: Vec>) -> Self { + for row in &mut rows { + // stable sort keeps input order among duplicates, so the last one wins + row.sort_by_key(|e| e.index); + dedup_keep_last(row); + row.retain(|e| e.value != 0.0); + } + CsrMatrix { rows } + } + + // Column lists come out sorted by row, which fixes the summation order of the + // matrix-vector product. + pub(crate) fn transpose(&self) -> CsrMatrix { + let mut counts = vec![0usize; self.rows.len()]; + for row in &self.rows { + for entry in row { + counts[entry.index] += 1; + } + } + let mut rows: Vec> = counts.into_iter().map(Vec::with_capacity).collect(); + for (i, row) in self.rows.iter().enumerate() { + for entry in row { + rows[entry.index].push(Entry::new(i, entry.value)); + } + } + CsrMatrix { rows } + } +} + +fn dedup_keep_last(row: &mut Vec) { + if row.len() < 2 { + return; + } + let mut write = 0; + for read in 1..row.len() { + if row[read].index == row[write].index { + row[write].value = row[read].value; + } else { + write += 1; + row[write] = row[read]; + } + } + row.truncate(write + 1); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn e(index: usize, value: f64) -> Entry { + Entry::new(index, value) + } + + #[test] + fn from_rows_sorts_dedups_and_drops_zeros() { + let m = CsrMatrix::from_rows(vec![ + vec![e(1, 1.0), e(0, 3.0), e(1, 5.0), e(2, 0.0)], + vec![e(1, 2.0), e(0, 4.0), e(0, 0.0)], + ]); + assert_eq!(m.rows, vec![vec![e(0, 3.0), e(1, 5.0)], vec![e(1, 2.0)]]); + } + + #[test] + fn transpose_round_trips() { + let m = CsrMatrix::from_rows(vec![ + vec![e(0, 100.0), e(1, 200.0), e(2, 300.0)], + vec![e(1, 400.0), e(3, 500.0)], + vec![], + vec![e(0, 600.0), e(1, 700.0), e(2, 800.0), e(3, 900.0)], + ]); + let t = m.transpose(); + assert_eq!( + t.rows, + vec![ + vec![e(0, 100.0), e(3, 600.0)], + vec![e(0, 200.0), e(1, 400.0), e(3, 700.0)], + vec![e(0, 300.0), e(3, 800.0)], + vec![e(1, 500.0), e(3, 900.0)], + ] + ); + assert_eq!(t.transpose(), m); + } +} diff --git a/src/algorithm/mod.rs b/src/algorithm/mod.rs new file mode 100644 index 0000000..df36fd3 --- /dev/null +++ b/src/algorithm/mod.rs @@ -0,0 +1,128 @@ +// The one EigenTrust implementation behind the public API, the CSV reader, the CLI and +// the WebAssembly bindings. + +mod matrix; +mod power; + +use crate::error::Input; +use crate::{EigenTrustError, EigenTrustOptions, PreTrust, TrustEdge, TrustScores}; +use matrix::{CsrMatrix, Entry}; + +pub(crate) fn run( + local_trust: impl IntoIterator, + pre_trust: impl IntoIterator, + options: &EigenTrustOptions, +) -> Result { + options.validate()?; + + let mut peers = 0; + let mut rows: Vec> = Vec::new(); + for (position, edge) in local_trust.into_iter().enumerate() { + check_weight(Input::LocalTrust, position, edge.weight)?; + let bound = peer_bound(Input::LocalTrust, position, edge.from.max(edge.to))?; + peers = peers.max(bound); + if edge.from >= rows.len() { + rows.resize_with(edge.from + 1, Vec::new); + } + rows[edge.from].push(Entry::new(edge.to, edge.weight)); + } + + let mut seeds: Vec = Vec::new(); + for (position, seed) in pre_trust.into_iter().enumerate() { + check_weight(Input::PreTrust, position, seed.weight)?; + peers = peers.max(peer_bound(Input::PreTrust, position, seed.peer)?); + seeds.push(seed); + } + + if peers == 0 { + return Err(EigenTrustError::EmptyNetwork); + } + rows.resize_with(peers, Vec::new); + + let p = normalize_pre_trust(&seeds, peers)?; + let c = normalize_local_trust(CsrMatrix::from_rows(rows), &p)?; + let dense_p = to_dense(&p, peers); + + let out = power::iterate( + &c, + &dense_p, + options.alpha(), + options.epsilon_for(peers), + options.max_iterations(), + )?; + Ok(TrustScores::new(out.scores, out.iterations, out.residual)) +} + +fn check_weight(input: Input, position: usize, weight: f64) -> Result<(), EigenTrustError> { + if !weight.is_finite() { + return Err(EigenTrustError::NonFiniteWeight { input, position }); + } + if weight < 0.0 { + return Err(EigenTrustError::NegativeWeight { + input, + position, + weight, + }); + } + Ok(()) +} + +// Number of peers needed to include `peer`. +fn peer_bound(input: Input, position: usize, peer: usize) -> Result { + peer.checked_add(1) + .ok_or(EigenTrustError::InvalidPeer { input, position }) +} + +// Sparse distribution over peers, sorted by peer. A repeated peer keeps its last weight; +// all-zero or empty pre-trust becomes uniform. +fn normalize_pre_trust(seeds: &[PreTrust], peers: usize) -> Result, EigenTrustError> { + let mut last: Vec> = vec![None; peers]; + for seed in seeds { + last[seed.peer] = Some(seed.weight); + } + let mut p: Vec = last + .into_iter() + .enumerate() + .filter_map(|(i, w)| w.map(|w| Entry::new(i, w))) + .collect(); + + let sum: f64 = p.iter().map(|e| e.value).sum(); + if !sum.is_finite() { + return Err(EigenTrustError::WeightOverflow(Input::PreTrust)); + } + if sum == 0.0 { + let uniform = 1.0 / peers as f64; + return Ok((0..peers).map(|i| Entry::new(i, uniform)).collect()); + } + for e in &mut p { + e.value /= sum; + } + Ok(p) +} + +// Scales every row to sum to 1. A peer that trusts nobody passes its trust on according +// to pre-trust. +fn normalize_local_trust(mut c: CsrMatrix, p: &[Entry]) -> Result { + for row in &mut c.rows { + let sum: f64 = row.iter().map(|e| e.value).sum(); + if !sum.is_finite() { + return Err(EigenTrustError::WeightOverflow(Input::LocalTrust)); + } + if sum == 0.0 { + *row = p.to_vec(); + } else { + for e in row.iter_mut() { + e.value /= sum; + } + } + } + Ok(c) +} + +fn to_dense(entries: &[Entry], len: usize) -> Vec { + let mut dense = vec![0.0; len]; + for e in entries { + dense[e.index] += e.value; + } + dense +} diff --git a/src/algorithm/power.rs b/src/algorithm/power.rs new file mode 100644 index 0000000..cb87e7f --- /dev/null +++ b/src/algorithm/power.rs @@ -0,0 +1,202 @@ +// Power iteration t <- (1 - alpha) * C^T t + alpha * p on dense vectors. +// +// The trust vector becomes dense after a few iterations, and a dense lookup makes each row +// dot product O(nnz(row)). Sums use Kahan-Babuska-Neumaier compensation in a fixed order, +// so results are reproducible bit for bit, with or without threads. + +use super::matrix::{CsrMatrix, Entry}; +use crate::EigenTrustError; + +pub(crate) struct Converged { + pub(crate) scores: Vec, + pub(crate) iterations: usize, + pub(crate) residual: f64, +} + +// `c` is row-stochastic (or close to it) and `p` a distribution, both of size n > 0. +pub(crate) fn iterate( + c: &CsrMatrix, + p: &[f64], + alpha: f64, + epsilon: f64, + max_iterations: usize, +) -> Result { + let n = p.len(); + let ct = c.transpose(); + let ap: Vec = p.iter().map(|v| v * alpha).collect(); + let mut t1 = p.to_vec(); + let mut prev = t1.clone(); + let mut t2 = vec![0.0; n]; + let mut residual = f64::INFINITY; + let mut iterations = 0; + + log::info!( + "EigenTrust started: peers={} nnz={} alpha={} epsilon={}", + n, + ct.rows.iter().map(Vec::len).sum::(), + alpha, + epsilon + ); + + loop { + if iterations >= 1 { + residual = delta_norm2(&t1, &prev); + prev.copy_from_slice(&t1); + log::trace!("iteration={} residual={}", iterations, residual); + if residual <= epsilon { + break; + } + } + if iterations == max_iterations { + return Err(EigenTrustError::NotConverged { + iterations, + residual, + }); + } + + mul(&ct, &t1, &mut t2); + for (x, ap_i) in t2.iter_mut().zip(&ap) { + *x = *x * (1.0 - alpha) + ap_i; + } + std::mem::swap(&mut t1, &mut t2); + iterations += 1; + } + + log::info!( + "EigenTrust finished: iterations={} residual={}", + iterations, + residual + ); + Ok(Converged { + scores: t1, + iterations, + residual, + }) +} + +// ||t - prev||2 +fn delta_norm2(t: &[f64], prev: &[f64]) -> f64 { + let mut sum = KbnSum::default(); + for (x, y) in t.iter().zip(prev) { + let d = x - y; + if d != 0.0 { + sum.add(d * d); + } + } + sum.value().sqrt() +} + +// out = m * v +fn mul(m: &CsrMatrix, v: &[f64], out: &mut [f64]) { + let row_dot = |row: &[Entry]| { + let mut sum = KbnSum::default(); + for entry in row { + let x = v[entry.index]; + if x != 0.0 { + sum.add(entry.value * x); + } + } + sum.value() + }; + + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + out.par_iter_mut() + .zip(m.rows.par_iter()) + .with_min_len(1024) + .for_each(|(o, row)| *o = row_dot(row)); + } + + #[cfg(not(feature = "parallel"))] + for (o, row) in out.iter_mut().zip(&m.rows) { + *o = row_dot(row); + } +} + +// Kahan-Babuska-Neumaier compensated summation. +#[derive(Default)] +struct KbnSum { + sum: f64, + compensation: f64, +} + +impl KbnSum { + fn add(&mut self, value: f64) { + let (more, less) = if self.sum.abs() < value.abs() { + (value, self.sum) + } else { + (self.sum, value) + }; + self.sum += value; + self.compensation += less - (self.sum - more); + } + + fn value(&self) -> f64 { + self.sum + self.compensation + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kbn_recovers_lost_precision() { + let mut s = KbnSum::default(); + for v in [1.0, 1e100, 1.0, -1e100] { + s.add(v); + } + assert_eq!(s.value(), 2.0); + } + + // Reference result carried over from the original implementation: a raw (not + // re-normalized) 8x8 matrix, alpha 0.5, epsilon 1.25e-7. + #[test] + fn matches_reference_result() { + let seventh = 0.14285714285714285; + let full = || (0..7).map(|i| Entry::new(i, seventh)).collect::>(); + let c = CsrMatrix { + rows: vec![ + vec![Entry::new(3, 1.0)], + full(), + vec![Entry::new(3, 1.0)], + full(), + vec![Entry::new(1, 1.0)], + full(), + vec![Entry::new(5, 1.0)], + full(), + ], + }; + let mut p = vec![seventh; 7]; + p.push(0.0); + + let out = iterate(&c, &p, 0.5, 1.25e-7, 10_000).unwrap(); + assert_eq!( + out.scores, + vec![ + 0.11111110842697292, + 0.16666666867977029, + 0.11111110842697292, + 0.22222222893256766, + 0.11111110842697292, + 0.16666666867977029, + 0.11111110842697292, + 0.0, + ] + ); + } + + #[test] + fn stops_at_max_iterations() { + // 0 <-> 1 with no teleport oscillates forever + let c = CsrMatrix { + rows: vec![vec![Entry::new(1, 1.0)], vec![Entry::new(0, 1.0)]], + }; + let err = iterate(&c, &[1.0, 0.0], 0.0, 1e-9, 50).err().unwrap(); + assert!(matches!( + err, + EigenTrustError::NotConverged { iterations: 50, .. } + )); + } +} diff --git a/src/basic/eigentrust.rs b/src/basic/eigentrust.rs deleted file mode 100644 index 9e3254c..0000000 --- a/src/basic/eigentrust.rs +++ /dev/null @@ -1,600 +0,0 @@ -use crate::error::{Error, Result}; -use crate::sparse::entry::Entry; -#[cfg(test)] -use crate::sparse::matrix::CSMatrix; -use crate::sparse::matrix::CSRMatrix; -use crate::sparse::util::KBNSummer; -use crate::sparse::vector::Vector; -use std::cmp; - -// Stops the iteration only once the ranking of the top `num_leaders` peers has stayed -// the same for `length` checks. compute() uses length 0, which disables it. -pub struct FlatTailChecker { - length: usize, - num_leaders: usize, - stats: FlatTailStats, -} - -impl FlatTailChecker { - pub fn new(length: usize, num_leaders: usize) -> FlatTailChecker { - FlatTailChecker { - length, - num_leaders, - stats: FlatTailStats { - length: 0, - threshold: 1, - delta_norm: 1.0, - ranking: vec![], - }, - } - } - - pub fn update(&mut self, t: &Vector, d: f64) { - let mut entries = t.entries.clone(); - entries.sort_by(|a, b| { - b.value - .partial_cmp(&a.value) - .unwrap_or(cmp::Ordering::Equal) - }); - // only the top num_leaders positions have to stay stable - let ranking: Vec = entries - .iter() - .take(self.num_leaders) - .map(|entry| entry.index) - .collect(); - - if ranking == self.stats.ranking { - self.stats.length += 1; - } else { - if self.stats.length > 0 && self.stats.threshold <= self.stats.length { - self.stats.threshold = self.stats.length + 1; - } - self.stats.length = 0; - self.stats.delta_norm = d; - self.stats.ranking = ranking; - } - } - - pub fn reached(&self) -> bool { - self.stats.length >= self.length - } -} - -pub struct FlatTailStats { - pub length: usize, - pub threshold: usize, - pub delta_norm: f64, - pub ranking: Vec, -} - -// Upper bound on power iterations when the caller does not set one. With alpha > 0 -// the error shrinks by (1 - alpha) per step; alpha = 0 on a periodic graph never converges. -pub const DEFAULT_MAX_ITERATIONS: usize = 10_000; - -// Compute function implements the EigenTrust algorithm. -// -// The iteration runs on dense vectors: after a couple of iterations the trust -// vector is (almost) dense anyway, and a dense lookup turns every row dot -// product into O(nnz(row)) instead of a sparse-sparse merge walk. -pub fn compute( - c: &CSRMatrix, - p: &Vector, - a: f64, - e: f64, - max_iterations: Option, - min_iterations: Option, -) -> Result { - if !(0.0..=1.0).contains(&a) { - return Err(Error::InvalidAlpha(a)); - } - - let n = c.cs_matrix.major_dim; - if n == 0 { - return Err(Error::EmptyLocalTrust); - } - - if p.dim != n { - return Err(Error::DimensionMismatch); - } - - let ct = c.transpose()?; - ct.cs_matrix.dim()?; - - let ap: Vec = p.to_dense().iter().map(|v| v * a).collect(); - let mut t1 = p.to_dense(); - let mut prev = t1.clone(); - let mut t2 = vec![0.0; n]; - - let flat_tail = 0; - let mut flat_tail_checker = FlatTailChecker::new(flat_tail, n); - - let mut iter = 0; - let max_iters = max_iterations.unwrap_or(DEFAULT_MAX_ITERATIONS); - let min_iters = min_iterations.unwrap_or(1); - - log::info!( - "Compute started dim={}, nnz={}, alpha={}, epsilon={}", - n, - ct.cs_matrix.nnz(), - a, - e, - ); - - while iter < max_iters { - if iter >= min_iters { - let d = dense_delta_norm2(&t1, &prev); - if !d.is_finite() { - return Err(Error::NonFiniteScores); - } - prev.copy_from_slice(&t1); - log::trace!("iteration={} delta={}", iter, d); - - // with flat_tail == 0 the ranking check is always satisfied, - // skip the O(n log n) sort per iteration - if flat_tail > 0 { - flat_tail_checker.update(&Vector::from_dense(&t1), d); - } - - if d <= e && flat_tail_checker.reached() { - break; - } - } - - // t2 = (1 - a) * C^T * t1 + a * p - mul_dense(&ct, &t1, &mut t2); - for (x, ap_i) in t2.iter_mut().zip(ap.iter()) { - *x = *x * (1.0 - a) + ap_i; - } - std::mem::swap(&mut t1, &mut t2); - - iter += 1; - } - - if iter >= max_iters { - return Err(Error::NotConverged { - iterations: max_iters, - alpha: a, - }); - } - - log::info!( - "finished: alpha={} dim={} nnz={} epsilon={} flatTail={} iterations={}", - a, - n, - ct.cs_matrix.nnz(), - e, - flat_tail, - iter, - ); - - Ok(Vector::from_dense(&t1)) -} - -// ||t - prev||2 with compensated summation, same order as the sparse version. -fn dense_delta_norm2(t: &[f64], prev: &[f64]) -> f64 { - let mut summer = KBNSummer::new(); - for (x, y) in t.iter().zip(prev.iter()) { - let d = x - y; - if d != 0.0 { - summer.add(d * d); - } - } - summer.sum().sqrt() -} - -// out = m * v for a square CSR matrix and a dense vector. -fn mul_dense(m: &CSRMatrix, v: &[f64], out: &mut [f64]) { - let row_dot = |row: &[Entry]| { - let mut summer = KBNSummer::new(); - for entry in row { - let x = v[entry.index]; - if x != 0.0 { - summer.add(entry.value * x); - } - } - summer.sum() - }; - - #[cfg(any(not(target_arch = "wasm32"), feature = "parallel"))] - { - use rayon::prelude::*; - out.par_iter_mut() - .zip(m.cs_matrix.entries.par_iter()) - .with_min_len(1024) - .for_each(|(o, row)| *o = row_dot(row)); - } - - #[cfg(all(target_arch = "wasm32", not(feature = "parallel")))] - for (o, row) in out.iter_mut().zip(m.cs_matrix.entries.iter()) { - *o = row_dot(row); - } -} - -// Subtracts from t each peer's trust-weighted distrust row: -// t -= sum_i t[i] * discounts[i], applied in distruster order. -// Subtracts each peer's trust-weighted distrust row. Not used by calculate_from_csv, see -// extract_distrust. -pub fn discount_trust_vector(t: &mut Vector, discounts: &CSRMatrix) -> Result<()> { - if discounts.cs_matrix.entries.iter().all(|row| row.is_empty()) { - return Ok(()); - } - - let trust = t.to_dense(); - let mut result = trust.clone(); - - for (distruster, distrusts) in discounts.cs_matrix.entries.iter().enumerate() { - let weight = match trust.get(distruster) { - Some(&w) if w != 0.0 => w, - _ => continue, - }; - for entry in distrusts { - if entry.index >= result.len() { - return Err(Error::DimensionMismatch); - } - let scaled = weight * entry.value; - if scaled != 0.0 { - result[entry.index] -= scaled; - } - } - } - - *t = Vector::from_dense(&result); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::sparse::entry::Entry; - use crate::sparse::matrix::CSRMatrix; - use crate::sparse::vector::Vector; - - #[test] - fn test_discount_trust_vector() { - struct TestCase { - name: &'static str, - t: Vector, - discounts: CSRMatrix, - expected: Vector, - } - - let test_cases = vec![TestCase { - name: "test1", - t: Vector::new( - 5, - vec![ - Entry { - index: 0, - value: 0.25, - }, - Entry { - index: 2, - value: 0.5, - }, - Entry { - index: 3, - value: 0.25, - }, - ], - ), - discounts: CSRMatrix { - cs_matrix: CSMatrix { - major_dim: 5, - minor_dim: 5, - entries: vec![ - // 0 - no distrust (empty) - vec![], - // 1 - doesn't matter because of zero trust - vec![ - Entry { - index: 2, - value: 0.5, - }, - Entry { - index: 3, - value: 0.5, - }, - ], - // 2 - scaled by 0.5 and applied - vec![ - Entry { - index: 0, - value: 0.25, - }, - Entry { - index: 4, - value: 0.75, - }, - ], - // 3 - scaled by 0.25 and applied - vec![ - Entry { - index: 2, - value: 0.5, - }, - Entry { - index: 4, - value: 0.5, - }, - ], - // 4 - no distrust, also zero global trust (empty) - vec![], - ], - }, - }, - expected: Vector::new( - 5, - vec![ - Entry { - index: 0, - value: 0.25 - 0.25 * 0.5, - }, // peer 2 - Entry { - index: 2, - value: 0.5 - 0.5 * 0.25, - }, // peer 3 - Entry { - index: 3, - value: 0.25, - }, - Entry { - index: 4, - value: 0.0 - 0.75 * 0.5 - 0.5 * 0.25, - }, // peer 2 & 3 - ], - ), - }]; - - for test in test_cases { - let mut t = test.t.clone(); - let result = discount_trust_vector(&mut t, &test.discounts); - assert!(result.is_ok(), "{}: DiscountTrustVector failed", test.name); - assert_eq!( - t, test.expected, - "{}: Vector does not match expected value", - test.name - ); - } - } - - #[test] - fn test_run() { - let e = 1.25e-7; - let a = 0.5; - - let p = Vector::new( - 8, - vec![ - Entry { - index: 0, - value: 0.14285714285714285, - }, - Entry { - index: 1, - value: 0.14285714285714285, - }, - Entry { - index: 2, - value: 0.14285714285714285, - }, - Entry { - index: 3, - value: 0.14285714285714285, - }, - Entry { - index: 4, - value: 0.14285714285714285, - }, - Entry { - index: 5, - value: 0.14285714285714285, - }, - Entry { - index: 6, - value: 0.14285714285714285, - }, - ], - ); - - let c = CSRMatrix { - cs_matrix: CSMatrix { - major_dim: 8, - minor_dim: 8, - entries: vec![ - vec![Entry { - index: 3, - value: 1.0, - }], - vec![ - Entry { - index: 0, - value: 0.14285714285714285, - }, - Entry { - index: 1, - value: 0.14285714285714285, - }, - Entry { - index: 2, - value: 0.14285714285714285, - }, - Entry { - index: 3, - value: 0.14285714285714285, - }, - Entry { - index: 4, - value: 0.14285714285714285, - }, - Entry { - index: 5, - value: 0.14285714285714285, - }, - Entry { - index: 6, - value: 0.14285714285714285, - }, - ], - vec![Entry { - index: 3, - value: 1.0, - }], - vec![ - Entry { - index: 0, - value: 0.14285714285714285, - }, - Entry { - index: 1, - value: 0.14285714285714285, - }, - Entry { - index: 2, - value: 0.14285714285714285, - }, - Entry { - index: 3, - value: 0.14285714285714285, - }, - Entry { - index: 4, - value: 0.14285714285714285, - }, - Entry { - index: 5, - value: 0.14285714285714285, - }, - Entry { - index: 6, - value: 0.14285714285714285, - }, - ], - vec![Entry { - index: 1, - value: 1.0, - }], - vec![ - Entry { - index: 0, - value: 0.14285714285714285, - }, - Entry { - index: 1, - value: 0.14285714285714285, - }, - Entry { - index: 2, - value: 0.14285714285714285, - }, - Entry { - index: 3, - value: 0.14285714285714285, - }, - Entry { - index: 4, - value: 0.14285714285714285, - }, - Entry { - index: 5, - value: 0.14285714285714285, - }, - Entry { - index: 6, - value: 0.14285714285714285, - }, - ], - vec![Entry { - index: 5, - value: 1.0, - }], - vec![ - Entry { - index: 0, - value: 0.14285714285714285, - }, - Entry { - index: 1, - value: 0.14285714285714285, - }, - Entry { - index: 2, - value: 0.14285714285714285, - }, - Entry { - index: 3, - value: 0.14285714285714285, - }, - Entry { - index: 4, - value: 0.14285714285714285, - }, - Entry { - index: 5, - value: 0.14285714285714285, - }, - Entry { - index: 6, - value: 0.14285714285714285, - }, - ], - ], - }, - }; - - let expected = Vector { - dim: 8, - entries: vec![ - Entry { - index: 0, - value: 0.11111110842697292, - }, - Entry { - index: 1, - value: 0.16666666867977029, - }, - Entry { - index: 2, - value: 0.11111110842697292, - }, - Entry { - index: 3, - value: 0.22222222893256766, - }, - Entry { - index: 4, - value: 0.11111110842697292, - }, - Entry { - index: 5, - value: 0.16666666867977029, - }, - Entry { - index: 6, - value: 0.11111110842697292, - }, - ], - }; - let result = compute(&c, &p, a, e, None, None).unwrap(); - assert_eq!(result, expected); - } - - #[test] - fn test_alpha_zero_on_periodic_graph_stops() { - // a <-> b with all seed trust on a oscillates forever without teleport - let c = CSRMatrix::new(2, 2, vec![(0, 1, 1.0), (1, 0, 1.0)]); - let p = Vector::new(2, vec![Entry::new(0, 1.0)]); - let err = compute(&c, &p, 0.0, 1e-9, None, None).unwrap_err(); - assert!(matches!(err, Error::NotConverged { .. }), "{}", err); - // with teleport it converges - assert!(compute(&c, &p, 0.1, 1e-9, None, None).is_ok()); - } - - #[test] - fn test_compute_rejects_bad_alpha() { - let c = CSRMatrix::new(1, 1, vec![(0, 0, 1.0)]); - let p = Vector::new(1, vec![Entry::new(0, 1.0)]); - for a in [f64::NAN, -0.1, 1.5] { - assert!(compute(&c, &p, a, 1e-9, None, None).is_err()); - } - } -} diff --git a/src/basic/engine.rs b/src/basic/engine.rs deleted file mode 100644 index 612fda7..0000000 --- a/src/basic/engine.rs +++ /dev/null @@ -1,134 +0,0 @@ -use crate::basic::eigentrust::compute; -use crate::basic::localtrust::{canonicalize_local_trust, read_local_trust_from_csv}; -use crate::basic::trustvector::canonicalize_trust_vector; -use crate::basic::trustvector::read_trust_vector_from_csv; -use crate::error::{Error, Result}; -#[cfg(test)] -use std::fs; - -// Ranks every peer from CSV input: `from,to[,weight]` local trust and `peer[,weight]` -// pre-trust. Returns (peer, score) sorted by score, highest first; scores sum to 1. - -pub fn calculate_from_csv( - localtrust_csv: &str, - pretrust_csv: &str, - alpha: Option, -) -> Result> { - log::info!("Compute starting..."); - - let a = alpha.unwrap_or(0.5); - if !(0.0..=1.0).contains(&a) { - return Err(Error::InvalidAlpha(a)); - } - - let (mut local_trust, peers) = read_local_trust_from_csv(localtrust_csv)?; - - let mut pre_trust = read_trust_vector_from_csv(pretrust_csv, &peers.map)?; - - let c_dim = local_trust.cs_matrix.dim()?; - - let e = 1e-6 / (c_dim as f64); - - let p_dim = pre_trust.dim; - if c_dim < p_dim { - local_trust.set_dim(p_dim, p_dim); - } else { - pre_trust.set_dim(c_dim); - } - - canonicalize_trust_vector(&mut pre_trust); - - // negative (distrust) weights are rejected while parsing, see localtrust.rs - canonicalize_local_trust(&mut local_trust, Some(&pre_trust))?; - - let trust_scores = compute(&local_trust, &pre_trust, a, e, None, None)?; - - let mut entries: Vec<(String, f64)> = trust_scores - .entries - .iter() - .map(|e| (peers.names[e.index].clone(), e.value)) - .collect(); - - entries.sort_by(|a, b| b.1.total_cmp(&a.1)); - - Ok(entries) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_calculate_from_csv() { - let localtrust_csv = - "i,j,v\nalice,bob,11.31571\n2,3,269916.08616\n4,5,3173339.366896588\n6,5,46589750.00759474"; - let pretrust_csv = - "i,j,v\nalice,0.14285714285714285\nbob,0.14285714285714285\n2,0.14285714285714285\n3,0.14285714285714285\n4,0.14285714285714285\n5,0.14285714285714285\n6,0.14285714285714285"; - let alpha = Some(0.5); - let entries = calculate_from_csv(localtrust_csv, pretrust_csv, alpha).unwrap(); - assert_eq!(entries.len(), 7); - assert!(entries[0].1 >= entries[1].1); - assert_eq!(entries[0].0, "5"); - assert_eq!(entries[0].1, 0.22222219873601323); - assert_eq!(entries[1].0, "bob"); - - let localtrust_csv = - "alice,bob,11.31571\n2,3,269916.08616\n4,5,3173339.366896588\n6,5,46589750.00759474"; - let pretrust_csv = "alice,1"; - let alpha = Some(0.5); - let entries = calculate_from_csv(localtrust_csv, pretrust_csv, alpha).unwrap(); - assert_eq!(entries.len(), 2); - assert!(entries[0].1 >= entries[1].1); - assert_eq!(entries[0].0, "alice"); - assert_eq!(entries[0].1, 0.6666666865348816); - } - - #[test] - fn test_calculate_from_csv_file() { - let localtrust_csv = fs::read_to_string("./example/localtrust2.csv") - .expect("Failed to read localtrust CSV file"); - let pretrust_csv = fs::read_to_string("./example/pretrust2.csv") - .expect("Failed to read pretrust CSV file"); - - let entries = calculate_from_csv(&localtrust_csv, &pretrust_csv, None).unwrap(); - - assert_eq!(entries.len(), 9); - assert!(entries[0].1 >= entries[1].1); - assert_eq!(entries[0].0, "0x84e1056ed1b76fb03b43e924ef98833dba394b2b"); - // the file contains duplicate (i, j) records, the last one wins - assert_eq!(entries[0].1, 0.40356129084997394); - assert_eq!(entries[1].0, "0x9fc3b33884e1d056a8ca979833d686abd267f9f8"); - } - - fn total(entries: &[(String, f64)]) -> f64 { - entries.iter().map(|(_, s)| s).sum() - } - - #[test] - fn test_scores_sum_to_one_with_duplicate_pretrust() { - let lt = "a,b,1\nb,c,1\nc,a,1\nc,d,1"; - let pt = "a,1\nd,1\na,5\na,2"; - let entries = calculate_from_csv(lt, pt, Some(0.3)).unwrap(); - assert!((total(&entries) - 1.0).abs() < 1e-9, "{}", total(&entries)); - // last value wins: a=2, d=1 is the same as a clean 2:1 pretrust - let clean = calculate_from_csv(lt, "a,2\nd,1", Some(0.3)).unwrap(); - assert_eq!(entries, clean); - } - - #[test] - fn test_rejects_bad_input() { - for (lt, pt) in [ - ("a,b,NaN", "a"), - ("a,b,inf", "a"), - ("a,b,-1", "a"), - ("a,b,1", "a,NaN"), - ] { - assert!(calculate_from_csv(lt, pt, None).is_err(), "{} / {}", lt, pt); - } - } - - #[test] - fn test_alpha_zero_periodic_returns_error() { - assert!(calculate_from_csv("a,b\nb,a", "a", Some(0.0)).is_err()); - } -} diff --git a/src/basic/input.rs b/src/basic/input.rs deleted file mode 100644 index 2452eac..0000000 --- a/src/basic/input.rs +++ /dev/null @@ -1,212 +0,0 @@ -// CSV input: a real CSV reader (quotes, embedded commas, escaped quotes), an optional -// header row, surrounding whitespace, CRLF and a UTF-8 BOM. - -use crate::error::{Error, Input, RecordError, Result}; -use csv::{ByteRecord, ReaderBuilder}; - -const HEADER_NAMES: &[&str] = &[ - "i", "j", "v", "from", "to", "value", "weight", "trust", "level", "peer", "id", "score", - "source", "target", "src", "dst", "truster", "trustee", -]; - -// Surrounding whitespace and quotes the CSV reader keeps, e.g. in ` "alice" `. -pub fn clean_field(field: &str) -> &str { - let field = field.trim(); - field - .strip_prefix('"') - .and_then(|f| f.strip_suffix('"')) - .unwrap_or(field) - .trim() -} - -// A first record is a header when its value column is not a number, or, when the value -// column is omitted (implicit weight 1), when every field is a well-known header name. -// `value_column` is 2 for local trust and 1 for pre-trust. -pub fn is_header(fields: &[&str], value_column: usize) -> bool { - match fields.get(value_column) { - Some(value) => value.parse::().is_err(), - None => fields - .iter() - .all(|f| HEADER_NAMES.contains(&f.to_ascii_lowercase().as_str())), - } -} - -// Calls `f(line, fields)` for every non-empty record after the optional header. -pub fn for_each_record(data: &str, input: Input, value_column: usize, mut f: F) -> Result<()> -where - F: FnMut(u64, &[&str]) -> Result<()>, -{ - let data = data.trim_start_matches('\u{feff}'); - let mut reader = ReaderBuilder::new() - .has_headers(false) - .flexible(true) - .from_reader(data.as_bytes()); - - // the csv crate reports a record's position before any blank lines it skipped and - // does not count those lines, so line numbers come from byte offsets - let bytes = data.as_bytes(); - let mut counted = (0usize, 1u64); - let mut line_at = |byte: u64| { - let mut byte = (byte as usize).min(bytes.len()); - while byte < bytes.len() && (bytes[byte] == b'\n' || bytes[byte] == b'\r') { - byte += 1; - } - if byte >= counted.0 { - counted.1 += bytes[counted.0..byte] - .iter() - .filter(|&&b| b == b'\n') - .count() as u64; - counted.0 = byte; - } - counted.1 - }; - - let mut record = ByteRecord::new(); - let mut first = true; - loop { - let more = match reader.read_byte_record(&mut record) { - Ok(more) => more, - Err(e) => { - return Err(Error::Record { - input, - line: e.position().map_or(0, |p| line_at(p.byte())), - error: RecordError::Malformed(e.to_string()), - }) - } - }; - if !more { - return Ok(()); - } - let line = record.position().map_or(0, |p| line_at(p.byte())); - - // at most 3 columns are used, extra columns are ignored - let mut buf = [""; 3]; - let mut n = 0; - for field in record.iter().take(buf.len()) { - // the input is a &str, but a quoted field could still split a code point - let field = std::str::from_utf8(field).map_err(|e| Error::Record { - input, - line, - error: RecordError::Malformed(e.to_string()), - })?; - buf[n] = clean_field(field); - n += 1; - } - let fields = &buf[..n]; - if fields.iter().all(|f| f.is_empty()) { - continue; - } - if first { - first = false; - if is_header(fields, value_column) { - continue; - } - } - f(line, fields)?; - } -} - -// A trust weight: finite and non-negative, 1 when omitted. -pub fn parse_weight(field: Option<&&str>) -> std::result::Result { - let Some(&field) = field else { - return Ok(1.0); - }; - let weight = field - .parse::() - .map_err(|_| RecordError::InvalidWeight(field.to_string()))?; - if !weight.is_finite() { - return Err(RecordError::NonFiniteWeight(field.to_string())); - } - if weight < 0.0 { - return Err(RecordError::NegativeWeight(field.to_string())); - } - Ok(weight) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn records(data: &str, value_column: usize) -> Result)>> { - let mut out = vec![]; - for_each_record(data, Input::LocalTrust, value_column, |line, f| { - out.push((line, f.iter().map(|s| s.to_string()).collect())); - Ok(()) - })?; - Ok(out) - } - - fn rows(data: &str, value_column: usize) -> Vec> { - records(data, value_column) - .unwrap() - .into_iter() - .map(|(_, f)| f) - .collect() - } - - #[test] - fn test_headers() { - assert_eq!(rows("i,j,v\na,b,1\n", 2), [["a", "b", "1"]]); - assert_eq!(rows("i,j,v\r\na,b,1\r\n", 2), [["a", "b", "1"]]); - assert_eq!(rows("\u{feff}from,to,weight\na,b,1", 2), [["a", "b", "1"]]); - // headerless two-column local trust keeps its first edge - assert_eq!(rows("a,b\nb,c", 2), [["a", "b"], ["b", "c"]]); - assert_eq!(rows("i,j\na,b", 2), [["a", "b"]]); - assert_eq!(rows("i,v\nalice,1", 1), [["alice", "1"]]); - assert_eq!(rows("alice\nbob", 1), [["alice"], ["bob"]]); - assert_eq!(rows("peer\nalice", 1), [["alice"]]); - assert!(rows("", 1).is_empty()); - } - - #[test] - fn test_real_csv() { - // quoted fields with commas and escaped quotes, blank lines, spaces - let data = "\"Smith, J\",\"O\"\"Neil\",2\n\n a , b , 3 \n \"c\" , \"d\" ,1\n"; - assert_eq!( - rows(data, 2), - [ - ["Smith, J", "O\"Neil", "2"], - ["a", "b", "3"], - ["c", "d", "1"] - ] - ); - } - - #[test] - fn test_line_numbers() { - let lines: Vec = records("i,j,v\na,b,1\n\nc,d,2\r\n\r\n\r\ne,f", 2) - .unwrap() - .into_iter() - .map(|(l, _)| l) - .collect(); - assert_eq!(lines, [2, 4, 7]); - } - - #[test] - fn test_parse_weight() { - assert_eq!(parse_weight(None), Ok(1.0)); - assert_eq!(parse_weight(Some(&"2.5")), Ok(2.5)); - assert!(matches!( - parse_weight(Some(&"x")), - Err(RecordError::InvalidWeight(_)) - )); - for bad in ["NaN", "inf", "-inf"] { - assert!(matches!( - parse_weight(Some(&bad)), - Err(RecordError::NonFiniteWeight(_)) - )); - } - assert!(matches!( - parse_weight(Some(&"-1")), - Err(RecordError::NegativeWeight(_)) - )); - } - - #[test] - fn test_clean_field() { - assert_eq!(clean_field(" alice "), "alice"); - assert_eq!(clean_field("\"alice\""), "alice"); - assert_eq!(clean_field(" \" 0.5 \" "), "0.5"); - assert_eq!(clean_field("\""), "\""); - } -} diff --git a/src/basic/localtrust.rs b/src/basic/localtrust.rs deleted file mode 100644 index 9008232..0000000 --- a/src/basic/localtrust.rs +++ /dev/null @@ -1,149 +0,0 @@ -use super::input::{for_each_record, parse_weight}; -use super::util::PeersMap; -use crate::error::{Error, Input, RecordError, Result}; -use crate::sparse::entry::Entry; -use crate::sparse::matrix::CSRMatrix; -use crate::sparse::vector::Vector; - -// Scales every row to sum to one. Rows without trust (dangling peers) get the pre-trust -// distribution, so their share flows back to the seeds. -pub fn canonicalize_local_trust( - local_trust: &mut CSRMatrix, - pre_trust: Option<&Vector>, -) -> Result<()> { - let n = local_trust.dims().0; - - if let Some(pre_trust) = pre_trust { - if pre_trust.entries.len() > n { - return Err(Error::DimensionMismatch); - } - } - - for row in local_trust.cs_matrix.entries.iter_mut() { - let row_sum: f64 = row.iter().map(|entry| entry.value).sum(); - if row_sum == 0.0 { - if let Some(pre_trust) = pre_trust { - row.clone_from(&pre_trust.entries); - } - } else { - for entry in row.iter_mut() { - entry.value /= row_sum; - } - } - } - - Ok(()) -} - -// Splits negative entries off into a separate distrust matrix (as positive values). -// Not used by calculate_from_csv: negative weights are rejected while parsing until the -// effect of distrust on the scores is defined. -pub fn extract_distrust(local_trust: &mut CSRMatrix) -> CSRMatrix { - let n = local_trust.dims().0; - let mut distrust = CSRMatrix::new(n, n, vec![]); - - for (truster, row) in local_trust.cs_matrix.entries.iter_mut().enumerate() { - let mut distrust_row = Vec::new(); - row.retain(|entry| { - if entry.value >= 0.0 { - true - } else { - distrust_row.push(Entry::new(entry.index, -entry.value)); - false - } - }); - distrust.set_row_vector(truster, Vector::new(n, distrust_row)); - } - - distrust -} - -// Reads `from,to[,weight]` records into a square CSR matrix, one row per truster. -// Peers get indices in order of first appearance. A repeated `from,to` pair keeps the -// last weight. -pub fn read_local_trust_from_csv(csv_data: &str) -> Result<(CSRMatrix, PeersMap)> { - // rows are filled directly while parsing; the matrix grows as new peers appear - let mut rows: Vec> = Vec::new(); - let mut peers = PeersMap::new(); - - for_each_record(csv_data, Input::LocalTrust, 2, |line, fields| { - let record_error = |error| Error::Record { - input: Input::LocalTrust, - line, - error, - }; - let (from, to) = match fields { - [from, to, ..] => (*from, *to), - _ => return Err(record_error(RecordError::TooFewFields)), - }; - let level = parse_weight(fields.get(2)).map_err(record_error)?; - let from = peers.insert_or_get(from); - let to = peers.insert_or_get(to); - if from >= rows.len() { - rows.resize_with(from + 1, Vec::new); - } - rows[from].push(Entry::new(to, level)); - Ok(()) - })?; - - let dim = peers.names.len(); - if dim == 0 { - return Err(Error::EmptyLocalTrust); - } - rows.resize_with(dim, Vec::new); - Ok((CSRMatrix::from_rows(dim, rows), peers)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_distrust() { - struct TestCase { - name: &'static str, - local_trust: CSRMatrix, - expected_trust: CSRMatrix, - expected_distrust: CSRMatrix, - } - - let test_cases = vec![TestCase { - name: "test1", - local_trust: CSRMatrix::new( - 3, - 3, - vec![(0, 0, 100.0), (0, 1, -50.0), (0, 2, -50.0), (2, 0, -100.0)], - ), - expected_trust: CSRMatrix::new(3, 3, vec![(0, 0, 100.0)]), - expected_distrust: CSRMatrix::new( - 3, - 3, - vec![(0, 1, 50.0), (0, 2, 50.0), (2, 0, 100.0)], - ), - }]; - - for test in test_cases { - let mut local_trust = test.local_trust.clone(); - let distrust = extract_distrust(&mut local_trust); - - assert_eq!( - local_trust, test.expected_trust, - "{}: local trust does not match expected value", - test.name - ); - assert_eq!( - distrust, test.expected_distrust, - "{}: distrust does not match expected value", - test.name - ); - } - } - - #[test] - fn test_local_trust_rejects_bad_levels() { - for bad in ["a,b,NaN", "a,b,inf", "a,b,-inf", "a,b,-1", "a,b,x"] { - assert!(read_local_trust_from_csv(bad).is_err(), "{}", bad); - } - assert!(read_local_trust_from_csv("a,b,0\nb,a,2.5").is_ok()); - } -} diff --git a/src/basic/mod.rs b/src/basic/mod.rs deleted file mode 100644 index c33416d..0000000 --- a/src/basic/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod eigentrust; -pub mod engine; -pub mod input; -pub mod localtrust; -pub mod trustvector; -pub mod util; diff --git a/src/basic/trustvector.rs b/src/basic/trustvector.rs deleted file mode 100644 index a6be60e..0000000 --- a/src/basic/trustvector.rs +++ /dev/null @@ -1,118 +0,0 @@ -use super::input::{for_each_record, parse_weight}; -use crate::error::{Error, Input, RecordError, Result}; -use crate::sparse::entry::Entry; -use crate::sparse::vector::Vector; -use std::collections::HashMap; - -// CanonicalizeTrustVector canonicalizes the trust vector in-place, -// scaling it so that the elements sum to one, -// or making it a uniform vector that sums to one if it's a zero vector. -pub fn canonicalize_trust_vector(v: &mut Vector) { - if !canonicalize(&mut v.entries) { - let dim = v.dim; - let c = 1.0 / dim as f64; - v.entries.clear(); - for i in 0..dim { - v.entries.push(Entry { index: i, value: c }); - } - } -} - -// Scales entries in place to sum to one. Returns false for a zero vector. -fn canonicalize(entries: &mut [Entry]) -> bool { - let sum: f64 = entries.iter().map(|entry| entry.value).sum(); - if sum == 0.0 { - return false; - } - for entry in entries.iter_mut() { - entry.value /= sum; - } - true -} - -// Reads `peer[,weight]` records. Weights must be finite and non-negative, default 1. -// A peer listed more than once keeps its last weight, same as local trust. -pub fn read_trust_vector_from_csv( - input: &str, - peer_indices: &HashMap, -) -> Result { - let mut levels: HashMap = HashMap::new(); - let mut max_peer = -1; - let mut duplicate_count = 0; - - for_each_record(input, Input::PreTrust, 1, |line, fields| { - let record_error = |error| Error::Record { - input: Input::PreTrust, - line, - error, - }; - let peer = *peer_indices - .get(fields[0]) - .ok_or_else(|| record_error(RecordError::UnknownPeer(fields[0].to_string())))?; - let level = parse_weight(fields.get(1)).map_err(record_error)?; - - if levels.insert(peer, level).is_some() { - duplicate_count += 1; - } - max_peer = max_peer.max(peer as isize); - Ok(()) - })?; - - if duplicate_count > 0 { - log::warn!( - "Pretrust contains {} duplicate peers, the last value wins", - duplicate_count - ); - } - - let entries = levels - .into_iter() - .map(|(index, value)| Entry { index, value }) - .collect(); - Ok(Vector::new((max_peer + 1) as usize, entries)) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn peers(names: &[&str]) -> HashMap { - names - .iter() - .enumerate() - .map(|(i, n)| (n.to_string(), i)) - .collect() - } - - #[test] - fn test_duplicate_pretrust_last_wins() { - let v = read_trust_vector_from_csv("a,1\nb,1\na,3", &peers(&["a", "b"])).unwrap(); - assert_eq!(v.entries, vec![Entry::new(0, 3.0), Entry::new(1, 1.0)]); - } - - #[test] - fn test_pretrust_rejects_non_finite_and_negative() { - // a bad first line reads as a header, so each bad line follows a valid one - for bad in [ - "a,1\na,NaN", - "a,1\na,inf", - "a,1\na,-1", - "a,1\na,x", - "a,1\nb,1", - ] { - assert!( - read_trust_vector_from_csv(bad, &peers(&["a"])).is_err(), - "{}", - bad - ); - } - } - - #[test] - fn test_canonicalize_zero_vector_is_uniform_over_dim() { - let mut v = Vector::new(4, vec![]); - canonicalize_trust_vector(&mut v); - assert_eq!(v.entries.len(), 4); - assert!(v.entries.iter().all(|e| e.value == 0.25)); - } -} diff --git a/src/basic/util.rs b/src/basic/util.rs deleted file mode 100644 index a898196..0000000 --- a/src/basic/util.rs +++ /dev/null @@ -1,54 +0,0 @@ -use std::collections::HashMap; - -pub fn init_logger() { - #[cfg(target_arch = "wasm32")] - { - // the demo recomputes on every slider move, keep the browser console quiet - console_log::init_with_level(log::Level::Warn).expect("Failed to initialize logger"); - } - - #[cfg(not(target_arch = "wasm32"))] - { - // RUST_LOG overrides, e.g. RUST_LOG=trace - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) - .format_timestamp_millis() - .init(); - log::debug!("Logger initialized for native environment"); - } -} - -pub struct PeersMap { - pub map: HashMap, - // index -> peer name - pub names: Vec, -} - -impl Default for PeersMap { - fn default() -> Self { - Self::new() - } -} - -impl PeersMap { - pub fn new() -> Self { - PeersMap { - map: HashMap::new(), - names: Vec::new(), - } - } - - pub fn insert_or_get(&mut self, key: &str) -> usize { - if let Some(&existing_value) = self.map.get(key) { - return existing_value; - } - - let index = self.names.len(); - self.map.insert(key.to_string(), index); - self.names.push(key.to_string()); - index - } - - pub fn get_max_value(&self) -> usize { - self.names.len() - } -} diff --git a/src/csv.rs b/src/csv.rs new file mode 100644 index 0000000..ee668de --- /dev/null +++ b/src/csv.rs @@ -0,0 +1,395 @@ +//! Named peers from CSV text. Needs the `csv` feature. +//! +//! Local trust has one `from,to[,weight]` record per line, pre-trust one `peer[,weight]` +//! record. Peer names are arbitrary strings and weights default to 1. +//! +//! ``` +//! use eigentrust::csv::Network; +//! use eigentrust::EigenTrustOptions; +//! +//! let network = Network::from_csv( +//! "from,to,weight\nalice,bob,2\nbob,carol,1\ncarol,alice,1\n", +//! "alice\n", +//! )?; +//! let result = network.eigentrust(&EigenTrustOptions::default())?; +//! let ranking = network.ranking(&result); +//! assert_eq!(ranking[0].0, "alice"); +//! # Ok::<(), Box>(()) +//! ``` +//! +//! The reader accepts standard CSV: quoted fields (`"Smith, J"`), escaped quotes, an +//! optional header row, spaces around fields, CRLF line endings and a UTF-8 BOM. Blank lines +//! are skipped and columns after the weight are ignored. +//! +//! The first record is a header when its weight column is not a number, or, for records +//! without a weight column, when every field is a common header name such as `from`, `to`, +//! `peer` or `weight`. A weight that fails to parse on any later line is an error. + +use crate::error::Input; +use crate::{eigentrust_with_options, EigenTrustError, EigenTrustOptions}; +use crate::{PreTrust, TrustEdge, TrustScores}; +use std::collections::HashMap; +use std::fmt; + +/// A network with named peers, read from CSV. +/// +/// Peers get indices in order of first appearance in local trust. Pre-trust may only name +/// peers that appear in local trust. +#[derive(Debug, Clone, PartialEq)] +pub struct Network { + peers: Vec, + local_trust: Vec, + pre_trust: Vec, +} + +impl Network { + /// Reads local trust and pre-trust CSV text. + /// + /// # Errors + /// + /// Returns an [`Error`] with the input and line of the first bad record. + pub fn from_csv(local_trust: &str, pre_trust: &str) -> Result { + let mut peers: Vec = Vec::new(); + let mut index: HashMap = HashMap::new(); + let mut edges = Vec::new(); + { + let mut peer_index = |name: &str| { + if let Some(&i) = index.get(name) { + return i; + } + let i = peers.len(); + index.insert(name.to_string(), i); + peers.push(name.to_string()); + i + }; + for_each_record(local_trust, Input::LocalTrust, 2, |line, fields| { + let error = |kind| Error::new(Input::LocalTrust, line, kind); + let [from, to, rest @ ..] = fields else { + return Err(error(ErrorKind::TooFewFields)); + }; + let weight = parse_weight(rest.first().copied()).map_err(error)?; + let from = peer_index(from); + let to = peer_index(to); + edges.push(TrustEdge::new(from, to, weight)); + Ok(()) + })?; + } + + let mut seeds = Vec::new(); + for_each_record(pre_trust, Input::PreTrust, 1, |line, fields| { + let error = |kind| Error::new(Input::PreTrust, line, kind); + let peer = *index + .get(fields[0]) + .ok_or_else(|| error(ErrorKind::UnknownPeer(fields[0].to_string())))?; + let weight = parse_weight(fields.get(1).copied()).map_err(error)?; + seeds.push(PreTrust::new(peer, weight)); + Ok(()) + })?; + + Ok(Network { + peers, + local_trust: edges, + pre_trust: seeds, + }) + } + + /// Peer names, indexed by peer. + pub fn peers(&self) -> &[String] { + &self.peers + } + + /// Local trust edges, in file order. + pub fn local_trust(&self) -> &[TrustEdge] { + &self.local_trust + } + + /// Pre-trust entries, in file order. + pub fn pre_trust(&self) -> &[PreTrust] { + &self.pre_trust + } + + /// Computes EigenTrust scores for this network. + /// + /// # Errors + /// + /// See [`eigentrust_with_options`]. + pub fn eigentrust(&self, options: &EigenTrustOptions) -> Result { + eigentrust_with_options( + self.local_trust.iter().copied(), + self.pre_trust.iter().copied(), + options, + ) + } + + /// `(name, score)` pairs, highest score first. Equal scores keep peer order. + pub fn ranking<'a>(&'a self, scores: &TrustScores) -> Vec<(&'a str, f64)> { + scores + .ranking() + .into_iter() + .filter_map(|(i, s)| self.peers.get(i).map(|name| (name.as_str(), s))) + .collect() + } +} + +/// A CSV record that could not be read. +#[derive(Debug, Clone, PartialEq)] +pub struct Error { + input: Input, + line: u64, + kind: ErrorKind, +} + +impl Error { + fn new(input: Input, line: u64, kind: ErrorKind) -> Self { + Error { input, line, kind } + } + + /// Which CSV text the record is in. + pub fn input(&self) -> Input { + self.input + } + + /// 1-based line number of the record. + pub fn line(&self) -> u64 { + self.line + } + + /// What is wrong with the record. + pub fn kind(&self) -> &ErrorKind { + &self.kind + } +} + +/// What is wrong with a CSV record. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum ErrorKind { + /// Not valid CSV, e.g. an unterminated quote or invalid UTF-8 inside quotes. + Malformed(String), + /// A local trust record without both peers. + TooFewFields, + /// The weight is not a number. + InvalidWeight(String), + /// The weight is NaN or infinite. + NonFiniteWeight(String), + /// The weight is negative. Distrust is not supported. + NegativeWeight(String), + /// A pre-trust peer that does not appear in local trust. + UnknownPeer(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} CSV, line {}: ", self.input, self.line)?; + match &self.kind { + ErrorKind::Malformed(msg) => write!(f, "malformed CSV: {}", msg), + ErrorKind::TooFewFields => f.write_str("too few fields"), + ErrorKind::InvalidWeight(w) => write!(f, "weight {:?} is not a number", w), + ErrorKind::NonFiniteWeight(w) => write!(f, "weight {:?} must be finite", w), + ErrorKind::NegativeWeight(w) => { + write!(f, "weight {:?} is negative; distrust is not supported", w) + } + ErrorKind::UnknownPeer(p) => write!(f, "peer {:?} does not appear in local trust", p), + } + } +} + +impl std::error::Error for Error {} + +const HEADER_NAMES: &[&str] = &[ + "i", "j", "v", "from", "to", "value", "weight", "trust", "level", "peer", "id", "score", + "source", "target", "src", "dst", "truster", "trustee", +]; + +// Surrounding whitespace and quotes the CSV reader keeps, e.g. in ` "alice" `. +fn clean_field(field: &str) -> &str { + let field = field.trim(); + field + .strip_prefix('"') + .and_then(|f| f.strip_suffix('"')) + .unwrap_or(field) + .trim() +} + +// `value_column` is 2 for local trust and 1 for pre-trust. +fn is_header(fields: &[&str], value_column: usize) -> bool { + match fields.get(value_column) { + Some(value) => value.parse::().is_err(), + None => fields + .iter() + .all(|f| HEADER_NAMES.contains(&f.to_ascii_lowercase().as_str())), + } +} + +// Calls `f(line, fields)` for every non-empty record after the optional header. +fn for_each_record(data: &str, input: Input, value_column: usize, mut f: F) -> Result<(), Error> +where + F: FnMut(u64, &[&str]) -> Result<(), Error>, +{ + let data = data.trim_start_matches('\u{feff}'); + let mut reader = ::csv::ReaderBuilder::new() + .has_headers(false) + .flexible(true) + .from_reader(data.as_bytes()); + + // The csv crate reports a record's position before any blank lines it skipped and + // does not count them, so line numbers come from byte offsets. + let bytes = data.as_bytes(); + let mut counted = (0usize, 1u64); + let mut line_at = |byte: u64| { + let mut byte = (byte as usize).min(bytes.len()); + while byte < bytes.len() && (bytes[byte] == b'\n' || bytes[byte] == b'\r') { + byte += 1; + } + if byte >= counted.0 { + counted.1 += bytes[counted.0..byte] + .iter() + .filter(|&&b| b == b'\n') + .count() as u64; + counted.0 = byte; + } + counted.1 + }; + + let mut record = ::csv::ByteRecord::new(); + let mut first = true; + loop { + let more = match reader.read_byte_record(&mut record) { + Ok(more) => more, + Err(e) => { + let line = e.position().map_or(0, |p| line_at(p.byte())); + return Err(Error::new(input, line, ErrorKind::Malformed(e.to_string()))); + } + }; + if !more { + return Ok(()); + } + let line = record.position().map_or(0, |p| line_at(p.byte())); + + // at most 3 columns are used, extra columns are ignored + let mut buf = [""; 3]; + let mut n = 0; + for field in record.iter().take(buf.len()) { + // the input is a &str, but a quoted field could still split a code point + let field = std::str::from_utf8(field) + .map_err(|e| Error::new(input, line, ErrorKind::Malformed(e.to_string())))?; + buf[n] = clean_field(field); + n += 1; + } + let fields = &buf[..n]; + if fields.iter().all(|f| f.is_empty()) { + continue; + } + if first { + first = false; + if is_header(fields, value_column) { + continue; + } + } + f(line, fields)?; + } +} + +// A weight: finite and non-negative, 1 when omitted. +fn parse_weight(field: Option<&str>) -> Result { + let Some(field) = field else { + return Ok(1.0); + }; + let weight = field + .parse::() + .map_err(|_| ErrorKind::InvalidWeight(field.to_string()))?; + if !weight.is_finite() { + return Err(ErrorKind::NonFiniteWeight(field.to_string())); + } + if weight < 0.0 { + return Err(ErrorKind::NegativeWeight(field.to_string())); + } + Ok(weight) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn records(data: &str, value_column: usize) -> Result)>, Error> { + let mut out = vec![]; + for_each_record(data, Input::LocalTrust, value_column, |line, f| { + out.push((line, f.iter().map(|s| s.to_string()).collect())); + Ok(()) + })?; + Ok(out) + } + + fn rows(data: &str, value_column: usize) -> Vec> { + records(data, value_column) + .unwrap() + .into_iter() + .map(|(_, f)| f) + .collect() + } + + #[test] + fn headers() { + assert_eq!(rows("i,j,v\na,b,1\n", 2), [["a", "b", "1"]]); + assert_eq!(rows("i,j,v\r\na,b,1\r\n", 2), [["a", "b", "1"]]); + assert_eq!(rows("\u{feff}from,to,weight\na,b,1", 2), [["a", "b", "1"]]); + // headerless two-column local trust keeps its first edge + assert_eq!(rows("a,b\nb,c", 2), [["a", "b"], ["b", "c"]]); + assert_eq!(rows("i,j\na,b", 2), [["a", "b"]]); + assert_eq!(rows("i,v\nalice,1", 1), [["alice", "1"]]); + assert_eq!(rows("alice\nbob", 1), [["alice"], ["bob"]]); + assert_eq!(rows("peer\nalice", 1), [["alice"]]); + assert!(rows("", 1).is_empty()); + } + + #[test] + fn quoting() { + let data = "\"Smith, J\",\"O\"\"Neil\",2\n\n a , b , 3 \n \"c\" , \"d\" ,1\n"; + assert_eq!( + rows(data, 2), + [ + ["Smith, J", "O\"Neil", "2"], + ["a", "b", "3"], + ["c", "d", "1"] + ] + ); + } + + #[test] + fn line_numbers_survive_blank_lines() { + let lines: Vec = records("i,j,v\na,b,1\n\nc,d,2\r\n\r\n\r\ne,f", 2) + .unwrap() + .into_iter() + .map(|(l, _)| l) + .collect(); + assert_eq!(lines, [2, 4, 7]); + } + + #[test] + fn weights() { + assert_eq!(parse_weight(None), Ok(1.0)); + assert_eq!(parse_weight(Some("2.5")), Ok(2.5)); + assert!(matches!( + parse_weight(Some("x")), + Err(ErrorKind::InvalidWeight(_)) + )); + for bad in ["NaN", "inf", "-inf"] { + assert!(matches!( + parse_weight(Some(bad)), + Err(ErrorKind::NonFiniteWeight(_)) + )); + } + assert!(matches!( + parse_weight(Some("-1")), + Err(ErrorKind::NegativeWeight(_)) + )); + } + + #[test] + fn clean_fields() { + assert_eq!(clean_field(" alice "), "alice"); + assert_eq!(clean_field("\"alice\""), "alice"); + assert_eq!(clean_field(" \" 0.5 \" "), "0.5"); + assert_eq!(clean_field("\""), "\""); + } +} diff --git a/src/error.rs b/src/error.rs index 64c82a1..a606c7d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,9 +1,11 @@ use std::fmt; -/// Which CSV input an error refers to. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Which input an [`EigenTrustError`] refers to. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Input { + /// The local trust edges. LocalTrust, + /// The pre-trust (seed) entries. PreTrust, } @@ -16,78 +18,103 @@ impl fmt::Display for Input { } } -/// What is wrong with a single CSV record. +/// Why [`eigentrust`](crate::eigentrust) could not compute scores. +/// +/// Input positions are 0-based indices into the iterator that was passed in. #[derive(Debug, Clone, PartialEq)] -pub enum RecordError { - /// The CSV itself is malformed, e.g. an unterminated quote. - Malformed(String), - TooFewFields, - InvalidWeight(String), - NonFiniteWeight(String), - /// Distrust has no defined effect on the scores yet, so it is rejected. - NegativeWeight(String), - /// A pre-trust peer that never appears in local trust. - UnknownPeer(String), -} - -impl fmt::Display for RecordError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - RecordError::Malformed(msg) => write!(f, "malformed CSV: {}", msg), - RecordError::TooFewFields => f.write_str("too few fields"), - RecordError::InvalidWeight(w) => write!(f, "weight {:?} is not a number", w), - RecordError::NonFiniteWeight(w) => write!(f, "weight {:?} must be finite", w), - RecordError::NegativeWeight(w) => { - write!(f, "weight {:?} is negative; distrust is not supported", w) - } - RecordError::UnknownPeer(p) => { - write!(f, "peer {:?} does not appear in local trust", p) - } - } - } -} - -#[derive(Debug, Clone, PartialEq)] -pub enum Error { - /// A CSV record could not be used. `line` is 1-based. - Record { +#[non_exhaustive] +pub enum EigenTrustError { + /// `alpha` is not a finite number in `[0, 1]`. + InvalidAlpha(f64), + /// `epsilon` is not a finite number greater than zero. + InvalidEpsilon(f64), + /// `max_iterations` is zero. + InvalidMaxIterations, + /// A weight is NaN or infinite. + NonFiniteWeight { + /// The input containing the weight. input: Input, - line: u64, - error: RecordError, + /// Position of the item in that input. + position: usize, }, - EmptyLocalTrust, - InvalidAlpha(f64), - DimensionMismatch, - /// The iteration produced NaN or infinity. - NonFiniteScores, + /// A weight is negative. Distrust is not supported. + NegativeWeight { + /// The input containing the weight. + input: Input, + /// Position of the item in that input. + position: usize, + /// The rejected weight. + weight: f64, + }, + /// A peer index of `usize::MAX`, which leaves no room to count the peers. + InvalidPeer { + /// The input containing the index. + input: Input, + /// Position of the item in that input. + position: usize, + }, + /// The weights of one truster, or all of pre-trust, add up to more than `f64::MAX`. + WeightOverflow(Input), + /// Neither input mentions any peer. + EmptyNetwork, + /// The scores did not settle within `max_iterations`. With `alpha` near zero some + /// networks oscillate instead of converging. NotConverged { + /// Iterations performed. iterations: usize, - alpha: f64, + /// Change between the last two iterations (L2 norm). + residual: f64, }, - InvalidUtf8(Input), } -impl fmt::Display for Error { +impl fmt::Display for EigenTrustError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Error::Record { input, line, error } => { - write!(f, "{} CSV, line {}: {}", input, line, error) + EigenTrustError::InvalidAlpha(a) => write!(f, "alpha must be in [0, 1], got {}", a), + EigenTrustError::InvalidEpsilon(e) => { + write!(f, "epsilon must be finite and greater than 0, got {}", e) + } + EigenTrustError::InvalidMaxIterations => { + f.write_str("max_iterations must be at least 1") } - Error::EmptyLocalTrust => f.write_str("local trust is empty"), - Error::InvalidAlpha(a) => write!(f, "alpha must be in [0, 1], got {}", a), - Error::DimensionMismatch => f.write_str("dimension mismatch"), - Error::NonFiniteScores => f.write_str("trust scores are not finite"), - Error::NotConverged { iterations, alpha } => write!( + EigenTrustError::NonFiniteWeight { input, position } => { + write!(f, "{} item {}: weight must be finite", input, position) + } + EigenTrustError::NegativeWeight { + input, + position, + weight, + } => write!( + f, + "{} item {}: weight {} is negative; distrust is not supported", + input, position, weight + ), + EigenTrustError::InvalidPeer { input, position } => { + write!( + f, + "{} item {}: peer index usize::MAX is not allowed", + input, position + ) + } + EigenTrustError::WeightOverflow(input) => { + write!( + f, + "{} weights are too large: their sum overflows f64", + input + ) + } + EigenTrustError::EmptyNetwork => f.write_str("the network has no peers"), + EigenTrustError::NotConverged { + iterations, + residual, + } => write!( f, - "did not converge in {} iterations with alpha {}; \ + "did not converge in {} iterations (residual {:.3e}); \ a small alpha on a periodic trust graph can oscillate, try a larger alpha", - iterations, alpha + iterations, residual ), - Error::InvalidUtf8(input) => write!(f, "{} CSV is not valid UTF-8", input), } } } -impl std::error::Error for Error {} - -pub type Result = std::result::Result; +impl std::error::Error for EigenTrustError {} diff --git a/src/lib.rs b/src/lib.rs index fefed8f..e5b7018 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,81 +1,146 @@ -#![cfg(target_arch = "wasm32")] -use crate::basic::engine::calculate_from_csv; -use crate::error::{Error, Input}; -use wasm_bindgen::prelude::*; +//! [EigenTrust](https://nlp.stanford.edu/pubs/eigentrust.pdf) reputation scores for +//! peer-to-peer networks, social graphs and other webs of trust. +//! +//! Each peer says how much it trusts some other peers (*local trust*). A few peers are +//! trusted from the start (*pre-trust*, or seeds). EigenTrust combines both into one global +//! score per peer: trust flows from the seeds along the network, so a cluster of fake +//! accounts that only vouch for each other ends up with almost nothing. Without seeds it +//! behaves like PageRank. +//! +//! This crate implements the algorithm from Kamvar, Schlosser and Garcia-Molina (2003) as +//! a power iteration over a sparse matrix. It runs on any Rust target, including +//! `wasm32-unknown-unknown`. +//! +//! # Example +//! +//! ``` +//! use eigentrust::{eigentrust, PreTrust, TrustEdge}; +//! +//! // peer 0 is the seed; 0 trusts 1 twice as much as 2; 1 and 2 trust each other +//! let local_trust = [ +//! TrustEdge::new(0, 1, 2.0), +//! TrustEdge::new(0, 2, 1.0), +//! TrustEdge::new(1, 2, 1.0), +//! TrustEdge::new(2, 1, 1.0), +//! ]; +//! let pre_trust = [PreTrust::new(0, 1.0)]; +//! +//! let result = eigentrust(local_trust, pre_trust)?; +//! let scores = result.scores(); +//! assert_eq!(scores.len(), 3); +//! assert!(scores[1] > scores[2]); +//! assert!((scores.iter().sum::() - 1.0).abs() < 1e-9); +//! # Ok::<(), eigentrust::EigenTrustError>(()) +//! ``` +//! +//! Use [`eigentrust_with_options`] to change `alpha`, the convergence threshold or the +//! iteration limit (see [`EigenTrustOptions`]). +//! +//! # Input +//! +//! - **Peers** are `usize` indices. The network has `n = max index + 1` peers, taken over +//! both inputs; every index below that is a peer, even one that appears nowhere. +//! Keep indices dense. +//! - **Weights** must be finite and non-negative. Zero means no trust. Negative trust +//! (distrust) is rejected with [`EigenTrustError::NegativeWeight`]. +//! - **Local trust** is normalized per truster: each peer's outgoing weights are scaled to +//! sum to 1, so only their ratios matter. A peer that trusts nobody passes its share on +//! according to pre-trust. +//! - **Pre-trust** is normalized to sum to 1. If it is empty or all zero, every peer is +//! equally pre-trusted. +//! - **Duplicates**: a repeated `(from, to)` edge or a repeated pre-trusted peer keeps the +//! last weight given. +//! - Self-trust (`from == to`) is allowed and treated like any other edge. +//! +//! # Output +//! +//! [`TrustScores`] holds one score per peer, indexed by peer. Scores are non-negative and sum +//! to 1. It also reports the number of iterations and the final residual. +//! +//! # Convergence +//! +//! Each iteration computes `t = (1 - alpha) * Cᵀ t + alpha * p`, starting from `t = p`, +//! where `C` is the normalized local trust and `p` the normalized pre-trust. It stops when +//! the L2 norm of the change is at most `epsilon` (default `1e-6 / n`). With `alpha > 0` the +//! error shrinks by a factor of `1 - alpha` per iteration. With `alpha = 0` some networks +//! oscillate forever; after `max_iterations` (default 10,000) the result is +//! [`EigenTrustError::NotConverged`]. +//! +//! Sums are compensated and always taken in the same order, so results are reproducible +//! bit for bit, with or without the `parallel` feature. +//! +//! # Features +//! +//! - `csv`: [`csv::Network`] reads named peers from CSV text. +//! - `parallel`: multithreaded iteration with rayon, for networks with many thousands of +//! peers. +//! +//! # Other interfaces +//! +//! The same implementation powers a command-line tool (the `eigentrust-cli` crate) and +//! WebAssembly bindings for the browser (the `eigentrust-wasm` crate in the repository). +//! Both are thin adapters over this crate's public API. -pub mod basic; -pub mod error; -pub mod sparse; -use crate::basic::util::init_logger; -use std::panic; -use std::str; +#![warn(missing_docs)] +#![cfg_attr(docsrs, feature(doc_cfg))] -#[cfg(feature = "parallel")] -pub use wasm_bindgen_rayon::init_thread_pool; +mod algorithm; +mod error; +mod options; +mod scores; +mod types; -#[wasm_bindgen(start)] -fn start() { - panic::set_hook(Box::new(console_error_panic_hook::hook)); - init_logger(); - log::debug!("WASM Eigentrust connected"); -} - -#[wasm_bindgen] -// Returns JSON: {"Ok": [[peer, score], ...]} sorted by score, or {"Err": "message"} -pub fn run(localtrust_csv: &[u8], pretrust_csv: &[u8], alpha: f64) -> String { - let result = str::from_utf8(localtrust_csv) - .map_err(|_| Error::InvalidUtf8(Input::LocalTrust)) - .and_then(|lt| { - let pt = - str::from_utf8(pretrust_csv).map_err(|_| Error::InvalidUtf8(Input::PreTrust))?; - calculate_from_csv(lt, pt, Some(alpha)) - }) - // the JS side gets the message: {"Err": "local trust CSV, line 3: ..."} - .map_err(|e| e.to_string()); - - serde_json::to_string(&result) - .unwrap_or_else(|e| serde_json::json!({ "Err": e.to_string() }).to_string()) -} - -#[cfg(test)] -mod tests { - use super::run; - use wasm_bindgen_test::wasm_bindgen_test; +#[cfg(feature = "csv")] +pub mod csv; - fn scores(json: &str) -> Vec<(String, f64)> { - let v: serde_json::Value = serde_json::from_str(json).unwrap(); - v["Ok"] - .as_array() - .expect("Ok result") - .iter() - .map(|e| (e[0].as_str().unwrap().to_string(), e[1].as_f64().unwrap())) - .collect() - } +// the README's Rust examples run as doc tests +#[cfg(doctest)] +#[doc = include_str!("../README.md")] +struct ReadmeDoctests; - #[wasm_bindgen_test] - fn run_returns_sorted_scores_summing_to_one() { - let out = run(b"alice,bob,2\nbob,carol,1\n", b"alice\n", 0.5); - let s = scores(&out); - assert_eq!( - s.iter().map(|(p, _)| p.as_str()).collect::>(), - ["alice", "bob", "carol"] - ); - let total: f64 = s.iter().map(|(_, v)| v).sum(); - assert!((total - 1.0).abs() < 1e-9); - } +pub use error::{EigenTrustError, Input}; +pub use options::EigenTrustOptions; +pub use scores::TrustScores; +pub use types::{PreTrust, TrustEdge}; - #[wasm_bindgen_test] - fn run_reports_errors_as_err() { - for (lt, pt) in [(&b"a,b,NaN"[..], &b"a"[..]), (b"a,b,-1", b"a"), (b"", b"")] { - let out = run(lt, pt, 0.5); - assert!(out.starts_with("{\"Err\":"), "{}", out); - } - assert!(run(&[0xff, 0xfe], b"a", 0.5).starts_with("{\"Err\":")); - } +/// Computes EigenTrust scores with the default [`EigenTrustOptions`]. +/// +/// See the [crate documentation](crate) for how the inputs are interpreted. +/// +/// # Errors +/// +/// Returns an [`EigenTrustError`] for invalid weights, an empty network, or if the scores +/// do not converge. +pub fn eigentrust( + local_trust: impl IntoIterator, + pre_trust: impl IntoIterator, +) -> Result { + algorithm::run(local_trust, pre_trust, &EigenTrustOptions::default()) +} - #[wasm_bindgen_test] - fn run_rejects_bad_alpha() { - assert!(run(b"a,b", b"a", f64::NAN).starts_with("{\"Err\":")); - assert!(run(b"a,b", b"a", 2.0).starts_with("{\"Err\":")); - } +/// Computes EigenTrust scores with custom options. +/// +/// ``` +/// use eigentrust::{eigentrust_with_options, EigenTrustOptions, PreTrust, TrustEdge}; +/// +/// let options = EigenTrustOptions::default().with_alpha(0.15); +/// let result = eigentrust_with_options( +/// [TrustEdge::new(0, 1, 1.0), TrustEdge::new(1, 0, 1.0)], +/// [PreTrust::new(0, 1.0)], +/// &options, +/// )?; +/// assert!(result.scores()[0] > result.scores()[1]); +/// # Ok::<(), eigentrust::EigenTrustError>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an [`EigenTrustError`] for invalid options or weights, an empty network, or if +/// the scores do not converge within [`EigenTrustOptions::max_iterations`]. +pub fn eigentrust_with_options( + local_trust: impl IntoIterator, + pre_trust: impl IntoIterator, + options: &EigenTrustOptions, +) -> Result { + algorithm::run(local_trust, pre_trust, options) } diff --git a/src/options.rs b/src/options.rs new file mode 100644 index 0000000..1ddac72 --- /dev/null +++ b/src/options.rs @@ -0,0 +1,108 @@ +use crate::EigenTrustError; + +/// Settings for [`eigentrust_with_options`](crate::eigentrust_with_options). +/// +/// Start from [`EigenTrustOptions::default()`] and change what you need. Values are checked +/// when the computation starts, before any work is done. +/// +/// ``` +/// use eigentrust::EigenTrustOptions; +/// +/// let options = EigenTrustOptions::default() +/// .with_alpha(0.2) +/// .with_max_iterations(500); +/// assert_eq!(options.alpha(), 0.2); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct EigenTrustOptions { + alpha: f64, + epsilon: Option, + max_iterations: usize, +} + +impl EigenTrustOptions { + /// Default [`alpha`](Self::alpha). + pub const DEFAULT_ALPHA: f64 = 0.5; + /// Default [`max_iterations`](Self::max_iterations). + pub const DEFAULT_MAX_ITERATIONS: usize = 10_000; + + /// Same as [`EigenTrustOptions::default()`]. + pub fn new() -> Self { + Self::default() + } + + /// Sets how strongly each iteration pulls trust back to the pre-trusted peers, in + /// `[0, 1]`. + /// + /// Higher values keep trust close to the seeds, which starves Sybil clusters; lower + /// values let it travel further. At `0` pre-trust only sets the starting point, and some + /// networks then oscillate instead of converging. + #[must_use] + pub fn with_alpha(mut self, alpha: f64) -> Self { + self.alpha = alpha; + self + } + + /// Sets the convergence threshold: iteration stops once the L2 norm of the change in + /// scores between two iterations is at most `epsilon`. Must be finite and positive. + /// + /// By default it is `1e-6 / n` for a network of `n` peers. + #[must_use] + pub fn with_epsilon(mut self, epsilon: f64) -> Self { + self.epsilon = Some(epsilon); + self + } + + /// Sets the iteration limit. Reaching it returns + /// [`EigenTrustError::NotConverged`]. Must be at least 1. + #[must_use] + pub fn with_max_iterations(mut self, max_iterations: usize) -> Self { + self.max_iterations = max_iterations; + self + } + + /// Share of trust returned to the pre-trusted peers each iteration. + pub fn alpha(&self) -> f64 { + self.alpha + } + + /// The fixed convergence threshold, or `None` for the default `1e-6 / n`. + pub fn epsilon(&self) -> Option { + self.epsilon + } + + /// The iteration limit. + pub fn max_iterations(&self) -> usize { + self.max_iterations + } + + pub(crate) fn validate(&self) -> Result<(), EigenTrustError> { + if !(0.0..=1.0).contains(&self.alpha) { + return Err(EigenTrustError::InvalidAlpha(self.alpha)); + } + if let Some(e) = self.epsilon { + if !(e.is_finite() && e > 0.0) { + return Err(EigenTrustError::InvalidEpsilon(e)); + } + } + if self.max_iterations == 0 { + return Err(EigenTrustError::InvalidMaxIterations); + } + Ok(()) + } + + pub(crate) fn epsilon_for(&self, peers: usize) -> f64 { + self.epsilon.unwrap_or(1e-6 / peers as f64) + } +} + +impl Default for EigenTrustOptions { + /// `alpha` 0.5, `epsilon` `1e-6 / n`, `max_iterations` 10,000. + fn default() -> Self { + EigenTrustOptions { + alpha: Self::DEFAULT_ALPHA, + epsilon: None, + max_iterations: Self::DEFAULT_MAX_ITERATIONS, + } + } +} diff --git a/src/scores.rs b/src/scores.rs new file mode 100644 index 0000000..1aba697 --- /dev/null +++ b/src/scores.rs @@ -0,0 +1,62 @@ +/// Global trust scores, one per peer, plus how the computation went. +/// +/// Scores are indexed by peer: `scores()[i]` belongs to peer `i`. They are non-negative and +/// add up to 1 (up to floating point rounding). +#[derive(Debug, Clone, PartialEq)] +pub struct TrustScores { + scores: Vec, + iterations: usize, + residual: f64, +} + +impl TrustScores { + pub(crate) fn new(scores: Vec, iterations: usize, residual: f64) -> Self { + TrustScores { + scores, + iterations, + residual, + } + } + + /// The score of every peer, indexed by peer. + pub fn scores(&self) -> &[f64] { + &self.scores + } + + /// The score of one peer, or `None` if the index is outside the network. + pub fn get(&self, peer: usize) -> Option { + self.scores.get(peer).copied() + } + + /// Number of peers. + pub fn len(&self) -> usize { + self.scores.len() + } + + /// Always `false`: a computed network has at least one peer. + pub fn is_empty(&self) -> bool { + self.scores.is_empty() + } + + /// `(peer, score)` pairs, highest score first. Equal scores keep peer order. + pub fn ranking(&self) -> Vec<(usize, f64)> { + let mut ranking: Vec<(usize, f64)> = self.scores.iter().copied().enumerate().collect(); + ranking.sort_by(|a, b| b.1.total_cmp(&a.1)); + ranking + } + + /// Power iterations performed. + pub fn iterations(&self) -> usize { + self.iterations + } + + /// Change between the last two iterations (L2 norm). At most the convergence threshold. + pub fn residual(&self) -> f64 { + self.residual + } + + /// The scores, indexed by peer. + pub fn into_scores(self) -> Vec { + self.scores + } +} diff --git a/src/sparse/entry.rs b/src/sparse/entry.rs deleted file mode 100644 index d252de4..0000000 --- a/src/sparse/entry.rs +++ /dev/null @@ -1,310 +0,0 @@ -use serde::Serialize; -use std::cmp::Ordering; - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct Entry { - pub index: usize, - pub value: f64, -} - -impl Entry { - pub fn new(index: usize, value: f64) -> Entry { - Entry { index, value } - } -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct CooEntry { - pub row: usize, - pub column: usize, - pub value: f64, -} - -impl CooEntry { - pub fn new(row: usize, column: usize, value: f64) -> CooEntry { - CooEntry { row, column, value } - } -} - -pub struct CSREntriesSort(Vec); - -impl CSREntriesSort { - pub fn new(entries: Vec) -> Self { - let mut sorted_entries = entries; - sorted_entries.sort_by(|a, b| match a.row.cmp(&b.row) { - Ordering::Equal => a.column.cmp(&b.column), - other => other, - }); - CSREntriesSort(sorted_entries) - } - - pub fn entries(&self) -> Vec { - self.0.clone() - } -} - -pub struct CSCEntriesSort(Vec); - -impl CSCEntriesSort { - pub fn new(entries: Vec) -> Self { - let mut sorted_entries = entries; - sorted_entries.sort_by(|a, b| match a.column.cmp(&b.column) { - Ordering::Equal => a.row.cmp(&b.row), - other => other, - }); - CSCEntriesSort(sorted_entries) - } - - pub fn entries(&self) -> Vec { - self.0.clone() - } -} - -pub fn sort_entries_by_index(entries: &mut [Entry]) { - entries.sort_by_key(|e| e.index); -} - -pub fn sort_entries_by_value(entries: &mut [Entry]) { - entries.sort_by(|a, b| a.value.total_cmp(&b.value)); -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_csr_entries_sort_len() { - let tests = vec![ - ( - "Normal", - vec![ - CooEntry::new(3, 1, 7.0), - CooEntry::new(1, 0, 4.0), - CooEntry::new(2, 8, 0.0), - CooEntry::new(5, 0, 0.0), - ], - 4, - ), - ("Empty", vec![], 0), - ]; - - for (name, entries, expected_len) in tests { - let len = entries.len(); - assert_eq!( - len, expected_len, - "{}: len = {}, want {}", - name, len, expected_len - ); - } - } - - #[test] - fn test_csr_entries_sort_swap() { - let mut entries = vec![ - CooEntry::new(3, 1, 7.0), - CooEntry::new(1, 0, 4.0), - CooEntry::new(2, 8, 0.0), - CooEntry::new(5, 0, 0.0), - ]; - - entries.swap(1, 2); - - let expected = vec![ - CooEntry::new(3, 1, 7.0), - CooEntry::new(2, 8, 0.0), - CooEntry::new(1, 0, 4.0), - CooEntry::new(5, 0, 0.0), - ]; - - assert_eq!(entries, expected); - } - - #[test] - fn test_csr_entries_sort_less() { - let tests = vec![ - ( - "xryc", - CooEntry::new(0, 2, 0.0), - CooEntry::new(1, 1, 0.0), - true, - ), - ( - "xr=yr,xcyc", - CooEntry::new(1, 2, 0.0), - CooEntry::new(1, 1, 0.0), - false, - ), - ( - "xr>yr,xcyr,xc=yc", - CooEntry::new(2, 1, 0.0), - CooEntry::new(1, 1, 0.0), - false, - ), - ( - "xr>yr,xc>yc", - CooEntry::new(2, 2, 0.0), - CooEntry::new(1, 1, 0.0), - false, - ), - ]; - - for (name, x, y, expected) in tests { - let entries = [x.clone(), y.clone()]; - let result = entries[0].row < entries[1].row - || (entries[0].row == entries[1].row && entries[0].column < entries[1].column); - assert_eq!( - result, expected, - "{}: got = {}, want {}", - name, result, expected - ); - } - } - - #[test] - fn test_csc_entries_sort_len() { - let tests = vec![ - ( - "Normal", - vec![ - CooEntry::new(3, 1, 7.0), - CooEntry::new(1, 0, 4.0), - CooEntry::new(2, 8, 0.0), - CooEntry::new(5, 0, 0.0), - ], - 4, - ), - ("Empty", vec![], 0), - ]; - - for (name, entries, expected_len) in tests { - let len = entries.len(); - assert_eq!( - len, expected_len, - "{}: len = {}, want {}", - name, len, expected_len - ); - } - } - - #[test] - fn test_csc_entries_sort_swap() { - let mut entries = vec![ - CooEntry::new(3, 1, 7.0), - CooEntry::new(1, 0, 4.0), - CooEntry::new(2, 8, 0.0), - CooEntry::new(5, 0, 0.0), - ]; - - entries.swap(1, 2); - - let expected = vec![ - CooEntry::new(3, 1, 7.0), - CooEntry::new(2, 8, 0.0), - CooEntry::new(1, 0, 4.0), - CooEntry::new(5, 0, 0.0), - ]; - - assert_eq!(entries, expected); - } - - #[test] - fn test_csc_entries_sort_less() { - let tests = vec![ - ( - "xryr,xcyr,xc=yc", - CooEntry::new(2, 1, 0.0), - CooEntry::new(1, 1, 0.0), - false, - ), - ( - "xryc", - CooEntry::new(0, 2, 0.0), - CooEntry::new(1, 1, 0.0), - false, - ), - ( - "xr=yr,xc>yc", - CooEntry::new(1, 2, 0.0), - CooEntry::new(1, 1, 0.0), - false, - ), - ( - "xr>yr,xc>yc", - CooEntry::new(2, 2, 0.0), - CooEntry::new(1, 1, 0.0), - false, - ), - ]; - - for (name, x, y, expected) in tests { - let entries = [x.clone(), y.clone()]; - let result = entries[0].column < entries[1].column - || (entries[0].column == entries[1].column && entries[0].row < entries[1].row); - assert_eq!( - result, expected, - "{}: got = {}, want {}", - name, result, expected - ); - } - } -} diff --git a/src/sparse/matrix.rs b/src/sparse/matrix.rs deleted file mode 100644 index b77253b..0000000 --- a/src/sparse/matrix.rs +++ /dev/null @@ -1,591 +0,0 @@ -use super::entry::Entry; -use super::vector::Vector; -use crate::error::{Error, Result}; - -#[derive(Clone, PartialEq, Debug)] -pub struct CSMatrix { - pub major_dim: usize, - pub minor_dim: usize, - pub entries: Vec>, -} - -impl Default for CSMatrix { - fn default() -> Self { - Self::new() - } -} - -impl CSMatrix { - pub fn new() -> Self { - Self { - major_dim: 0, - minor_dim: 0, - entries: Vec::new(), - } - } - - pub fn reset(&mut self) { - self.major_dim = 0; - self.minor_dim = 0; - self.entries.clear(); - } - - pub fn dim(&self) -> Result { - if self.major_dim != self.minor_dim { - return Err(Error::DimensionMismatch); - } - Ok(self.major_dim) - } - - pub fn set_major_dim(&mut self, dim: usize) { - if self.entries.capacity() < dim { - let mut new_entries = Vec::with_capacity(dim); - new_entries.append(&mut self.entries); - self.entries = new_entries; - } - self.entries.resize_with(dim, Vec::new); - self.major_dim = dim; - } - - pub fn set_minor_dim(&mut self, dim: usize) { - for entries in &mut self.entries { - entries.retain(|e| e.index < dim); - } - self.minor_dim = dim; - } - - pub fn nnz(&self) -> usize { - self.entries.iter().map(|row| row.len()).sum() - } - - pub fn transpose(&self) -> Result { - let mut nnzs = vec![0; self.minor_dim]; - for row_entries in &self.entries { - for entry in row_entries { - nnzs[entry.index] += 1; - } - } - - let mut transposed_entries = vec![Vec::new(); self.minor_dim]; - for (col, &nnz) in nnzs.iter().enumerate() { - if nnz != 0 { - transposed_entries[col].reserve(nnz); - } - } - - for (row, row_entries) in self.entries.iter().enumerate() { - for entry in row_entries { - transposed_entries[entry.index].push(Entry { - index: row, - value: entry.value, - }); - } - } - - Ok(CSMatrix { - major_dim: self.minor_dim, - minor_dim: self.major_dim, - entries: transposed_entries, - }) - } - - pub fn merge(&mut self, other: &mut CSMatrix) { - self.set_major_dim(self.major_dim.max(other.major_dim)); - self.set_minor_dim(self.minor_dim.max(other.minor_dim)); - for i in 0..other.major_dim { - self.entries[i] = merge_span(&self.entries[i], &other.entries[i]); - } - other.reset(); - } -} -fn merge_span(s1: &[Entry], s2: &[Entry]) -> Vec { - let mut s = Vec::with_capacity(s1.len() + s2.len()); - let mut i1 = 0; - let mut i2 = 0; - - while i1 < s1.len() || i2 < s2.len() { - if i2 >= s2.len() { - s.push(s1[i1].clone()); - i1 += 1; - } else if i1 >= s1.len() { - s.push(s2[i2].clone()); - i2 += 1; - } else if s1[i1].index < s2[i2].index { - s.push(s1[i1].clone()); - i1 += 1; - } else if s1[i1].index > s2[i2].index { - s.push(s2[i2].clone()); - i2 += 1; - } else { - s.push(s2[i2].clone()); - i1 += 1; - i2 += 1; - } - } - - s.shrink_to_fit(); - s -} - -fn dedup_keep_last(row: &mut Vec) { - if row.len() < 2 { - return; - } - let mut write = 0; - for read in 1..row.len() { - if row[read].index == row[write].index { - row[write].value = row[read].value; - } else { - write += 1; - row[write] = row[read].clone(); - } - } - row.truncate(write + 1); -} - -#[derive(Clone, PartialEq, Debug)] -pub struct CSRMatrix { - pub cs_matrix: CSMatrix, -} - -impl CSRMatrix { - pub fn new(rows: usize, cols: usize, entries: Vec<(usize, usize, f64)>) -> Self { - let mut matrix_entries = vec![Vec::new(); rows]; - - for (row, col, value) in entries { - matrix_entries[row].push(Entry { index: col, value }); - } - - Self::from_rows(cols, matrix_entries) - } - - // Builds a matrix from unsorted rows. Duplicate columns in a row collapse to the - // last one (same as go-eigentrust merge semantics), zero values are dropped. - pub fn from_rows(cols: usize, mut rows: Vec>) -> Self { - for row in &mut rows { - // stable sort keeps input order among duplicates, so the last record wins - row.sort_by_key(|e| e.index); - dedup_keep_last(row); - row.retain(|e| e.value != 0.0); - } - - CSRMatrix { - cs_matrix: CSMatrix { - major_dim: rows.len(), - minor_dim: cols, - entries: rows, - }, - } - } - - pub fn dims(&self) -> (usize, usize) { - (self.cs_matrix.major_dim, self.cs_matrix.minor_dim) - } - - pub fn set_dim(&mut self, rows: usize, cols: usize) { - self.cs_matrix.set_major_dim(rows); - self.cs_matrix.set_minor_dim(cols); - } - - pub fn row_vector(&self, index: usize) -> Vector { - Vector { - dim: self.cs_matrix.minor_dim, - entries: self.cs_matrix.entries[index].clone(), - } - } - - pub fn set_row_vector(&mut self, index: usize, vector: Vector) { - self.cs_matrix.entries[index] = vector.entries; - } - - pub fn transpose(&self) -> Result { - let transposed = self.cs_matrix.transpose()?; - Ok(CSRMatrix { - cs_matrix: transposed, - }) - } - pub fn transpose_to_csc(&self) -> CSCMatrix { - CSCMatrix { - cs_matrix: CSMatrix { - major_dim: self.cs_matrix.minor_dim, - minor_dim: self.cs_matrix.major_dim, - entries: self.cs_matrix.entries.clone(), - }, - } - } -} - -#[derive(Clone, PartialEq, Debug)] -pub struct CSCMatrix { - pub cs_matrix: CSMatrix, -} - -impl CSCMatrix { - pub fn dims(&self) -> (usize, usize) { - (self.cs_matrix.minor_dim, self.cs_matrix.major_dim) - } - - pub fn set_dim(&mut self, rows: usize, cols: usize) { - self.cs_matrix.set_major_dim(cols); - self.cs_matrix.set_minor_dim(rows); - } - - pub fn column_vector(&self, index: usize) -> Vector { - Vector { - dim: self.cs_matrix.minor_dim, - entries: self.cs_matrix.entries[index].clone(), - } - } - - pub fn transpose(&self) -> Result { - let transposed = self.cs_matrix.transpose()?; - Ok(CSCMatrix { - cs_matrix: transposed, - }) - } - - pub fn transpose_to_csr(&self) -> CSRMatrix { - CSRMatrix { - cs_matrix: CSMatrix { - major_dim: self.cs_matrix.minor_dim, - minor_dim: self.cs_matrix.major_dim, - entries: self.cs_matrix.entries.clone(), - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_cs_matrix_transpose() { - let original = CSMatrix { - major_dim: 5, - minor_dim: 4, - entries: vec![ - vec![ - Entry { - index: 0, - value: 100.0, - }, - Entry { - index: 1, - value: 200.0, - }, - Entry { - index: 2, - value: 300.0, - }, - ], - vec![ - Entry { - index: 1, - value: 400.0, - }, - Entry { - index: 3, - value: 500.0, - }, - ], - vec![], - vec![ - Entry { - index: 0, - value: 600.0, - }, - Entry { - index: 1, - value: 700.0, - }, - Entry { - index: 2, - value: 800.0, - }, - Entry { - index: 3, - value: 900.0, - }, - ], - vec![Entry { - index: 2, - value: 1000.0, - }], - ], - }; - - let transposed = CSMatrix { - major_dim: 4, - minor_dim: 5, - entries: vec![ - vec![ - Entry { - index: 0, - value: 100.0, - }, - Entry { - index: 3, - value: 600.0, - }, - ], - vec![ - Entry { - index: 0, - value: 200.0, - }, - Entry { - index: 1, - value: 400.0, - }, - Entry { - index: 3, - value: 700.0, - }, - ], - vec![ - Entry { - index: 0, - value: 300.0, - }, - Entry { - index: 3, - value: 800.0, - }, - Entry { - index: 4, - value: 1000.0, - }, - ], - vec![ - Entry { - index: 1, - value: 500.0, - }, - Entry { - index: 3, - value: 900.0, - }, - ], - ], - }; - - let result = original.transpose().unwrap(); - assert_eq!(result, transposed); - - let double_transpose = result.transpose().unwrap(); - assert_eq!(double_transpose, original); - } - - #[test] - fn test_cs_matrix_merge() { - let mut m = CSMatrix { - major_dim: 3, - minor_dim: 3, - entries: vec![ - vec![], - vec![Entry { - index: 2, - value: 5.0, - }], - vec![ - Entry { - index: 1, - value: 5.0, - }, - Entry { - index: 2, - value: 5.0, - }, - ], - ], - }; - - let mut m2 = CSMatrix { - major_dim: 4, - minor_dim: 4, - entries: vec![ - vec![ - Entry { - index: 0, - value: 8.0, - }, - Entry { - index: 2, - value: 8.0, - }, - ], - vec![Entry { - index: 0, - value: 8.0, - }], - vec![ - Entry { - index: 1, - value: 8.0, - }, - Entry { - index: 3, - value: 8.0, - }, - ], - vec![ - Entry { - index: 1, - value: 8.0, - }, - Entry { - index: 2, - value: 8.0, - }, - ], - ], - }; - - let merged = CSMatrix { - major_dim: 4, - minor_dim: 4, - entries: vec![ - vec![ - Entry { - index: 0, - value: 8.0, - }, - Entry { - index: 2, - value: 8.0, - }, - ], - vec![ - Entry { - index: 0, - value: 8.0, - }, - Entry { - index: 2, - value: 5.0, - }, - ], - vec![ - Entry { - index: 1, - value: 8.0, - }, - Entry { - index: 2, - value: 5.0, - }, - Entry { - index: 3, - value: 8.0, - }, - ], - vec![ - Entry { - index: 1, - value: 8.0, - }, - Entry { - index: 2, - value: 8.0, - }, - ], - ], - }; - - m.merge(&mut m2); - assert_eq!(m, merged); - } - - #[test] - fn test_new_csr_matrix_duplicates_last_wins() { - let m = CSRMatrix::new( - 2, - 2, - vec![(0, 1, 1.0), (0, 0, 3.0), (0, 1, 5.0), (1, 1, 2.0)], - ); - assert_eq!( - m.cs_matrix.entries, - vec![ - vec![Entry::new(0, 3.0), Entry::new(1, 5.0)], - vec![Entry::new(1, 2.0)], - ] - ); - } - - #[test] - fn test_new_csr_matrix() { - let entries = vec![ - (0, 0, 100.0), - (3, 0, 600.0), - (3, 1, 700.0), - (1, 1, 400.0), - (0, 1, 200.0), - (2, 1, 0.0), // zero value should be dropped - (1, 3, 500.0), - (3, 3, 900.0), - (4, 2, 1000.0), - (0, 2, 300.0), - (3, 2, 800.0), - ]; - - let expected = CSRMatrix { - cs_matrix: CSMatrix { - major_dim: 5, - minor_dim: 4, - entries: vec![ - vec![ - Entry { - index: 0, - value: 100.0, - }, - Entry { - index: 1, - value: 200.0, - }, - Entry { - index: 2, - value: 300.0, - }, - ], - vec![ - Entry { - index: 1, - value: 400.0, - }, - Entry { - index: 3, - value: 500.0, - }, - ], - vec![], - vec![ - Entry { - index: 0, - value: 600.0, - }, - Entry { - index: 1, - value: 700.0, - }, - Entry { - index: 2, - value: 800.0, - }, - Entry { - index: 3, - value: 900.0, - }, - ], - vec![Entry { - index: 2, - value: 1000.0, - }], - ], - }, - }; - - let result = CSRMatrix::new(5, 4, entries); - assert_eq!(result, expected); - } -} diff --git a/src/sparse/mod.rs b/src/sparse/mod.rs deleted file mode 100644 index 7988548..0000000 --- a/src/sparse/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod entry; -pub mod matrix; -pub mod util; -pub mod vector; diff --git a/src/sparse/util.rs b/src/sparse/util.rs deleted file mode 100644 index 26c2f63..0000000 --- a/src/sparse/util.rs +++ /dev/null @@ -1,91 +0,0 @@ -pub fn nil_if_empty(slice: Vec) -> Option> { - if slice.is_empty() { - None - } else { - Some(slice) - } -} - -pub fn filter(slice: Vec, pred: F) -> Vec -where - F: Fn(&T) -> bool, -{ - slice.into_iter().filter(|x| pred(x)).collect() -} - -// KBNSummer is the Kahan-Babushka-Neumaier compensated summation algorithm. -pub struct KBNSummer { - sum: f64, - compensation: f64, -} - -impl Default for KBNSummer { - fn default() -> Self { - Self::new() - } -} - -impl KBNSummer { - pub fn new() -> Self { - Self { - sum: 0.0, - compensation: 0.0, - } - } - - pub fn add(&mut self, value: f64) { - let mut more_sig = self.sum; - let mut less_sig = value; - - if more_sig.abs() < less_sig.abs() { - std::mem::swap(&mut more_sig, &mut less_sig); - } - - self.sum += value; - - // Recover truncated less_sig used in the addition. - let truncated_less_sig = self.sum - more_sig; - - // Calculate and add the compensation. - self.compensation += less_sig - truncated_less_sig; - } - - pub fn sum(&self) -> f64 { - self.sum + self.compensation - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_nil_if_empty() { - let not_empty_slice = vec![3, 4]; - let empty_slice: Vec = vec![]; - let nil_slice: Vec = vec![]; - - assert_eq!(nil_if_empty(not_empty_slice.clone()), Some(not_empty_slice)); - assert_eq!(nil_if_empty(empty_slice.clone()), None); - assert_eq!(nil_if_empty(nil_slice.clone()), None); - } - - #[test] - fn test_filter() { - let slice = vec![3, 1, -4, -1, 5, 9, -2, -6, 5, 3, -5, -8, 9, 7]; - - let positive = |&x: &i32| x > 0; - let negative = |&x: &i32| x < 0; - let zero = |&x: &i32| x == 0; - - assert_eq!( - filter(slice.clone(), positive), - vec![3, 1, 5, 9, 5, 3, 9, 7] - ); - assert_eq!( - filter(slice.clone(), negative), - vec![-4, -1, -2, -6, -5, -8] - ); - assert_eq!(filter(slice.clone(), zero), vec![] as Vec); - } -} diff --git a/src/sparse/vector.rs b/src/sparse/vector.rs deleted file mode 100644 index ce4ea5d..0000000 --- a/src/sparse/vector.rs +++ /dev/null @@ -1,245 +0,0 @@ -#[cfg(not(target_arch = "wasm32"))] -use rayon::prelude::*; -use serde::Serialize; -use std::cmp::Ordering; - -use super::entry::Entry; -use super::matrix::CSRMatrix; -use super::util::KBNSummer; -use crate::error::{Error, Result}; - -#[derive(Clone, PartialEq, Debug, Serialize)] -pub struct Vector { - pub dim: usize, - pub entries: Vec, -} - -impl Vector { - pub fn new(dim: usize, entries: Vec) -> Self { - let mut vector = Vector { dim, entries }; - vector.sort_entries_by_index(); - vector - } - - pub fn to_dense(&self) -> Vec { - let mut dense = vec![0.0; self.dim]; - // add, not assign: a sparse vector with repeated indices means their sum - for e in &self.entries { - dense[e.index] += e.value; - } - dense - } - - pub fn from_dense(dense: &[f64]) -> Self { - let entries = dense - .iter() - .enumerate() - .filter(|(_, &v)| v != 0.0) - .map(|(index, &value)| Entry { index, value }) - .collect(); - Vector { - dim: dense.len(), - entries, - } - } - - pub fn nnz(&self) -> usize { - self.entries.len() - } - - pub fn assign(&mut self, other: &Self) { - *self = other.clone(); - } - - pub fn set_dim(&mut self, dim: usize) { - if dim < self.dim { - self.entries.retain(|e| e.index < dim); - } - self.dim = dim; - } - - pub fn sum(&self) -> f64 { - self.entries.iter().map(|e| e.value).sum() - } - - pub fn add_vec(&mut self, v1: &Self, v2: &Self) -> Result<()> { - self.binary_operation(v1, v2, |x, y| x + y) - } - - pub fn sub_vec(&mut self, v1: &Self, v2: &Self) -> Result<()> { - self.binary_operation(v1, v2, |x, y| x - y) - } - - pub fn scale_vec(&mut self, a: f64, v1: &Self) -> Result<()> { - if a.is_nan() { - return Err(Error::InvalidAlpha(a)); - } - if a == 0.0 { - self.dim = v1.dim; - self.entries.clear(); - } else { - self.assign(v1); - self.scale_in_place(a); - } - Ok(()) - } - - pub fn norm2(&self) -> f64 { - let mut summer = KBNSummer::new(); - for e in &self.entries { - summer.add(e.value * e.value); - } - summer.sum().sqrt() - } - - #[cfg(not(target_arch = "wasm32"))] - pub fn mul_vec(&mut self, m: &CSRMatrix, v1: &Self) -> Result<()> { - let dim = m.cs_matrix.dim()?; - if dim != v1.dim { - return Err(Error::DimensionMismatch); - } - - let dense = v1.to_dense(); - let entries: Vec = (0..dim) - .into_par_iter() - .filter_map(|row| { - let product = dense_dot(&m.cs_matrix.entries[row], &dense); - if product != 0.0 { - Some(Entry { - index: row, - value: product, - }) - } else { - None - } - }) - .collect(); - - self.dim = dim; - self.entries = entries; - - Ok(()) - } - - #[cfg(target_arch = "wasm32")] - pub fn mul_vec(&mut self, m: &CSRMatrix, v1: &Self) -> Result<()> { - let dim = m.cs_matrix.dim()?; - if dim != v1.dim { - return Err(Error::DimensionMismatch); - } - - let dense = v1.to_dense(); - let mut entries = Vec::with_capacity(dim); - for row in 0..dim { - let product = dense_dot(&m.cs_matrix.entries[row], &dense); - if product != 0.0 { - entries.push(Entry { - index: row, - value: product, - }); - } - } - - self.dim = dim; - self.entries = entries; - - Ok(()) - } - - fn sort_entries_by_index(&mut self) { - self.entries.sort_by_key(|e| e.index); - } - - fn binary_operation(&mut self, v1: &Self, v2: &Self, op: F) -> Result<()> - where - F: Fn(f64, f64) -> f64, - { - if v1.dim != v2.dim { - return Err(Error::DimensionMismatch); - } - - let mut entries = Vec::with_capacity(v1.entries.len() + v2.entries.len()); - let mut i1 = 0; - let mut i2 = 0; - - while i1 < v1.entries.len() || i2 < v2.entries.len() { - let entry = match (v1.entries.get(i1), v2.entries.get(i2)) { - (Some(e1), Some(e2)) => match e1.index.cmp(&e2.index) { - Ordering::Less => { - i1 += 1; - e1.clone() - } - Ordering::Greater => { - i2 += 1; - Entry { - index: e2.index, - value: op(0.0, e2.value), - } - } - Ordering::Equal => { - i1 += 1; - i2 += 1; - Entry { - index: e1.index, - value: op(e1.value, e2.value), - } - } - }, - (Some(e1), None) => { - i1 += 1; - e1.clone() - } - (None, Some(e2)) => { - i2 += 1; - Entry { - index: e2.index, - value: op(0.0, e2.value), - } - } - (None, None) => break, - }; - entries.push(entry); - } - - self.dim = v1.dim; - self.entries = entries; - - Ok(()) - } - - fn scale_in_place(&mut self, a: f64) { - self.entries.iter_mut().for_each(|e| e.value *= a); - self.entries.retain(|e| e.value != 0.0); - } -} - -fn dense_dot(row: &[Entry], dense: &[f64]) -> f64 { - let mut summer = KBNSummer::new(); - for e in row { - let x = dense[e.index]; - if x != 0.0 { - summer.add(e.value * x); - } - } - summer.sum() -} - -pub fn vec_dot(v1: &Vector, v2: &Vector) -> f64 { - let mut i2_iter = v2.entries.iter(); - let mut i2_opt = i2_iter.next(); - let mut summer = KBNSummer::new(); - - for e1 in &v1.entries { - while let Some(e2) = i2_opt { - if e2.index > e1.index { - break; - } - if e2.index == e1.index { - summer.add(e1.value * e2.value); - } - i2_opt = i2_iter.next(); - } - } - - summer.sum() -} diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..862e9b3 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,64 @@ +/// One peer's trust in another: `from` trusts `to` with `weight`. +/// +/// Weights are relative: each truster's weights are scaled to sum to 1, so `(0, 1, 2.0)` +/// and `(0, 2, 1.0)` mean peer 0 gives two thirds of its trust to 1 and one third to 2. +/// +/// ``` +/// use eigentrust::TrustEdge; +/// +/// let edge = TrustEdge::new(0, 1, 2.0); +/// assert_eq!(edge, (0, 1, 2.0).into()); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TrustEdge { + /// The truster. + pub from: usize, + /// The trusted peer. + pub to: usize, + /// How much `from` trusts `to`. Finite and non-negative; 0 means no trust. + pub weight: f64, +} + +impl TrustEdge { + /// Creates an edge. + pub const fn new(from: usize, to: usize, weight: f64) -> Self { + TrustEdge { from, to, weight } + } +} + +impl From<(usize, usize, f64)> for TrustEdge { + fn from((from, to, weight): (usize, usize, f64)) -> Self { + TrustEdge { from, to, weight } + } +} + +/// A seed peer: someone trusted before any local trust is considered. +/// +/// Pre-trust weights are relative and scaled to sum to 1. Trust flows out from these peers, +/// and every iteration returns a share `alpha` of it to them. +/// +/// ``` +/// use eigentrust::PreTrust; +/// +/// assert_eq!(PreTrust::new(0, 1.0), (0, 1.0).into()); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PreTrust { + /// The seed peer. + pub peer: usize, + /// How much it is trusted. Finite and non-negative. + pub weight: f64, +} + +impl PreTrust { + /// Creates a pre-trust entry. + pub const fn new(peer: usize, weight: f64) -> Self { + PreTrust { peer, weight } + } +} + +impl From<(usize, f64)> for PreTrust { + fn from((peer, weight): (usize, f64)) -> Self { + PreTrust { peer, weight } + } +} diff --git a/tests/api.rs b/tests/api.rs new file mode 100644 index 0000000..078cf2e --- /dev/null +++ b/tests/api.rs @@ -0,0 +1,325 @@ +// The public API, used exactly like an external crate would. + +use eigentrust::{ + eigentrust, eigentrust_with_options, EigenTrustError, EigenTrustOptions, Input, PreTrust, + TrustEdge, TrustScores, +}; + +fn edges(list: &[(usize, usize, f64)]) -> Vec { + list.iter().copied().map(TrustEdge::from).collect() +} + +fn seeds(list: &[(usize, f64)]) -> Vec { + list.iter().copied().map(PreTrust::from).collect() +} + +fn assert_normalized(result: &TrustScores) { + let total: f64 = result.scores().iter().sum(); + assert!((total - 1.0).abs() < 1e-9, "scores sum to {}", total); + assert!(result.scores().iter().all(|&s| s >= 0.0)); +} + +#[test] +fn basic_network() { + let result = eigentrust( + edges(&[(0, 1, 2.0), (1, 2, 1.0), (2, 0, 1.0), (0, 2, 1.0)]), + seeds(&[(0, 1.0)]), + ) + .unwrap(); + + assert_eq!(result.len(), 3); + assert!(!result.is_empty()); + assert_normalized(&result); + assert!(result.iterations() > 0); + assert!(result.residual() <= 1e-6 / 3.0); + assert_eq!(result.ranking()[0].0, 0); + assert_eq!(result.get(1), Some(result.scores()[1])); + assert_eq!(result.get(3), None); + assert_eq!(result.clone().into_scores(), result.scores()); +} + +#[test] +fn defaults() { + let options = EigenTrustOptions::default(); + assert_eq!(options, EigenTrustOptions::new()); + assert_eq!(options.alpha(), 0.5); + assert_eq!(options.alpha(), EigenTrustOptions::DEFAULT_ALPHA); + assert_eq!(options.epsilon(), None); + assert_eq!(options.max_iterations(), 10_000); + + let input = edges(&[(0, 1, 1.0), (1, 0, 3.0), (1, 2, 1.0)]); + let a = eigentrust(input.clone(), seeds(&[(0, 1.0)])).unwrap(); + let b = eigentrust_with_options(input, seeds(&[(0, 1.0)]), &options).unwrap(); + assert_eq!(a, b); +} + +#[test] +fn options_change_the_result() { + let input = edges(&[(0, 1, 1.0), (1, 2, 1.0), (2, 1, 1.0)]); + let low = EigenTrustOptions::default().with_alpha(0.1); + let high = EigenTrustOptions::default().with_alpha(0.9); + let low = eigentrust_with_options(input.clone(), seeds(&[(0, 1.0)]), &low).unwrap(); + let high = eigentrust_with_options(input, seeds(&[(0, 1.0)]), &high).unwrap(); + // more alpha keeps more trust at the seed + assert!(high.scores()[0] > low.scores()[0]); + assert_normalized(&low); + assert_normalized(&high); +} + +#[test] +fn custom_epsilon() { + let input = edges(&[(0, 1, 1.0), (1, 2, 1.0), (2, 0, 1.0), (2, 1, 1.0)]); + let loose = EigenTrustOptions::default().with_epsilon(1e-2); + let tight = EigenTrustOptions::default().with_epsilon(1e-12); + let loose = eigentrust_with_options(input.clone(), [], &loose).unwrap(); + let tight = eigentrust_with_options(input, [], &tight).unwrap(); + assert!(loose.iterations() < tight.iterations()); + assert!(tight.residual() <= 1e-12); +} + +#[test] +fn invalid_options() { + let input = || edges(&[(0, 1, 1.0)]); + for alpha in [-0.1, 1.5, f64::NAN, f64::INFINITY] { + let options = EigenTrustOptions::default().with_alpha(alpha); + let err = eigentrust_with_options(input(), [], &options).unwrap_err(); + assert!(matches!(err, EigenTrustError::InvalidAlpha(_)), "{}", err); + } + for epsilon in [0.0, -1.0, f64::NAN, f64::INFINITY] { + let options = EigenTrustOptions::default().with_epsilon(epsilon); + let err = eigentrust_with_options(input(), [], &options).unwrap_err(); + assert!(matches!(err, EigenTrustError::InvalidEpsilon(_)), "{}", err); + } + let options = EigenTrustOptions::default().with_max_iterations(0); + let err = eigentrust_with_options(input(), [], &options).unwrap_err(); + assert_eq!(err, EigenTrustError::InvalidMaxIterations); +} + +#[test] +fn options_are_checked_before_input() { + let options = EigenTrustOptions::default().with_alpha(2.0); + let err = eigentrust_with_options([], [], &options).unwrap_err(); + assert!(matches!(err, EigenTrustError::InvalidAlpha(_))); +} + +#[test] +fn invalid_weights() { + let err = eigentrust(edges(&[(0, 1, 1.0), (1, 0, -2.0)]), []).unwrap_err(); + assert_eq!( + err, + EigenTrustError::NegativeWeight { + input: Input::LocalTrust, + position: 1, + weight: -2.0 + } + ); + + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + let err = eigentrust(edges(&[(0, 1, bad)]), []).unwrap_err(); + assert_eq!( + err, + EigenTrustError::NonFiniteWeight { + input: Input::LocalTrust, + position: 0 + } + ); + let err = eigentrust(edges(&[(0, 1, 1.0)]), seeds(&[(0, 1.0), (1, bad)])).unwrap_err(); + assert_eq!( + err, + EigenTrustError::NonFiniteWeight { + input: Input::PreTrust, + position: 1 + } + ); + } + + let err = eigentrust(edges(&[(0, 1, f64::MAX), (0, 2, f64::MAX)]), []).unwrap_err(); + assert_eq!(err, EigenTrustError::WeightOverflow(Input::LocalTrust)); +} + +#[test] +fn invalid_peers() { + let err = eigentrust(edges(&[(0, usize::MAX, 1.0)]), []).unwrap_err(); + assert_eq!( + err, + EigenTrustError::InvalidPeer { + input: Input::LocalTrust, + position: 0 + } + ); + assert_eq!( + eigentrust([], []).unwrap_err(), + EigenTrustError::EmptyNetwork + ); +} + +#[test] +fn duplicate_edges_keep_the_last_weight() { + let dup = eigentrust( + edges(&[ + (0, 1, 5.0), + (0, 2, 1.0), + (0, 1, 1.0), + (1, 0, 1.0), + (2, 0, 1.0), + ]), + seeds(&[(0, 1.0)]), + ) + .unwrap(); + let clean = eigentrust( + edges(&[(0, 2, 1.0), (0, 1, 1.0), (1, 0, 1.0), (2, 0, 1.0)]), + seeds(&[(0, 1.0)]), + ) + .unwrap(); + assert_eq!(dup.scores(), clean.scores()); + assert_normalized(&dup); +} + +#[test] +fn duplicate_pre_trust_keeps_the_last_weight() { + let input = edges(&[(0, 1, 1.0), (1, 2, 1.0), (2, 0, 1.0), (2, 3, 1.0)]); + let dup = eigentrust( + input.clone(), + seeds(&[(0, 1.0), (3, 1.0), (0, 5.0), (0, 2.0)]), + ) + .unwrap(); + let clean = eigentrust(input, seeds(&[(0, 2.0), (3, 1.0)])).unwrap(); + assert_eq!(dup.scores(), clean.scores()); + assert_normalized(&dup); +} + +#[test] +fn zero_and_empty_pre_trust_are_uniform() { + let input = edges(&[(0, 1, 1.0), (1, 2, 1.0), (2, 0, 2.0), (2, 1, 1.0)]); + let empty = eigentrust(input.clone(), []).unwrap(); + let zero = eigentrust(input.clone(), seeds(&[(1, 0.0)])).unwrap(); + let uniform = eigentrust(input, seeds(&[(0, 1.0), (1, 1.0), (2, 1.0)])).unwrap(); + assert_eq!(empty.scores(), zero.scores()); + assert_eq!(empty.scores(), uniform.scores()); +} + +#[test] +fn weights_are_relative_per_truster() { + let a = eigentrust(edges(&[(0, 1, 1.0), (0, 2, 3.0), (1, 0, 1.0)]), []).unwrap(); + let b = eigentrust(edges(&[(0, 1, 10.0), (0, 2, 30.0), (1, 0, 7.0)]), []).unwrap(); + assert_eq!(a.scores(), b.scores()); +} + +#[test] +fn zero_weight_means_no_trust() { + let with_zero = eigentrust(edges(&[(0, 1, 1.0), (0, 2, 0.0), (1, 0, 1.0)]), []).unwrap(); + let without = eigentrust(edges(&[(0, 1, 1.0), (1, 0, 1.0), (2, 2, 0.0)]), []).unwrap(); + assert_eq!(with_zero.scores(), without.scores()); +} + +#[test] +fn every_index_below_the_maximum_is_a_peer() { + // peer 1 appears nowhere but still gets a score + let result = eigentrust(edges(&[(0, 2, 1.0), (2, 0, 1.0)]), []).unwrap(); + assert_eq!(result.len(), 3); + assert_normalized(&result); + // pre-trust alone can extend the network + let result = eigentrust(edges(&[(0, 1, 1.0)]), seeds(&[(4, 1.0)])).unwrap(); + assert_eq!(result.len(), 5); + assert_normalized(&result); +} + +#[test] +fn sybil_cluster_gets_little_trust() { + // honest ring 0..4 seeded at 0; sybils 5..9 vouch only for each other and get one + // link from peer 4 + let mut input = Vec::new(); + for i in 0..5 { + input.push(TrustEdge::new(i, (i + 1) % 5, 1.0)); + } + input.push(TrustEdge::new(4, 5, 0.1)); + for a in 5..10 { + for b in 5..10 { + if a != b { + input.push(TrustEdge::new(a, b, 10.0)); + } + } + } + let options = EigenTrustOptions::default().with_alpha(0.5); + let result = eigentrust_with_options(input, [PreTrust::new(0, 1.0)], &options).unwrap(); + let sybil_share: f64 = result.scores()[5..].iter().sum(); + assert!(sybil_share < 0.05, "sybils hold {}", sybil_share); +} + +#[test] +fn alpha_zero_on_a_periodic_network_does_not_converge() { + let options = EigenTrustOptions::default() + .with_alpha(0.0) + .with_max_iterations(100); + let err = eigentrust_with_options( + edges(&[(0, 1, 1.0), (1, 0, 1.0)]), + seeds(&[(0, 1.0)]), + &options, + ) + .unwrap_err(); + match err { + EigenTrustError::NotConverged { + iterations, + residual, + } => { + assert_eq!(iterations, 100); + assert!(residual > 1.0); + } + other => panic!("unexpected {}", other), + } + // with any teleport it converges + let options = EigenTrustOptions::default().with_alpha(0.05); + let result = eigentrust_with_options( + edges(&[(0, 1, 1.0), (1, 0, 1.0)]), + seeds(&[(0, 1.0)]), + &options, + ) + .unwrap(); + assert_normalized(&result); +} + +#[test] +fn deterministic() { + let input: Vec = (0..2_000) + .map(|i| TrustEdge::new(i % 997, (i * 7 + 3) % 997, (i % 5 + 1) as f64)) + .collect(); + let a = eigentrust(input.clone(), [PreTrust::new(0, 1.0)]).unwrap(); + let b = eigentrust(input, [PreTrust::new(0, 1.0)]).unwrap(); + assert_eq!(a, b); + assert_normalized(&a); +} + +#[test] +fn errors_are_std_errors_with_messages() { + fn as_std(e: EigenTrustError) -> Box { + Box::new(e) + } + let messages = [ + EigenTrustError::InvalidAlpha(2.0), + EigenTrustError::EmptyNetwork, + EigenTrustError::NegativeWeight { + input: Input::PreTrust, + position: 3, + weight: -1.0, + }, + ] + .map(|e| as_std(e).to_string()); + assert_eq!(messages[0], "alpha must be in [0, 1], got 2"); + assert_eq!(messages[1], "the network has no peers"); + assert_eq!( + messages[2], + "pre-trust item 3: weight -1 is negative; distrust is not supported" + ); +} + +#[test] +fn tuples_convert_into_inputs() { + assert_eq!(TrustEdge::from((1, 2, 0.5)), TrustEdge::new(1, 2, 0.5)); + assert_eq!(PreTrust::from((4, 0.5)), PreTrust::new(4, 0.5)); + let result = eigentrust( + [(0, 1, 1.0), (1, 0, 1.0)].map(TrustEdge::from), + [(0, 1.0)].map(PreTrust::from), + ) + .unwrap(); + assert_normalized(&result); +} diff --git a/tests/csv.rs b/tests/csv.rs new file mode 100644 index 0000000..45ca26c --- /dev/null +++ b/tests/csv.rs @@ -0,0 +1,110 @@ +// eigentrust::csv, used like an external crate would. Needs the `csv` feature. +#![cfg(feature = "csv")] + +use eigentrust::csv::{ErrorKind, Network}; +use eigentrust::{EigenTrustError, EigenTrustOptions, Input}; + +fn rank(lt: &str, pt: &str, alpha: f64) -> Vec<(String, f64)> { + let network = Network::from_csv(lt, pt).unwrap(); + let options = EigenTrustOptions::default().with_alpha(alpha); + let result = network.eigentrust(&options).unwrap(); + network + .ranking(&result) + .into_iter() + .filter(|&(_, s)| s > 0.0) + .map(|(n, s)| (n.to_string(), s)) + .collect() +} + +// Reference values carried over from the original implementation. +#[test] +fn reference_results() { + let lt = + "i,j,v\nalice,bob,11.31571\n2,3,269916.08616\n4,5,3173339.366896588\n6,5,46589750.00759474"; + let pt = "i,v\nalice,0.14285714285714285\nbob,0.14285714285714285\n2,0.14285714285714285\n3,0.14285714285714285\n4,0.14285714285714285\n5,0.14285714285714285\n6,0.14285714285714285"; + let r = rank(lt, pt, 0.5); + assert_eq!(r.len(), 7); + assert_eq!(r[0], ("5".to_string(), 0.22222219873601323)); + assert_eq!(r[1].0, "bob"); + + let r = rank( + "alice,bob,11.31571\n2,3,269916.08616\n4,5,3173339.366896588\n6,5,46589750.00759474", + "alice,1", + 0.5, + ); + assert_eq!(r.len(), 2); + assert_eq!(r[0], ("alice".to_string(), 0.6666666865348816)); +} + +#[test] +fn reference_file() { + let r = rank( + include_str!("../example/localtrust2.csv"), + include_str!("../example/pretrust2.csv"), + 0.5, + ); + assert_eq!(r.len(), 9); + // the file contains duplicate (i, j) records, the last one wins + assert_eq!( + r[0], + ( + "0x84e1056ed1b76fb03b43e924ef98833dba394b2b".to_string(), + 0.40356129084997394 + ) + ); + assert_eq!(r[1].0, "0x9fc3b33884e1d056a8ca979833d686abd267f9f8"); +} + +#[test] +fn peers_are_indexed_by_first_appearance() { + let network = Network::from_csv("b,a\nc,b,2\na,c", "b").unwrap(); + assert_eq!(network.peers(), ["b", "a", "c"]); + assert_eq!(network.local_trust().len(), 3); + assert_eq!(network.local_trust()[1].weight, 2.0); + assert_eq!(network.pre_trust()[0].peer, 0); +} + +#[test] +fn quoted_names() { + let r = rank( + "\"Smith, J\",alice\nalice,\"Smith, J\",2", + "\"Smith, J\"", + 0.5, + ); + assert_eq!(r[0].0, "Smith, J"); +} + +#[test] +fn errors_have_input_and_line() { + let err = Network::from_csv("from,to,weight\na,b,1\n\nb,c,NaN", "a").unwrap_err(); + assert_eq!(err.input(), Input::LocalTrust); + assert_eq!(err.line(), 4); + assert_eq!(err.kind(), &ErrorKind::NonFiniteWeight("NaN".to_string())); + assert_eq!( + err.to_string(), + "local trust CSV, line 4: weight \"NaN\" must be finite" + ); + + let err = Network::from_csv("a,b,1\nb,c,-2", "a").unwrap_err(); + assert!(matches!(err.kind(), ErrorKind::NegativeWeight(_))); + + let err = Network::from_csv("a,b\n", "a\nzed").unwrap_err(); + assert_eq!(err.input(), Input::PreTrust); + assert_eq!(err.line(), 2); + assert_eq!(err.kind(), &ErrorKind::UnknownPeer("zed".to_string())); + + let err = Network::from_csv("a,b,1\njust-one-field", "").unwrap_err(); + assert_eq!(err.kind(), &ErrorKind::TooFewFields); + + let err = Network::from_csv("a,b,1\na,c,x", "").unwrap_err(); + assert_eq!(err.kind(), &ErrorKind::InvalidWeight("x".to_string())); +} + +#[test] +fn empty_input_is_an_empty_network() { + let network = Network::from_csv("", "").unwrap(); + let err = network + .eigentrust(&EigenTrustOptions::default()) + .unwrap_err(); + assert_eq!(err, EigenTrustError::EmptyNetwork); +} diff --git a/wasm/Cargo.toml b/wasm/Cargo.toml new file mode 100644 index 0000000..c53a64c --- /dev/null +++ b/wasm/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "eigentrust-wasm" +version = "0.2.0" +edition = "2021" +authors = ["Jenya "] +license = "MIT OR Apache-2.0" +description = "WebAssembly bindings for the eigentrust crate" +repository = "https://github.com/hypnagonia/eigentrust-rust" +# distributed as a wasm-pack package, see build.sh +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +## Multithreaded build (wasm-bindgen-rayon). Needs nightly, +atomics and a cross-origin +## isolated page, see build.sh and demo/vercel.json. +threads = ["eigentrust/parallel", "dep:wasm-bindgen-rayon"] + +[dependencies] +eigentrust = { version = "0.2.0", path = "..", features = ["csv"] } +serde_json = "1.0" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2.100" +console_error_panic_hook = "0.1" +console_log = "1.0" +log = "0.4" +wasm-bindgen-rayon = { version = "1.3", optional = true } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/wasm/src/bindings.rs b/wasm/src/bindings.rs new file mode 100644 index 0000000..a7c5896 --- /dev/null +++ b/wasm/src/bindings.rs @@ -0,0 +1,34 @@ +// JavaScript-facing exports. Only compiled for wasm32. + +use wasm_bindgen::prelude::*; + +#[cfg(feature = "threads")] +pub use wasm_bindgen_rayon::init_thread_pool; + +#[wasm_bindgen(start)] +fn start() { + std::panic::set_hook(Box::new(console_error_panic_hook::hook)); + // the demo recomputes on every slider move, keep the browser console quiet + let _ = console_log::init_with_level(log::Level::Warn); +} + +/// Ranks peers from CSV bytes. +/// +/// Returns JSON: `{"Ok": [[peer, score], ...]}` with non-zero scores, highest first, +/// or `{"Err": "message"}`. +#[wasm_bindgen] +pub fn run(local_trust: &[u8], pre_trust: &[u8], alpha: f64) -> String { + crate::convert::rank_csv_json(local_trust, pre_trust, alpha) +} + +#[cfg(test)] +mod tests { + use wasm_bindgen_test::wasm_bindgen_test; + + #[wasm_bindgen_test] + fn run_returns_json() { + let out = super::run(b"alice,bob,2\nbob,carol,1\n", b"alice\n", 0.5); + assert!(out.starts_with("{\"Ok\":[[\"alice\","), "{}", out); + assert!(super::run(b"a,b,NaN", b"a", 0.5).starts_with("{\"Err\":")); + } +} diff --git a/wasm/src/convert.rs b/wasm/src/convert.rs new file mode 100644 index 0000000..f9f8856 --- /dev/null +++ b/wasm/src/convert.rs @@ -0,0 +1,71 @@ +use eigentrust::csv::Network; +use eigentrust::EigenTrustOptions; +use serde_json::{json, Value}; + +/// Ranks peers from CSV bytes and returns JSON for JavaScript: +/// `{"Ok": [[peer, score], ...]}` with non-zero scores, highest first, or +/// `{"Err": "message"}`. +pub fn rank_csv_json(local_trust: &[u8], pre_trust: &[u8], alpha: f64) -> String { + let result = match rank(local_trust, pre_trust, alpha) { + Ok(ranking) => json!({ "Ok": ranking }), + Err(message) => json!({ "Err": message }), + }; + result.to_string() +} + +fn rank(local_trust: &[u8], pre_trust: &[u8], alpha: f64) -> Result, String> { + let local_trust = + std::str::from_utf8(local_trust).map_err(|_| "local trust CSV is not valid UTF-8")?; + let pre_trust = + std::str::from_utf8(pre_trust).map_err(|_| "pre-trust CSV is not valid UTF-8")?; + + let network = Network::from_csv(local_trust, pre_trust).map_err(|e| e.to_string())?; + let options = EigenTrustOptions::default().with_alpha(alpha); + let result = network.eigentrust(&options).map_err(|e| e.to_string())?; + + Ok(network + .ranking(&result) + .into_iter() + .filter(|&(_, score)| score > 0.0) + .map(|(name, score)| json!([name, score])) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::rank_csv_json; + use serde_json::Value; + + fn parse(json: &str) -> Value { + serde_json::from_str(json).unwrap() + } + + #[test] + fn ranks_and_sums_to_one() { + let out = parse(&rank_csv_json( + b"alice,bob,2\nbob,carol,1\n", + b"alice\n", + 0.5, + )); + let ranking = out["Ok"].as_array().unwrap(); + let names: Vec<&str> = ranking.iter().map(|e| e[0].as_str().unwrap()).collect(); + assert_eq!(names, ["alice", "bob", "carol"]); + let total: f64 = ranking.iter().map(|e| e[1].as_f64().unwrap()).sum(); + assert!((total - 1.0).abs() < 1e-9); + } + + #[test] + fn errors_become_err() { + for (lt, pt, alpha) in [ + (&b"a,b,NaN"[..], &b"a"[..], 0.5), + (b"a,b,-1", b"a", 0.5), + (b"", b"", 0.5), + (&[0xff, 0xfe], b"a", 0.5), + (b"a,b", b"a", f64::NAN), + (b"a,b", b"a", 2.0), + ] { + let out = parse(&rank_csv_json(lt, pt, alpha)); + assert!(out["Err"].is_string(), "{}", out); + } + } +} diff --git a/wasm/src/lib.rs b/wasm/src/lib.rs new file mode 100644 index 0000000..90c3e0d --- /dev/null +++ b/wasm/src/lib.rs @@ -0,0 +1,15 @@ +//! WebAssembly bindings for the [`eigentrust`] crate. +//! +//! This crate only converts between JavaScript and the native API: CSV bytes in, JSON out. +//! Build it with `wasm-pack` (see `build.sh`); the multithreaded build enables the `threads` +//! feature and exports `initThreadPool`. + +mod convert; + +#[cfg(target_arch = "wasm32")] +mod bindings; + +#[cfg(target_arch = "wasm32")] +pub use bindings::*; + +pub use convert::rank_csv_json; From 35b91e396711779d5f1c1773d91a31aec36c0eea Mon Sep 17 00:00:00 2001 From: jenya Date: Wed, 30 Sep 2026 17:43:23 +0300 Subject: [PATCH 2/4] README: Rust library, CLI and browser sections --- README.md | 105 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 6d1671b..b70f553 100644 --- a/README.md +++ b/README.md @@ -14,44 +14,69 @@

- EigenTrust playground: an interactive trust graph ranked live in the browser with WebAssembly + EigenTrust playground: an interactive trust graph ranked live in the browser with WebAssembly

## What is EigenTrust? -EigenTrust turns local trust ("alice trusts bob") into a global reputation score for every peer. Trust spreads from a few seed peers you already trust, so fake accounts that only vouch for each other get almost nothing. +EigenTrust turns local trust ("alice trusts bob") into a global reputation score for every peer. Trust spreads from a few seed peers you already trust, so fake accounts that only vouch for each other get almost nothing. With no seeds it behaves like PageRank. -- **Input:** who trusts whom (`alice,bob,3`), plus seed peers. -- **Output:** a trust score for every peer. The scores add up to 1. -- **Algorithm:** power iteration on a sparse trust matrix, as in the [EigenTrust paper](https://nlp.stanford.edu/pubs/eigentrust.pdf) (Kamvar, Schlosser, Garcia-Molina, 2003). With no seeds it behaves like PageRank. +It is the algorithm from the [EigenTrust paper](https://nlp.stanford.edu/pubs/eigentrust.pdf) (Kamvar, Schlosser, Garcia-Molina, 2003), used for reputation in peer-to-peer and decentralized networks, Sybil and spam resistance, and ranking accounts or contributors by who vouches for them. -## Use cases +**Try it: [eigentrust.jenyadoesapps.com](https://eigentrust.jenyadoesapps.com)**, a live playground in 10 languages that can also rank 250,000 peers in the browser. -- Reputation systems for peer-to-peer and decentralized networks -- Sybil and spam resistance in social graphs and communities -- Ranking accounts, contributors or nodes by who vouches for them -- Web of trust and endorsement graphs +## Rust library -## Live demo +```toml +[dependencies] +eigentrust = { git = "https://github.com/hypnagonia/eigentrust-rust" } +``` + +```rust +use eigentrust::{eigentrust, PreTrust, TrustEdge}; + +fn main() -> Result<(), eigentrust::EigenTrustError> { + let local_trust = [ + TrustEdge::new(0, 1, 2.0), // peer 0 trusts peer 1 with weight 2 + TrustEdge::new(0, 2, 1.0), + TrustEdge::new(1, 2, 1.0), + TrustEdge::new(2, 0, 1.0), + ]; + let pre_trust = [PreTrust::new(0, 1.0)]; // peer 0 is the seed + + let result = eigentrust(local_trust, pre_trust)?; + for (peer, score) in result.ranking() { + println!("{peer}: {score:.4}"); + } + Ok(()) +} +``` -**[eigentrust.jenyadoesapps.com](https://eigentrust.jenyadoesapps.com)**: build a trust network, move the α slider and watch the ranking update. You can also rank 250,000 peers in the browser. +- `eigentrust_with_options` takes `EigenTrustOptions` for `alpha` (default 0.5), the convergence threshold and the iteration limit. +- Results come back as `TrustScores`: one score per peer, summing to 1, plus the iteration count and residual. +- Errors are a typed `EigenTrustError`. +- Optional features: + - `csv` adds `eigentrust::csv::Network` for named peers from CSV. + - `parallel` runs the iteration on all cores with rayon. +- There are no required dependencies beyond `log`, and the same code builds for `wasm32`. -Available in English, Español, 中文, हिन्दी, العربية, Português, Français, Deutsch, Русский and 日本語. +The crate documentation (`cargo doc --open`) covers the exact input rules and convergence behavior. See also [`examples/`](examples). -## Quick start (CLI) +## Command line ```sh -cargo run --release -- ./example/localtrust.csv ./example/pretrust.csv [alpha] +cargo install --git https://github.com/hypnagonia/eigentrust-rust eigentrust-cli +eigentrust localtrust.csv pretrust.csv [alpha] ``` -``` +```text alice,0.6666666865348816 bob,0.3333333134651184 ``` -α defaults to `0.5`. A higher α keeps trust closer to the seeds. +It prints `peer,score` for every peer with a non-zero score, highest first. Errors go to stderr with exit code 1. -## Use in the browser (WebAssembly) +## Browser (WebAssembly) ```js import init, { run } from './pkg/eigentrust.js' @@ -62,20 +87,22 @@ const result = JSON.parse(run(enc.encode('alice,bob,2\nbob,carol,1\n'), enc.enco // { Ok: [["alice", ...], ["bob", ...], ["carol", ...]] } or { Err: "..." } ``` -Build with `./build.sh`. For a ready-made Web Worker, see [`demo/worker.js`](demo/worker.js). +- `./build.sh` builds `pkg/` and a multithreaded `pkg-parallel/` from the [`wasm`](wasm) crate. +- The multithreaded build needs a cross-origin isolated page; see [`demo/vercel.json`](demo/vercel.json). +- [`demo/worker.js`](demo/worker.js) runs the engine in a Web Worker and picks the right build. -## Input format +## Input rules -| File | Line | Example | +| Input | CSV line | Rust type | | --- | --- | --- | -| Local trust | `from,to[,weight]` | `alice,bob,2` | -| Seeds (pre-trust) | `peer[,weight]` | `alice,1` | +| Local trust | `from,to[,weight]` | `TrustEdge { from, to, weight }` | +| Pre-trust (seeds) | `peer[,weight]` | `PreTrust { peer, weight }` | -- Standard CSV: quoted fields (`"Smith, J"`), a header row, spaces, CRLF and a UTF-8 BOM are fine. -- Weights default to 1 and must be finite and non-negative. Negative trust (distrust) is not supported yet. -- A repeated `from,to` pair or seed peer: the last line wins. -- α must be in [0, 1]. At α = 0 some networks oscillate instead of converging; the engine stops after 10,000 iterations with an error. -- Errors name the file and line, e.g. `local trust CSV, line 5: weight "NaN" must be finite`. +- **Weights:** finite and non-negative, default 1. Zero means no trust. Negative trust (distrust) is rejected. +- **Normalization:** each truster's weights are scaled to sum to 1, and so is pre-trust. With no pre-trust, every peer starts equal. +- **Duplicates:** a repeated edge or seed keeps its last weight. +- **Convergence:** at α = 0 some networks oscillate instead of converging. The engine stops after 10,000 iterations with an error. +- **CSV:** standard CSV, so quoted fields (`"Smith, J"`), a header row, spaces, CRLF and a UTF-8 BOM are fine. Errors name the file and line. ## Performance @@ -84,18 +111,28 @@ Random trust graphs, 10 links per peer, CSV parsing included. | Peers | Links | CLI | Browser | | ---: | ---: | ---: | ---: | | 20,000 | 200,000 | 0.06 s | 58 ms | -| 100,000 | 1,000,000 | 0.4 s | 0.2 s | +| 100,000 | 1,000,000 | 0.3 s | 0.2 s | | 250,000 | 2,500,000 | | 0.6 s | -The browser build is multithreaded when the page is cross-origin isolated (see [`demo/vercel.json`](demo/vercel.json)). +Results are reproducible bit for bit, with or without threads. + +## Repository layout + +| Path | Crate | Role | +| --- | --- | --- | +| [`src/`](src) | `eigentrust` | the library: one implementation of the algorithm | +| [`cli/`](cli) | `eigentrust-cli` | the `eigentrust` command, built on the library's public API | +| [`wasm/`](wasm) | `eigentrust-wasm` | JavaScript bindings, built on the same API | +| [`demo/`](demo) | | the web playground | ## Development ```sh -cargo test --release # tests -./build.sh # WASM builds, copied into demo/ -python3 -m http.server -d demo # run the playground locally -git config core.hooksPath .githooks # once per clone +cargo test --workspace --all-features # tests, including doc tests +cargo run --example basic # library example +./build.sh # WASM builds, copied into demo/ +python3 -m http.server -d demo # run the playground locally +git config core.hooksPath .githooks # once per clone ``` ## License From 3da8c5a47205854881ebcc8fb8f4ebd33c822cbc Mon Sep 17 00:00:00 2001 From: jenya Date: Wed, 30 Sep 2026 17:43:23 +0300 Subject: [PATCH 3/4] CI: feature matrix, docs, packaging, machete, informational semver check --- .github/workflows/ci.yml | 74 +++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cf5940..2dd6aa0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: rustfmt - - run: cargo fmt --check + - run: cargo fmt --all --check clippy: runs-on: ubuntu-latest @@ -26,10 +26,12 @@ jobs: components: clippy targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - - name: Native - run: cargo clippy --release --all-targets -- -D warnings + - name: Native, all features + run: cargo clippy --workspace --all-targets --all-features -- -D warnings + - name: Native, no features + run: cargo clippy -p eigentrust --all-targets -- -D warnings - name: WebAssembly - run: cargo clippy --release --target wasm32-unknown-unknown --all-targets -- -D warnings + run: cargo clippy -p eigentrust -p eigentrust-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings test: runs-on: ubuntu-latest @@ -37,7 +39,31 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo test --release + - name: Every feature combination of the library compiles + run: | + cargo check -p eigentrust --all-targets + cargo check -p eigentrust --all-targets --features csv + cargo check -p eigentrust --all-targets --features parallel + cargo check -p eigentrust --all-targets --all-features + - name: Library without features + run: cargo test -p eigentrust + - name: Workspace, all features (unit, integration and doc tests) + run: cargo test --workspace --all-features + - name: Examples + run: | + cargo run --example basic + cargo run --example csv --features csv + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@nightly + - uses: Swatinem/rust-cache@v2 + - name: Build docs as docs.rs does + run: cargo doc -p eigentrust --all-features --no-deps + env: + RUSTDOCFLAGS: --cfg docsrs -D warnings wasm: runs-on: ubuntu-latest @@ -59,18 +85,48 @@ jobs: with: node-version: 22 - name: Tests in Node - run: wasm-pack test --node --release + run: wasm-pack test --node --release wasm - name: Single-threaded and multithreaded builds run: ./build.sh - audit: + package: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Package and build the published crates from their tarballs + run: cargo package -p eigentrust -p eigentrust-cli + - name: Library package contents + run: cargo package -p eigentrust --list + + dependencies: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-audit,cargo-machete + - name: Security advisories + run: cargo audit + - name: Unused dependencies + run: cargo machete + + # Reports public API changes that need a version bump, compared with main. + # Informational until the first crates.io release; then remove continue-on-error. + semver: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@v2 with: - tool: cargo-audit - - run: cargo audit + tool: cargo-semver-checks + - run: cargo semver-checks check-release -p eigentrust --baseline-rev origin/${{ github.base_ref }} attribution: runs-on: ubuntu-latest From 277744a4ba7e6eda2971e781be1d0ce33a7ff864 Mon Sep 17 00:00:00 2001 From: jenya Date: Wed, 30 Sep 2026 17:43:23 +0300 Subject: [PATCH 4/4] CLAUDE.md: workspace layout and commands --- CLAUDE.md | 52 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 81c76fa..9138ae3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,36 +8,46 @@ - This is enforced by `.githooks/commit-msg` and by CI. Enable the hook once per clone: `git config core.hooksPath .githooks` - Never bypass it with `--no-verify`. +- PRs get squash-merged quickly: start follow-up work from a fresh branch off `main`. ## Project -EigenTrust in Rust, runs natively and as WASM in the browser. +EigenTrust in Rust. Cargo workspace: -- `src/basic/engine.rs` - `calculate_from_csv`: CSV in, sorted `(peer, score)` out -- `src/basic/input.rs` - CSV reading (csv crate), header detection, weight validation -- `src/basic/eigentrust.rs` - power iteration (`compute`), runs on dense vectors -- `src/error.rs` - `Error` enum used everywhere; `Display` gives user-facing messages -- `src/sparse/` - CSR matrix / sparse vector -- `src/lib.rs` - wasm-bindgen entry `run(localtrust, pretrust, alpha)` (wasm32 only) -- `src/main.rs` - native CLI, reports errors as `error: ...` with exit code 1 -- `demo/` - static interactive web demo deployed to Vercel +- `eigentrust` (root, `src/`) - the library. The only implementation of the algorithm. + - `lib.rs` - crate docs, the public API (`eigentrust`, `eigentrust_with_options`, re-exports) + - `types.rs` (`TrustEdge`, `PreTrust`), `options.rs`, `scores.rs` (`TrustScores`), `error.rs` + - `algorithm/` - private: input validation and normalization (`mod.rs`), CSR matrix, + power iteration (`power.rs`) + - `csv.rs` - `csv` feature: named peers from CSV (`csv::Network`), its own error type + - features: `csv`, `parallel` (rayon); default none +- `eigentrust-cli` (`cli/`) - binary `eigentrust`, uses only the public API +- `eigentrust-wasm` (`wasm/`) - JS bindings; `convert.rs` is plain Rust, `bindings.rs` wasm32 only; + `threads` feature for the multithreaded build; `publish = false` +- `demo/` - static web playground deployed to Vercel + +Keep the public surface small: modules private by default, export through `lib.rs`, +`#![warn(missing_docs)]` is on. ## Commands -- Test: `cargo test --release` -- WASM tests: `wasm-pack test --node --release` -- Lint: `cargo fmt --check`, `cargo clippy --release --all-targets -- -D warnings` - (also with `--target wasm32-unknown-unknown`) -- CLI: `cargo run --release -- ./example/localtrust.csv ./example/pretrust.csv [alpha]` -- WASM: `./build.sh` (builds `pkg/` and `pkg-parallel/` and copies them into `demo/`) +- Test: `cargo test --workspace --all-features` (unit, integration, doc tests incl. README) +- Library alone: `cargo test -p eigentrust` +- WASM tests: `wasm-pack test --node --release wasm` +- Lint: `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, + and `cargo clippy -p eigentrust -p eigentrust-wasm --target wasm32-unknown-unknown --all-targets -- -D warnings` +- Docs: `RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo +nightly doc -p eigentrust --all-features --no-deps` +- Package: `cargo package -p eigentrust -p eigentrust-cli`, check `cargo package -p eigentrust --list` +- CLI: `cargo run --release -p eigentrust-cli -- ./example/localtrust.csv ./example/pretrust.csv [alpha]` +- WASM: `./build.sh` (builds `pkg/` and `pkg-parallel/` from `wasm/` and copies them into `demo/`) - Demo locally: `python3 -m http.server -d demo` then open http://localhost:8000 - Deploy: `vercel deploy --prod` from `demo/` ## Notes -- Tests compare floats with exact equality. The iteration uses Kahan-Babuska-Neumaier summation - in row order; keep the summation order if you touch `compute` / `mul_dense`. -- Duplicate `(i, j)` local trust records and duplicate pre-trust peers: the last one wins. -- Weights must be finite and non-negative. Distrust (negative weights) is rejected until its - effect on the scores is defined; `extract_distrust` / `discount_trust_vector` are kept for that. -- `compute` stops after `DEFAULT_MAX_ITERATIONS` (10,000) with `Error::NotConverged`. +- Results must stay bit-identical: compensated (KBN) sums in a fixed order in `power.rs`, plain + sums for normalization, stable sorts. Tests compare floats exactly. +- Duplicate edges and duplicate pre-trust peers: the last one wins. +- Weights must be finite and non-negative; distrust (negative weights) is rejected. +- The iteration stops after `max_iterations` (default 10,000) with `EigenTrustError::NotConverged`. +- Version 0.2.0 is not on crates.io yet; the CI semver job is informational until it is.