Skip to main content

simulator_api/reroute_report/
probes.rs

1//! Per-template accounting of direct-fill probes. Probes have their own table
2//! (`direct_fill_probes`) and their own wire variant (`ReplacementNotification::DirectFill`), so
3//! a population arrives pre-selected; nothing here checks that, and metis rows fed in still
4//! render as a plausible-looking probe table.
5
6use std::collections::BTreeMap;
7
8use serde::Serialize;
9
10use super::{LegRecord, Prices, SwapMode, fmt::commas_u};
11
12/// The bucket vocabulary [`ProbeReport::from_records`] keys `outcomes` on, reconstructed from a
13/// probe's split outcome/category pair: reading `category` straight through would rebucket
14/// `unattributed`/`unsimulatable` — which carry none — as `"filled"`. Keyed on the wire strings,
15/// which the row's typed `outcome` renders to via its `#[sea_orm(string_value = ...)]`.
16pub fn bucket(outcome: &str, category: Option<&str>) -> Option<String> {
17    match outcome {
18        "filled" => None,
19        "reverted" => Some(format!("reverted_{}", category.unwrap_or_default())),
20        other => Some(other.to_string()),
21    }
22}
23
24#[derive(Default, Clone, Serialize)]
25pub struct TemplateProbes {
26    /// `filled`, `reverted_*`, `unattributed`, `unsimulatable` — the keys the run metrics
27    /// count, so report and log reconcile.
28    pub outcomes: BTreeMap<String, u64>,
29    /// Probes with a recorded template-vs-historical change.
30    pub scored_vs_historical: u64,
31    /// Sum of the recorded `output_bps`, which the writer clamps.
32    pub bps_vs_historical_total: i64,
33    /// Probes where both the template and the same-state original filled — the like-for-like.
34    pub pairs_vs_same_state: u64,
35    /// Computed here, unclamped: a ratio of two same-state simulations of one hop is
36    /// structurally bounded where a market ratio would not be.
37    pub bps_vs_same_state_total: f64,
38    /// Dollars behind `scored_vs_historical`, and those probes' bps weighted by them. Zero when
39    /// the range priced no mint the probes traded.
40    pub notional_usd: f64,
41    pub weighted_bps_usd: f64,
42}
43
44impl TemplateProbes {
45    fn probes(&self) -> u64 {
46        self.outcomes.values().sum()
47    }
48
49    fn filled(&self) -> u64 {
50        self.outcomes.get("filled").copied().unwrap_or(0)
51    }
52
53    fn mean(total: f64, count: u64, unit: &str) -> String {
54        match count {
55            0 => "—".to_string(),
56            n => format!("{:+.2} ({} {unit})", total / n as f64, commas_u(n)),
57        }
58    }
59
60    fn row(&self, template: &str) -> String {
61        let probes = self.probes();
62        let filled = self.filled();
63        let pct = filled as f64 / probes as f64 * 100.0;
64        let vs_historical = Self::mean(
65            self.bps_vs_historical_total as f64,
66            self.scored_vs_historical,
67            "scored",
68        );
69        let vs_same_state = Self::mean(
70            self.bps_vs_same_state_total,
71            self.pairs_vs_same_state,
72            "pairs",
73        );
74        let per_dollar = match self.notional_usd {
75            notional if notional > 0.0 => format!(
76                "{:+.2} (${})",
77                self.weighted_bps_usd / notional,
78                commas_u(notional as u64)
79            ),
80            _ => "—".to_string(),
81        };
82        format!(
83            "| {template} | {} | {} ({pct:.1}%) | {vs_historical} | {per_dollar} | {vs_same_state} |",
84            commas_u(probes),
85            commas_u(filled),
86        )
87    }
88
89    fn outcomes_line(&self, template: &str) -> String {
90        let parts: Vec<String> = self
91            .outcomes
92            .iter()
93            .map(|(outcome, n)| format!("{outcome} {}", commas_u(*n)))
94            .collect();
95        format!("{template} outcomes: {}", parts.join(", "))
96    }
97}
98
99#[derive(Default, Clone, Serialize)]
100pub struct ProbeReport {
101    pub by_template: BTreeMap<String, TemplateProbes>,
102}
103
104impl ProbeReport {
105    pub fn from_records(records: &[LegRecord], prices: Option<&Prices>) -> Self {
106        let mut report = Self::default();
107        for record in records {
108            let template = record
109                .template
110                .clone()
111                .unwrap_or_else(|| "(unnamed)".to_string());
112            let entry = report.by_template.entry(template).or_default();
113            let outcome = record
114                .error_category
115                .clone()
116                .unwrap_or_else(|| "filled".to_string());
117            *entry.outcomes.entry(outcome).or_default() += 1;
118            if let Some(bps) = record.output_bps {
119                entry.scored_vs_historical += 1;
120                entry.bps_vs_historical_total += bps;
121                let priced_mint = match record.swap_mode {
122                    SwapMode::ExactIn => &record.input_mint,
123                    SwapMode::ExactOut => &record.output_mint,
124                };
125                if let Some(usd) = prices.and_then(|p| p.usd(priced_mint, record.amount as f64)) {
126                    entry.notional_usd += usd;
127                    entry.weighted_bps_usd += usd * bps as f64;
128                }
129            }
130            if let (Some(new), Some(original)) =
131                (record.new_output_amount, record.quoted_output_amount)
132                && original > 0
133            {
134                entry.pairs_vs_same_state += 1;
135                let change = (new as f64 / original as f64 - 1.0) * 1e4;
136                entry.bps_vs_same_state_total += match record.swap_mode {
137                    SwapMode::ExactIn => change,
138                    SwapMode::ExactOut => -change,
139                };
140            }
141        }
142        report
143    }
144
145    pub fn merge(&mut self, other: &Self) {
146        for (template, probes) in &other.by_template {
147            let entry = self.by_template.entry(template.clone()).or_default();
148            entry.notional_usd += probes.notional_usd;
149            entry.weighted_bps_usd += probes.weighted_bps_usd;
150            for (outcome, n) in &probes.outcomes {
151                *entry.outcomes.entry(outcome.clone()).or_default() += n;
152            }
153            entry.scored_vs_historical += probes.scored_vs_historical;
154            entry.bps_vs_historical_total += probes.bps_vs_historical_total;
155            entry.pairs_vs_same_state += probes.pairs_vs_same_state;
156            entry.bps_vs_same_state_total += probes.bps_vs_same_state_total;
157        }
158    }
159
160    pub fn is_empty(&self) -> bool {
161        self.by_template.is_empty()
162    }
163
164    pub fn render(&self, name: &str) -> String {
165        let mut lines = vec![
166            format!("### {name}: direct-fill probes"),
167            "| Template | probes | filled | per hop (bps) | per dollar (bps) | vs same-state (bps) |"
168                .to_string(),
169            "|---|---|---|---|---|---|".to_string(),
170        ];
171        for (template, probes) in &self.by_template {
172            lines.push(probes.row(template));
173        }
174        for (template, probes) in &self.by_template {
175            lines.push(probes.outcomes_line(template));
176        }
177        lines.push(String::new());
178        lines.join("\n")
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use rstest::rstest;
185
186    use super::*;
187    use crate::{
188        mints,
189        reroute_report::{Samples, testing::record_of},
190    };
191
192    /// Fixes SOL at $100 from the stream's own swaps, as the benchmark tests do.
193    fn prices() -> Prices {
194        let mut samples = Samples::default();
195        for n in 1..=3u64 {
196            samples.push(&LegRecord {
197                original_realized_output_amount: Some(n * 100_000_000),
198                ..record_of(mints::SOL, mints::USDC, n * 1_000_000_000, None, None, None)
199            });
200        }
201        samples.prices().expect("anchored")
202    }
203
204    /// A hop counts for the money through it, so one large good fill outweighs many tiny bad
205    /// ones — the per-hop mean says the template lost, the per-dollar mean says it won.
206    #[test]
207    fn the_per_dollar_mean_weighs_hops_by_notional() {
208        let hop = |lamports: u64, bps: i64| LegRecord {
209            template: Some("Tempest".to_string()),
210            output_bps: Some(bps),
211            ..record_of(mints::SOL, mints::USDC, lamports, None, None, None)
212        };
213        // Three hundredths of a SOL at -100 bps against one whole SOL at +10.
214        let records = [
215            hop(10_000_000, -100),
216            hop(10_000_000, -100),
217            hop(1_000_000_000, 10),
218        ];
219        let template = |prices: Option<&Prices>| {
220            ProbeReport::from_records(&records, prices).by_template["Tempest"].clone()
221        };
222
223        let weighted = template(Some(&prices()));
224        let per_hop: i64 = (-100 - 100 + 10) / 3;
225        let per_dollar = (0.01 * -100.0 + 0.01 * -100.0 + 1.0 * 10.0) / 1.02;
226        assert_eq!(weighted.bps_vs_historical_total / 3, per_hop, "per hop");
227        assert!(
228            (weighted.weighted_bps_usd / weighted.notional_usd - per_dollar).abs() < 0.01,
229            "per dollar follows the large hop, not the count"
230        );
231        // Without prices the column is simply absent rather than silently unweighted.
232        assert_eq!(template(None).notional_usd, 0.0);
233    }
234
235    fn probe(template: &str) -> LegRecord {
236        LegRecord {
237            template: Some(template.to_string()),
238            ..record_of(mints::SOL, mints::USDC, 1_000_000, None, None, None)
239        }
240    }
241
242    #[rstest]
243    #[case::success_falls_back_to_filled(None, "filled")]
244    #[case::reverted_keeps_its_category(Some("reverted_slippage".to_string()), "reverted_slippage")]
245    #[case::unattributed_keeps_its_category(Some("unattributed".to_string()), "unattributed")]
246    fn from_records_buckets_outcomes_by_error_category_else_filled(
247        #[case] error_category: Option<String>,
248        #[case] expected: &str,
249    ) {
250        let record = LegRecord {
251            error_category,
252            ..probe("Tempest")
253        };
254        let report = ProbeReport::from_records(&[record], None);
255        assert_eq!(
256            report.by_template["Tempest"].outcomes.get(expected),
257            Some(&1)
258        );
259    }
260
261    #[test]
262    fn scored_vs_historical_counts_only_rows_carrying_output_bps() {
263        let scored = LegRecord {
264            output_bps: Some(12),
265            ..probe("Tempest")
266        };
267        let unscored = probe("Tempest");
268
269        let report = ProbeReport::from_records(&[scored, unscored], None);
270        let template = &report.by_template["Tempest"];
271        assert_eq!(template.scored_vs_historical, 1);
272        assert_eq!(template.bps_vs_historical_total, 12);
273    }
274
275    #[rstest]
276    #[case::both_present_and_positive(Some(1_010_000), Some(1_000_000), 1)]
277    #[case::new_amount_missing(None, Some(1_000_000), 0)]
278    #[case::original_amount_missing(Some(1_010_000), None, 0)]
279    #[case::original_amount_zero(Some(1_010_000), Some(0), 0)]
280    fn same_state_pairs_require_both_amounts_and_a_positive_original(
281        #[case] new_output_amount: Option<u64>,
282        #[case] quoted_output_amount: Option<u64>,
283        #[case] expected_pairs: u64,
284    ) {
285        let record = LegRecord {
286            new_output_amount,
287            quoted_output_amount,
288            ..probe("Tempest")
289        };
290        let report = ProbeReport::from_records(&[record], None);
291        assert_eq!(
292            report.by_template["Tempest"].pairs_vs_same_state,
293            expected_pairs
294        );
295    }
296
297    #[test]
298    fn same_state_bps_is_the_relative_change_from_quoted_to_new() {
299        let record = LegRecord {
300            new_output_amount: Some(1_010_000),
301            quoted_output_amount: Some(1_000_000),
302            ..probe("Tempest")
303        };
304        let report = ProbeReport::from_records(&[record], None);
305        let template = &report.by_template["Tempest"];
306        assert_eq!(template.pairs_vs_same_state, 1);
307        assert!((template.bps_vs_same_state_total - 100.0).abs() < 1e-9);
308    }
309
310    #[test]
311    fn merge_carries_every_field() {
312        let a = LegRecord {
313            output_bps: Some(10),
314            new_output_amount: Some(1_010_000),
315            quoted_output_amount: Some(1_000_000),
316            ..probe("Tempest")
317        };
318        let b = LegRecord {
319            error_category: Some("reverted_slippage".to_string()),
320            output_bps: Some(20),
321            new_output_amount: Some(1_020_000),
322            quoted_output_amount: Some(1_000_000),
323            ..probe("Tempest")
324        };
325
326        let mut report = ProbeReport::from_records(&[a], None);
327        report.merge(&ProbeReport::from_records(&[b], None));
328
329        let template = &report.by_template["Tempest"];
330        assert_eq!(template.outcomes.get("filled"), Some(&1));
331        assert_eq!(template.outcomes.get("reverted_slippage"), Some(&1));
332        assert_eq!(template.scored_vs_historical, 2);
333        assert_eq!(template.bps_vs_historical_total, 30);
334        assert_eq!(template.pairs_vs_same_state, 2);
335        assert!((template.bps_vs_same_state_total - 300.0).abs() < 1e-9);
336    }
337
338    #[test]
339    fn a_record_with_no_template_lands_under_unnamed() {
340        let record = record_of(mints::SOL, mints::USDC, 1_000_000, None, None, None);
341        let report = ProbeReport::from_records(&[record], None);
342        assert!(report.by_template.contains_key("(unnamed)"));
343    }
344
345    #[test]
346    fn render_contains_the_template_row_and_outcomes_line() {
347        let filled_a = LegRecord {
348            output_bps: Some(100),
349            new_output_amount: Some(1_010_000),
350            quoted_output_amount: Some(1_000_000),
351            ..probe("Tempest")
352        };
353        let filled_b = LegRecord {
354            output_bps: Some(200),
355            new_output_amount: Some(1_020_000),
356            quoted_output_amount: Some(1_000_000),
357            ..probe("Tempest")
358        };
359        let reverted = LegRecord {
360            error_category: Some("reverted_slippage".to_string()),
361            ..probe("Tempest")
362        };
363
364        let report = ProbeReport::from_records(&[filled_a, filled_b, reverted], None);
365        let rendered = report.render("R1");
366
367        assert!(rendered.contains("### R1: direct-fill probes"));
368        assert!(rendered.contains("| Template | probes | filled |"));
369        assert!(
370            rendered.contains(
371                "| Tempest | 3 | 2 (66.7%) | +150.00 (2 scored) | — | +150.00 (2 pairs) |"
372            )
373        );
374        assert!(rendered.contains("Tempest outcomes: filled 2, reverted_slippage 1"));
375    }
376
377    #[test]
378    fn render_shows_an_em_dash_when_a_mean_has_no_observations() {
379        let reverted = LegRecord {
380            error_category: Some("reverted_slippage".to_string()),
381            ..probe("Vertigo")
382        };
383
384        let report = ProbeReport::from_records(&[reverted], None);
385        let rendered = report.render("R2");
386
387        assert!(rendered.contains("| Vertigo | 1 | 0 (0.0%) | — | — |"));
388        assert!(rendered.contains("Vertigo outcomes: reverted_slippage 1"));
389    }
390
391    #[test]
392    fn is_empty_reflects_an_empty_template_map() {
393        assert!(ProbeReport::default().is_empty());
394        let report = ProbeReport::from_records(&[probe("Tempest")], None);
395        assert!(!report.is_empty());
396    }
397
398    /// An ExactOut probe's `amount` is the output it was sized at, so its notional is the output
399    /// mint's, not the input's: 1_000_000 base units is $1 of USDC, not 0.001 SOL at $100.
400    #[test]
401    fn an_exact_out_notional_is_priced_in_the_output_mint() {
402        let record = LegRecord {
403            swap_mode: SwapMode::ExactOut,
404            output_bps: Some(10),
405            ..probe("Tempest")
406        };
407        let report = ProbeReport::from_records(&[record], Some(&prices()));
408        let template = &report.by_template["Tempest"];
409        assert!(
410            (template.notional_usd - 1.0).abs() < 1e-9,
411            "{}",
412            template.notional_usd
413        );
414    }
415
416    #[rstest]
417    #[case::cheaper(900, 1_000.0)]
418    #[case::dearer(1_100, -1_000.0)]
419    fn an_exact_out_comparison_scores_input_saved(#[case] charged: u64, #[case] expected: f64) {
420        let record = LegRecord {
421            swap_mode: SwapMode::ExactOut,
422            new_output_amount: Some(charged),
423            quoted_output_amount: Some(1_000),
424            ..probe("t")
425        };
426        let report = ProbeReport::from_records(&[record], None);
427        assert!((report.by_template["t"].bps_vs_same_state_total - expected).abs() < 1e-9);
428    }
429}