From 01c7ad4cf7a93b7220a7cfdadb5365fc3648de2b Mon Sep 17 00:00:00 2001 From: Theo Butler Date: Sun, 5 May 2024 19:41:12 -0400 Subject: [PATCH 1/2] feat: rework performance tracking --- Cargo.lock | 68 +++--- Cargo.toml | 2 +- candidate-selection/Cargo.toml | 1 - candidate-selection/src/criteria.rs | 2 - candidate-selection/src/criteria/decay.rs | 216 ------------------ .../src/criteria/performance.rs | 194 ---------------- candidate-selection/src/lib.rs | 1 - indexer-selection/Cargo.toml | 1 + indexer-selection/src/lib.rs | 129 ++++++----- indexer-selection/src/performance.rs | 72 ++++++ indexer-selection/src/test.rs | 120 ++++++++-- simulator/Cargo.toml | 10 - simulator/src/main.rs | 160 ------------- 13 files changed, 273 insertions(+), 703 deletions(-) delete mode 100644 candidate-selection/src/criteria.rs delete mode 100644 candidate-selection/src/criteria/decay.rs delete mode 100644 candidate-selection/src/criteria/performance.rs create mode 100644 indexer-selection/src/performance.rs delete mode 100644 simulator/Cargo.toml delete mode 100644 simulator/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 54d0bce..106c1b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,9 +13,9 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c715249705afa1e32be79dabfd35e2ef0f1cc02ad2cf48c9d1e20026ee637b" +checksum = "525448f6afc1b70dd0f9d0a8145631bf2f5e434678ab23ab18409ca264cae6b3" dependencies = [ "alloy-rlp", "bytes", @@ -45,9 +45,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef9a94a27345fb31e3fcb5f5e9f592bb4847493b07fa1e47dd9fde2222f2e28" +checksum = "89c80a2cb97e7aa48611cbb63950336f9824a174cdf670527cc6465078a26ea1" dependencies = [ "alloy-sol-macro-input", "const-hex", @@ -63,9 +63,9 @@ dependencies = [ [[package]] name = "alloy-sol-macro-input" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31fe73cd259527e24dc2dbfe64bc95e5ddfcd2b2731f670a11ff72b2be2c25b" +checksum = "c58894b58ac50979eeac6249661991ac40b9d541830d9a725f7714cc9ef08c23" dependencies = [ "const-hex", "dunce", @@ -78,9 +78,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afaffed78bfb17526375754931e045f96018aa810844b29c7aef823266dd4b4b" +checksum = "399287f68d1081ed8b1f4903c49687658b95b142207d7cb4ae2f4813915343ef" dependencies = [ "alloy-primitives", "alloy-sol-macro", @@ -246,9 +246,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "base16ct" @@ -258,9 +258,9 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" -version = "0.22.0" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9475866fec1451be56a3c2400fd081ff546538961565ccb5b7142cbd22bc7a51" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" @@ -352,15 +352,14 @@ version = "0.1.0" dependencies = [ "arrayvec", "ordered-float", - "permutation", "proptest", ] [[package]] name = "cc" -version = "1.0.96" +version = "1.0.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "065a29261d53ba54260972629f9ca6bffa69bac13cd1fed61420f7fa68b9f8bd" +checksum = "099a5357d84c4c61eb35fc8eafa9a79a902c2f76911e5747ced4e032edd8d9b4" [[package]] name = "cfg-if" @@ -781,9 +780,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b22e06ecb0110981051723910cbf0b5f5e09a2062dd7663334ee79a9d1286c" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", "libc", @@ -932,6 +931,7 @@ version = "0.1.0" dependencies = [ "candidate-selection", "custom_debug", + "permutation", "proptest", "thegraph-core", "url", @@ -1088,9 +1088,9 @@ dependencies = [ [[package]] name = "num-traits" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", "libm", @@ -1203,9 +1203,9 @@ checksum = "df202b0b0f5b8e389955afd5f27b007b00fb948162953f1db9c70d2c7e3157d7" [[package]] name = "pest" -version = "2.7.9" +version = "2.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "311fb059dee1a7b802f036316d790138c613a4e8b180c822e3925a662e9f0c95" +checksum = "560131c633294438da9f7c4b08189194b20946c8274c6b9e38881a7874dc8ee8" dependencies = [ "memchr", "thiserror", @@ -1605,18 +1605,18 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.199" +version = "1.0.200" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9f6e76df036c77cd94996771fb40db98187f096dd0b9af39c6c6e452ba966a" +checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.199" +version = "1.0.200" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11bd257a6541e141e42ca6d24ae26f7714887b47e89aa739099104c7e4d3b7fc" +checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb" dependencies = [ "proc-macro2", "quote", @@ -1705,16 +1705,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "simulator" -version = "0.1.0" -dependencies = [ - "candidate-selection", - "indexer-selection", - "rand", - "thegraph-core", -] - [[package]] name = "spki" version = "0.7.3" @@ -1789,9 +1779,9 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70aba06097b6eda3c15f6eebab8a6339e121475bcf08bbe6758807e716c372a1" +checksum = "5aa0cefd02f532035d83cfec82647c6eb53140b0485220760e669f4bad489e36" dependencies = [ "paste", "proc-macro2", @@ -1830,9 +1820,9 @@ dependencies = [ [[package]] name = "thegraph-core" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a631d199e2ebee326994005b884d3a3ee9b09e9e9204e953e1814fbd11687ecf" +checksum = "b0f179034f9427c1238d3024f771ac1b728562c27491f219b9abd831855d5b7e" dependencies = [ "alloy-primitives", "alloy-sol-types", diff --git a/Cargo.toml b/Cargo.toml index b91a366..87fda4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] resolver = "2" -members = ["candidate-selection", "indexer-selection", "simulator"] +members = ["candidate-selection", "indexer-selection"] diff --git a/candidate-selection/Cargo.toml b/candidate-selection/Cargo.toml index 08ef8fe..562b7fc 100644 --- a/candidate-selection/Cargo.toml +++ b/candidate-selection/Cargo.toml @@ -6,5 +6,4 @@ edition = "2021" [dependencies] arrayvec = "0.7.4" ordered-float = { version = "4.2.0", default-features = false } -permutation = "0.4.1" proptest = "1.4.0" diff --git a/candidate-selection/src/criteria.rs b/candidate-selection/src/criteria.rs deleted file mode 100644 index 87c3c69..0000000 --- a/candidate-selection/src/criteria.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod decay; -pub mod performance; diff --git a/candidate-selection/src/criteria/decay.rs b/candidate-selection/src/criteria/decay.rs deleted file mode 100644 index 5332eae..0000000 --- a/candidate-selection/src/criteria/decay.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::time::Duration; - -/// DecayBuffer approximates a histogram of data points over time to inform a prediction. Data -/// points are collected in the first (current) bin. Each call to `decay` rotates the bins to the -/// right and resets the current bin. The information stored in each bin is decayed away at a rate -/// of `1 - (0.001 * D)` per decay cycle (https://www.desmos.com/calculator/7kfwwvtkc1). -/// -/// We'll consider query count for this example: -/// e.g. [c_0, c_1, c_2, ..., c_5461] where c_i is the count time T-i. -/// Imagine we get a query roughly once per decay, we could see something like: -/// [1, 0, 2, 0, 0, 1, ..., 2] -/// As a cycle passes, we shift the data down because T-0 is now T-1 and T-500 is now T-501. So -/// shifting gives us this: -/// [0, 1, 0, 2, 0, 0, ..., 2, 2] -/// (The final 1 disappeared into the first member of the ellipsis, and the 2 popped out from the -/// last member of the ellipsis) -/// -/// There is no actual decay yet in the above description. Note though that if we shift multiple -/// times the sum should be the same for a while. -/// e.g. [1, 0, 0, ...] -> [0, 1, 0, ...] -> [0, 0, 1, ...] -/// The sum of all frames here is 1 until the 1 drops off the end. -/// -/// The purpose of the decay is to weigh more recent data exponentially more than old data. If the -/// decay per frame is 1% then we would get approximately this: -/// [1, 0, 0, ...] -> [0, .99, 0, ...] -> [0, 0, .98] -/// (This looks linear, but is exponential I've just rounded the numbers). -/// -/// Note that every time we call decay, the sum of all values decreases. -/// -/// We consider the accuracy of timestamp of recent data is more important than the accuracy of -/// timestamp of old data. For example, it's useful to know if a failed request happened 2 seconds -/// ago vs 12 seconds ago. But less useful to know whether it happened 1002 vs 1012 seconds ago even -/// though that's the same duration. So for the approximation of our histogram, we use time frames -/// with intervals of F consecutive powers of 4. -/// e.g. [1, 4, 16, 64] if F = 4 - -#[derive(Clone, Debug)] -pub struct DecayBuffer { - frames: [T; F], -} - -pub trait Decay { - fn decay(&mut self, prev: &Self, retain: f64, take: f64); -} - -impl Default for DecayBuffer -where - [T; F]: Default, -{ - fn default() -> Self { - debug_assert!(F > 0); - debug_assert!(D < 1000); - Self { - frames: Default::default(), - } - } -} - -impl DecayBuffer -where - Self: Default, -{ - pub fn new() -> Self { - Default::default() - } -} - -impl DecayBuffer { - pub fn current_mut(&mut self) -> &mut T { - &mut self.frames[0] - } - - pub fn frames(&self) -> &[T] { - &self.frames - } - - pub fn map<'a, I>(&'a self, f: impl FnMut(&T) -> I + 'a) -> impl Iterator + 'a { - self.frames.iter().map(f) - } -} - -impl DecayBuffer -where - T: Decay + Default, -{ - pub fn decay(&mut self) { - // BQN: (1-1e¯3×d)×((1-4⋆-↕f)×⊢)+(«4⋆-↕f)×⊢ - // LLVM should be capable of constant folding & unrolling this loop nicely. - // https://rust.godbolt.org/z/K13dj78Ge - for i in (1..self.frames.len()).rev() { - let retain = 1.0 - 4_f64.powi(-(i as i32)); - let take = 4_f64.powi(-(i as i32 - 1)); - let decay = 1.0 - 1e-3 * D as f64; - let (cur, prev) = self.frames[..=i].split_last_mut().unwrap(); - cur.decay(prev.last().unwrap(), retain * decay, take * decay); - } - self.frames[0] = T::default(); - } -} - -impl Decay for f64 { - fn decay(&mut self, prev: &Self, retain: f64, take: f64) { - *self = (*self * retain) + (prev * take); - } -} - -impl Decay for Duration { - fn decay(&mut self, prev: &Self, retain: f64, take: f64) { - let mut v = self.as_secs_f64(); - v.decay(&prev.as_secs_f64(), retain, take); - *self = Duration::from_secs_f64(v); - } -} - -// This could have been done more automatically by using a proc-macro, but this is simpler. -#[macro_export] -macro_rules! impl_struct_decay { - ($name:ty {$($field:ident),*}) => { - impl decay::Decay for $name { - fn decay(&mut self, prev: &Self, retain: f64, take: f64) { - // Doing a destructure ensures that we don't miss any fields, should they be added - // in the future. I tried it and the compiler even gives you a nice error message: - // - // missing structure fields: - // -{name} - let Self { $($field: _),* } = self; - $( - self.$field.decay(&prev.$field, retain, take); - )* - } - } - }; -} - -#[cfg(test)] -mod test { - use super::*; - use crate::num::assert_within; - use arrayvec::ArrayVec; - use std::iter; - - struct Model(Vec); - - impl Model { - fn new() -> Self { - Self((0..F).flat_map(|i| iter::repeat(0.0).take(w(i))).collect()) - } - - fn decay(&mut self) { - // BQN: »(1-d×1e¯3)×⊢ - for x in &mut self.0 { - *x *= 1.0 - 1e-3 * D as f64; - } - self.0.rotate_right(1); - self.0[0] = 0.0; - } - - fn frames(&self) -> ArrayVec { - (0..F) - .scan(0, |i, f| { - let offset = *i; - let len = w(f); - *i += len; - Some(self.0[offset..][..len].iter().sum::()) - }) - .collect() - } - } - - fn w(i: usize) -> usize { - 4_u64.pow(i as u32) as usize - } - - #[test] - fn test() { - model_check::<7, 0>(); - model_check::<7, 1>(); - model_check::<7, 5>(); - model_check::<7, 10>(); - } - - fn model_check() - where - [f64; F]: Default, - { - let mut model = Model::::new(); - let mut buf = DecayBuffer::::default(); - - for _ in 0..1000 { - model.0[0] = 1.0; - model.decay(); - *buf.current_mut() = 1.0; - buf.decay(); - - let value = buf.frames().iter().sum::(); - let expected = model.frames().iter().sum::(); - - println!("---",); - println!("{:.2e} {}", expected, show(&model.frames())); - println!("{:.2e} {}", value, show(buf.frames())); - println!("{}", (value - expected) / expected); - - assert_within(value, expected, 0.013 * expected); - } - } - - fn show(v: &[f64]) -> String { - format!( - "[{}]", - v.iter() - .map(|f| format!("{f:.2e}")) - .collect::>() - .join(" ") - ) - } -} diff --git a/candidate-selection/src/criteria/performance.rs b/candidate-selection/src/criteria/performance.rs deleted file mode 100644 index a0a9b01..0000000 --- a/candidate-selection/src/criteria/performance.rs +++ /dev/null @@ -1,194 +0,0 @@ -use super::decay::{self, DecayBuffer}; -use crate::{impl_struct_decay, Normalized}; -use arrayvec::ArrayVec; -use ordered_float::NotNan; -use std::fmt::Debug; - -#[derive(Clone, Copy, Debug)] -pub struct ExpectedPerformance { - pub success_rate: Normalized, - pub latency_success_ms: u32, - pub latency_failure_ms: u32, -} - -impl ExpectedPerformance { - pub fn latency_ms(&self) -> u32 { - let p = self.success_rate.as_f64(); - ((p * self.latency_success_ms as f64) + ((1.0 - p) * self.latency_failure_ms as f64)) as u32 - } -} - -/// Tracks success rate & expected latency in milliseconds. For information decay to take effect, -/// `decay` must be called periodically at 1 second intervals. -#[derive(Default)] -pub struct Performance { - latency_success: DecayBuffer, - latency_failure: DecayBuffer, -} - -#[derive(Default)] -struct Frame { - total_latency_ms: f64, - response_count: f64, -} - -impl_struct_decay!(Frame { - total_latency_ms, - response_count -}); - -impl Debug for Performance { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let dbg_frame = |f: &Frame| -> f64 { f.total_latency_ms / f.response_count.max(1.0) }; - f.debug_struct("Performance") - .field( - "latency_success", - &self.latency_success.map(dbg_frame).collect::>(), - ) - .field( - "latency_failure", - &self.latency_failure.map(dbg_frame).collect::>(), - ) - .finish() - } -} - -impl Performance { - pub fn decay(&mut self) { - self.latency_success.decay(); - self.latency_failure.decay(); - } - - pub fn feedback(&mut self, success: bool, latency_ms: u32) { - let data_set = if success { - self.latency_success.current_mut() - } else { - self.latency_failure.current_mut() - }; - data_set.total_latency_ms += latency_ms as f64; - data_set.response_count += 1.0; - } - - pub fn expected_performance(&self) -> ExpectedPerformance { - ExpectedPerformance { - success_rate: self.success_rate(), - latency_success_ms: *self.latency_success() as u32, - latency_failure_ms: *self.latency_failure() as u32, - } - } - - fn success_rate(&self) -> Normalized { - let mut successful_responses: f64 = self.latency_success.map(|f| f.response_count).sum(); - // This results in decay pulling success rate upward. - successful_responses += 1.0; - let failed_responses: f64 = self.latency_failure.map(|f| f.response_count).sum(); - Normalized::new(successful_responses / (successful_responses + failed_responses).max(1.0)) - .unwrap() - } - - fn latency_success(&self) -> NotNan { - let total_latency: f64 = self.latency_success.map(|f| f.total_latency_ms).sum(); - let total_responses: f64 = self.latency_success.map(|f| f.response_count).sum(); - NotNan::new(total_latency / total_responses.max(1.0)).unwrap() - } - - fn latency_failure(&self) -> NotNan { - let total_latency: f64 = self.latency_failure.map(|f| f.total_latency_ms).sum(); - let total_responses: f64 = self.latency_failure.map(|f| f.response_count).sum(); - NotNan::new(total_latency / total_responses.max(1.0)).unwrap() - } -} - -/// Given some combination of selected candidates, return an array of the corresponding -/// probabilities that each candidate's response will be used. This assumes that requests are made -/// in parallel, and that only the first successful response is used. -/// -/// For example, with millisecond latencies on successful response `ls = [50, 20, 200]` and success -/// rates `ps = [0.99, 0.5, 0.8]`, the result will be `r = [0.495, 0.5, 0.004]`. To get the expected -/// value for latency, do `ls.iter().zip(r).map(|(l, r)| l.recip() * r).sum().recip()`. The -/// `recip()` calls are only necessary to avoid the expected value tending toward zero when success -/// rates are low (because, for latency, lower is better). -pub fn expected_value_probabilities( - selections: &[ExpectedPerformance], -) -> ArrayVec { - let mut ps: ArrayVec = selections.iter().map(|p| p.success_rate).collect(); - let mut ls: ArrayVec, LIMIT> = selections - .iter() - .map(|p| NotNan::new(p.latency_success_ms as f64).unwrap()) - .collect(); - - let mut sort = permutation::sort_unstable_by_key(&mut ls, |r| *r); - sort.apply_slice_in_place(&mut ps); - sort.apply_slice_in_place(&mut ls); - - let pf: ArrayVec = ps - .iter() - .map(|p| 1.0 - p.as_f64()) - .scan(1.0, |s, x| { - *s *= x; - Some(*s) - }) - .collect(); - let mut ps: ArrayVec = std::iter::once(&1.0) - .chain(&pf) - .take(LIMIT) - .zip(&ps) - .map(|(&p, &r)| Normalized::new(p).unwrap() * r) - .collect(); - - sort.inverse().apply_slice_in_place(&mut ps); - ps -} - -#[cfg(test)] -mod test { - use super::Performance; - use crate::{criteria::performance::ExpectedPerformance, num::assert_within, Normalized}; - use arrayvec::ArrayVec; - - #[test] - fn expected_value_probabilities_example() { - let mut candidates: [Performance; 3] = Default::default(); - - for _ in 0..99 { - candidates[0].feedback(true, 50); - } - candidates[0].feedback(false, 50); - assert_within(candidates[0].success_rate().as_f64(), 0.99, 0.001); - assert_eq!(candidates[0].expected_performance().latency_ms(), 50); - - candidates[1].feedback(true, 20); - candidates[1].feedback(false, 20); - assert_within(candidates[1].success_rate().as_f64(), 0.66, 0.01); - assert_eq!(candidates[1].expected_performance().latency_ms(), 20); - - for _ in 0..4 { - candidates[2].feedback(true, 200); - } - candidates[2].feedback(false, 200); - assert_within(candidates[2].success_rate().as_f64(), 0.8, 0.1); - assert_eq!(candidates[2].expected_performance().latency_ms(), 200); - - let selections: ArrayVec = candidates - .iter() - .map(|c| c.expected_performance()) - .collect(); - let result: ArrayVec = super::expected_value_probabilities(&selections); - - assert_within(result[0].as_f64(), 0.33, 1e-4); - assert_within(result[1].as_f64(), 0.6666, 1e-4); - assert_within(result[2].as_f64(), 0.0028, 1e-4); - - let latencies: ArrayVec = candidates - .iter() - .map(|c| c.expected_performance().latency_ms()) - .collect(); - let expected_latency = latencies - .iter() - .zip(&result) - .map(|(l, r)| (*l as f64).recip() * r.as_f64()) - .sum::() - .recip(); - assert_within(expected_latency, 25.00, 0.1); - } -} diff --git a/candidate-selection/src/lib.rs b/candidate-selection/src/lib.rs index 5cd4d31..cae9ed9 100644 --- a/candidate-selection/src/lib.rs +++ b/candidate-selection/src/lib.rs @@ -1,4 +1,3 @@ -pub mod criteria; pub mod num; #[cfg(test)] mod test; diff --git a/indexer-selection/Cargo.toml b/indexer-selection/Cargo.toml index 58f6d15..dca4544 100644 --- a/indexer-selection/Cargo.toml +++ b/indexer-selection/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] candidate-selection = { path = "../candidate-selection" } custom_debug = "0.6.1" +permutation = "0.4.1" thegraph-core = "0.3.1" url = "2.5.0" diff --git a/indexer-selection/src/lib.rs b/indexer-selection/src/lib.rs index 24c7ea4..8e7fe97 100644 --- a/indexer-selection/src/lib.rs +++ b/indexer-selection/src/lib.rs @@ -1,20 +1,19 @@ -use std::collections::hash_map::DefaultHasher; -use std::f64::consts::E; -use std::fmt::Display; -use std::hash::{Hash as _, Hasher as _}; +mod performance; +#[cfg(test)] +mod test; +pub use crate::performance::Performance; +pub use candidate_selection::{ArrayVec, Normalized}; use custom_debug::CustomDebug; -use thegraph_core::types::alloy_primitives::Address; -use thegraph_core::types::DeploymentId; +use std::{ + collections::hash_map::DefaultHasher, + f64::consts::E, + fmt::Display, + hash::{Hash as _, Hasher as _}, +}; +use thegraph_core::types::{alloy_primitives::Address, DeploymentId}; use url::Url; -use candidate_selection::criteria::performance::expected_value_probabilities; -pub use candidate_selection::criteria::performance::{ExpectedPerformance, Performance}; -pub use candidate_selection::{ArrayVec, Normalized}; - -#[cfg(test)] -mod test; - #[derive(CustomDebug)] pub struct Candidate { pub indexer: Address, @@ -22,13 +21,22 @@ pub struct Candidate { #[debug(with = Display::fmt)] pub url: Url, pub perf: ExpectedPerformance, + /// fee as a fraction of the budget pub fee: Normalized, + /// seconds behind chain head pub seconds_behind: u32, pub slashable_grt: u64, - pub subgraph_versions_behind: u8, + /// subgraph versions behind + pub versions_behind: u8, pub zero_allocation: bool, } +#[derive(Clone, Copy, Debug)] +pub struct ExpectedPerformance { + pub success_rate: Normalized, + pub latency_ms_p99: u16, +} + pub fn select(candidates: &[Candidate]) -> ArrayVec<&Candidate, LIMIT> { candidate_selection::select(candidates) } @@ -50,10 +58,10 @@ impl candidate_selection::Candidate for Candidate { fn score(&self) -> Normalized { [ score_success_rate(self.perf.success_rate), - score_latency(self.perf.latency_ms()), + score_latency(self.perf.latency_ms_p99), score_seconds_behind(self.seconds_behind), score_slashable_grt(self.slashable_grt), - score_subgraph_versions_behind(self.subgraph_versions_behind), + score_versions_behind(self.versions_behind), score_zero_allocation(self.zero_allocation), ] .into_iter() @@ -66,52 +74,50 @@ impl candidate_selection::Candidate for Candidate { return Normalized::ZERO; } - let perf: ArrayVec = - candidates.iter().map(|c| c.perf).collect(); - let p = expected_value_probabilities::(&perf); - - let success_rate = - Normalized::new(p.iter().map(|p| p.as_f64()).sum()).unwrap_or(Normalized::ONE); - let latency = candidates - .iter() - .map(|c| c.perf.latency_ms() as f64) - .zip(&p) - .map(|(x, p)| x.recip() * p.as_f64()) - .sum::() - .recip() as u32; - let subgraph_versions_behind = candidates - .iter() - .map(|c| c.subgraph_versions_behind) - .zip(&p) - .map(|(x, p)| x as f64 * p.as_f64()) - .sum::() as u8; - let seconds_behind = candidates - .iter() - .map(|c| c.seconds_behind) - .zip(&p) - .map(|(x, p)| x as f64 * p.as_f64()) - .sum::() as u32; - let slashable_grt = candidates - .iter() - .map(|c| c.slashable_grt) - .zip(&p) - .map(|(x, p)| x as f64 * p.as_f64()) - .sum::() as u64; - let p_zero_allocation = candidates + // candidate latencies + let ls: ArrayVec = candidates.iter().map(|c| c.perf.latency_ms_p99).collect(); + // probability of candidate responses returning to client, based on `ls` + let ps = { + let mut ps: ArrayVec = + candidates.iter().map(|c| c.perf.success_rate).collect(); + let mut ls = ls.clone(); + let mut sort = permutation::sort_unstable(&mut ls); + sort.apply_slice_in_place(&mut ls); + sort.apply_slice_in_place(&mut ps); + let pf: ArrayVec = ps + .iter() + .map(|p| 1.0 - p.as_f64()) + .scan(1.0, |s, x| { + *s *= x; + Some(*s) + }) + .collect(); + let mut ps: ArrayVec = std::iter::once(&1.0) + .chain(&pf) + .take(LIMIT) + .zip(&ps) + .map(|(&p, &s)| p * s.as_f64()) + .collect(); + sort.inverse().apply_slice_in_place(&mut ps); + ps + }; + + let success_rate = Normalized::new(ps.iter().sum()).unwrap_or(Normalized::ONE); + // perform calculation under inversion to pull values toward infinity rather than zero + let latency = ls .iter() - .map(|c| c.zero_allocation) - .zip(&p) - .map(|(x, p)| x as u8 as f64 * p.as_f64()) + .zip(&ps) + .map(|(l, p)| (*l as f64).recip() * p) .sum::() - > 0.5; + .recip() as u16; [ score_success_rate(success_rate), score_latency(latency), - score_seconds_behind(seconds_behind), - score_slashable_grt(slashable_grt), - score_subgraph_versions_behind(subgraph_versions_behind), - score_zero_allocation(p_zero_allocation), + score_seconds_behind(candidates.iter().map(|c| c.seconds_behind).max().unwrap()), + score_slashable_grt(candidates.iter().map(|c| c.slashable_grt).min().unwrap()), + score_versions_behind(candidates.iter().map(|c| c.versions_behind).max().unwrap()), + score_zero_allocation(candidates.iter().all(|c| c.zero_allocation)), ] .into_iter() .product() @@ -121,9 +127,10 @@ impl candidate_selection::Candidate for Candidate { // When picking curves to use consider the following reference: // https://en.wikipedia.org/wiki/Logistic_function -/// Avoid serving deployments at versions behind, unless newer versions have poor indexer support. -fn score_subgraph_versions_behind(subgraph_versions_behind: u8) -> Normalized { - Normalized::new(0.25_f64.powi(subgraph_versions_behind as i32)).unwrap() +/// Avoid serving subgraph versions prior to the latest, unless newer versions have poor indexer +/// support. +fn score_versions_behind(versions_behind: u8) -> Normalized { + Normalized::new(0.25_f64.powi(versions_behind as i32)).unwrap() } /// https://www.desmos.com/calculator/gzmp7rbiai @@ -152,8 +159,8 @@ fn score_zero_allocation(zero_allocation: bool) -> Normalized { } /// https://www.desmos.com/calculator/v2vrfktlpl -pub fn score_latency(latency_ms: u32) -> Normalized { - let s = |x: u32| 1.0 + E.powf(((x as f64) - 400.0) / 300.0); +pub fn score_latency(latency_ms: u16) -> Normalized { + let s = |x: u16| 1.0 + E.powf(((x as f64) - 400.0) / 300.0); Normalized::new(s(0) / s(latency_ms)).unwrap() } diff --git a/indexer-selection/src/performance.rs b/indexer-selection/src/performance.rs new file mode 100644 index 0000000..e2ea814 --- /dev/null +++ b/indexer-selection/src/performance.rs @@ -0,0 +1,72 @@ +use candidate_selection::Normalized; + +#[derive(Clone, Default)] +pub struct Performance { + /// histogram of response latency, in milliseconds + pub latency_hist: [f32; 29], + pub failure_count: f64, +} + +const LATENCY_BINS: [u16; 29] = [ + 32, 64, 96, 128, // + 2^5 + 192, 256, 320, 384, // + 2^6 + 512, 640, 768, 896, // + 2^7 + 1152, 1408, 1664, 1920, // + 2^8 + 2432, 2944, 3456, 3968, // + 2^9 + 4992, 6016, 7040, 8064, // + 2^10 + 10112, 12160, 14208, 16256, // + 2^11 + 20352, // + 2^12 +]; + +impl Performance { + pub fn decay(&mut self) { + let retain = 0.90; + self.failure_count *= retain; + for l in &mut self.latency_hist { + *l *= retain as f32; + } + } + + pub fn feedback(&mut self, success: bool, latency_ms: u16) { + if !success { + self.failure_count += 1.0; + } + + for (count, bin_value) in self + .latency_hist + .iter_mut() + .zip(&LATENCY_BINS) + .take(LATENCY_BINS.len() - 1) + { + if latency_ms <= *bin_value { + *count += 1.0; + return; + } + } + *self.latency_hist.last_mut().unwrap() += 1.0; + } + + pub fn success_rate(&self) -> Normalized { + let s = self.success_count() + 1.0; + let f = self.failure_count; + Normalized::new(s / (s + f)).unwrap() + } + + pub fn latency_percentile(&self, p: u8) -> u16 { + debug_assert!((1..=99).contains(&p)); + let target = (self.success_count() + self.failure_count) * (p as f64 / 100.0); + let mut sum = 0.0; + for (count, bin_value) in self.latency_hist.iter().zip(&LATENCY_BINS) { + sum += *count as f64; + if sum >= target { + return *bin_value; + } + } + panic!("failed to calculate latency percentile"); + } + + fn success_count(&self) -> f64 { + let s = self.latency_hist.iter().map(|c| *c as u64).sum::(); + (s as f64 - self.failure_count).max(0.0) + } +} diff --git a/indexer-selection/src/test.rs b/indexer-selection/src/test.rs index c3f9202..287b264 100644 --- a/indexer-selection/src/test.rs +++ b/indexer-selection/src/test.rs @@ -13,13 +13,12 @@ fn candidate_should_use_url_display_for_debug() { url: expected_url.parse().expect("valid url"), perf: ExpectedPerformance { success_rate: Normalized::ZERO, - latency_success_ms: 0, - latency_failure_ms: 0, + latency_ms_p99: 0, }, fee: Normalized::ZERO, seconds_behind: 0, slashable_grt: 0, - subgraph_versions_behind: 0, + versions_behind: 0, zero_allocation: false, }; assert!(format!("{candidate:?}").contains(expected_url)); @@ -42,7 +41,7 @@ prop_compose! { prop_compose! { fn candidate()( fee in normalized(), - subgraph_versions_behind in 0..=3_u8, + versions_behind in 0..=3_u8, seconds_behind in 0..=7500_u16, slashable_grt: u32, zero_allocation: bool, @@ -50,25 +49,25 @@ prop_compose! { avg_success_rate_percent in 0..=100_u8, ) -> Candidate { let mut deployment_bytes = [0; 32]; - deployment_bytes[0] = subgraph_versions_behind; + deployment_bytes[0] = versions_behind; let mut performance = Performance::default(); for _ in 0..avg_success_rate_percent { - performance.feedback(true, avg_latency_ms as u32); + performance.feedback(true, avg_latency_ms); } for _ in avg_success_rate_percent..100 { - performance.feedback(false, avg_latency_ms as u32); + performance.feedback(false, avg_latency_ms); } Candidate { indexer: Default::default(), deployment: deployment_bytes.into(), url: "https://example.com".parse().unwrap(), - perf: performance.expected_performance(), + perf: ExpectedPerformance { success_rate: performance.success_rate(), latency_ms_p99: performance.latency_percentile(99) }, fee, seconds_behind: seconds_behind as u32, slashable_grt: slashable_grt as u64, - subgraph_versions_behind, + versions_behind, zero_allocation, } } @@ -117,13 +116,12 @@ fn sensitivity_seconds_behind() { url: "https://example.com".parse().unwrap(), perf: ExpectedPerformance { success_rate: Normalized::ONE, - latency_success_ms: 0, - latency_failure_ms: 0, + latency_ms_p99: 0, }, fee: Normalized::ZERO, seconds_behind: 86400, slashable_grt: 1_000_000, - subgraph_versions_behind: 0, + versions_behind: 0, zero_allocation: false, }, Candidate { @@ -132,26 +130,27 @@ fn sensitivity_seconds_behind() { .into(), url: "https://example.com".parse().unwrap(), perf: ExpectedPerformance { - success_rate: Normalized::new(0.5).unwrap(), - latency_success_ms: 1000, - latency_failure_ms: 1000, + success_rate: Normalized::new(0.50).unwrap(), + latency_ms_p99: 1_000, }, fee: Normalized::ONE, seconds_behind: 120, slashable_grt: 100_000, - subgraph_versions_behind: 0, + versions_behind: 0, zero_allocation: false, }, ]; println!( - "score {} {:?}", + "score {} {:?} {:?}", candidates[0].indexer, + candidates[0].fee, candidates[0].score(), ); println!( - "score {} {:?}", + "score {} {:?} {:?}", candidates[1].indexer, + candidates[1].fee, candidates[1].score(), ); assert!(candidates[0].score() <= candidates[1].score()); @@ -164,3 +163,88 @@ fn sensitivity_seconds_behind() { "select candidate closer to chain head", ); } + +#[test] +fn perf_decay() { + let mut perf = Performance::default(); + let mut candidate = Candidate { + indexer: hex!("0000000000000000000000000000000000000000").into(), + deployment: hex!("0000000000000000000000000000000000000000000000000000000000000000").into(), + url: "https://example.com".parse().unwrap(), + perf: ExpectedPerformance { + success_rate: perf.success_rate(), + latency_ms_p99: perf.latency_percentile(99), + }, + fee: Normalized::ZERO, + seconds_behind: 0, + slashable_grt: 1_000_000, + versions_behind: 0, + zero_allocation: false, + }; + + let mut simulate = |seconds, success, latency_ms| { + let feedback_hz = 20; + for _ in 0..seconds { + for _ in 0..feedback_hz { + perf.feedback(success, latency_ms); + } + perf.decay(); + } + candidate.perf = ExpectedPerformance { + success_rate: perf.success_rate(), + latency_ms_p99: perf.latency_percentile(99), + }; + candidate.score() + }; + + let s0 = simulate(120, true, 200).as_f64(); + let s1 = simulate(2, false, 10).as_f64(); + let s2 = simulate(2, true, 200).as_f64(); + let s3 = simulate(120, true, 200).as_f64(); + + println!("{s0:.4}, {s1:.4}, {s2:.4}, {s3:.4}"); + assert_within(s1, s0 * 0.20, 0.05); // fast response + assert_within(s2, s0 * 0.30, 0.10); // slower recovery + assert_within(s3, s0 * 1.00, 0.01); // recovery +} + +#[test] +fn perf_combine() { + let candidates = [ + Candidate { + indexer: hex!("0000000000000000000000000000000000000000").into(), + deployment: hex!("0000000000000000000000000000000000000000000000000000000000000000") + .into(), + url: "https://example.com".parse().unwrap(), + perf: ExpectedPerformance { + success_rate: Normalized::new(0.90).unwrap(), + latency_ms_p99: 200, + }, + fee: Normalized::ZERO, + seconds_behind: 0, + slashable_grt: 100_000, + versions_behind: 0, + zero_allocation: false, + }, + Candidate { + indexer: hex!("0000000000000000000000000000000000000001").into(), + deployment: hex!("0000000000000000000000000000000000000000000000000000000000000000") + .into(), + url: "https://example.com".parse().unwrap(), + perf: ExpectedPerformance { + success_rate: Normalized::new(0.90).unwrap(), + latency_ms_p99: 150, + }, + fee: Normalized::ZERO, + seconds_behind: 0, + slashable_grt: 100_000, + versions_behind: 0, + zero_allocation: false, + }, + ]; + let combined_score = Candidate::score_many::<3>(&[&candidates[0], &candidates[1]]); + assert!(candidates[0].score() < combined_score); + assert!(candidates[1].score() < combined_score); + let selections: ArrayVec<&Candidate, 3> = crate::select(&candidates); + assert!(selections.len() == 2); +} diff --git a/simulator/Cargo.toml b/simulator/Cargo.toml deleted file mode 100644 index 0dfb10d..0000000 --- a/simulator/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "simulator" -version = "0.1.0" -edition = "2021" - -[dependencies] -candidate-selection = { path = "../candidate-selection" } -indexer-selection = { path = "../indexer-selection" } -rand = "0.8.5" -thegraph-core = "0.3.1" diff --git a/simulator/src/main.rs b/simulator/src/main.rs deleted file mode 100644 index 8ff25fc..0000000 --- a/simulator/src/main.rs +++ /dev/null @@ -1,160 +0,0 @@ -use std::{collections::BTreeMap, io::stdin, time::Instant}; - -use rand::{thread_rng, Rng as _}; -use thegraph_core::types::alloy_primitives::Address; - -use candidate_selection::{ - criteria::performance::Performance, num::assert_within, ArrayVec, Normalized, -}; -use indexer_selection::{select, Candidate}; - -struct IndexerCharacteristics { - address: Address, - success_rate: Normalized, - latency_ms: u32, - fee_usd: f64, - seconds_behind: u32, - slashable_grt: u64, - zero_allocation: bool, -} - -fn main() { - let header = - "address,fee_usd,seconds_behind,latency_ms,success_rate,slashable_grt,zero_allocation"; - let characteristics: Vec = stdin() - .lines() - .filter_map(|line| { - let line = line.unwrap(); - if line.starts_with(header) { - return None; - } - let fields = line.split(',').collect::>(); - Some(IndexerCharacteristics { - address: fields[0].parse().expect("address"), - fee_usd: fields[1].parse().expect("fee_usd"), - seconds_behind: fields[2].parse().expect("seconds_behind"), - latency_ms: fields[3].parse().expect("latency_ms"), - success_rate: fields[4] - .parse::() - .ok() - .and_then(Normalized::new) - .expect("success_rate"), - slashable_grt: fields[5].parse().expect("slashable_grt"), - zero_allocation: fields[6].parse().expect("zero_allocation"), - }) - }) - .collect(); - - let mut rng = thread_rng(); - - let mut perf: BTreeMap = characteristics - .iter() - .map(|c| { - let mut perf = Performance::default(); - for _ in 0..10000 { - perf.feedback(rng.gen_bool(c.success_rate.as_f64()), c.latency_ms); - } - let expected = perf.expected_performance(); - assert_within(expected.latency_ms() as f64, c.latency_ms as f64, 1.0); - assert_within( - expected.success_rate.as_f64(), - c.success_rate.as_f64(), - 0.01, - ); - (c.address, perf) - }) - .collect(); - - let total_client_queries = 10_000; - let mut total_selection_μs = 0; - let mut total_latency_ms: u64 = 0; - let mut total_successes: u64 = 0; - let mut total_seconds_behind: u64 = 0; - let mut total_fees_usd = 0.0; - - let budget = 20e-6; - let client_queries_per_second = 100; - for client_query_index in 0..total_client_queries { - if (client_query_index % client_queries_per_second) == 0 { - for p in perf.values_mut() { - p.decay(); - } - } - - let candidates: Vec = characteristics - .iter() - .map(|c| Candidate { - indexer: c.address, - deployment: [0; 32].into(), - url: "https://example.com".parse().unwrap(), - perf: perf.get(&c.address).unwrap().expected_performance(), - fee: Normalized::new(c.fee_usd / budget).expect("invalid fee or budget"), - seconds_behind: c.seconds_behind, - slashable_grt: c.slashable_grt, - subgraph_versions_behind: 0, - zero_allocation: c.zero_allocation, - }) - .collect(); - - let t0 = Instant::now(); - let selections: ArrayVec<&Candidate, 3> = select(&candidates); - total_selection_μs += Instant::now().duration_since(t0).as_micros(); - total_fees_usd += selections - .iter() - .map(|c| c.fee.as_f64() * budget) - .sum::(); - - struct IndexerOutcome { - indexer: Address, - latency_ms: u32, - success: bool, - seconds_behind: u32, - } - let mut indexer_query_outcomes: ArrayVec = selections - .iter() - .map(|c| IndexerOutcome { - indexer: c.indexer, - latency_ms: c.perf.latency_ms(), - success: rng.gen_bool(c.perf.success_rate.as_f64()), - seconds_behind: c.seconds_behind, - }) - .collect(); - indexer_query_outcomes.sort_unstable_by_key(|o| o.success.then_some(o.latency_ms)); - let client_outcome = indexer_query_outcomes.iter().find(|o| o.success); - - total_successes += client_outcome.is_some() as u64; - total_latency_ms += client_outcome.map(|o| o.latency_ms).unwrap_or_else(|| { - selections - .iter() - .map(|c| c.perf.latency_ms()) - .max() - .unwrap_or(0) - }) as u64; - total_seconds_behind += client_outcome.map(|o| o.seconds_behind).unwrap_or(0) as u64; - - drop(selections); - for outcome in indexer_query_outcomes { - perf.get_mut(&outcome.indexer) - .unwrap() - .feedback(outcome.success, outcome.latency_ms); - } - } - - println!( - "avg_selection_μs: {}", - total_selection_μs as f64 / total_client_queries as f64 - ); - println!( - "success_rate: {:.4}", - total_successes as f64 / total_client_queries as f64 - ); - println!( - "avg_latency_ms: {:.2}", - total_latency_ms as f64 / total_client_queries as f64 - ); - println!( - "total_seconds_behind: {:.2}", - total_seconds_behind as f64 / total_client_queries as f64 - ); - println!("total_fees_usd: {:.2}", total_fees_usd); -} From ea58b8a490c897c57e166b7ff181ae4f4357191b Mon Sep 17 00:00:00 2001 From: Theo Butler Date: Mon, 6 May 2024 15:51:13 -0400 Subject: [PATCH 2/2] try P50 --- indexer-selection/src/lib.rs | 6 +++--- indexer-selection/src/test.rs | 16 ++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/indexer-selection/src/lib.rs b/indexer-selection/src/lib.rs index 8e7fe97..4b66867 100644 --- a/indexer-selection/src/lib.rs +++ b/indexer-selection/src/lib.rs @@ -34,7 +34,7 @@ pub struct Candidate { #[derive(Clone, Copy, Debug)] pub struct ExpectedPerformance { pub success_rate: Normalized, - pub latency_ms_p99: u16, + pub latency_ms_p50: u16, } pub fn select(candidates: &[Candidate]) -> ArrayVec<&Candidate, LIMIT> { @@ -58,7 +58,7 @@ impl candidate_selection::Candidate for Candidate { fn score(&self) -> Normalized { [ score_success_rate(self.perf.success_rate), - score_latency(self.perf.latency_ms_p99), + score_latency(self.perf.latency_ms_p50), score_seconds_behind(self.seconds_behind), score_slashable_grt(self.slashable_grt), score_versions_behind(self.versions_behind), @@ -75,7 +75,7 @@ impl candidate_selection::Candidate for Candidate { } // candidate latencies - let ls: ArrayVec = candidates.iter().map(|c| c.perf.latency_ms_p99).collect(); + let ls: ArrayVec = candidates.iter().map(|c| c.perf.latency_ms_p50).collect(); // probability of candidate responses returning to client, based on `ls` let ps = { let mut ps: ArrayVec = diff --git a/indexer-selection/src/test.rs b/indexer-selection/src/test.rs index 287b264..2ca8d8d 100644 --- a/indexer-selection/src/test.rs +++ b/indexer-selection/src/test.rs @@ -13,7 +13,7 @@ fn candidate_should_use_url_display_for_debug() { url: expected_url.parse().expect("valid url"), perf: ExpectedPerformance { success_rate: Normalized::ZERO, - latency_ms_p99: 0, + latency_ms_p50: 0, }, fee: Normalized::ZERO, seconds_behind: 0, @@ -63,7 +63,7 @@ prop_compose! { indexer: Default::default(), deployment: deployment_bytes.into(), url: "https://example.com".parse().unwrap(), - perf: ExpectedPerformance { success_rate: performance.success_rate(), latency_ms_p99: performance.latency_percentile(99) }, + perf: ExpectedPerformance { success_rate: performance.success_rate(), latency_ms_p50: performance.latency_percentile(50) }, fee, seconds_behind: seconds_behind as u32, slashable_grt: slashable_grt as u64, @@ -116,7 +116,7 @@ fn sensitivity_seconds_behind() { url: "https://example.com".parse().unwrap(), perf: ExpectedPerformance { success_rate: Normalized::ONE, - latency_ms_p99: 0, + latency_ms_p50: 0, }, fee: Normalized::ZERO, seconds_behind: 86400, @@ -131,7 +131,7 @@ fn sensitivity_seconds_behind() { url: "https://example.com".parse().unwrap(), perf: ExpectedPerformance { success_rate: Normalized::new(0.50).unwrap(), - latency_ms_p99: 1_000, + latency_ms_p50: 1_000, }, fee: Normalized::ONE, seconds_behind: 120, @@ -173,7 +173,7 @@ fn perf_decay() { url: "https://example.com".parse().unwrap(), perf: ExpectedPerformance { success_rate: perf.success_rate(), - latency_ms_p99: perf.latency_percentile(99), + latency_ms_p50: perf.latency_percentile(50), }, fee: Normalized::ZERO, seconds_behind: 0, @@ -192,7 +192,7 @@ fn perf_decay() { } candidate.perf = ExpectedPerformance { success_rate: perf.success_rate(), - latency_ms_p99: perf.latency_percentile(99), + latency_ms_p50: perf.latency_percentile(50), }; candidate.score() }; @@ -218,7 +218,7 @@ fn perf_combine() { url: "https://example.com".parse().unwrap(), perf: ExpectedPerformance { success_rate: Normalized::new(0.90).unwrap(), - latency_ms_p99: 200, + latency_ms_p50: 200, }, fee: Normalized::ZERO, seconds_behind: 0, @@ -233,7 +233,7 @@ fn perf_combine() { url: "https://example.com".parse().unwrap(), perf: ExpectedPerformance { success_rate: Normalized::new(0.90).unwrap(), - latency_ms_p99: 150, + latency_ms_p50: 150, }, fee: Normalized::ZERO, seconds_behind: 0,