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
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/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.
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/README.md b/README.md
index 6d1671b..b70f553 100644
--- a/README.md
+++ b/README.md
@@ -14,44 +14,69 @@
-
+
## 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
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;