Skip to main content

driftbrake_reconcile/
lib.rs

1//! `driftbrake-reconcile`: the default [`driftbrake_core::HaltPolicy`]
2//! implementation — the dual-guard reconciliation mechanism.
3//!
4//! This module carries the name **phantom-guard** internally and in
5//! documentation (see `docs/ARCHITECTURE.md`'s "Naming" section):
6//! "phantom profit" is the existing term of art for a bot believing it
7//! made money it did not actually make, and `phantom-guard` is the name
8//! for the halt mechanism itself, distinct from the umbrella `driftbrake`
9//! crate name.
10//!
11//! The mechanism is specified formally in `docs/whitepaper.md`. This
12//! crate is the implementation of that specification: two independent
13//! guards, evaluated on every new `(predicted, realized)` pair, that halt
14//! a strategy when the relationship between prediction and outcome
15//! degrades beyond a configurable threshold.
16//!
17//! - **Fast guard** (Section 4.2): halts on `k_f` consecutive ratios all
18//!   below `T_f`. Catches a sudden, severe break.
19//! - **Slow guard** (Section 4.3): halts when the mean of the last `k_s`
20//!   ratios drops below `T_s`. Catches a slow, individually-forgivable
21//!   bleed that never trips the fast guard.
22//!
23//! Both are disjunctive (Section 4.4: `Halt = FastHalt OR SlowHalt`) —
24//! neither guard substitutes for the other (Property 2, "guard
25//! independence"), and removing either one strictly reduces detection
26//! coverage.
27
28use driftbrake_core::{HaltDecision, HaltPolicy, HaltReason, ReconcileHistory};
29
30/// Fast-guard configuration: halts on `window` consecutive ratios all
31/// below `threshold`.
32///
33/// Defaults (`T_f = 0.50`, `k_f = 3`) are not asserted constants — they're
34/// derived from the benchmark sweep in `docs/BENCHMARK.md` and should be
35/// re-tuned per chain rather than assumed to transfer.
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub struct FastGuardConfig {
38    pub threshold: f64,
39    pub window: usize,
40}
41
42impl Default for FastGuardConfig {
43    fn default() -> Self {
44        Self {
45            threshold: 0.50,
46            window: 3,
47        }
48    }
49}
50
51/// Slow-guard configuration: halts when the mean of the last `window`
52/// ratios drops below `threshold`.
53///
54/// Defaults (`T_s = 0.70`, `k_s = 20`) — see [`FastGuardConfig`]'s note on
55/// re-tuning.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct SlowGuardConfig {
58    pub threshold: f64,
59    pub window: usize,
60}
61
62impl Default for SlowGuardConfig {
63    fn default() -> Self {
64        Self {
65            threshold: 0.70,
66            window: 20,
67        }
68    }
69}
70
71/// Revert-burst configuration: halts if `limit` consecutive transactions revert on-chain.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct RevertBurstConfig {
74    pub limit: usize,
75}
76
77impl Default for RevertBurstConfig {
78    fn default() -> Self {
79        Self { limit: 3 }
80    }
81}
82
83/// Revert gas-budget configuration: halts if cumulative gas fees burned on reverts
84/// within the last `window` timeline entries exceeds `budget_limit`.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub struct RevertGasConfig {
87    pub budget_limit: u128,
88    pub window: usize,
89}
90
91/// Volume-weighted average ratio configuration: halts if VWAR of the last `window`
92/// confirmed pairs drops below `threshold`.
93#[derive(Debug, Clone, Copy, PartialEq)]
94pub struct VolumeWeightedConfig {
95    pub threshold: f64,
96    pub window: usize,
97}
98
99impl Default for VolumeWeightedConfig {
100    fn default() -> Self {
101        Self {
102            threshold: 0.75,
103            window: 20,
104        }
105    }
106}
107
108/// Absolute net capital drawdown configuration: halts if cumulative prediction deficit
109/// (sum(predicted - realized)) across the last `window` pairs exceeds `max_drawdown_limit`.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct DrawdownConfig {
112    pub max_drawdown_limit: i128,
113    pub window: usize,
114}
115
116/// The default `HaltPolicy`: the phantom-guard dual guard, optionally extended
117/// with revert and capital-weighted risk guards.
118///
119/// Construct via [`ReconcilePolicy::default_dual_guard`] for the
120/// whitepaper's default thresholds, [`ReconcilePolicy::institutional_default`]
121/// for revert-burst and volume-weighted protections, or [`ReconcilePolicy::new`]
122/// for custom parameters.
123#[derive(Debug, Clone, Copy, PartialEq)]
124pub struct ReconcilePolicy {
125    fast: FastGuardConfig,
126    slow: SlowGuardConfig,
127    revert_burst: Option<RevertBurstConfig>,
128    revert_gas: Option<RevertGasConfig>,
129    volume_weighted: Option<VolumeWeightedConfig>,
130    drawdown: Option<DrawdownConfig>,
131}
132
133impl ReconcilePolicy {
134    /// Build a policy with explicit guard configuration.
135    ///
136    /// Per whitepaper Property 2, `slow.threshold` should be greater than
137    /// `fast.threshold` for the two guards to be non-redundant as stated.
138    ///
139    /// # Panics
140    /// Panics if `fast.window == 0` or `slow.window == 0`.
141    pub fn new(fast: FastGuardConfig, slow: SlowGuardConfig) -> Self {
142        assert!(
143            fast.window > 0,
144            "fast-guard window must be > 0 (see docs/whitepaper.md Section 4.2)"
145        );
146        assert!(
147            slow.window > 0,
148            "slow-guard window must be > 0 (see docs/whitepaper.md Section 4.3)"
149        );
150        debug_assert!(
151            slow.threshold > fast.threshold,
152            "slow-guard threshold ({}) should exceed fast-guard threshold ({}) \
153             for Property 2 (guard independence) to hold as stated — see docs/whitepaper.md",
154            slow.threshold,
155            fast.threshold
156        );
157        Self {
158            fast,
159            slow,
160            revert_burst: None,
161            revert_gas: None,
162            volume_weighted: None,
163            drawdown: None,
164        }
165    }
166
167    /// Enable consecutive revert burst detection.
168    pub fn with_revert_burst(mut self, config: RevertBurstConfig) -> Self {
169        assert!(config.limit > 0, "revert-burst limit must be > 0");
170        self.revert_burst = Some(config);
171        self
172    }
173
174    /// Enable cumulative revert gas budget monitoring.
175    pub fn with_revert_gas(mut self, config: RevertGasConfig) -> Self {
176        assert!(config.window > 0, "revert-gas window must be > 0");
177        self.revert_gas = Some(config);
178        self
179    }
180
181    /// Enable volume-weighted average ratio (VWAR) reconciliation.
182    pub fn with_volume_weighted(mut self, config: VolumeWeightedConfig) -> Self {
183        assert!(config.window > 0, "volume-weighted window must be > 0");
184        self.volume_weighted = Some(config);
185        self
186    }
187
188    /// Enable net capital slippage drawdown monitoring.
189    pub fn with_drawdown(mut self, config: DrawdownConfig) -> Self {
190        assert!(config.window > 0, "drawdown window must be > 0");
191        self.drawdown = Some(config);
192        self
193    }
194
195    /// The whitepaper's default configuration: `T_f = 0.50`, `k_f = 3`,
196    /// `T_s = 0.70`, `k_s = 20` (Section 7).
197    pub fn default_dual_guard() -> Self {
198        Self::new(FastGuardConfig::default(), SlowGuardConfig::default())
199    }
200
201    /// Institutional multi-guard policy enabling revert-burst ($k_{rev}=3$)
202    /// and volume-weighted ratio ($T_{vw}=0.75, k_{vw}=20$) protections.
203    pub fn institutional_default() -> Self {
204        Self::default_dual_guard()
205            .with_revert_burst(RevertBurstConfig::default())
206            .with_volume_weighted(VolumeWeightedConfig::default())
207    }
208
209    pub fn fast_guard_config(&self) -> FastGuardConfig {
210        self.fast
211    }
212
213    pub fn slow_guard_config(&self) -> SlowGuardConfig {
214        self.slow
215    }
216
217    pub fn revert_burst_config(&self) -> Option<RevertBurstConfig> {
218        self.revert_burst
219    }
220
221    pub fn revert_gas_config(&self) -> Option<RevertGasConfig> {
222        self.revert_gas
223    }
224
225    pub fn volume_weighted_config(&self) -> Option<VolumeWeightedConfig> {
226        self.volume_weighted
227    }
228
229    pub fn drawdown_config(&self) -> Option<DrawdownConfig> {
230        self.drawdown
231    }
232}
233
234impl Default for ReconcilePolicy {
235    fn default() -> Self {
236        Self::default_dual_guard()
237    }
238}
239
240impl HaltPolicy for ReconcilePolicy {
241    fn evaluate(&mut self, history: &ReconcileHistory) -> HaltDecision {
242        // 1. Revert burst check (immediate infrastructure failure)
243        if let Some(burst) = self.revert_burst {
244            let consecutive = history.consecutive_reverts();
245            if consecutive >= burst.limit {
246                return HaltDecision::Halt(HaltReason::RevertBurst {
247                    consecutive_reverts: consecutive,
248                    limit: burst.limit,
249                });
250            }
251        }
252
253        // 2. Revert gas budget check
254        if let Some(gas_cfg) = self.revert_gas {
255            let burned = history.recent_revert_gas_burned(gas_cfg.window);
256            if burned > gas_cfg.budget_limit {
257                return HaltDecision::Halt(HaltReason::RevertGasBudgetExceeded {
258                    gas_cost_burned: burned,
259                    budget_limit: gas_cfg.budget_limit,
260                    window: gas_cfg.window,
261                });
262            }
263        }
264
265        // 3. Fast guard (whitepaper Section 4.2, Equation 2): halt on `k_f`
266        // consecutive ratios all below `T_f`.
267        let fast_window = history.recent_ratios(self.fast.window);
268        if fast_window.len() == self.fast.window
269            && fast_window.iter().all(|ratio| *ratio < self.fast.threshold)
270        {
271            return HaltDecision::Halt(HaltReason::FastGuard {
272                window: fast_window,
273                threshold: self.fast.threshold,
274            });
275        }
276
277        // 4. Absolute net capital drawdown check
278        if let Some(dd_cfg) = self.drawdown {
279            let pairs_in_window = history
280                .pairs
281                .iter()
282                .rev()
283                .take(dd_cfg.window)
284                .collect::<Vec<_>>();
285            if pairs_in_window.len() == dd_cfg.window {
286                let net_slippage_loss: i128 = pairs_in_window
287                    .iter()
288                    .map(|(p, r)| p.0 - r.0)
289                    .sum();
290                if net_slippage_loss > dd_cfg.max_drawdown_limit {
291                    return HaltDecision::Halt(HaltReason::NetDrawdownExceeded {
292                        net_slippage_loss,
293                        max_drawdown_limit: dd_cfg.max_drawdown_limit,
294                        window: dd_cfg.window,
295                    });
296                }
297            }
298        }
299
300        // 5. Volume-weighted average ratio check
301        if let Some(vw_cfg) = self.volume_weighted {
302            let valid_pairs = history
303                .pairs
304                .iter()
305                .rev()
306                .filter(|(p, _)| p.0 > 0)
307                .take(vw_cfg.window)
308                .collect::<Vec<_>>();
309            if valid_pairs.len() == vw_cfg.window {
310                let total_predicted: i128 = valid_pairs.iter().map(|(p, _)| p.0).sum();
311                let total_realized: i128 = valid_pairs.iter().map(|(_, r)| r.0).sum();
312                if total_predicted > 0 {
313                    let weighted_ratio = total_realized as f64 / total_predicted as f64;
314                    if weighted_ratio < vw_cfg.threshold {
315                        return HaltDecision::Halt(HaltReason::VolumeWeightedDrift {
316                            weighted_ratio,
317                            threshold: vw_cfg.threshold,
318                            window: vw_cfg.window,
319                        });
320                    }
321                }
322            }
323        }
324
325        // 6. Slow guard (whitepaper Section 4.3, Equation 3): halt if the
326        // mean of the last `k_s` ratios drops below `T_s`.
327        let slow_window = history.recent_ratios(self.slow.window);
328        if slow_window.len() == self.slow.window {
329            let mean_ratio = slow_window.iter().sum::<f64>() / slow_window.len() as f64;
330            if mean_ratio < self.slow.threshold {
331                return HaltDecision::Halt(HaltReason::SlowGuard {
332                    mean_ratio,
333                    window_size: self.slow.window,
334                    threshold: self.slow.threshold,
335                });
336            }
337        }
338
339        HaltDecision::Continue
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346    use driftbrake_core::{PredictedProfit, RealizedProfit};
347
348    /// Push a sequence of realized/predicted ratios (as `(predicted,
349    /// realized)` pairs with `predicted` fixed at 100 for readability)
350    /// into a fresh history.
351    fn history_from_ratios(ratios: &[f64]) -> ReconcileHistory {
352        let mut history = ReconcileHistory::new();
353        for &ratio in ratios {
354            let predicted = 100i128;
355            let realized = (predicted as f64 * ratio).round() as i128;
356            history.append(PredictedProfit(predicted), RealizedProfit(realized));
357        }
358        history
359    }
360
361    // -----------------------------------------------------------------
362    // Property 1 (ratio-direction correctness) — the single most
363    // important test in this suite (see CONTRIBUTING.md: "run the
364    // ratio-direction regression test explicitly ... a passing test
365    // suite that happens to skip this one is not sufficient"). Kept
366    // indefinitely as insurance against a silent inversion regression,
367    // per whitepaper Section 5 / Section 6's security table.
368    // -----------------------------------------------------------------
369    #[test]
370    fn ratio_direction_is_realized_over_predicted_and_flags_underperformance_not_overperformance() {
371        let mut policy = ReconcilePolicy::new(
372            FastGuardConfig {
373                threshold: 0.50,
374                window: 1,
375            },
376            SlowGuardConfig {
377                threshold: 0.99, // neutered via an unreachable window below, not via threshold ordering
378                window: usize::MAX,
379            },
380        );
381
382        // Whitepaper Section 4.1 worked example: predicted 100, realized
383        // 42 => rho = 0.42, which is underperformance and must halt.
384        let underperforming = history_from_ratios(&[0.42]);
385        assert!(matches!(
386            policy.evaluate(&underperforming),
387            HaltDecision::Halt(HaltReason::FastGuard { .. })
388        ));
389
390        // The mirror case: realized *exceeds* predicted (rho = 2.38,
391        // i.e. predicted 100 / realized 238). Under the correct
392        // definition (realized / predicted) this is >= 1 and must NOT
393        // halt. Under the inverted (wrong) definition this would look
394        // like underperformance and incorrectly halt.
395        let overperforming = history_from_ratios(&[2.38]);
396        assert_eq!(policy.evaluate(&overperforming), HaltDecision::Continue);
397    }
398
399    #[test]
400    fn ratio_direction_worked_example_from_whitepaper_section_4_1() {
401        // p_hat = 100, r = 42 => rho = 0.42, below T_f = 0.50.
402        let mut history = ReconcileHistory::new();
403        history.append(PredictedProfit(100), RealizedProfit(42));
404        assert_eq!(history.recent_ratios(1), vec![0.42]);
405    }
406
407    // -----------------------------------------------------------------
408    // Fast guard (Section 4.2)
409    // -----------------------------------------------------------------
410    #[test]
411    fn fast_guard_trips_on_three_consecutive_bad_ratios_after_healthy_history() {
412        // Whitepaper Section 4.2 worked example: 0.9, 0.9, 0.3, 0.2, 0.1
413        // — the last three are all < 0.50, so FastHalt is true, even
414        // though the two before them were healthy (memoryless by design
415        // w.r.t. anything outside its window).
416        let mut policy = ReconcilePolicy::default_dual_guard();
417        let history = history_from_ratios(&[0.9, 0.9, 0.3, 0.2, 0.1]);
418
419        match policy.evaluate(&history) {
420            HaltDecision::Halt(HaltReason::FastGuard { window, threshold }) => {
421                assert_eq!(window, vec![0.3, 0.2, 0.1]);
422                assert_eq!(threshold, 0.50);
423            }
424            other => panic!("expected FastGuard halt, got {other:?}"),
425        }
426    }
427
428    #[test]
429    fn fast_guard_does_not_trip_on_a_single_bad_ratio() {
430        let mut policy = ReconcilePolicy::default_dual_guard();
431        let history = history_from_ratios(&[0.9, 0.9, 0.1]); // only 1 of 3 bad
432        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
433    }
434
435    #[test]
436    fn fast_guard_requires_the_bad_run_to_be_the_most_recent_and_unbroken() {
437        let mut policy = ReconcilePolicy::default_dual_guard();
438        // Bad, good, bad, bad: the most recent 3 are [good, bad, bad] —
439        // not all below threshold, so no halt.
440        let history = history_from_ratios(&[0.1, 0.9, 0.1, 0.2]);
441        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
442    }
443
444    #[test]
445    fn fast_guard_is_configurable_not_hardcoded() {
446        let mut policy = ReconcilePolicy::new(
447            FastGuardConfig {
448                threshold: 0.80, // stricter than the 0.50 default
449                window: 2,
450            },
451            SlowGuardConfig {
452                threshold: 0.99,
453                window: usize::MAX,
454            },
455        );
456        // 0.75 would pass the *default* 0.50 threshold but must trip
457        // this custom, stricter 0.80 threshold over a window of 2.
458        let history = history_from_ratios(&[0.75, 0.75]);
459        assert!(matches!(
460            policy.evaluate(&history),
461            HaltDecision::Halt(HaltReason::FastGuard { .. })
462        ));
463    }
464
465    // -----------------------------------------------------------------
466    // Slow guard (Section 4.3)
467    // -----------------------------------------------------------------
468    #[test]
469    fn slow_guard_trips_on_a_slow_bleed_the_fast_guard_misses() {
470        // Whitepaper Section 4.3 worked example: 20 ratios averaging
471        // 0.68, individually ranging 0.65-0.72 (well above T_f = 0.50,
472        // so the fast guard never trips), but the mean is below
473        // T_s = 0.70.
474        let mut policy = ReconcilePolicy::default_dual_guard();
475        let ratios: Vec<f64> = (0..20)
476            .map(|i| 0.65 + (i % 8) as f64 * 0.01) // stays within 0.65-0.72
477            .collect();
478        let mean = ratios.iter().sum::<f64>() / ratios.len() as f64;
479        assert!(mean < 0.70, "test fixture's mean ({mean}) must be < 0.70");
480        assert!(
481            ratios.iter().all(|r| *r >= 0.50),
482            "test fixture must never trip the fast guard on its own"
483        );
484
485        let history = history_from_ratios(&ratios);
486        match policy.evaluate(&history) {
487            HaltDecision::Halt(HaltReason::SlowGuard {
488                window_size,
489                threshold,
490                ..
491            }) => {
492                assert_eq!(window_size, 20);
493                assert_eq!(threshold, 0.70);
494            }
495            other => panic!("expected SlowGuard halt, got {other:?}"),
496        }
497    }
498
499    #[test]
500    fn slow_guard_does_not_trip_with_fewer_than_the_full_window() {
501        let mut policy = ReconcilePolicy::default_dual_guard();
502        // Only 19 ratios, all bad enough to fail the mean test if it
503        // were (wrongly) computed over a partial window.
504        let history = history_from_ratios(&[0.6; 19]);
505        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
506    }
507
508    // -----------------------------------------------------------------
509    // Property 2 (guard independence / non-redundancy)
510    // -----------------------------------------------------------------
511    #[test]
512    fn property_2_slow_guard_can_trip_when_fast_guard_would_not() {
513        let mut policy = ReconcilePolicy::default_dual_guard();
514        let ratios: Vec<f64> = (0..20).map(|i| 0.65 + (i % 8) as f64 * 0.01).collect();
515        let history = history_from_ratios(&ratios);
516        assert!(matches!(
517            policy.evaluate(&history),
518            HaltDecision::Halt(HaltReason::SlowGuard { .. })
519        ));
520    }
521
522    #[test]
523    fn property_2_fast_guard_can_trip_when_slow_guard_would_not() {
524        let mut policy = ReconcilePolicy::default_dual_guard();
525        // 17 perfect trades, then 3 catastrophic ones: the rolling mean
526        // over 20 stays comfortably above 0.70, but the last 3 trip the
527        // fast guard.
528        let mut ratios = vec![1.0; 17];
529        ratios.extend([0.1, 0.1, 0.1]);
530        let history = history_from_ratios(&ratios);
531
532        match policy.evaluate(&history) {
533            HaltDecision::Halt(HaltReason::FastGuard { .. }) => {}
534            other => panic!("expected FastGuard halt, got {other:?}"),
535        }
536    }
537
538    // -----------------------------------------------------------------
539    // Construction-time guards: a window of 0 is a footgun, not a valid
540    // configuration.
541    // -----------------------------------------------------------------
542    #[test]
543    #[should_panic(expected = "fast-guard window must be > 0")]
544    fn rejects_a_zero_fast_guard_window() {
545        // Regression test: window=0 used to vacuously trip the guard on
546        // every call (an empty window trivially satisfies "all ratios
547        // below threshold"), including on a completely empty history.
548        // That's a footgun, not a valid configuration, and must be
549        // rejected at construction rather than silently misbehaving.
550        ReconcilePolicy::new(
551            FastGuardConfig {
552                threshold: 0.5,
553                window: 0,
554            },
555            SlowGuardConfig::default(),
556        );
557    }
558
559    #[test]
560    #[should_panic(expected = "slow-guard window must be > 0")]
561    fn rejects_a_zero_slow_guard_window() {
562        ReconcilePolicy::new(
563            FastGuardConfig::default(),
564            SlowGuardConfig {
565                threshold: 0.9,
566                window: 0,
567            },
568        );
569    }
570
571    // -----------------------------------------------------------------
572    // HaltPolicy contract: must not panic on short/empty history.
573    // -----------------------------------------------------------------
574    #[test]
575    fn continues_on_empty_history() {
576        let mut policy = ReconcilePolicy::default_dual_guard();
577        let history = ReconcileHistory::new();
578        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
579    }
580
581    #[test]
582    fn continues_when_history_is_shorter_than_either_window() {
583        let mut policy = ReconcilePolicy::default_dual_guard();
584        let history = history_from_ratios(&[0.1, 0.1]); // shorter than k_f = 3
585        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
586    }
587
588    // -----------------------------------------------------------------
589    // Property 4 (no silent zero-division), exercised through the
590    // policy rather than ReconcileHistory directly (core already tests
591    // ReconcileHistory::recent_ratios in isolation).
592    // -----------------------------------------------------------------
593    #[test]
594    fn non_positive_predicted_profit_is_excluded_from_both_guards() {
595        let mut policy = ReconcilePolicy::default_dual_guard();
596        let mut history = ReconcileHistory::new();
597        // Three genuinely bad ratios...
598        history.append(PredictedProfit(100), RealizedProfit(10));
599        history.append(PredictedProfit(100), RealizedProfit(10));
600        history.append(PredictedProfit(100), RealizedProfit(10));
601        // ...but the strategy should never have submitted this one, and
602        // it must not dilute or dodge the fast guard's window either way.
603        history.append(PredictedProfit(-5), RealizedProfit(999_999));
604
605        assert!(matches!(
606            policy.evaluate(&history),
607            HaltDecision::Halt(HaltReason::FastGuard { .. })
608        ));
609    }
610
611    // -----------------------------------------------------------------
612    // New Institutional Multi-Guard Tests (v0.2.0)
613    // -----------------------------------------------------------------
614    #[test]
615    fn revert_burst_trips_on_consecutive_reverts() {
616        let mut policy = ReconcilePolicy::default_dual_guard()
617            .with_revert_burst(RevertBurstConfig { limit: 3 });
618        let mut history = ReconcileHistory::new();
619
620        // 10 healthy pairs
621        for _ in 0..10 {
622            history.append(PredictedProfit(100), RealizedProfit(100));
623        }
624        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
625
626        let rev = driftbrake_core::RevertEvent {
627            tx_hash: [0u8; 32],
628            block_number: 1,
629            reason: None,
630            gas_used: 21_000,
631            effective_gas_price: 30_000_000_000,
632        };
633
634        history.record_revert(rev.clone());
635        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
636        history.record_revert(rev.clone());
637        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
638        history.record_revert(rev);
639
640        assert_eq!(
641            policy.evaluate(&history),
642            HaltDecision::Halt(HaltReason::RevertBurst {
643                consecutive_reverts: 3,
644                limit: 3,
645            })
646        );
647    }
648
649    #[test]
650    fn confirmed_pair_resets_revert_burst_counter() {
651        let mut policy = ReconcilePolicy::default_dual_guard()
652            .with_revert_burst(RevertBurstConfig { limit: 3 });
653        let mut history = ReconcileHistory::new();
654
655        let rev = driftbrake_core::RevertEvent {
656            tx_hash: [0u8; 32],
657            block_number: 1,
658            reason: None,
659            gas_used: 21_000,
660            effective_gas_price: 30_000_000_000,
661        };
662
663        // 2 reverts, then 1 confirmed trade, then 2 reverts: never reaches limit of 3
664        history.record_revert(rev.clone());
665        history.record_revert(rev.clone());
666        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
667
668        history.append(PredictedProfit(100), RealizedProfit(100));
669        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
670
671        history.record_revert(rev.clone());
672        history.record_revert(rev);
673        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
674    }
675
676    #[test]
677    fn revert_gas_budget_trips_when_burned_gas_exceeds_limit() {
678        let mut policy = ReconcilePolicy::default_dual_guard()
679            .with_revert_gas(RevertGasConfig {
680                budget_limit: 100_000_000,
681                window: 5,
682            });
683        let mut history = ReconcileHistory::new();
684
685        let rev = driftbrake_core::RevertEvent {
686            tx_hash: [0u8; 32],
687            block_number: 1,
688            reason: None,
689            gas_used: 50_000,
690            effective_gas_price: 1_000, // 50,000,000 cost
691        };
692
693        history.record_revert(rev.clone());
694        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
695
696        // Second revert burns another 50,000,000 => total 100,000,000 (not exceeded yet)
697        history.record_revert(rev.clone());
698        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
699
700        // Third revert pushes total to 150,000,000 > 100,000,000 limit
701        history.record_revert(rev);
702        assert!(matches!(
703            policy.evaluate(&history),
704            HaltDecision::Halt(HaltReason::RevertGasBudgetExceeded {
705                gas_cost_burned: 150_000_000,
706                budget_limit: 100_000_000,
707                window: 5,
708            })
709        ));
710    }
711
712    #[test]
713    fn volume_weighted_guard_trips_on_whale_collapse_even_if_small_trades_win() {
714        // Asymmetric capital vulnerability: 19 small wins + 1 giant loss
715        let mut policy = ReconcilePolicy::default_dual_guard()
716            .with_volume_weighted(VolumeWeightedConfig {
717                threshold: 0.75,
718                window: 20,
719            });
720        let mut history = ReconcileHistory::new();
721
722        // 19 small trades: predicted $10, realized $12 (ratio 1.20)
723        for _ in 0..19 {
724            history.append(PredictedProfit(10), RealizedProfit(12));
725        }
726
727        // 1 large whale trade: predicted $100,000, realized $40,000 (ratio 0.40)
728        history.append(PredictedProfit(100_000), RealizedProfit(40_000));
729
730        // Unweighted mean ratio = (19 * 1.2 + 0.4) / 20 = 1.16 => Slow guard thinks it's great!
731        // But volume-weighted ratio = (19*12 + 40,000) / (19*10 + 100,000) = 40,228 / 100,190 ~ 0.4015
732        match policy.evaluate(&history) {
733            HaltDecision::Halt(HaltReason::VolumeWeightedDrift {
734                weighted_ratio,
735                threshold,
736                window,
737            }) => {
738                assert!(weighted_ratio < 0.41);
739                assert_eq!(threshold, 0.75);
740                assert_eq!(window, 20);
741            }
742            other => panic!("expected VolumeWeightedDrift halt, got {other:?}"),
743        }
744    }
745
746    #[test]
747    fn drawdown_guard_trips_on_absolute_capital_deficit() {
748        let mut policy = ReconcilePolicy::default_dual_guard()
749            .with_drawdown(DrawdownConfig {
750                max_drawdown_limit: 50_000,
751                window: 3,
752            });
753        let mut history = ReconcileHistory::new();
754
755        // 3 trades where slippage gap is 20,000 each => total 60,000 > 50,000 limit
756        history.append(PredictedProfit(100_000), RealizedProfit(80_000));
757        history.append(PredictedProfit(100_000), RealizedProfit(80_000));
758        assert_eq!(policy.evaluate(&history), HaltDecision::Continue);
759
760        history.append(PredictedProfit(100_000), RealizedProfit(80_000));
761        assert_eq!(
762            policy.evaluate(&history),
763            HaltDecision::Halt(HaltReason::NetDrawdownExceeded {
764                net_slippage_loss: 60_000,
765                max_drawdown_limit: 50_000,
766                window: 3,
767            })
768        );
769    }
770
771    #[test]
772    fn institutional_default_enables_revert_and_volume_weighted_guards() {
773        let policy = ReconcilePolicy::institutional_default();
774        assert!(policy.revert_burst_config().is_some());
775        assert!(policy.volume_weighted_config().is_some());
776        assert_eq!(policy.revert_burst_config().unwrap().limit, 3);
777        assert_eq!(policy.volume_weighted_config().unwrap().threshold, 0.75);
778    }
779}