diff --git a/Cargo.Bazel.Fuzzing.json.lock b/Cargo.Bazel.Fuzzing.json.lock index 08fd98a11dc..c4032abf4a2 100644 --- a/Cargo.Bazel.Fuzzing.json.lock +++ b/Cargo.Bazel.Fuzzing.json.lock @@ -1,5 +1,5 @@ { - "checksum": "befed3db2258ef5e97774c44951feb5c8ca098af84913123d4a8945afeac827c", + "checksum": "35ef034b295f0b24c8651990aa834145ac9dd68a479ab6ebca823a9cd5256a0d", "crates": { "abnf 0.12.0": { "name": "abnf", @@ -1465,6 +1465,8 @@ ], "crate_features": { "common": [ + "compile-time-rng", + "const-random", "default", "getrandom", "runtime-rng", @@ -1482,6 +1484,10 @@ "id": "cfg-if 1.0.0", "target": "cfg_if" }, + { + "id": "const-random 0.1.18", + "target": "const_random" + }, { "id": "getrandom 0.2.10", "target": "getrandom" @@ -13827,6 +13833,110 @@ ], "license_file": "LICENSE-APACHE" }, + "const-random 0.1.18": { + "name": "const-random", + "version": "0.1.18", + "package_url": "https://github.com/tkaitchuck/constrandom", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/const-random/0.1.18/download", + "sha256": "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" + } + }, + "targets": [ + { + "Library": { + "crate_name": "const_random", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "const_random", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "edition": "2018", + "proc_macro_deps": { + "common": [ + { + "id": "const-random-macro 0.1.16", + "target": "const_random_macro" + } + ], + "selects": {} + }, + "version": "0.1.18" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "LICENSE-APACHE" + }, + "const-random-macro 0.1.16": { + "name": "const-random-macro", + "version": "0.1.16", + "package_url": "https://github.com/tkaitchuck/constrandom", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/const-random-macro/0.1.16/download", + "sha256": "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" + } + }, + "targets": [ + { + "ProcMacro": { + "crate_name": "const_random_macro", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "const_random_macro", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "deps": { + "common": [ + { + "id": "getrandom 0.2.10", + "target": "getrandom" + }, + { + "id": "once_cell 1.19.0", + "target": "once_cell" + }, + { + "id": "tiny-keccak 2.0.2", + "target": "tiny_keccak" + } + ], + "selects": {} + }, + "edition": "2018", + "version": "0.1.16" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "LICENSE-APACHE" + }, "convert_case 0.4.0": { "name": "convert_case", "version": "0.4.0", @@ -18351,6 +18461,10 @@ "id": "addr 0.15.6", "target": "addr" }, + { + "id": "ahash 0.8.11", + "target": "ahash" + }, { "id": "aide 0.13.4", "target": "aide" @@ -72259,46 +72373,60 @@ ], "selects": { "aarch64-apple-darwin": [ - "sha3" + "sha3", + "shake" ], "aarch64-pc-windows-msvc": [ - "sha3" + "sha3", + "shake" ], "aarch64-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "aarch64-unknown-nixos-gnu": [ - "sha3" + "sha3", + "shake" ], "arm-unknown-linux-gnueabi": [ - "sha3" + "sha3", + "shake" ], "i686-pc-windows-msvc": [ - "sha3" + "sha3", + "shake" ], "i686-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "powerpc-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "s390x-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "x86_64-apple-darwin": [ - "sha3" + "sha3", + "shake" ], "x86_64-pc-windows-msvc": [ - "sha3" + "sha3", + "shake" ], "x86_64-unknown-freebsd": [ - "sha3" + "sha3", + "shake" ], "x86_64-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "x86_64-unknown-nixos-gnu": [ - "sha3" + "sha3", + "shake" ] } }, @@ -87303,6 +87431,7 @@ "actix-rt 2.10.0", "actix-web 4.9.0", "addr 0.15.6", + "ahash 0.8.11", "aide 0.13.4", "anyhow 1.0.93", "arbitrary 1.3.2", diff --git a/Cargo.Bazel.Fuzzing.toml.lock b/Cargo.Bazel.Fuzzing.toml.lock index 05fafc63a0d..97dd1876a7b 100644 --- a/Cargo.Bazel.Fuzzing.toml.lock +++ b/Cargo.Bazel.Fuzzing.toml.lock @@ -275,6 +275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if 1.0.0", + "const-random", "getrandom", "once_cell", "version_check", @@ -2276,6 +2277,26 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -2972,6 +2993,7 @@ dependencies = [ "actix-rt", "actix-web", "addr", + "ahash 0.8.11", "aide", "anyhow", "arbitrary", diff --git a/Cargo.Bazel.json.lock b/Cargo.Bazel.json.lock index b5e8dae02b1..28157296b55 100644 --- a/Cargo.Bazel.json.lock +++ b/Cargo.Bazel.json.lock @@ -1,5 +1,5 @@ { - "checksum": "16c350a57ca08666035e4f0e31f17c9715860d84ed6755b026870a84de503a09", + "checksum": "fe8b0cdc955eee4e9086b15b5cfbbce1f3c847dfb81b961cb895dd6b1d026db8", "crates": { "abnf 0.12.0": { "name": "abnf", @@ -1469,6 +1469,8 @@ ], "crate_features": { "common": [ + "compile-time-rng", + "const-random", "default", "getrandom", "runtime-rng", @@ -1486,6 +1488,10 @@ "id": "cfg-if 1.0.0", "target": "cfg_if" }, + { + "id": "const-random 0.1.18", + "target": "const_random" + }, { "id": "getrandom 0.2.10", "target": "getrandom" @@ -13655,6 +13661,110 @@ ], "license_file": "LICENSE-APACHE" }, + "const-random 0.1.18": { + "name": "const-random", + "version": "0.1.18", + "package_url": "https://github.com/tkaitchuck/constrandom", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/const-random/0.1.18/download", + "sha256": "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" + } + }, + "targets": [ + { + "Library": { + "crate_name": "const_random", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "const_random", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "edition": "2018", + "proc_macro_deps": { + "common": [ + { + "id": "const-random-macro 0.1.16", + "target": "const_random_macro" + } + ], + "selects": {} + }, + "version": "0.1.18" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "LICENSE-APACHE" + }, + "const-random-macro 0.1.16": { + "name": "const-random-macro", + "version": "0.1.16", + "package_url": "https://github.com/tkaitchuck/constrandom", + "repository": { + "Http": { + "url": "https://static.crates.io/crates/const-random-macro/0.1.16/download", + "sha256": "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" + } + }, + "targets": [ + { + "ProcMacro": { + "crate_name": "const_random_macro", + "crate_root": "src/lib.rs", + "srcs": { + "allow_empty": true, + "include": [ + "**/*.rs" + ] + } + } + } + ], + "library_target_name": "const_random_macro", + "common_attrs": { + "compile_data_glob": [ + "**" + ], + "deps": { + "common": [ + { + "id": "getrandom 0.2.10", + "target": "getrandom" + }, + { + "id": "once_cell 1.19.0", + "target": "once_cell" + }, + { + "id": "tiny-keccak 2.0.2", + "target": "tiny_keccak" + } + ], + "selects": {} + }, + "edition": "2018", + "version": "0.1.16" + }, + "license": "MIT OR Apache-2.0", + "license_ids": [ + "Apache-2.0", + "MIT" + ], + "license_file": "LICENSE-APACHE" + }, "convert_case 0.4.0": { "name": "convert_case", "version": "0.4.0", @@ -18179,6 +18289,10 @@ "id": "addr 0.15.6", "target": "addr" }, + { + "id": "ahash 0.8.11", + "target": "ahash" + }, { "id": "aide 0.13.4", "target": "aide" @@ -72105,46 +72219,60 @@ ], "selects": { "aarch64-apple-darwin": [ - "sha3" + "sha3", + "shake" ], "aarch64-pc-windows-msvc": [ - "sha3" + "sha3", + "shake" ], "aarch64-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "aarch64-unknown-nixos-gnu": [ - "sha3" + "sha3", + "shake" ], "arm-unknown-linux-gnueabi": [ - "sha3" + "sha3", + "shake" ], "i686-pc-windows-msvc": [ - "sha3" + "sha3", + "shake" ], "i686-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "powerpc-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "s390x-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "x86_64-apple-darwin": [ - "sha3" + "sha3", + "shake" ], "x86_64-pc-windows-msvc": [ - "sha3" + "sha3", + "shake" ], "x86_64-unknown-freebsd": [ - "sha3" + "sha3", + "shake" ], "x86_64-unknown-linux-gnu": [ - "sha3" + "sha3", + "shake" ], "x86_64-unknown-nixos-gnu": [ - "sha3" + "sha3", + "shake" ] } }, @@ -87182,6 +87310,7 @@ "actix-rt 2.10.0", "actix-web 4.9.0", "addr 0.15.6", + "ahash 0.8.11", "aide 0.13.4", "anyhow 1.0.93", "arbitrary 1.3.2", diff --git a/Cargo.Bazel.toml.lock b/Cargo.Bazel.toml.lock index c017af7c37a..68006a8f02c 100644 --- a/Cargo.Bazel.toml.lock +++ b/Cargo.Bazel.toml.lock @@ -276,6 +276,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if 1.0.0", + "const-random", "getrandom", "once_cell", "version_check", @@ -2265,6 +2266,26 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "795bc6e66a8e340f075fcf6227e417a2dc976b92b91f3cdc778bb858778b6747" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -2961,6 +2982,7 @@ dependencies = [ "actix-rt", "actix-web", "addr", + "ahash 0.8.11", "aide", "anyhow", "arbitrary", diff --git a/Cargo.lock b/Cargo.lock index 9b761dcc7bc..2ac67a341d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11164,7 +11164,12 @@ name = "ic-registry-node-provider-rewards" version = "0.9.0" dependencies = [ "ic-base-types", + "ic-management-canister-types", "ic-protobuf", + "itertools 0.12.1", + "num-traits", + "rust_decimal", + "rust_decimal_macros", ] [[package]] diff --git a/bazel/external_crates.bzl b/bazel/external_crates.bzl index a62c47466ce..3e799182160 100644 --- a/bazel/external_crates.bzl +++ b/bazel/external_crates.bzl @@ -179,6 +179,13 @@ def external_crates_repository(name, cargo_lockfile, lockfile, sanitizers_enable "idna", ], ), + "ahash": crate.spec( + version = "^0.8.11", + default_features = False, + features = [ + "compile-time-rng", + ], + ), "aide": crate.spec( version = "^0.13.4", features = [ diff --git a/rs/registry/node_provider_rewards/BUILD.bazel b/rs/registry/node_provider_rewards/BUILD.bazel index 866b7e44949..967402130c9 100644 --- a/rs/registry/node_provider_rewards/BUILD.bazel +++ b/rs/registry/node_provider_rewards/BUILD.bazel @@ -4,17 +4,26 @@ package(default_visibility = ["//visibility:public"]) DEPENDENCIES = [ "//rs/types/base_types", + "//rs/types/management_canister_types", "//rs/protobuf", + "@crate_index//:ahash", + "@crate_index//:num-traits", + "@crate_index//:itertools", + "@crate_index//:lazy_static", + "@crate_index//:rust_decimal", + "@crate_index//:serde", + "@crate_index//:candid", ] -DEV_DEPENDENCIES = [ - "@crate_index//:maplit", +MACRO_DEPENDENCIES = [ + "@crate_index//:rust_decimal_macros", ] rust_library( name = "node_provider_rewards", srcs = glob(["src/**/*.rs"]), crate_name = "ic_registry_node_provider_rewards", + proc_macro_deps = MACRO_DEPENDENCIES, version = "0.9.0", deps = DEPENDENCIES, ) @@ -22,5 +31,5 @@ rust_library( rust_test( name = "node_provider_rewards_test", crate = ":node_provider_rewards", - deps = DEPENDENCIES + DEV_DEPENDENCIES, + deps = DEPENDENCIES, ) diff --git a/rs/registry/node_provider_rewards/Cargo.toml b/rs/registry/node_provider_rewards/Cargo.toml index c5057697970..6cfdfba548e 100644 --- a/rs/registry/node_provider_rewards/Cargo.toml +++ b/rs/registry/node_provider_rewards/Cargo.toml @@ -7,5 +7,10 @@ documentation.workspace = true edition.workspace = true [dependencies] -ic-base-types = { path = "../../types/base_types/" } +ic-base-types = { path = "../../types/base_types" } ic-protobuf = { path = "../../protobuf" } +itertools = { workspace = true } +num-traits = { workspace = true } +rust_decimal = "1.36.0" +rust_decimal_macros = "1.36.0" +ic-management-canister-types = { path = "../../types/management_canister_types" } diff --git a/rs/registry/node_provider_rewards/src/lib.rs b/rs/registry/node_provider_rewards/src/lib.rs index e632de2984b..5db103ba36a 100644 --- a/rs/registry/node_provider_rewards/src/lib.rs +++ b/rs/registry/node_provider_rewards/src/lib.rs @@ -1,12 +1,15 @@ +use crate::logs::{LogEntry, RewardsPerNodeProviderLog}; use ic_base_types::PrincipalId; use ic_protobuf::registry::{ dc::v1::DataCenterRecord, node_operator::v1::NodeOperatorRecord, node_rewards::v2::{NodeRewardRate, NodeRewardsTable}, }; -use logs::{LogEntry, RewardsPerNodeProviderLog}; use std::collections::{BTreeMap, HashMap}; pub mod logs; +pub mod v1_logs; +pub mod v1_rewards; +pub mod v1_types; pub struct RewardsPerNodeProvider { pub rewards_per_node_provider: BTreeMap, diff --git a/rs/registry/node_provider_rewards/src/v1_logs.rs b/rs/registry/node_provider_rewards/src/v1_logs.rs new file mode 100644 index 00000000000..67c7ffeb287 --- /dev/null +++ b/rs/registry/node_provider_rewards/src/v1_logs.rs @@ -0,0 +1,278 @@ +use ic_base_types::{NodeId, PrincipalId}; +use itertools::Itertools; +use rust_decimal::{prelude::Zero, Decimal}; +use std::fmt; + +fn round_dp_4(dec: &Decimal) -> Decimal { + dec.round_dp(4) +} + +#[derive(Clone)] +pub enum Operation { + Sum(Vec), + Avg(Vec), + Subtract(Decimal, Decimal), + Multiply(Decimal, Decimal), + Divide(Decimal, Decimal), + Set(Decimal), + SumOps(Vec), +} + +impl Operation { + fn sum(operators: &[Decimal]) -> Decimal { + operators.iter().fold(Decimal::zero(), |acc, val| acc + val) + } + + fn format_values(items: &[T], prefix: &str) -> String { + if items.is_empty() { + "0".to_string() + } else { + format!( + "{}({})", + prefix, + items.iter().map(|item| format!("{}", item)).join(","), + ) + } + } + + fn execute(&self) -> Decimal { + match self { + Operation::Sum(operators) => Self::sum(operators), + Operation::Avg(operators) => { + Self::sum(operators) / Decimal::from(operators.len().max(1)) + } + Operation::Subtract(o1, o2) => o1 - o2, + Operation::Divide(o1, o2) => o1 / o2, + Operation::Multiply(o1, o2) => o1 * o2, + Operation::Set(o1) => *o1, + Operation::SumOps(operations) => Self::sum( + &operations + .iter() + .map(|operation| operation.execute()) + .collect_vec(), + ), + } + } +} + +impl fmt::Display for Operation { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let (symbol, o1, o2) = match self { + Operation::Sum(values) => { + return write!( + f, + "{}", + Operation::format_values(&values.iter().map(round_dp_4).collect_vec(), "sum") + ) + } + Operation::SumOps(operations) => { + return write!(f, "{}", Operation::format_values(operations, "sum")) + } + Operation::Avg(values) => { + return write!( + f, + "{}", + Operation::format_values(&values.iter().map(round_dp_4).collect_vec(), "avg") + ) + } + Operation::Subtract(o1, o2) => ("-", o1, o2), + Operation::Divide(o1, o2) => ("/", o1, o2), + Operation::Multiply(o1, o2) => ("*", o1, o2), + Operation::Set(o1) => return write!(f, "set {}", o1), + }; + + write!(f, "{} {} {}", round_dp_4(o1), symbol, round_dp_4(o2)) + } +} + +pub enum LogEntry { + RewardsXDRTotal(Decimal, Decimal), + Execute { + reason: String, + operation: Operation, + result: Decimal, + }, + RateNotFoundInRewardTable { + node_type: String, + region: String, + }, + RewardTableEntry { + node_type: String, + region: String, + coeff: Decimal, + base_rewards: Decimal, + node_count: u32, + }, + ActiveIdiosyncraticFailureRates { + node_id: NodeId, + failure_rates: Vec, + }, + ComputeRewardsForNode { + node_id: NodeId, + node_type: String, + region: String, + }, + CalculateRewardsForNodeProvider(PrincipalId), + BaseRewards(Decimal), + IdiosyncraticFailureRates(Vec), + RewardsReductionPercent { + failure_rate: Decimal, + min_fr: Decimal, + max_fr: Decimal, + max_rr: Decimal, + rewards_reduction: Decimal, + }, + ComputeBaseRewardsForRegionNodeType, + ComputeUnassignedFailureRate, + NodeStatusAssigned, + NodeStatusUnassigned, +} + +impl fmt::Display for LogEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + LogEntry::Execute { + reason, + operation, + result, + } => { + write!(f, "{}: {} = {}", reason, operation, round_dp_4(result)) + } + LogEntry::RewardsXDRTotal(rewards_xdr_total, rewards_xdr_total_adjusted) => { + write!( + f, + "Total rewards XDR permyriad: {}\nTotal rewards XDR permyriad not adjusted: {}", + round_dp_4(rewards_xdr_total), + round_dp_4(rewards_xdr_total_adjusted) + ) + } + LogEntry::RateNotFoundInRewardTable { node_type, region } => { + write!( + f, + "RateNotFoundInRewardTable | node_type: {}, region: {}", + node_type, region + ) + } + LogEntry::RewardTableEntry { + node_type, + region, + coeff, + base_rewards, + node_count, + } => { + write!( + f, + "node_type: {}, region: {}, coeff: {}, base_rewards: {}, node_count: {}", + node_type, region, coeff, base_rewards, node_count + ) + } + LogEntry::ActiveIdiosyncraticFailureRates { + node_id, + failure_rates, + } => { + write!( + f, + "ActiveIdiosyncraticFailureRates | node_id={}, failure_rates_discounted={:?}", + node_id, failure_rates + ) + } + LogEntry::ComputeRewardsForNode { + node_id, + node_type, + region, + } => { + write!( + f, + "Compute Rewards For Node | node_id={}, node_type={}, region={}", + node_id, node_type, region + ) + } + LogEntry::CalculateRewardsForNodeProvider(node_provider_id) => { + write!( + f, + "CalculateRewardsForNodeProvider | node_provider_id={}", + node_provider_id + ) + } + LogEntry::BaseRewards(rewards_xdr) => { + write!(f, "Base rewards XDRs: {}", round_dp_4(rewards_xdr)) + } + LogEntry::IdiosyncraticFailureRates(failure_rates) => { + write!( + f, + "Idiosyncratic daily failure rates : {}", + failure_rates.iter().join(",") + ) + } + LogEntry::RewardsReductionPercent { + failure_rate, + min_fr, + max_fr, + max_rr, + rewards_reduction, + } => { + write!( + f, + "Rewards reduction percent: ({} - {}) / ({} - {}) * {} = {}", + round_dp_4(failure_rate), + min_fr, + max_fr, + min_fr, + max_rr, + round_dp_4(rewards_reduction) + ) + } + LogEntry::ComputeBaseRewardsForRegionNodeType => { + write!(f, "Compute Base Rewards For RegionNodeType") + } + LogEntry::ComputeUnassignedFailureRate => { + write!(f, "Compute Unassigned Days Failure Rate") + } + LogEntry::NodeStatusAssigned => { + write!(f, "Node status: Assigned") + } + LogEntry::NodeStatusUnassigned => { + write!(f, "Node status: Unassigned") + } + } + } +} + +pub enum LogLevel { + High, + Mid, + Low, +} + +#[derive(Default)] +pub struct RewardsLog { + entries: Vec<(LogLevel, LogEntry)>, +} + +impl RewardsLog { + pub fn add_entry(&mut self, log_level: LogLevel, entry: LogEntry) { + self.entries.push((log_level, entry)); + } + + pub fn execute(&mut self, reason: &str, operation: Operation) -> Decimal { + let result = operation.execute(); + let entry = LogEntry::Execute { + reason: reason.to_string(), + operation, + result, + }; + self.add_entry(LogLevel::Mid, entry); + result + } + + pub fn get_log(&self) -> Vec { + self.entries + .iter() + .map(|(log_level, entry)| match log_level { + LogLevel::High => format!("{}", entry), + LogLevel::Mid => format!(" - {}", entry), + LogLevel::Low => format!(" - {}", entry), + }) + .collect_vec() + } +} diff --git a/rs/registry/node_provider_rewards/src/v1_rewards.rs b/rs/registry/node_provider_rewards/src/v1_rewards.rs new file mode 100644 index 00000000000..df12de45003 --- /dev/null +++ b/rs/registry/node_provider_rewards/src/v1_rewards.rs @@ -0,0 +1,531 @@ +use ic_base_types::{NodeId, PrincipalId, SubnetId}; +use ic_protobuf::registry::node_rewards::v2::{NodeRewardRate, NodeRewardsTable}; +use itertools::Itertools; +use num_traits::ToPrimitive; + +use crate::v1_logs::LogLevel; +use crate::v1_types::TimestampNanos; +use crate::{ + v1_logs::{LogEntry, Operation, RewardsLog}, + v1_types::{ + DailyNodeMetrics, RegionNodeTypeCategory, RewardableNode, Rewards, RewardsPerNodeProvider, + }, +}; +use ic_management_canister_types::{NodeMetrics, NodeMetricsHistoryResponse}; +use rust_decimal::Decimal; +use rust_decimal_macros::dec; +use std::collections::HashMap; + +const FULL_REWARDS_MACHINES_LIMIT: u32 = 3; +const MIN_FAILURE_RATE: Decimal = dec!(0.1); +const MAX_FAILURE_RATE: Decimal = dec!(0.6); +const MAX_REWARDS_REDUCTION: Decimal = dec!(0.8); +const RF: &str = "Linear Reduction factor"; + +pub fn calculate_rewards( + days_in_period: u64, + rewards_table: &NodeRewardsTable, + subnet_metrics: HashMap>, + rewardable_nodes: &[RewardableNode], +) -> RewardsPerNodeProvider { + let mut rewards_per_node_provider = HashMap::default(); + let mut rewards_log_per_node_provider = HashMap::default(); + + let mut metrics_in_rewarding_period = metrics_in_rewarding_period(subnet_metrics); + let subnets_systematic_fr = systematic_fr_per_subnet(&metrics_in_rewarding_period); + let node_provider_rewardables = rewardable_nodes_by_node_provider(rewardable_nodes); + + for (node_provider_id, nodes) in node_provider_rewardables { + let mut logger = RewardsLog::default(); + logger.add_entry( + LogLevel::High, + LogEntry::CalculateRewardsForNodeProvider(node_provider_id), + ); + + let daily_metrics_by_node: HashMap> = nodes + .iter() + .filter_map(|node| { + metrics_in_rewarding_period + .remove(&node.node_id) + .map(|daily_metrics| (node.node_id, daily_metrics)) + }) + .collect(); + let node_daily_fr = compute_relative_node_failure_rate( + &mut logger, + &daily_metrics_by_node, + &subnets_systematic_fr, + ); + + let rewards = node_provider_rewards( + &mut logger, + &nodes, + node_daily_fr, + days_in_period, + rewards_table, + ); + + rewards_log_per_node_provider.insert(node_provider_id, logger); + rewards_per_node_provider.insert(node_provider_id, rewards); + } + + RewardsPerNodeProvider { + rewards_per_node_provider, + rewards_log_per_node_provider, + } +} + +/// Computes the relative node failure rates discounting the subnet systematic failure rate. +fn compute_relative_node_failure_rate( + logger: &mut RewardsLog, + assigned_metrics: &HashMap>, + subnets_systematic_fr: &HashMap<(SubnetId, TimestampNanos), Decimal>, +) -> HashMap> { + let mut nodes_idiosyncratic_fr: HashMap> = HashMap::new(); + + for (node_id, daily_metrics) in assigned_metrics { + let failure_rates = nodes_idiosyncratic_fr.entry(*node_id).or_default(); + + for metrics in daily_metrics { + let systematic_fr = subnets_systematic_fr + .get(&(metrics.subnet_assigned, metrics.ts)) + .expect("Systematic failure rate not found"); + let fr = if metrics.failure_rate < *systematic_fr { + Decimal::ZERO + } else { + metrics.failure_rate - *systematic_fr + }; + failure_rates.push(fr); + } + logger.add_entry( + LogLevel::High, + LogEntry::ActiveIdiosyncraticFailureRates { + node_id: *node_id, + failure_rates: failure_rates.clone(), + }, + ); + } + + nodes_idiosyncratic_fr +} + +fn node_provider_rewards( + logger: &mut RewardsLog, + rewardables: &[RewardableNode], + nodes_idiosyncratic_fr: HashMap>, + days_in_period: u64, + rewards_table: &NodeRewardsTable, +) -> Rewards { + let mut rewards_xdr_total = Vec::new(); + let mut rewards_xdr_no_penalty_total = Vec::new(); + + let mut nodes_active_fr: Vec = Vec::new(); + let mut region_node_type_rewardables = HashMap::new(); + + let rewardable_nodes_count = rewardables.len() as u32; + let mut nodes_idiosyncratic_fr = nodes_idiosyncratic_fr; + + // 0. Compute base rewards for each region and node type + logger.add_entry( + LogLevel::High, + LogEntry::ComputeBaseRewardsForRegionNodeType, + ); + for node in rewardables { + let nodes_count = region_node_type_rewardables + .entry((node.region.clone(), node.node_type.clone())) + .or_default(); + *nodes_count += 1; + } + let region_nodetype_rewards: HashMap = + base_rewards_region_nodetype(logger, ®ion_node_type_rewardables, rewards_table); + + // 1. Extrapolate the unassigned daily failure rate from the active nodes + logger.add_entry(LogLevel::High, LogEntry::ComputeUnassignedFailureRate); + for node in rewardables { + if let Some(fr) = nodes_idiosyncratic_fr.get(&node.node_id) { + let avg_fr = logger.execute( + &format!("Avg. failure rate for node: {}", node.node_id), + Operation::Avg(fr.clone()), + ); + nodes_active_fr.push(avg_fr); + } + } + let unassigned_fr: Decimal = if !nodes_active_fr.is_empty() { + logger.execute( + "Unassigned days failure rate:", + Operation::Avg(nodes_active_fr), + ) + } else { + dec!(1) + }; + + // 2. Compute rewards multiplier for fully unassigned nodes + let rewards_reduction_unassigned = rewards_reduction_percent(logger, &unassigned_fr); + let multiplier_unassigned = logger.execute( + "Reward multiplier fully unassigned nodes:", + Operation::Subtract(dec!(1), rewards_reduction_unassigned), + ); + + // 3. reward the nodes of node provider + let mut sorted_rewardables = rewardables.to_vec(); + sorted_rewardables.sort_by(|a, b| a.region.cmp(&b.region).then(a.node_type.cmp(&b.node_type))); + for node in sorted_rewardables { + logger.add_entry( + LogLevel::High, + LogEntry::ComputeRewardsForNode { + node_id: node.node_id, + node_type: node.node_type.clone(), + region: node.region.clone(), + }, + ); + + let node_type = node.node_type.clone(); + let region = node.region.clone(); + + let rewards_xdr_no_penalty = if node_type.starts_with("type3") { + let region_key = region_type3_key(region.clone()); + region_nodetype_rewards + .get(®ion_key) + .expect("Type3 rewards already filled") + } else { + region_nodetype_rewards + .get(&(node.region.clone(), node.node_type.clone())) + .expect("Rewards already filled") + }; + + logger.add_entry( + LogLevel::Mid, + LogEntry::BaseRewards(*rewards_xdr_no_penalty), + ); + + rewards_xdr_no_penalty_total.push(*rewards_xdr_no_penalty); + + // Node Providers with less than 4 machines are rewarded fully, independently of their performance + if rewardable_nodes_count <= FULL_REWARDS_MACHINES_LIMIT { + rewards_xdr_total.push(*rewards_xdr_no_penalty); + continue; + } + + let reward_multiplier = if let Some(mut daily_idiosyncratic_fr) = + nodes_idiosyncratic_fr.remove(&node.node_id) + { + logger.add_entry(LogLevel::Mid, LogEntry::NodeStatusAssigned); + daily_idiosyncratic_fr.resize(days_in_period as usize, unassigned_fr); + + logger.add_entry( + LogLevel::Mid, + LogEntry::IdiosyncraticFailureRates(daily_idiosyncratic_fr.clone()), + ); + + assigned_multiplier(logger, daily_idiosyncratic_fr) + } else { + logger.add_entry(LogLevel::Mid, LogEntry::NodeStatusUnassigned); + + multiplier_unassigned + }; + + let rewards_xdr = logger.execute( + "Rewards XDR for the node", + Operation::Multiply(*rewards_xdr_no_penalty, reward_multiplier), + ); + rewards_xdr_total.push(rewards_xdr); + } + + let rewards_xdr_total = logger.execute( + "Compute total permyriad XDR", + Operation::Sum(rewards_xdr_total), + ); + let rewards_xdr_no_reduction_total = logger.execute( + "Compute total permyriad XDR no performance penalty", + Operation::Sum(rewards_xdr_no_penalty_total), + ); + logger.add_entry( + LogLevel::High, + LogEntry::RewardsXDRTotal(rewards_xdr_total, rewards_xdr_no_reduction_total), + ); + + Rewards { + xdr_permyriad: rewards_xdr_total.to_u64().unwrap(), + xdr_permyriad_no_reduction: rewards_xdr_no_reduction_total.to_u64().unwrap(), + } +} + +fn assigned_multiplier(logger: &mut RewardsLog, daily_failure_rate: Vec) -> Decimal { + let average_fr = logger.execute("Failure rate average", Operation::Avg(daily_failure_rate)); + let rewards_reduction = rewards_reduction_percent(logger, &average_fr); + + logger.execute( + "Reward Multiplier", + Operation::Subtract(dec!(1), rewards_reduction), + ) +} + +/// Computes the systematic failure rate for each subnet per day. +/// +/// This function calculates the 75th percentile of failure rates for each subnet on a daily basis. +/// This represents the systematic failure rate for all the nodes in the subnet for that day. +fn systematic_fr_per_subnet( + daily_node_metrics: &HashMap>, +) -> HashMap<(SubnetId, TimestampNanos), Decimal> { + fn percentile_75(mut values: Vec) -> Decimal { + values.sort(); + let len = values.len(); + if len == 0 { + return Decimal::ZERO; + } + let idx = ((len as f64) * 0.75).ceil() as usize - 1; + values[idx] + } + + let mut subnet_daily_failure_rates: HashMap<(SubnetId, u64), Vec> = HashMap::new(); + + for metrics in daily_node_metrics.values() { + for metric in metrics { + subnet_daily_failure_rates + .entry((metric.subnet_assigned, metric.ts)) + .or_default() + .push(metric.failure_rate); + } + } + + subnet_daily_failure_rates + .into_iter() + .map(|((subnet, ts), failure_rates)| ((subnet, ts), percentile_75(failure_rates))) + .collect() +} + +fn metrics_in_rewarding_period( + subnets_metrics: HashMap>, +) -> HashMap> { + let mut subnets_metrics = subnets_metrics + .into_iter() + .flat_map(|(subnet_id, metrics)| { + metrics.into_iter().map(move |metrics| (subnet_id, metrics)) + }) + .collect_vec(); + subnets_metrics.sort_by_key(|(_, metrics)| metrics.timestamp_nanos); + + let mut metrics_in_rewarding_period: HashMap> = + HashMap::default(); + + for (subnet_id, metrics) in subnets_metrics { + for node_metrics in metrics.node_metrics { + metrics_in_rewarding_period + .entry(node_metrics.node_id.into()) + .or_default() + .push((subnet_id, metrics.timestamp_nanos, node_metrics)); + } + } + + metrics_in_rewarding_period + .into_iter() + .map(|(node_id, metrics)| { + let mut daily_metrics = Vec::new(); + let mut previous_proposed_total = 0; + let mut previous_failed_total = 0; + + for (subnet_id, ts, node_metrics) in metrics { + let current_proposed_total = node_metrics.num_blocks_proposed_total; + let current_failed_total = node_metrics.num_block_failures_total; + + let (num_blocks_proposed, num_blocks_failed) = if previous_failed_total + > current_failed_total + || previous_proposed_total > current_proposed_total + { + // This is the case when node is deployed again + (current_proposed_total, current_failed_total) + } else { + ( + current_proposed_total - previous_proposed_total, + current_failed_total - previous_failed_total, + ) + }; + + daily_metrics.push(DailyNodeMetrics::new( + ts, + subnet_id, + num_blocks_proposed, + num_blocks_failed, + )); + + previous_proposed_total = current_proposed_total; + previous_failed_total = current_failed_total; + } + (node_id, daily_metrics) + }) + .collect() +} + +/// Calculates the rewards reduction based on the failure rate. +/// +/// if `failure_rate` is: +/// - Below the `MIN_FAILURE_RATE`, no reduction in rewards applied. +/// - Above the `MAX_FAILURE_RATE`, maximum reduction in rewards applied. +/// - Within the defined range (`MIN_FAILURE_RATE` to `MAX_FAILURE_RATE`), +/// the function calculates the reduction from the linear reduction function. +fn rewards_reduction_percent(logger: &mut RewardsLog, failure_rate: &Decimal) -> Decimal { + if failure_rate < &MIN_FAILURE_RATE { + logger.execute( + &format!( + "No Reduction applied because {} is less than {} failure rate.\n{}", + failure_rate.round_dp(4), + MIN_FAILURE_RATE, + RF + ), + Operation::Set(dec!(0)), + ) + } else if failure_rate > &MAX_FAILURE_RATE { + logger.execute( + &format!( + "Max reduction applied because {} is over {} failure rate.\n{}", + failure_rate.round_dp(4), + MAX_FAILURE_RATE, + RF + ), + Operation::Set(dec!(0.8)), + ) + } else { + let rewards_reduction = (*failure_rate - MIN_FAILURE_RATE) + / (MAX_FAILURE_RATE - MIN_FAILURE_RATE) + * MAX_REWARDS_REDUCTION; + logger.add_entry( + LogLevel::Mid, + LogEntry::RewardsReductionPercent { + failure_rate: *failure_rate, + min_fr: MIN_FAILURE_RATE, + max_fr: MAX_FAILURE_RATE, + max_rr: MAX_REWARDS_REDUCTION, + rewards_reduction, + }, + ); + + rewards_reduction + } +} + +fn region_type3_key(region: String) -> RegionNodeTypeCategory { + // The rewards table contains entries of this form DC Continent + DC Country + DC State/City. + // The grouping for type3* nodes will be on DC Continent + DC Country level. This group is used for computing + // the reduction coefficient and base reward for the group. + + let region_key = region + .splitn(3, ',') + .take(2) + .collect::>() + .join(":"); + (region_key, "type3*".to_string()) +} + +fn base_rewards_region_nodetype( + logger: &mut RewardsLog, + rewardable_nodes: &HashMap, + rewards_table: &NodeRewardsTable, +) -> HashMap { + let mut type3_coefficients_rewards: HashMap< + RegionNodeTypeCategory, + (Vec, Vec), + > = HashMap::default(); + let mut region_nodetype_rewards: HashMap = HashMap::default(); + + for ((region, node_type), node_count) in rewardable_nodes { + let rate = match rewards_table.get_rate(region, node_type) { + Some(rate) => rate, + None => { + logger.add_entry( + LogLevel::High, + LogEntry::RateNotFoundInRewardTable { + node_type: node_type.to_string(), + region: region.to_string(), + }, + ); + + NodeRewardRate { + xdr_permyriad_per_node_per_month: 1, + reward_coefficient_percent: Some(100), + } + } + }; + let base_rewards = Decimal::from(rate.xdr_permyriad_per_node_per_month); + let mut coeff = dec!(1); + + if node_type.starts_with("type3") && *node_count > 0 { + // For nodes which are type3* the base rewards for the single node is computed as the average of base rewards + // on DC Country level. Moreover, to de-stimulate the same NP having too many nodes in the same country, + // the node rewards is reduced for each node the NP has in the given country. The reduction coefficient is + // computed as the average of reduction coefficients on DC Country level. + + coeff = Decimal::from(rate.reward_coefficient_percent.unwrap_or(80)) / dec!(100); + let coefficients = vec![coeff; *node_count as usize]; + let base_rewards = vec![base_rewards; *node_count as usize]; + let region_key = region_type3_key(region.to_string()); + + type3_coefficients_rewards + .entry(region_key) + .and_modify(|(entry_coefficients, entry_rewards)| { + entry_coefficients.extend(&coefficients); + entry_rewards.extend(&base_rewards); + }) + .or_insert((coefficients, base_rewards)); + } else { + // For `rewardable_nodes` which are not type3* the base rewards for the sigle node is the entry + // in the rewards table for the specific region (DC Continent + DC Country + DC State/City) and node type. + + region_nodetype_rewards.insert((region.clone(), node_type.clone()), base_rewards); + } + + logger.add_entry( + LogLevel::Mid, + LogEntry::RewardTableEntry { + node_type: node_type.to_string(), + region: region.to_string(), + coeff, + base_rewards, + node_count: *node_count, + }, + ); + } + + // Computes node rewards for type3* nodes in all regions and add it to region_nodetype_rewards + for (key, (coefficients, rewards)) in type3_coefficients_rewards { + let rewards_len = rewards.len(); + let mut running_coefficient = dec!(1); + let mut region_rewards = Vec::new(); + + let coefficients_avg = logger.execute("Coefficients avg.", Operation::Avg(coefficients)); + let rewards_avg = logger.execute("Rewards avg.", Operation::Avg(rewards)); + for _ in 0..rewards_len { + region_rewards.push(Operation::Multiply(rewards_avg, running_coefficient)); + running_coefficient *= coefficients_avg; + } + let region_rewards = logger.execute( + "Total rewards after coefficient reduction", + Operation::SumOps(region_rewards), + ); + let region_rewards_avg = logger.execute( + "Rewards average after coefficient reduction", + Operation::Divide(region_rewards, Decimal::from(rewards_len)), + ); + + region_nodetype_rewards.insert(key, region_rewards_avg); + } + + region_nodetype_rewards +} + +fn rewardable_nodes_by_node_provider( + nodes: &[RewardableNode], +) -> HashMap> { + let mut node_provider_rewardables: HashMap> = + HashMap::default(); + + nodes.iter().for_each(|node| { + let rewardable_nodes = node_provider_rewardables + .entry(node.node_provider_id) + .or_default(); + rewardable_nodes.push(node.clone()); + }); + + node_provider_rewardables +} + +#[cfg(test)] +mod tests; diff --git a/rs/registry/node_provider_rewards/src/v1_rewards/tests.rs b/rs/registry/node_provider_rewards/src/v1_rewards/tests.rs new file mode 100644 index 00000000000..359298111d5 --- /dev/null +++ b/rs/registry/node_provider_rewards/src/v1_rewards/tests.rs @@ -0,0 +1,752 @@ +use super::*; +use ic_protobuf::registry::node_rewards::v2::NodeRewardRates; +use num_traits::FromPrimitive; +use std::collections::BTreeMap; + +#[derive(Clone)] +struct MockedMetrics { + days: u64, + proposed_blocks: u64, + failed_blocks: u64, +} + +impl MockedMetrics { + fn new(days: u64, proposed_blocks: u64, failed_blocks: u64) -> Self { + MockedMetrics { + days, + proposed_blocks, + failed_blocks, + } + } +} + +impl DailyNodeMetrics { + fn from_fr_dummy(ts: u64, subnet_assigned: SubnetId, failure_rate: Decimal) -> Self { + let num_blocks_proposed = 10; + let num_blocks_failed = if failure_rate.is_zero() { + 0 + } else { + let total_blocks = + (failure_rate / (Decimal::ONE - failure_rate)) * Decimal::from(num_blocks_proposed); + total_blocks.floor().to_u64().unwrap_or(0) + }; + DailyNodeMetrics { + ts, + subnet_assigned, + num_blocks_proposed, + num_blocks_failed, + failure_rate, + } + } +} + +fn daily_mocked_failure_rates(metrics: Vec) -> Vec { + metrics + .into_iter() + .flat_map(|mocked_metrics: MockedMetrics| { + (0..mocked_metrics.days).map(move |i| { + DailyNodeMetrics::new( + i, + PrincipalId::new_anonymous().into(), + mocked_metrics.proposed_blocks, + mocked_metrics.failed_blocks, + ) + .failure_rate + }) + }) + .collect() +} +fn mocked_rewards_table() -> NodeRewardsTable { + let mut rates_outer: BTreeMap = BTreeMap::new(); + let mut rates_inner: BTreeMap = BTreeMap::new(); + let mut table: BTreeMap = BTreeMap::new(); + + let rate_outer = NodeRewardRate { + xdr_permyriad_per_node_per_month: 1000, + reward_coefficient_percent: Some(97), + }; + + let rate_inner = NodeRewardRate { + xdr_permyriad_per_node_per_month: 1500, + reward_coefficient_percent: Some(95), + }; + + rates_outer.insert("type0".to_string(), rate_outer); + rates_outer.insert("type1".to_string(), rate_outer); + rates_outer.insert("type3".to_string(), rate_outer); + + rates_inner.insert("type3.1".to_string(), rate_inner); + + table.insert("A,B,C".to_string(), NodeRewardRates { rates: rates_inner }); + table.insert("A,B".to_string(), NodeRewardRates { rates: rates_outer }); + + NodeRewardsTable { table } +} +#[test] +fn test_daily_node_metrics() { + let subnet1: SubnetId = PrincipalId::new_user_test_id(1).into(); + let subnet2: SubnetId = PrincipalId::new_user_test_id(2).into(); + + let node1 = PrincipalId::new_user_test_id(101); + let node2 = PrincipalId::new_user_test_id(102); + + let sub1_day1 = NodeMetricsHistoryResponse { + timestamp_nanos: 1, + node_metrics: vec![ + NodeMetrics { + node_id: node1, + num_blocks_proposed_total: 10, + num_block_failures_total: 2, + }, + NodeMetrics { + node_id: node2, + num_blocks_proposed_total: 20, + num_block_failures_total: 5, + }, + ], + }; + + let sub1_day2 = NodeMetricsHistoryResponse { + timestamp_nanos: 2, + node_metrics: vec![ + NodeMetrics { + node_id: node1, + num_blocks_proposed_total: 20, + num_block_failures_total: 12, + }, + NodeMetrics { + node_id: node2, + num_blocks_proposed_total: 25, + num_block_failures_total: 8, + }, + ], + }; + + // This happens when the node gets redeployed + let sub1_day3 = NodeMetricsHistoryResponse { + timestamp_nanos: 3, + node_metrics: vec![NodeMetrics { + node_id: node1, + num_blocks_proposed_total: 15, + num_block_failures_total: 3, + }], + }; + + // Simulating subnet change + let sub2_day3 = NodeMetricsHistoryResponse { + timestamp_nanos: 3, + node_metrics: vec![NodeMetrics { + node_id: node2, + num_blocks_proposed_total: 35, + num_block_failures_total: 10, + }], + }; + + let input_metrics = HashMap::from([ + (subnet1, vec![sub1_day1, sub1_day2, sub1_day3]), + (subnet2, vec![sub2_day3]), + ]); + + let result = metrics_in_rewarding_period(input_metrics); + + let metrics_node1 = result.get(&node1.into()).expect("Node1 metrics not found"); + assert_eq!(metrics_node1[0].subnet_assigned, subnet1); + assert_eq!(metrics_node1[0].num_blocks_proposed, 10); + assert_eq!(metrics_node1[0].num_blocks_failed, 2); + + assert_eq!(metrics_node1[1].subnet_assigned, subnet1); + assert_eq!(metrics_node1[1].num_blocks_proposed, 10); + assert_eq!(metrics_node1[1].num_blocks_failed, 10); + + assert_eq!(metrics_node1[2].subnet_assigned, subnet1); + assert_eq!(metrics_node1[2].num_blocks_proposed, 15); + assert_eq!(metrics_node1[2].num_blocks_failed, 3); + + let metrics_node2 = result.get(&node2.into()).expect("Node2 metrics not found"); + assert_eq!(metrics_node2[0].subnet_assigned, subnet1); + assert_eq!(metrics_node2[0].num_blocks_proposed, 20); + assert_eq!(metrics_node2[0].num_blocks_failed, 5); + + assert_eq!(metrics_node2[1].subnet_assigned, subnet1); + assert_eq!(metrics_node2[1].num_blocks_proposed, 5); + assert_eq!(metrics_node2[1].num_blocks_failed, 3); + + assert_eq!(metrics_node2[2].subnet_assigned, subnet2); + assert_eq!(metrics_node2[2].num_blocks_proposed, 10); + assert_eq!(metrics_node2[2].num_blocks_failed, 2); +} +#[test] +fn test_rewards_percent() { + let mut logger = RewardsLog::default(); + let daily_fr: Vec = daily_mocked_failure_rates(vec![ + // Avg. failure rate = 0.4 + MockedMetrics::new(20, 6, 4), + // Avg. failure rate = 0.2 + MockedMetrics::new(20, 8, 2), + ]); + + let result = assigned_multiplier(&mut logger, daily_fr); + // Avg. failure rate = 0.3 -> 1 - (0.3-0.1) / (0.6-0.1) * 0.8 = 0.68 + assert_eq!(result, dec!(0.68)); + + let daily_fr: Vec = daily_mocked_failure_rates(vec![ + // Avg. failure rate = 0.5 + MockedMetrics::new(1, 5, 5), + ]); + let result = assigned_multiplier(&mut logger, daily_fr); + // Avg. failure rate = 0.5 -> 1 - (0.5-0.1) / (0.6-0.1) * 0.8 = 0.36 + assert_eq!(result, dec!(0.36)); + + let daily_fr: Vec = daily_mocked_failure_rates(vec![ + // Avg. failure rate = 0.6666666667 + MockedMetrics::new(1, 200, 400), + // Avg. failure rate = 0.8333333333 + MockedMetrics::new(1, 5, 25), // no penalty + ]); + let result = assigned_multiplier(&mut logger, daily_fr); + // Avg. failure rate = (0.6666666667 + 0.8333333333) / 2 = 0.75 + // 1 - (0.75-0.1) / (0.6-0.1) * 0.8 = 0.2 + assert_eq!(result, dec!(0.2)); +} + +#[test] +fn test_rewards_percent_max_reduction() { + let mut logger = RewardsLog::default(); + + let daily_fr: Vec = daily_mocked_failure_rates(vec![ + // Avg. failure rate = 0.95 + MockedMetrics::new(10, 5, 95), + ]); + let result = assigned_multiplier(&mut logger, daily_fr); + assert_eq!(result, dec!(0.2)); +} + +#[test] +fn test_rewards_percent_min_reduction() { + let mut logger = RewardsLog::default(); + + let daily_fr: Vec = daily_mocked_failure_rates(vec![ + // Avg. failure rate = 0.1 + MockedMetrics::new(10, 9, 1), + ]); + let result = assigned_multiplier(&mut logger, daily_fr); + assert_eq!(result, dec!(1)); +} + +#[test] +fn test_same_rewards_percent_if_gaps_no_penalty() { + let mut logger = RewardsLog::default(); + let gap = MockedMetrics::new(1, 10, 0); + let daily_fr_mid_gap: Vec = daily_mocked_failure_rates(vec![ + MockedMetrics::new(1, 6, 4), + gap.clone(), + MockedMetrics::new(1, 7, 3), + ]); + let daily_fr_left_gap: Vec = daily_mocked_failure_rates(vec![ + gap.clone(), + MockedMetrics::new(1, 6, 4), + MockedMetrics::new(1, 7, 3), + ]); + let daily_fr_right_gap: Vec = daily_mocked_failure_rates(vec![ + gap.clone(), + MockedMetrics::new(1, 6, 4), + MockedMetrics::new(1, 7, 3), + ]); + + assert_eq!( + assigned_multiplier(&mut logger, daily_fr_mid_gap.clone()), + dec!(0.7866666666666666666666666667) + ); + + assert_eq!( + assigned_multiplier(&mut logger, daily_fr_mid_gap.clone()), + assigned_multiplier(&mut logger, daily_fr_left_gap.clone()) + ); + assert_eq!( + assigned_multiplier(&mut logger, daily_fr_right_gap.clone()), + assigned_multiplier(&mut logger, daily_fr_left_gap) + ); +} + +fn from_subnet_daily_metrics( + subnet_id: SubnetId, + daily_subnet_fr: Vec<(TimestampNanos, Vec)>, +) -> HashMap> { + let mut daily_node_metrics = HashMap::new(); + for (day, fr) in daily_subnet_fr { + fr.into_iter().enumerate().for_each(|(i, fr)| { + let node_metrics: &mut Vec = daily_node_metrics + .entry(NodeId::from(PrincipalId::new_user_test_id(i as u64))) + .or_default(); + + node_metrics.push(DailyNodeMetrics { + ts: day, + subnet_assigned: subnet_id, + failure_rate: Decimal::from_f64(fr).unwrap(), + ..DailyNodeMetrics::default() + }); + }); + } + daily_node_metrics +} +#[test] +fn test_systematic_fr_calculation() { + let subnet1 = SubnetId::new(PrincipalId::new_user_test_id(1)); + + let assigned_metrics = from_subnet_daily_metrics( + subnet1, + vec![ + (1, vec![0.2, 0.21, 0.1, 0.9, 0.3]), // Ordered: [0.1, 0.2, 0.21, * 0.3, 0.9] + (2, vec![0.8, 0.9, 0.5, 0.6, 0.7]), // Ordered: [0.5, 0.6, 0.7, * 0.8, 0.9] + (3, vec![0.5, 0.6, 0.64, 0.8]), // Ordered: [0.5, 0.6, * 0.64, 0.8] + (4, vec![0.5, 0.6]), // Ordered: [0.5, * 0.6] + (5, vec![0.2, 0.21, 0.1, 0.9, 0.3, 0.23]), // Ordered: [0.1, 0.2, 0.21, 0.23, * 0.3, 0.9] + ], + ); + + let result = systematic_fr_per_subnet(&assigned_metrics); + + let expected: HashMap<(SubnetId, TimestampNanos), Decimal> = HashMap::from([ + ((subnet1, 1), dec!(0.3)), + ((subnet1, 2), dec!(0.8)), + ((subnet1, 3), dec!(0.64)), + ((subnet1, 4), dec!(0.6)), + ((subnet1, 5), dec!(0.3)), + ]); + + assert_eq!(result, expected); +} + +#[test] +fn test_idiosyncratic_daily_fr_correct_values() { + let mut logger = RewardsLog::default(); + let node1 = NodeId::from(PrincipalId::new_user_test_id(1)); + let node2 = NodeId::from(PrincipalId::new_user_test_id(2)); + let subnet1 = SubnetId::from(PrincipalId::new_user_test_id(10)); + + let assigned_metrics = HashMap::from([ + ( + node1, + vec![ + DailyNodeMetrics::from_fr_dummy(1, subnet1, dec!(0.2)), + DailyNodeMetrics::from_fr_dummy(2, subnet1, dec!(0.5)), + DailyNodeMetrics::from_fr_dummy(3, subnet1, dec!(0.849)), + ], + ), + ( + node2, + vec![DailyNodeMetrics::from_fr_dummy(1, subnet1, dec!(0.5))], + ), + ]); + + let subnets_systematic_fr = HashMap::from([ + ((subnet1, 1), dec!(0.1)), + ((subnet1, 2), dec!(0.2)), + ((subnet1, 3), dec!(0.1)), + ]); + + let result = + compute_relative_node_failure_rate(&mut logger, &assigned_metrics, &subnets_systematic_fr); + + let expected = HashMap::from([ + (node1, vec![dec!(0.1), dec!(0.3), dec!(0.749)]), // (0.2 - 0.1), (0.5 - 0.2), (0.849 - 0.1) + (node2, vec![dec!(0.4)]), // (0.5 - 0.1) + ]); + + assert_eq!(result, expected); +} + +#[test] +#[should_panic(expected = "Systematic failure rate not found")] +fn test_idiosyncratic_daily_fr_missing_systematic_fr() { + let mut logger = RewardsLog::default(); + let node1: NodeId = PrincipalId::new_user_test_id(1).into(); + let subnet1: SubnetId = PrincipalId::new_user_test_id(10).into(); + + let assigned_metrics = HashMap::from([( + node1, + vec![DailyNodeMetrics::from_fr_dummy(1, subnet1, dec!(0.2))], + )]); + + let subnets_systematic_fr = HashMap::from([((subnet1, 2), dec!(0.1))]); + + compute_relative_node_failure_rate(&mut logger, &assigned_metrics, &subnets_systematic_fr); +} + +#[test] +fn test_idiosyncratic_daily_fr_negative_failure_rate() { + let mut logger = RewardsLog::default(); + let node1: NodeId = PrincipalId::new_user_test_id(1).into(); + let subnet1: SubnetId = PrincipalId::new_user_test_id(10).into(); + + let assigned_metrics = HashMap::from([( + node1, + vec![DailyNodeMetrics::from_fr_dummy(1, subnet1, dec!(0.05))], + )]); + + let subnets_systematic_fr = HashMap::from([((subnet1, 1), dec!(0.1))]); + + let result = + compute_relative_node_failure_rate(&mut logger, &assigned_metrics, &subnets_systematic_fr); + + // Expecting zero due to saturation + let expected = HashMap::from([(node1, vec![Decimal::ZERO])]); + + assert_eq!(result, expected); +} + +#[test] +fn test_node_provider_rewards_no_nodes() { + let mut logger = RewardsLog::default(); + let rewardables = vec![]; + let nodes_idiosyncratic_fr = HashMap::new(); + let days_in_period = 30; + let rewards_table = NodeRewardsTable::default(); + + let rewards = node_provider_rewards( + &mut logger, + &rewardables, + nodes_idiosyncratic_fr, + days_in_period, + &rewards_table, + ); + + assert_eq!(rewards.xdr_permyriad, 0); + assert_eq!(rewards.xdr_permyriad_no_reduction, 0); +} + +#[test] +fn test_node_provider_below_min_limit() { + let mut logger = RewardsLog::default(); + let node_provider_id = PrincipalId::new_anonymous(); + let rewardables = vec![ + RewardableNode { + node_id: PrincipalId::new_user_test_id(1).into(), + node_provider_id, + region: "region1".to_string(), + node_type: "type1".to_string(), + }, + RewardableNode { + node_id: PrincipalId::new_user_test_id(2).into(), + node_provider_id, + region: "region1".to_string(), + node_type: "type3.1".to_string(), + }, + ]; + let nodes_idiosyncratic_fr = HashMap::new(); + let days_in_period = 30; + let rewards_table = NodeRewardsTable::default(); + + let rewards = node_provider_rewards( + &mut logger, + &rewardables, + nodes_idiosyncratic_fr, + days_in_period, + &rewards_table, + ); + + assert_eq!(rewards.xdr_permyriad, 2); + assert_eq!(rewards.xdr_permyriad_no_reduction, 2); +} + +#[test] +fn test_node_provider_rewards_one_assigned() { + let mut logger = RewardsLog::default(); + let node_rewards_table: NodeRewardsTable = mocked_rewards_table(); + let days_in_period = 30; + + let rewardables = (1..=5) + .map(|i| RewardableNode { + node_id: PrincipalId::new_user_test_id(i).into(), + node_provider_id: PrincipalId::new_anonymous(), + region: "A,B".to_string(), + node_type: "type1".to_string(), + }) + .collect_vec(); + + let mut nodes_idiosyncratic_fr: HashMap> = HashMap::new(); + nodes_idiosyncratic_fr.insert( + PrincipalId::new_user_test_id(1).into(), + vec![dec!(0.4), dec!(0.2), dec!(0.3), dec!(0.4)], // Avg. 0.325 + ); + + let rewards = node_provider_rewards( + &mut logger, + &rewardables, + nodes_idiosyncratic_fr, + days_in_period, + &node_rewards_table, + ); + + // Compute Base Rewards For RegionNodeType + // - node_type: type1, region: A,B, coeff: 1, base_rewards: 1000, node_count: 5 + // Compute Unassigned Days Failure Rate + // - Avg. failure rate for node: 6fyp7-3ibaa-aaaaa-aaaap-4ai: avg(0.4,0.2,0.3,0.4) = 0.325 + // - Unassigned days failure rate:: avg(0.325) = 0.325 + // - Rewards reduction percent: (0.325 - 0.1) / (0.6 - 0.1) * 0.8 = 0.360 + // - Reward multiplier fully unassigned nodes:: 1 - 0.360 = 0.640 + // Compute Rewards For Node | node_id=6fyp7-3ibaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Assigned + // - Idiosyncratic daily failure rates : 0.4,0.2,0.3,0.4,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325 + // - Failure rate average: avg(0.4,0.2,0.3,0.4,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325) = 0.325 + // - Rewards reduction percent: (0.325 - 0.1) / (0.6 - 0.1) * 0.8 = 0.360 + // - Reward Multiplier: 1 - 0.360 = 0.640 + // - Rewards XDR for the node: 1000 * 0.640 = 640.000 + // Compute Rewards For Node | node_id=djduj-3qcaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Unassigned + // - Rewards XDR for the node: 1000 * 0.640 = 640.000 + // Compute Rewards For Node | node_id=6wcs7-uadaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Unassigned + // - Rewards XDR for the node: 1000 * 0.640 = 640.000 + // Compute Rewards For Node | node_id=c5mtj-kieaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Unassigned + // - Rewards XDR for the node: 1000 * 0.640 = 640.000 + // Compute Rewards For Node | node_id=7cnv7-fyfaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Unassigned + // - Rewards XDR for the node: 1000 * 0.640 = 640.000 + // - Compute total permyriad XDR: sum(640.000,640.000,640.000,640.000,640.000) = 3200.000 + // - Compute total permyriad XDR no performance penalty: sum(1000,1000,1000,1000,1000) = 5000 + // Total rewards XDR permyriad: 3200.000 + // Total rewards XDR permyriad not adjusted: 5000 + assert_eq!(rewards.xdr_permyriad, 3200); + assert_eq!(rewards.xdr_permyriad_no_reduction, 5000); +} + +#[test] +fn test_node_provider_rewards_two_assigned() { + let mut logger = RewardsLog::default(); + let node_rewards_table: NodeRewardsTable = mocked_rewards_table(); + let days_in_period = 30; + + let rewardables = (1..=5) + .map(|i| RewardableNode { + node_id: PrincipalId::new_user_test_id(i).into(), + node_provider_id: PrincipalId::new_anonymous(), + region: "A,B".to_string(), + node_type: "type1".to_string(), + }) + .collect_vec(); + + let mut nodes_idiosyncratic_fr: HashMap> = HashMap::new(); + nodes_idiosyncratic_fr.insert( + PrincipalId::new_user_test_id(1).into(), + vec![dec!(0.4), dec!(0.2), dec!(0.3), dec!(0.4)], // Avg. 0.325 + ); + nodes_idiosyncratic_fr.insert( + PrincipalId::new_user_test_id(2).into(), + vec![dec!(0.9), dec!(0.6), dec!(0.304), dec!(0.102)], // Avg. 0.4765 + ); + + let rewards = node_provider_rewards( + &mut logger, + &rewardables, + nodes_idiosyncratic_fr, + days_in_period, + &node_rewards_table, + ); + + // Compute Base Rewards For RegionNodeType + // - node_type: type1, region: A,B, coeff: 1, base_rewards: 1000, node_count: 5 + // Compute Unassigned Days Failure Rate + // - Avg. failure rate for node: 6fyp7-3ibaa-aaaaa-aaaap-4ai: avg(0.4,0.2,0.3,0.4) = 0.325 + // - Avg. failure rate for node: djduj-3qcaa-aaaaa-aaaap-4ai: avg(0.9,0.6,0.304,0.102) = 0.4765 + // - Unassigned days failure rate:: avg(0.325,0.4765) = 0.4008 + // - Rewards reduction percent: (0.4008 - 0.1) / (0.6 - 0.1) * 0.8 = 0.4812 + // - Reward multiplier fully unassigned nodes:: 1 - 0.4812 = 0.5188 + // Compute Rewards For Node | node_id=6fyp7-3ibaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Assigned + // - Idiosyncratic daily failure rates : 0.4,0.2,0.3,0.4,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075 + // - Failure rate average: avg(0.4,0.2,0.3,0.4,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008) = 0.3906 + // - Rewards reduction percent: (0.3906 - 0.1) / (0.6 - 0.1) * 0.8 = 0.4650 + // - Reward Multiplier: 1 - 0.4650 = 0.5350 + // - Rewards XDR for the node: 1000 * 0.5350 = 534.9600 + // Compute Rewards For Node | node_id=djduj-3qcaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Assigned + // - Idiosyncratic daily failure rates : 0.9,0.6,0.304,0.102,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075,0.40075 + // - Failure rate average: avg(0.9,0.6,0.304,0.102,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008,0.4008) = 0.4108 + // - Rewards reduction percent: (0.4108 - 0.1) / (0.6 - 0.1) * 0.8 = 0.4974 + // - Reward Multiplier: 1 - 0.4974 = 0.5026 + // - Rewards XDR for the node: 1000 * 0.5026 = 502.6400 + // Compute Rewards For Node | node_id=6wcs7-uadaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Unassigned + // - Rewards XDR for the node: 1000 * 0.5188 = 518.8000 + // Compute Rewards For Node | node_id=c5mtj-kieaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Unassigned + // - Rewards XDR for the node: 1000 * 0.5188 = 518.8000 + // Compute Rewards For Node | node_id=7cnv7-fyfaa-aaaaa-aaaap-4ai, node_type=type1, region=A,B + // - Base rewards XDRs: 1000 + // - Node status: Unassigned + // - Rewards XDR for the node: 1000 * 0.5188 = 518.8000 + // - Compute total permyriad XDR: sum(534.9600,502.6400,518.8000,518.8000,518.8000) = 2594.0000 + // - Compute total permyriad XDR no performance penalty: sum(1000,1000,1000,1000,1000) = 5000 + // Total rewards XDR permyriad: 2594.0000 + // Total rewards XDR permyriad not adjusted: 5000 + + assert_eq!(rewards.xdr_permyriad, 2594); + assert_eq!(rewards.xdr_permyriad_no_reduction, 5000); +} + +#[test] +fn test_np_rewards_type3_coeff() { + let mut logger = RewardsLog::default(); + let node_rewards_table: NodeRewardsTable = mocked_rewards_table(); + let days_in_period = 30; + + // 4 nodes in period: 1 assigned, 3 unassigned + let rewardables = (1..=4) + .map(|i| RewardableNode { + node_id: PrincipalId::new_user_test_id(i).into(), + node_provider_id: PrincipalId::new_anonymous(), + region: "A,B,C".to_string(), + node_type: "type3.1".to_string(), + }) + .collect_vec(); + let mut nodes_idiosyncratic_fr: HashMap> = HashMap::new(); + nodes_idiosyncratic_fr.insert( + PrincipalId::new_user_test_id(1).into(), + vec![dec!(0.4), dec!(0.2), dec!(0.3), dec!(0.4)], // Avg. 0.325 + ); + + let rewards = node_provider_rewards( + &mut logger, + &rewardables, + nodes_idiosyncratic_fr, + days_in_period, + &node_rewards_table, + ); + + // Compute Base Rewards For RegionNodeType + // - node_type: type3.1, region: A,B,C, coeff: 0.95, base_rewards: 1500, node_count: 4 + // - Coefficients avg.: avg(0.95,0.95,0.95,0.95) = 0.95 + // - Rewards avg.: avg(1500,1500,1500,1500) = 1500 + // - Total rewards after coefficient reduction: sum(1500 * 1,1500 * 0.95,1500 * 0.9025,1500 * 0.8574) = 5564.8125 + // - Rewards average after coefficient reduction: 5564.8125 / 4 = 1391.2031 + // Compute Unassigned Days Failure Rate + // - Avg. failure rate for node: 6fyp7-3ibaa-aaaaa-aaaap-4ai: avg(0.4,0.2,0.3,0.4) = 0.325 + // - Unassigned days failure rate:: avg(0.325) = 0.325 + // - Rewards reduction percent: (0.325 - 0.1) / (0.6 - 0.1) * 0.8 = 0.360 + // - Reward multiplier fully unassigned nodes:: 1 - 0.360 = 0.640 + // Compute Rewards For Node | node_id=6fyp7-3ibaa-aaaaa-aaaap-4ai, node_type=type3.1, region=A,B,C + // - Base rewards XDRs: 1391.2031 + // - Node status: Assigned + // - Idiosyncratic daily failure rates : 0.4,0.2,0.3,0.4,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325 + // - Failure rate average: avg(0.4,0.2,0.3,0.4,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325,0.325) = 0.325 + // - Rewards reduction percent: (0.325 - 0.1) / (0.6 - 0.1) * 0.8 = 0.360 + // - Reward Multiplier: 1 - 0.360 = 0.640 + // - Rewards XDR for the node: 1391.2031 * 0.640 = 890.3700 + // Compute Rewards For Node | node_id=djduj-3qcaa-aaaaa-aaaap-4ai, node_type=type3.1, region=A,B,C + // - Base rewards XDRs: 1391.2031 + // - Node status: Unassigned + // - Rewards XDR for the node: 1391.2031 * 0.640 = 890.3700 + // Compute Rewards For Node | node_id=6wcs7-uadaa-aaaaa-aaaap-4ai, node_type=type3.1, region=A,B,C + // - Base rewards XDRs: 1391.2031 + // - Node status: Unassigned + // - Rewards XDR for the node: 1391.2031 * 0.640 = 890.3700 + // Compute Rewards For Node | node_id=c5mtj-kieaa-aaaaa-aaaap-4ai, node_type=type3.1, region=A,B,C + // - Base rewards XDRs: 1391.2031 + // - Node status: Unassigned + // - Rewards XDR for the node: 1391.2031 * 0.640 = 890.3700 + // - Compute total permyriad XDR: sum(890.3700,890.3700,890.3700,890.3700) = 3561.4800 + // - Compute total permyriad XDR no performance penalty: sum(1391.2031,1391.2031,1391.2031,1391.2031) = 5564.8125 + // Total rewards XDR permyriad: 3561.4800 + // Total rewards XDR permyriad not adjusted: 5564.8125 + + assert_eq!(rewards.xdr_permyriad, 3561); + assert_eq!(rewards.xdr_permyriad_no_reduction, 5564); +} + +#[test] +fn test_np_rewards_type3_mix() { + let mut logger = RewardsLog::default(); + let node_rewards_table: NodeRewardsTable = mocked_rewards_table(); + let days_in_period = 30; + + // 4 nodes in period: 1 assigned, 3 unassigned + let mut rewardables = (1..=3) + .map(|i| RewardableNode { + node_id: PrincipalId::new_user_test_id(i).into(), + node_provider_id: PrincipalId::new_anonymous(), + region: "A,B,C".to_string(), + node_type: "type3.1".to_string(), + }) + .collect_vec(); + + rewardables.push(RewardableNode { + node_id: PrincipalId::new_user_test_id(4).into(), + node_provider_id: PrincipalId::new_anonymous(), + region: "A,B,D".to_string(), + node_type: "type3".to_string(), + }); + + let mut nodes_idiosyncratic_fr: HashMap> = HashMap::new(); + nodes_idiosyncratic_fr.insert( + PrincipalId::new_user_test_id(3).into(), + vec![dec!(0.1), dec!(0.12), dec!(0.23), dec!(0.12)], + ); + nodes_idiosyncratic_fr.insert( + PrincipalId::new_user_test_id(4).into(), + vec![dec!(0.2), dec!(0.32), dec!(0.123), dec!(0.432)], + ); + + let rewards = node_provider_rewards( + &mut logger, + &rewardables, + nodes_idiosyncratic_fr, + days_in_period, + &node_rewards_table, + ); + + // Compute Base Rewards For RegionNodeType + // - node_type: type3, region: A,B,D, coeff: 0.97, base_rewards: 1000, node_count: 1 + // - node_type: type3.1, region: A,B,C, coeff: 0.95, base_rewards: 1500, node_count: 3 + // - Coefficients avg.: avg(0.97,0.95,0.95,0.95) = 0.9550 + // - Rewards avg.: avg(1000,1500,1500,1500) = 1375 + // - Total rewards after coefficient reduction: sum(1375 * 1,1375 * 0.9550,1375 * 0.9120,1375 * 0.8710) = 5139.7622 + // - Rewards average after coefficient reduction: 5139.7622 / 4 = 1284.9406 + // Compute Unassigned Days Failure Rate + // - Avg. failure rate for node: 6wcs7-uadaa-aaaaa-aaaap-4ai: avg(0.1,0.12,0.23,0.12) = 0.1425 + // - Avg. failure rate for node: c5mtj-kieaa-aaaaa-aaaap-4ai: avg(0.2,0.32,0.123,0.432) = 0.2688 + // - Unassigned days failure rate:: avg(0.1425,0.2688) = 0.2056 + // - Rewards reduction percent: (0.2056 - 0.1) / (0.6 - 0.1) * 0.8 = 0.1690 + // - Reward multiplier fully unassigned nodes:: 1 - 0.1690 = 0.8310 + // Compute Rewards For Node | node_id=6fyp7-3ibaa-aaaaa-aaaap-4ai, node_type=type3.1, region=A,B,C + // - Base rewards XDRs: 1284.9406 + // - Node status: Unassigned + // - Rewards XDR for the node: 1284.9406 * 0.8310 = 1067.7856 + // Compute Rewards For Node | node_id=djduj-3qcaa-aaaaa-aaaap-4ai, node_type=type3.1, region=A,B,C + // - Base rewards XDRs: 1284.9406 + // - Node status: Unassigned + // - Rewards XDR for the node: 1284.9406 * 0.8310 = 1067.7856 + // Compute Rewards For Node | node_id=6wcs7-uadaa-aaaaa-aaaap-4ai, node_type=type3.1, region=A,B,C + // - Base rewards XDRs: 1284.9406 + // - Node status: Assigned + // - Idiosyncratic daily failure rates : 0.1,0.12,0.23,0.12,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250 + // - Failure rate average: avg(0.1,0.12,0.23,0.12,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056) = 0.1972 + // - Rewards reduction percent: (0.1972 - 0.1) / (0.6 - 0.1) * 0.8 = 0.1555 + // - Reward Multiplier: 1 - 0.1555 = 0.8445 + // - Rewards XDR for the node: 1284.9406 * 0.8445 = 1085.0895 + // Compute Rewards For Node | node_id=c5mtj-kieaa-aaaaa-aaaap-4ai, node_type=type3, region=A,B,D + // - Base rewards XDRs: 1284.9406 + // - Node status: Assigned + // - Idiosyncratic daily failure rates : 0.2,0.32,0.123,0.432,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250,0.2056250 + // - Failure rate average: avg(0.2,0.32,0.123,0.432,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056,0.2056) = 0.2140 + // - Rewards reduction percent: (0.2140 - 0.1) / (0.6 - 0.1) * 0.8 = 0.1825 + // - Reward Multiplier: 1 - 0.1825 = 0.8175 + // - Rewards XDR for the node: 1284.9406 * 0.8175 = 1050.4817 + // - Compute total permyriad XDR: sum(1067.7856,1067.7856,1085.0895,1050.4817) = 4271.1424 + // - Compute total permyriad XDR no performance penalty: sum(1284.9406,1284.9406,1284.9406,1284.9406) = 5139.7622 + // Total rewards XDR permyriad: 4271.1424 + // Total rewards XDR permyriad not adjusted: 5139.7622 + + assert_eq!(rewards.xdr_permyriad, 4271); + assert_eq!(rewards.xdr_permyriad_no_reduction, 5139); +} diff --git a/rs/registry/node_provider_rewards/src/v1_types.rs b/rs/registry/node_provider_rewards/src/v1_types.rs new file mode 100644 index 00000000000..52a51cee17d --- /dev/null +++ b/rs/registry/node_provider_rewards/src/v1_types.rs @@ -0,0 +1,84 @@ +use ic_base_types::{NodeId, PrincipalId, SubnetId}; +use ic_management_canister_types::NodeMetricsHistoryResponse; +use num_traits::FromPrimitive; +use rust_decimal::Decimal; +use std::collections::HashMap; +use std::fmt; + +use crate::v1_logs::RewardsLog; + +pub type RegionNodeTypeCategory = (String, String); +pub type TimestampNanos = u64; +pub type SubnetMetricsHistory = (PrincipalId, Vec); + +#[derive(Clone, Hash, Eq, PartialEq)] +pub struct RewardableNode { + pub node_id: NodeId, + pub node_provider_id: PrincipalId, + pub region: String, + pub node_type: String, +} + +#[derive(Clone, Hash, Eq, PartialEq, Debug)] +pub struct DailyNodeMetrics { + pub ts: u64, + pub subnet_assigned: SubnetId, + pub num_blocks_proposed: u64, + pub num_blocks_failed: u64, + pub failure_rate: Decimal, +} + +impl Default for DailyNodeMetrics { + fn default() -> Self { + DailyNodeMetrics { + ts: 0, + subnet_assigned: SubnetId::from(PrincipalId::new_anonymous()), + num_blocks_proposed: 0, + num_blocks_failed: 0, + failure_rate: Decimal::ZERO, + } + } +} +impl fmt::Display for DailyNodeMetrics { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "num_blocks_proposed: {}, num_blocks_failed: {}, failure_rate: {}", + self.num_blocks_proposed, self.num_blocks_failed, self.failure_rate + ) + } +} + +impl DailyNodeMetrics { + pub fn new( + ts: u64, + subnet_assigned: SubnetId, + num_blocks_proposed: u64, + num_blocks_failed: u64, + ) -> Self { + let daily_total = num_blocks_proposed + num_blocks_failed; + let failure_rate = if daily_total == 0 { + Decimal::ZERO + } else { + Decimal::from_f64(num_blocks_failed as f64 / daily_total as f64).unwrap() + }; + DailyNodeMetrics { + ts, + num_blocks_proposed, + num_blocks_failed, + subnet_assigned, + failure_rate, + } + } +} + +pub struct RewardsPerNodeProvider { + pub rewards_per_node_provider: HashMap, + pub rewards_log_per_node_provider: HashMap, +} + +#[derive(Debug, Clone)] +pub struct Rewards { + pub xdr_permyriad: u64, + pub xdr_permyriad_no_reduction: u64, +}