diff --git a/neurons/.cargo/config.toml b/neurons/.cargo/config.toml new file mode 100644 index 0000000..c0a269b --- /dev/null +++ b/neurons/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +lint = "clippy --all-targets --all-features -- -W clippy::pedantic -A clippy::missing_errors_doc -A clippy::module_name_repetitions" diff --git a/neurons/.gitignore b/neurons/.gitignore new file mode 100644 index 0000000..e190553 --- /dev/null +++ b/neurons/.gitignore @@ -0,0 +1,3 @@ +data +target +result diff --git a/neurons/Cargo.lock b/neurons/Cargo.lock new file mode 100644 index 0000000..aec43f5 --- /dev/null +++ b/neurons/Cargo.lock @@ -0,0 +1,115 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anyhow" +version = "1.0.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" + +[[package]] +name = "camino" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59e92b5a388f549b863a7bea62612c09f24c8393560709a54558a9abdfb3b9c" + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "neurons" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" + +[[package]] +name = "serde" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.197" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2e6b945e9d3df726b65d6ee24060aff8e3533d431f677a9695db04eff9dfdb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" diff --git a/neurons/Cargo.toml b/neurons/Cargo.toml new file mode 100644 index 0000000..b572515 --- /dev/null +++ b/neurons/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "neurons" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.80" +camino = "1.1.6" +serde = { version = "1.0.197", features = ["derive"] } +serde_json = "1.0.114" +serde_repr = "0.1.18" diff --git a/neurons/README.md b/neurons/README.md new file mode 100644 index 0000000..7fedf8b --- /dev/null +++ b/neurons/README.md @@ -0,0 +1,37 @@ +# Neurons + +This code is an implementation of `neurons` +from the [Neural Quorum Governance](https://stellarcommunityfund.gitbook.io/module-library). +The data computed by this package is uploaded to the voting contract. + +This is the source code used to calculate voting powers for each neuron used in NQG mechanism. +It also normalized the votes of voters (converts delegations to final votes) for them to be uploaded to the contract. + +## Inputs + +Neurons expect the inputs to be provided in `json` format. The inputs are loaded from `data/` directory. + +## Outputs + +Computed voting powers and normalized votes are written to `result/` directory. + +## Running + +```shell +cargo run +``` + +## Neurons + +### Assigned Reputation Neuron + +Assigns voting power based on voter discord rank. + +### Prior Voting History Neuron + +Assigns voting power based on rounds voter previously participated in. + +### Trust Graph Neuron + +Assigns voting power based on trust assigned to voter by other voters. +It uses min-max normalized PageRank algorithm to compute the score. diff --git a/neurons/src/lib.rs b/neurons/src/lib.rs new file mode 100644 index 0000000..738003b --- /dev/null +++ b/neurons/src/lib.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +pub mod neurons; +pub mod quorum; + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub enum Vote { + Yes, + No, + Delegate, + Abstain, +} diff --git a/neurons/src/main.rs b/neurons/src/main.rs new file mode 100644 index 0000000..7e35d04 --- /dev/null +++ b/neurons/src/main.rs @@ -0,0 +1,79 @@ +use camino::Utf8Path; +use neurons::neurons::assigned_reputation::AssignedReputationNeuron; +use neurons::neurons::prior_voting_history::PriorVotingHistoryNeuron; +use neurons::neurons::trust_graph::TrustGraphNeuron; +use neurons::neurons::Neuron; +use neurons::quorum::normalize_votes; +use neurons::Vote; +use serde::Serialize; +use std::collections::{BTreeMap, HashMap}; +use std::fs; + +pub const DECIMALS: i64 = 1_000_000_000_000_000_000; + +fn write_result(file_name: &str, data: &T) +where + T: Serialize, +{ + let serialized = serde_json::to_string(&data).unwrap(); + fs::write(file_name, serialized).unwrap(); +} + +fn to_sorted_map(data: HashMap) -> BTreeMap +where + K: Ord, +{ + data.into_iter().collect() +} + +#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)] +fn to_fixed_point_decimal(val: f64) -> i128 { + (val * DECIMALS as f64) as i128 +} + +fn calculate_neuron_results(users: &[String], neurons: Vec>) { + for neuron in neurons { + let result = neuron.calculate_result(users); + let result: HashMap = result + .into_iter() + .map(|(key, value)| (key, to_fixed_point_decimal(value).to_string())) + .collect(); + let result = to_sorted_map(result); + write_result(&format!("result/{}.json", neuron.name()), &result); + } +} + +fn main() { + let path = Utf8Path::new("data/previous_rounds_for_users.json"); + let prior_voting_history_neuron = PriorVotingHistoryNeuron::try_from_file(path).unwrap(); + + let path = Utf8Path::new("data/users_reputation.json"); + let assigned_reputation_neuron = AssignedReputationNeuron::try_from_file(path).unwrap(); + + let path = Utf8Path::new("data/trusted_for_user.json"); + let trust_graph_neuron = TrustGraphNeuron::try_from_file(path).unwrap(); + + let users_raw = fs::read_to_string("data/voters.json").unwrap(); + let users: Vec = serde_json::from_str(users_raw.as_str()).unwrap(); + + let votes_raw = fs::read_to_string("data/votes.json").unwrap(); + let votes: HashMap> = + serde_json::from_str(votes_raw.as_str()).unwrap(); + let delegatees_for_user_raw = fs::read_to_string("data/delegatees_for_user.json").unwrap(); + let delegatees_for_user: HashMap> = + serde_json::from_str(delegatees_for_user_raw.as_str()).unwrap(); + let normalized_votes = normalize_votes(votes, &delegatees_for_user).unwrap(); + write_result( + "result/normalized_votes.json", + &to_sorted_map(normalized_votes), + ); + + calculate_neuron_results( + &users, + vec![ + Box::new(prior_voting_history_neuron), + Box::new(assigned_reputation_neuron), + Box::new(trust_graph_neuron), + ], + ); +} diff --git a/neurons/src/neurons.rs b/neurons/src/neurons.rs new file mode 100644 index 0000000..8b1c9e8 --- /dev/null +++ b/neurons/src/neurons.rs @@ -0,0 +1,11 @@ +use std::collections::HashMap; + +pub mod assigned_reputation; +pub mod prior_voting_history; +pub mod trust_graph; + +pub trait Neuron { + fn name(&self) -> String; + + fn calculate_result(&self, users: &[String]) -> HashMap; +} diff --git a/neurons/src/neurons/assigned_reputation.rs b/neurons/src/neurons/assigned_reputation.rs new file mode 100644 index 0000000..d8b78e2 --- /dev/null +++ b/neurons/src/neurons/assigned_reputation.rs @@ -0,0 +1,58 @@ +use crate::neurons::Neuron; +use anyhow::Result; +use camino::Utf8Path; +use serde_repr::Deserialize_repr; +use std::collections::HashMap; +use std::fs::File; +use std::io::BufReader; + +#[derive(Deserialize_repr, Clone, Debug)] +#[repr(i32)] +pub enum ReputationTier { + Unknown = -1, + Verified = 0, + Pathfinder = 1, + Navigator = 2, + Pilot = 3, +} + +#[derive(Clone, Debug)] +pub struct AssignedReputationNeuron { + users_reputation: HashMap, +} + +impl AssignedReputationNeuron { + pub fn try_from_file(path: &Utf8Path) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let users_reputation = serde_json::from_reader(reader)?; + Ok(Self { users_reputation }) + } +} + +fn reputation_bonus(reputation_tier: &ReputationTier) -> f64 { + match reputation_tier { + ReputationTier::Unknown | ReputationTier::Verified => 0.0, + ReputationTier::Pathfinder => 0.1, + ReputationTier::Navigator => 0.2, + ReputationTier::Pilot => 0.3, + } +} + +impl Neuron for AssignedReputationNeuron { + fn name(&self) -> String { + "assigned_reputation_neuron".to_string() + } + + fn calculate_result(&self, users: &[String]) -> HashMap { + let mut result = HashMap::new(); + + for user in users { + let bonus = reputation_bonus(self.users_reputation.get(user).unwrap()); + result.insert(user.into(), bonus); + } + + result + } +} diff --git a/neurons/src/neurons/prior_voting_history.rs b/neurons/src/neurons/prior_voting_history.rs new file mode 100644 index 0000000..d494020 --- /dev/null +++ b/neurons/src/neurons/prior_voting_history.rs @@ -0,0 +1,64 @@ +use crate::neurons::Neuron; +use anyhow::Result; +use camino::Utf8Path; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs::File; +use std::io::BufReader; + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +#[derive(Clone, Debug)] +pub struct PriorVotingHistoryNeuron { + users_round_history: HashMap>, +} + +impl PriorVotingHistoryNeuron { + // FIXME this design is not scalable, what if there are multiple files required + pub fn try_from_file(path: &Utf8Path) -> Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let users_round_history = serde_json::from_reader(reader)?; + Ok(Self { + users_round_history, + }) + } +} + +fn round_bonus(round: u32) -> f64 { + match round { + 21.. => 0.1, + _ => 0.0, + } +} + +fn calculate_bonus(rounds_participated: &[u32]) -> f64 { + rounds_participated + .iter() + .map(|round| round_bonus(*round)) + .sum() +} + +impl Neuron for PriorVotingHistoryNeuron { + fn name(&self) -> String { + "prior_voting_history_neuron".to_string() + } + + fn calculate_result(&self, users: &[String]) -> HashMap { + let mut result = HashMap::new(); + + for user in users { + let bonus = calculate_bonus( + &self + .users_round_history + .get(user) + .cloned() + .unwrap_or_else(Vec::new), + ); + result.insert(user.into(), bonus); + } + + result + } +} diff --git a/neurons/src/neurons/trust_graph.rs b/neurons/src/neurons/trust_graph.rs new file mode 100644 index 0000000..54b3a41 --- /dev/null +++ b/neurons/src/neurons/trust_graph.rs @@ -0,0 +1,135 @@ +use crate::neurons::Neuron; +use camino::Utf8Path; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::io::BufReader; + +#[derive(Clone, Debug)] +pub struct TrustGraphNeuron { + trusted_for_user: HashMap>, +} + +impl TrustGraphNeuron { + pub fn try_from_file(path: &Utf8Path) -> anyhow::Result { + let file = File::open(path)?; + let reader = BufReader::new(file); + + let trusted_for_user = serde_json::from_reader(reader)?; + Ok(Self { trusted_for_user }) + } +} + +#[allow(clippy::cast_precision_loss)] +fn calculate_page_rank( + nodes: &Vec, + edges: &Vec<(String, Vec)>, + iterations: u32, + damping_factor: f64, +) -> HashMap { + let mut page_ranks: HashMap = HashMap::new(); + for node in nodes { + page_ranks.insert(node.clone(), 1.0 / nodes.len() as f64); + } + for _ in 0..iterations { + let mut new_ranks: HashMap = HashMap::new(); + for node in nodes { + let mut rank = (1.0 - damping_factor) / nodes.len() as f64; + for (other_node, other_node_edges) in edges { + if other_node_edges.contains(node) { + let pr = page_ranks.get(other_node).unwrap_or(&0.0); + rank += (damping_factor * pr) / other_node_edges.len() as f64; + } + } + new_ranks.insert(node.clone(), rank); + } + page_ranks = new_ranks; + } + + page_ranks +} + +fn min_max_normalize_result(result: HashMap) -> HashMap { + let min = result.values().copied().reduce(f64::min).unwrap(); + let max = result.values().copied().reduce(f64::max).unwrap(); + + result + .into_iter() + .map(|(key, value)| { + let new_value = (value - min) / (max - min); + (key, new_value) + }) + .collect() +} + +impl Neuron for TrustGraphNeuron { + fn name(&self) -> String { + "trust_graph_neuron".to_string() + } + + fn calculate_result(&self, users: &[String]) -> HashMap { + let mut result = HashMap::new(); + + let mut nodes = HashSet::new(); + let mut edges = Vec::new(); + + for (user, edge) in &self.trusted_for_user { + nodes.insert(user.clone()); + for other_user in edge { + nodes.insert(other_user.clone()); + } + edges.push((user.clone(), edge.clone())); + } + let nodes: Vec = nodes.into_iter().collect(); + + let page_rank_result = calculate_page_rank(&nodes, &edges, 1000, 0.85); + let page_rank_result = min_max_normalize_result(page_rank_result); + + for user in users { + let page_rank = *page_rank_result.get(user).unwrap_or(&0.0); + result.insert(user.into(), page_rank); + } + + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! assert_f64_near { + ( $a:expr, $b:expr ) => { + let eps = 0.001f64; + assert!( + ($a - $b).abs() < eps, + "Values a = {}, b = {} are not near", + $a, + $b + ); + }; + } + + #[test] + fn simple() { + let mut trusted_for_user = HashMap::new(); + trusted_for_user.insert("A".to_string(), vec!["B".to_string(), "C".to_string()]); + trusted_for_user.insert("B".to_string(), vec!["A".to_string()]); + trusted_for_user.insert("C".to_string(), vec!["A".to_string(), "B".to_string()]); + trusted_for_user.insert("D".to_string(), vec!["A".to_string()]); + trusted_for_user.insert("E".to_string(), vec![]); + + let trust_graph_neuron = TrustGraphNeuron { trusted_for_user }; + let result = trust_graph_neuron.calculate_result( + &["A", "B", "C", "D", "E"] + .into_iter() + .map(std::string::ToString::to_string) + .collect::>(), + ); + + assert_f64_near!(result.get("A").unwrap(), &1.0); + assert_f64_near!(result.get("B").unwrap(), &0.704); + assert_f64_near!(result.get("C").unwrap(), &0.465); + assert_f64_near!(result.get("D").unwrap(), &0.0); + assert_f64_near!(result.get("E").unwrap(), &0.0); + } +} diff --git a/neurons/src/quorum.rs b/neurons/src/quorum.rs new file mode 100644 index 0000000..0b87a46 --- /dev/null +++ b/neurons/src/quorum.rs @@ -0,0 +1,105 @@ +use crate::Vote; +use anyhow::{anyhow, bail, Result}; +use std::collections::HashMap; + +const QUORUM_SIZE: u32 = 5; +const QUORUM_ABSOLUTE_PARTICIPATION_THRESHOLD: f64 = 1.0 / 2.0; +const QUORUM_RELATIVE_PARTICIPATION_THRESHOLD: f64 = 2.0 / 3.0; + +#[allow(clippy::implicit_hasher)] +pub fn normalize_votes( + votes: HashMap>, + delegatees_for_user: &HashMap>, +) -> Result>> { + votes + .into_iter() + .map(|(submission, submission_votes)| { + let submission_votes = + normalize_votes_for_submission(&submission_votes, delegatees_for_user)?; + Ok((submission, submission_votes)) + }) + .collect::>() +} + +fn normalize_votes_for_submission( + submission_votes: &HashMap, + delegatees_for_user: &HashMap>, +) -> Result> { + submission_votes + .clone() + .into_iter() + .map(|(user, vote)| { + if vote == Vote::Delegate { + let delegatees = delegatees_for_user + .get(&user) + .ok_or_else(|| anyhow!("Delegatees missing for user {user}"))?; + let normalized_vote = calculate_quorum_consensus(delegatees, submission_votes)?; + Ok((user, normalized_vote)) + } else { + Ok((user, vote)) + } + }) + .collect::>() +} + +fn calculate_quorum_consensus( + delegatees: &[String], + submission_votes: &HashMap, +) -> Result { + let valid_delegates: Vec<&String> = delegatees + .iter() + .filter(|delegatee| { + let delegatee_vote = submission_votes.get(*delegatee).unwrap_or(&Vote::Abstain); + matches!(delegatee_vote, Vote::Yes | Vote::No) + }) + .collect(); + + let selected_delegatees = if valid_delegates.len() < QUORUM_SIZE as usize { + valid_delegates.as_slice() + } else { + &valid_delegates[..QUORUM_SIZE as usize] + }; + + let mut quorum_size = 0; + let mut agreement: i32 = 0; + for &delegatee in selected_delegatees { + let delegatee_vote = submission_votes.get(delegatee).unwrap_or(&Vote::Abstain); + + if delegatee_vote == &Vote::Delegate { + continue; + } + + quorum_size += 1; + match delegatee_vote { + Vote::Yes => agreement += 1, + Vote::No => agreement -= 1, + Vote::Abstain => {} + Vote::Delegate => { + bail!("Invalid delegatee operation"); + } + }; + } + + let absolute_agreement: f64 = f64::from(agreement) / f64::from(QUORUM_SIZE); + let relative_agreement: f64 = if quorum_size > 0 { + f64::from(agreement) / f64::from(quorum_size) + } else { + 0.0 + }; + + Ok( + if absolute_agreement.abs() > QUORUM_ABSOLUTE_PARTICIPATION_THRESHOLD { + if relative_agreement.abs() > QUORUM_RELATIVE_PARTICIPATION_THRESHOLD { + if relative_agreement > 0.0 { + Vote::Yes + } else { + Vote::No + } + } else { + Vote::Abstain + } + } else { + Vote::Abstain + }, + ) +}