Skip to main content

scion_stack/path/strategy/
scoring.rs

1// Copyright 2025 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//   http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Path scoring is the central component of path selection.
16//!
17//! Each path is scored based on multiple metrics, and the scores are aggregated to form a final
18//! score. Higher scores indicate more preferred paths.
19//!
20//! The scoring system is designed to be extensible, allowing new scoring metrics to be added as
21//! needed. Scores from multiple metrics can be weighted to reflect their relative importance in
22//! path selection.
23
24use std::{collections::BTreeMap, fmt::Display, sync::Arc, time::SystemTime};
25
26use crate::path::types::{PathManagerPath, Score};
27
28/// Trait for scoring paths based on specific metrics.
29///
30/// Implementors provide a method to score a path, returning a floating point score between -1.0 and
31/// 1.0. Higher scores indicate more preferred paths.
32///
33/// Scores from multiple implementations are aggregated to form a composite path score, which is
34/// used for selecting a preferred path.
35pub trait PathScoring: 'static + Send + Sync {
36    /// Name of the metric being scored.
37    /// Used for debugging path scoring decisions.
38    fn metric_name(&self) -> &'static str;
39    /// Scores the given path, returning a floating point score.
40    ///
41    /// Higher scores indicate more preferred paths.
42    ///
43    /// `path` - The path to score.
44    /// `now` - The current system time for time sensitive scores.
45    fn score(&self, path: &PathManagerPath, now: SystemTime) -> Score;
46}
47
48/// Scores paths based on their length with a 0.02 penalty per hop.
49///
50/// Shorter paths receive slightly higher scores.
51struct PathLengthScorer;
52
53impl PathScoring for PathLengthScorer {
54    fn metric_name(&self) -> &'static str {
55        "Path Length"
56    }
57
58    fn score(&self, path: &PathManagerPath, _now: SystemTime) -> Score {
59        let length = match &path.path.data_plane_path {
60            scion_proto::path::DataPlanePath::EmptyPath => 0,
61            scion_proto::path::DataPlanePath::Standard(encoded_standard_path) => {
62                encoded_standard_path
63                    .segments()
64                    .map(|seg| seg.hop_fields().len() - 2)
65                    .sum()
66            }
67            scion_proto::path::DataPlanePath::Unsupported { .. } => {
68                HOP_COUNT_FOR_MIN_SCORE as usize
69            }
70        };
71
72        const MAX_SCORE: f32 = 1.0;
73        const MIN_SCORE: f32 = 0.0;
74        const HOP_COUNT_FOR_MIN_SCORE: f32 = 50.0;
75        const PER_HOP_PENALTY: f32 = (MAX_SCORE - MIN_SCORE) / HOP_COUNT_FOR_MIN_SCORE;
76        let score_value = MAX_SCORE - (length as f32 * PER_HOP_PENALTY);
77        Score::new_clamped(score_value)
78    }
79}
80
81/// Scores paths based on their reliability metric.
82///
83/// Without this scorer, path issues will be ignored in path selection.
84pub struct PathReliabilityScorer;
85
86impl PathScoring for PathReliabilityScorer {
87    fn metric_name(&self) -> &'static str {
88        "Reliability"
89    }
90
91    fn score(&self, path: &PathManagerPath, now: SystemTime) -> Score {
92        path.reliability.score(now)
93    }
94}
95
96/// Aggregates multiple path scorers into a single scoring function.
97#[derive(Clone)]
98pub struct PathScorer {
99    scorers: Vec<(Arc<dyn PathScoring>, f32)>,
100}
101
102impl Default for PathScorer {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl PathScorer {
109    fn new() -> Self {
110        Self { scorers: vec![] }
111    }
112
113    /// Returns false if no scorers are configured.
114    pub fn is_empty(&self) -> bool {
115        self.scorers.is_empty()
116    }
117
118    /// Default impact weight for reliability scorer.
119    pub const DEFAULT_RELIABILITY_IMPACT: f32 = 1.0;
120    /// Default impact weight for length scorer.
121    pub const DEFAULT_LENGTH_IMPACT: f32 = 0.1;
122
123    /// Uses default path scorers
124    ///
125    /// - [PathReliabilityScorer] with weight [PathScorer::DEFAULT_RELIABILITY_IMPACT]
126    /// - [PathLengthScorer] with weight [PathScorer::DEFAULT_LENGTH_IMPACT]
127    ///
128    /// The PathLengthScorer's impact on path decision is minimal, to avoid ignoring reliability.
129    pub(crate) fn use_default_scorers(&mut self) {
130        self.scorers.push((
131            Arc::new(PathReliabilityScorer),
132            Self::DEFAULT_RELIABILITY_IMPACT,
133        ));
134        self.scorers
135            .push((Arc::new(PathLengthScorer), Self::DEFAULT_LENGTH_IMPACT));
136    }
137
138    /// Adds a scorer with the given impact weight.
139    ///
140    /// `scorer` - The path scorer to add.
141    /// `impact` - The weight of the scorer in the final score aggregation.
142    ///            e.g. Impact of 0.2 means the scorer can change the final score by up to ±0.2.
143    ///
144    /// Note:
145    /// The impact weight does not need to sum to 1.0 across all scorers.
146    pub fn with_scorer(mut self, scorer: impl PathScoring + 'static, impact: f32) -> Self {
147        self.scorers.push((Arc::new(scorer), impact));
148        self
149    }
150
151    /// Scores the given path by aggregating scores from all configured scorers.
152    ///
153    /// Total score is the weighted sum of individual scorer scores.
154    /// No Normalization is applied.
155    pub fn score(&self, path: &PathManagerPath, now: SystemTime) -> f32 {
156        let mut total_score = 0.0;
157        for (scorer, impact) in &self.scorers {
158            let score = scorer.score(path, now).value();
159            total_score += score * impact;
160        }
161        total_score
162    }
163
164    /// Generates a report detailing individual scorer contributions to the total score of the path.
165    pub fn score_report(&self, path: &PathManagerPath, now: SystemTime) -> ScoreReport {
166        let mut report = ScoreReport::default();
167        for (scorer, impact) in &self.scorers {
168            let score = scorer.score(path, now).value();
169            report.add_score(scorer.metric_name(), score * impact);
170        }
171        report
172    }
173}
174
175/// A report of weighted scores contributing to a path's total score.
176///
177/// Used for debugging path scoring decisions.
178#[derive(Default, Debug)]
179pub struct ScoreReport(pub BTreeMap<&'static str, f32>);
180
181impl ScoreReport {
182    fn add_score(&mut self, metric: &'static str, score: f32) {
183        self.0.insert(metric, score);
184    }
185}
186
187impl Display for ScoreReport {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        let total: f32 = self.0.values().sum();
190        for (metric, score) in &self.0 {
191            write!(f, "{}: {:.3} ", metric, score)?;
192        }
193        write!(f, "Total: {:.3}", total)?;
194
195        Ok(())
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use std::{
202        cmp::Ordering,
203        hash::{DefaultHasher, Hash, Hasher},
204        net::{IpAddr, Ipv4Addr},
205    };
206
207    use scion_proto::{
208        address::{Asn, EndhostAddr, Isd, IsdAsn},
209        path::{Path, test_builder::TestPathBuilder},
210    };
211
212    use super::*;
213    use crate::path::types::PathManagerPath;
214
215    pub const SRC_ADDR: EndhostAddr = EndhostAddr::new(
216        IsdAsn::new(Isd(1), Asn(1)),
217        IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
218    );
219    pub const DST_ADDR: EndhostAddr = EndhostAddr::new(
220        IsdAsn::new(Isd(2), Asn(1)),
221        IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2)),
222    );
223
224    pub fn path(hop_count: u16, timestamp: u32, exp_units: u8, asn_seed: u32) -> Path {
225        let mut builder = TestPathBuilder::new(SRC_ADDR, DST_ADDR)
226            .using_info_timestamp(timestamp)
227            .with_hop_expiry(exp_units)
228            .up();
229
230        builder = builder.add_hop(0, 1);
231
232        for cnt in 0..hop_count {
233            let mut hash = DefaultHasher::new();
234            asn_seed.hash(&mut hash);
235            cnt.hash(&mut hash);
236            let hash = hash.finish() as u32;
237
238            builder = builder.with_asn(hash).add_hop(cnt + 1, cnt + 2);
239        }
240
241        builder = builder.add_hop(1, 0);
242
243        builder.build(timestamp).path()
244    }
245
246    struct Simulation {
247        paths: Vec<PathManagerPath>,
248        current_path_index: usize,
249        scoring: PathScorer,
250        // (sec since start, action)
251        actions: Vec<(usize, SimulationAction)>,
252        switch_threshold: f32,
253    }
254
255    enum SimulationAction {
256        UpdateReliability { path_index: usize, score: Score },
257        Evaluate,
258    }
259
260    impl Simulation {
261        fn new(
262            scoring: PathScorer,
263            initial_paths: Vec<PathManagerPath>,
264            switch_threshold: f32,
265        ) -> Self {
266            Self {
267                paths: initial_paths,
268                current_path_index: 0,
269                scoring,
270                actions: vec![],
271                switch_threshold,
272            }
273        }
274
275        fn add_step(self, time: usize, action: SimulationAction) -> Self {
276            let mut sim = self;
277            sim.actions.push((time, action));
278            sim
279        }
280
281        fn run(&mut self) {
282            let actions = std::mem::take(&mut self.actions);
283            const BASE_TIME: SystemTime = SystemTime::UNIX_EPOCH;
284            for (time_delta, action) in actions.into_iter() {
285                println!("(Time +{}s) -------------------", time_delta);
286                let timestamp = BASE_TIME + std::time::Duration::from_secs(time_delta as u64);
287                match action {
288                    SimulationAction::UpdateReliability { path_index, score } => {
289                        println!(
290                            "Updating reliability of path {} to score {:.3}",
291                            path_index,
292                            score.value(),
293                        );
294                        let path = &mut self.paths[path_index];
295                        path.reliability.update(score, timestamp);
296                        self.maybe_switch_path(timestamp);
297                    }
298                    SimulationAction::Evaluate => {
299                        println!("Evaluating paths");
300                        self.maybe_switch_path(timestamp);
301                    }
302                }
303                println!("-------------------------------");
304                self.print_all(timestamp);
305                println!("-------------------------------");
306            }
307        }
308
309        fn maybe_switch_path(&mut self, now: SystemTime) {
310            let best_path_index = self.best_path_idx(now);
311            let current_path = &self.paths[self.current_path_index];
312            let current_score = self.scoring.score(current_path, now);
313
314            let best_path = &self.paths[best_path_index];
315            let best_score = self.scoring.score(best_path, now);
316
317            let diff = best_score - current_score;
318
319            if best_path_index == self.current_path_index {
320                println!(
321                    "Staying on current path {} (score {:.3}) is best path",
322                    self.current_path_index, current_score,
323                );
324                return;
325            }
326
327            if diff > self.switch_threshold {
328                println!(
329                    "Switching from path {} (score {:.3}) to path {} (score {:.3})",
330                    self.current_path_index, current_score, best_path_index, best_score
331                );
332                println!("New path: {}", self.scoring.score_report(best_path, now));
333                println!("Old path: {}", self.scoring.score_report(current_path, now));
334
335                self.current_path_index = best_path_index;
336            } else {
337                println!(
338                    "Staying on current path {} (score {:.3}), best path {} (score {:.3}) diff {:.3} below threshold {:.3}",
339                    self.current_path_index,
340                    current_score,
341                    best_path_index,
342                    best_score,
343                    diff,
344                    self.switch_threshold
345                );
346            }
347        }
348
349        fn print_all(&self, now: SystemTime) {
350            let mut sorted = self.paths.iter().enumerate().collect::<Vec<_>>();
351            sorted.sort_by(|(_, a), (_, b)| {
352                let score_a = self.scoring.score(a, now);
353                let score_b = self.scoring.score(b, now);
354                score_b.partial_cmp(&score_a).unwrap_or(Ordering::Equal)
355            });
356
357            for (idx, path) in sorted.iter() {
358                println!("Path {}: {}", idx, self.scoring.score_report(path, now));
359            }
360        }
361
362        fn best_path_idx(&self, now: SystemTime) -> usize {
363            self.paths
364                .iter()
365                .enumerate()
366                .max_by(|(_, a), (_, b)| {
367                    let score_a = self.scoring.score(a, now);
368                    let score_b = self.scoring.score(b, now);
369                    score_a.partial_cmp(&score_b).unwrap_or(Ordering::Equal)
370                })
371                .unwrap()
372                .0
373        }
374    }
375
376    #[test]
377    #[ignore = "Simulation test for manual inspection"]
378    fn simulation() {
379        // Create some sample paths with different lengths and reliability scores.
380        /// Score differences after which we switch preference between two paths.
381        const SWITCH_THRESHOLD: f32 = 0.4;
382
383        let paths: Vec<_> = (1..=10)
384            .map(|len| {
385                let path = path(len, 1000, 100, len as u32);
386                PathManagerPath::new(path)
387            })
388            .collect();
389
390        let scoring = PathScorer::new()
391            .with_scorer(PathReliabilityScorer, 1.0)
392            .with_scorer(PathLengthScorer, 0.125);
393
394        use SimulationAction::*;
395        Simulation::new(scoring, paths, SWITCH_THRESHOLD)
396            .add_step(0, Evaluate)
397            .add_step(
398                10,
399                UpdateReliability {
400                    path_index: 0,
401                    score: Score::new_clamped(-0.5),
402                },
403            )
404            .add_step(
405                20,
406                UpdateReliability {
407                    path_index: 5,
408                    score: Score::new_clamped(0.5),
409                },
410            )
411            .add_step(30, Evaluate)
412            .add_step(60, Evaluate)
413            .add_step(600, Evaluate)
414            .add_step(1200, Evaluate)
415            .run();
416    }
417}