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}