livesplit_core/analysis/
skill_curve.rs

1use crate::{platform::prelude::*, Segment, TimeSpan, TimingMethod};
2use core::cmp::Ordering;
3
4const WEIGHT: f64 = 0.75;
5const TRIES: usize = 50;
6
7/// The skill curve analyzes the [`SegmentHistory`](crate::run::SegmentHistory)
8/// segment history across all segments. For each [`Segment`](crate::run::Segment),
9/// the segment times are sorted by length and weighted by their recency.
10///
11/// Plotting this on a graph with the y-axis representing the segment time and
12/// the x-axis representing the percentile, with the shortest time at 0
13/// and the longest time at 1, yields the so called "skill curve". If you sum
14/// all the different curves together for all the segments, you get the overall
15/// curve for the whole run.
16///
17/// # Properties of the Skill Curve
18///
19/// If you sample the curve at 0, you get the simple sum of best segments, and if
20/// you sample the curve at 1, you get the simple sum of worst segments. At 0.5,
21/// you get the median segments. If you sample the individual segments at the
22/// same percentile where you find the Personal Best on the overall run's curve,
23/// you get the Balanced PB. The position of the Balanced PB on the x-axis is the
24/// PB chance.
25#[derive(Default, Clone)]
26pub struct SkillCurve {
27    all_weighted_segment_times: Vec<Vec<(f64, TimeSpan)>>,
28}
29
30impl SkillCurve {
31    /// Constructs a new empty skill curve. Before querying information, you
32    /// need to calculate the curve for some segments.
33    pub fn new() -> Self {
34        Default::default()
35    }
36
37    /// Returns the number of segments this skill curve is comprised of.
38    pub fn len(&self) -> usize {
39        self.all_weighted_segment_times.len()
40    }
41
42    /// Returns `true` if there are no segments in this skill curve.
43    pub fn is_empty(&self) -> bool {
44        self.all_weighted_segment_times.is_empty()
45    }
46
47    /// Reduces the number of segments that are being considered by this curve.
48    pub fn truncate(&mut self, len: usize) {
49        self.all_weighted_segment_times.truncate(len);
50    }
51
52    /// Calculate the skill curve for the segments and timing method provided.
53    /// All previous information available in the skill curve will be discarded.
54    /// The segment curve may not always contain information for all segments.
55    /// Once a segment with no segment history is encountered, the remainder of
56    /// the segments get discarded.
57    pub fn for_segments(&mut self, segments: &[Segment], method: TimingMethod) {
58        let mut len = segments.len();
59
60        self.all_weighted_segment_times
61            .resize_with(len, Default::default);
62
63        for ((i, segment), weighted_segment_times) in segments
64            .iter()
65            .enumerate()
66            .zip(&mut self.all_weighted_segment_times)
67        {
68            weighted_segment_times.clear();
69
70            // Collect initial weighted segments
71            let mut current_weight = 1.0;
72            for &(id, time) in segment.segment_history().iter_actual_runs().rev() {
73                if let Some(time) = time[method] {
74                    // Skip all the combined segments
75                    let skip = catch! {
76                        segments[i.checked_sub(1)?].segment_history().get(id)?[method].is_none()
77                    }
78                    .unwrap_or(false);
79
80                    if !skip {
81                        weighted_segment_times.push((current_weight, time));
82                        current_weight *= WEIGHT;
83                    }
84                }
85            }
86
87            // End early if we don't have any segment times anymore
88            if weighted_segment_times.is_empty() {
89                len = i;
90                break;
91            }
92
93            // Sort everything by the times
94            weighted_segment_times.sort_unstable_by_key(|&(_, time)| time);
95
96            // Cumulative sum of the weights
97            let mut sum = 0.0;
98            for (weight, _) in weighted_segment_times.iter_mut() {
99                sum += *weight;
100                *weight = sum;
101            }
102
103            // Reweigh all of the weights to be in the range 0..1
104            let min = weighted_segment_times
105                .first()
106                .map(|&(w, _)| w)
107                .unwrap_or_default();
108
109            let max = weighted_segment_times
110                .last()
111                .map(|&(w, _)| w)
112                .unwrap_or_default();
113
114            let diff = max - min;
115
116            if diff != 0.0 {
117                for (weight, _) in weighted_segment_times.iter_mut() {
118                    *weight = (*weight - min) / diff;
119                }
120            }
121        }
122
123        // Limit the slice to only the segments that have segment times
124        self.truncate(len);
125    }
126
127    /// This function returns an iterator that iterates over each segment and
128    /// yields their segment times for the percentile specified. A percentile of
129    /// 0 yields the fastest times, while a percentile of 1 yields the slowest
130    /// times.
131    pub fn iter_segment_times_at_percentile(
132        &self,
133        percentile: f64,
134    ) -> impl Iterator<Item = TimeSpan> + '_ {
135        self.all_weighted_segment_times
136            .iter()
137            .map(move |weighted_segment_times| {
138                let found_index = weighted_segment_times.binary_search_by(|&(w, _)| {
139                    if w < percentile {
140                        Ordering::Less
141                    } else if w > percentile {
142                        Ordering::Greater
143                    } else {
144                        Ordering::Equal
145                    }
146                });
147
148                match found_index {
149                    // The percentile perfectly matched a segment time
150                    Ok(index) => weighted_segment_times[index].1,
151                    // The percentile didn't perfectly match, interpolate instead
152                    Err(right_index) => {
153                        // Saturate both left and right index at the bounds of the
154                        // array. Both could go out of bounds.
155                        let left_index = right_index.saturating_sub(1);
156                        // This assumes len can never be 0. This only works out
157                        // for us because we truncate all the empty segments away.
158                        let right_index = right_index.min(weighted_segment_times.len() - 1);
159
160                        if left_index == right_index {
161                            weighted_segment_times[left_index].1
162                        } else {
163                            let right = weighted_segment_times[right_index];
164                            let left = weighted_segment_times[left_index];
165
166                            interpolate(percentile, left, right)
167                        }
168                    }
169                }
170            })
171    }
172
173    /// This function returns an iterator that iterates over each segment and
174    /// yields their split times for the percentile specified. A percentile of 0
175    /// yields the fastest times, while a percentile of 1 yields the slowest
176    /// times. The offset provided is the initial split time going into the
177    /// first segment. This is only relevant when the segments don't represent
178    /// the beginning of the run.
179    pub fn iter_split_times_at_percentile(
180        &self,
181        percentile: f64,
182        offset: TimeSpan,
183    ) -> impl Iterator<Item = TimeSpan> + '_ {
184        let mut sum = offset;
185        self.iter_segment_times_at_percentile(percentile)
186            .map(move |segment_time| {
187                sum += segment_time;
188                sum
189            })
190    }
191
192    /// Searches the curve for the final run time specified and returns the
193    /// percentile the time can be found at. The percentile is always within the
194    /// range 0..1. If the time specified cannot be found, the percentile
195    /// saturates at one of those boundaries.
196    pub fn find_percentile_for_time(&self, offset: TimeSpan, time_to_find: TimeSpan) -> f64 {
197        let (mut perc_min, mut perc_max) = (0.0, 1.0);
198
199        // Try to find the correct percentile
200        for _ in 0..TRIES {
201            let percentile = 0.5 * (perc_max + perc_min);
202
203            let sum = self
204                .iter_segment_times_at_percentile(percentile)
205                .fold(offset, |sum, segment_time| sum + segment_time);
206
207            // Binary search the correct percentile
208            match sum.cmp(&time_to_find) {
209                Ordering::Equal => return percentile,
210                Ordering::Less => perc_min = percentile,
211                Ordering::Greater => perc_max = percentile,
212            }
213        }
214
215        0.5 * (perc_max + perc_min)
216    }
217}
218
219fn interpolate(
220    perc: f64,
221    (weight_left, time_left): (f64, TimeSpan),
222    (weight_right, time_right): (f64, TimeSpan),
223) -> TimeSpan {
224    let weight_diff_recip = (weight_right - weight_left).recip();
225    let perc_down = (weight_right - perc) * time_left.total_seconds() * weight_diff_recip;
226    let perc_up = (perc - weight_left) * time_right.total_seconds() * weight_diff_recip;
227    TimeSpan::from_seconds(perc_up + perc_down)
228}