Skip to main content

oris_economics/
lib.rs

1//! Non-financial EVU accounting for local publish and validation incentives.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7// ── EVU transaction journal ────────────────────────────────────────────────
8
9/// One durable record written for every EVU-affecting event.
10/// The journal represents the ground-truth ledger; balances are derived by
11/// replaying the sequence.
12#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
13pub struct LedgerEntry {
14    /// Monotonically increasing sequence number (1-based).
15    pub seq: u64,
16    /// Gene that was replayed, or empty for non-gene events.
17    pub gene_id: String,
18    /// Node whose balance is credited / debited.
19    pub node_id: String,
20    /// Signed EVU delta for this event.
21    pub delta: i64,
22    /// Milliseconds of inference cost that was avoided.
23    pub latency_saved_ms: u64,
24    /// Human-readable event type tag.
25    pub event_type: LedgerEventType,
26    /// Unix-epoch milliseconds when the entry was recorded.
27    pub recorded_at_ms: u64,
28    /// Running balance after applying this entry.
29    pub cumulative_balance: i64,
30}
31
32#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
33pub enum LedgerEventType {
34    ReplaySuccess,
35    PublishStakeReserved,
36    ReuseReward,
37    ValidationPenalty,
38    AntiInflationCap,
39    ManualAdjustment,
40}
41
42// ── Replay ROI calculator ──────────────────────────────────────────────────
43
44/// Parameters governing how many EVU a replay earns.
45#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct RoiPolicy {
47    /// EVU earned per complete `roi_window_ms` of inference cost avoided.
48    pub evu_per_window: i64,
49    /// Window size in milliseconds (default: 200 ms).
50    pub roi_window_ms: u64,
51    /// Hard ceiling on EVU earned from a single replay event.
52    pub max_reward_per_replay: i64,
53    /// Absolute cap on any node's balance (anti-inflation ceiling).
54    pub balance_cap: i64,
55}
56
57impl Default for RoiPolicy {
58    fn default() -> Self {
59        Self {
60            evu_per_window: 1,
61            roi_window_ms: 200,
62            max_reward_per_replay: 10,
63            balance_cap: 10_000,
64        }
65    }
66}
67
68/// Compute the EVU delta for a single successful replay, clamped to the policy
69/// ceiling.
70pub fn compute_replay_evu(latency_saved_ms: u64, policy: &RoiPolicy) -> i64 {
71    if policy.roi_window_ms == 0 {
72        return 0;
73    }
74    let windows = (latency_saved_ms / policy.roi_window_ms) as i64;
75    (windows * policy.evu_per_window).min(policy.max_reward_per_replay)
76}
77
78// ── EVU ledger journal ─────────────────────────────────────────────────────
79
80/// Append-only journal of all EVU events for a single node.
81/// Balances are re-derivable by replaying the sequence, giving restart
82/// recovery "for free".
83#[derive(Clone, Debug, Default, Serialize, Deserialize)]
84pub struct LedgerJournal {
85    pub node_id: String,
86    entries: Vec<LedgerEntry>,
87}
88
89impl LedgerJournal {
90    pub fn new(node_id: impl Into<String>) -> Self {
91        Self {
92            node_id: node_id.into(),
93            entries: Vec::new(),
94        }
95    }
96
97    /// Current EVU balance derived from the journal (O(1), stored in last entry).
98    pub fn balance(&self) -> i64 {
99        self.entries
100            .last()
101            .map(|e| e.cumulative_balance)
102            .unwrap_or(0)
103    }
104
105    /// Number of committed journal entries.
106    pub fn len(&self) -> usize {
107        self.entries.len()
108    }
109
110    pub fn is_empty(&self) -> bool {
111        self.entries.is_empty()
112    }
113
114    /// Immutable view of all entries (for serialisation / persistence).
115    pub fn entries(&self) -> &[LedgerEntry] {
116        &self.entries
117    }
118
119    /// Re-derive the balance by replaying all entries from scratch.
120    /// Returns the final balance, confirming the journal is self-consistent.
121    pub fn replay_balance(&self) -> i64 {
122        self.entries.iter().fold(0i64, |acc, e| acc + e.delta)
123    }
124
125    /// Record a successful replay event.
126    /// Returns the resulting `LedgerEntry`.
127    pub fn record_replay_success(
128        &mut self,
129        gene_id: impl Into<String>,
130        latency_saved_ms: u64,
131        now_ms: u64,
132        roi_policy: &RoiPolicy,
133    ) -> LedgerEntry {
134        let raw_delta = compute_replay_evu(latency_saved_ms, roi_policy);
135        let current = self.balance();
136        // Anti-inflation: clamp so balance never exceeds the cap.
137        let delta = if current + raw_delta > roi_policy.balance_cap {
138            (roi_policy.balance_cap - current).max(0)
139        } else {
140            raw_delta
141        };
142        let entry = LedgerEntry {
143            seq: self.entries.len() as u64 + 1,
144            gene_id: gene_id.into(),
145            node_id: self.node_id.clone(),
146            delta,
147            latency_saved_ms,
148            event_type: if delta < raw_delta {
149                LedgerEventType::AntiInflationCap
150            } else {
151                LedgerEventType::ReplaySuccess
152            },
153            recorded_at_ms: now_ms,
154            cumulative_balance: current + delta,
155        };
156        self.entries.push(entry.clone());
157        entry
158    }
159
160    /// Append a pre-built entry (used during journal replay / restore).
161    /// Entries are trusted; no further mutation is applied.
162    pub fn restore_entry(&mut self, entry: LedgerEntry) {
163        self.entries.push(entry);
164    }
165
166    /// Compute the average ROI (EVU per replay) across all replay events and
167    /// check whether it deviates from `baseline_roi` by more than `tolerance`
168    /// (expressed as a fraction, e.g. 0.05 for 5 %).
169    /// Returns `Ok(observed_roi)` when within tolerance,
170    ///         `Err(observed_roi)` when the deviation exceeds the threshold.
171    pub fn roi_stable(&self, baseline_roi: f64, tolerance: f64) -> Result<f64, f64> {
172        let replay_entries: Vec<&LedgerEntry> = self
173            .entries
174            .iter()
175            .filter(|e| {
176                e.event_type == LedgerEventType::ReplaySuccess
177                    || e.event_type == LedgerEventType::AntiInflationCap
178            })
179            .collect();
180        if replay_entries.is_empty() {
181            return Ok(0.0);
182        }
183        let total_evu: i64 = replay_entries.iter().map(|e| e.delta).sum();
184        let observed = total_evu as f64 / replay_entries.len() as f64;
185        let deviation = (observed - baseline_roi).abs() / baseline_roi.max(f64::EPSILON);
186        if deviation <= tolerance {
187            Ok(observed)
188        } else {
189            Err(observed)
190        }
191    }
192}
193
194/// Reconstruct a `LedgerJournal` from a serialised entry slice (e.g. loaded
195/// from disk / SQLite).  The balance column in each restored entry is
196/// re-validated to ensure consistency.
197pub fn journal_from_snapshot(
198    node_id: impl Into<String>,
199    entries: Vec<LedgerEntry>,
200) -> LedgerJournal {
201    let mut j = LedgerJournal::new(node_id);
202    for e in entries {
203        j.restore_entry(e);
204    }
205    j
206}
207
208#[derive(Clone, Debug, Default, Serialize, Deserialize)]
209pub struct EvuAccount {
210    pub node_id: String,
211    pub balance: i64,
212}
213
214#[derive(Clone, Debug, Default, Serialize, Deserialize)]
215pub struct ReputationRecord {
216    pub node_id: String,
217    pub publish_success_rate: f32,
218    pub validator_accuracy: f32,
219    pub reuse_impact: u64,
220}
221
222#[derive(Clone, Debug, Serialize, Deserialize)]
223pub struct StakePolicy {
224    pub publish_cost: i64,
225    pub reuse_reward: i64,
226    pub validator_penalty: i64,
227}
228
229impl Default for StakePolicy {
230    fn default() -> Self {
231        Self {
232            publish_cost: 1,
233            reuse_reward: 2,
234            validator_penalty: 1,
235        }
236    }
237}
238
239#[derive(Clone, Debug, Default, Serialize, Deserialize)]
240pub struct EconomicsSignal {
241    pub available_evu: i64,
242    pub publish_success_rate: f32,
243    pub validator_accuracy: f32,
244    pub reuse_impact: u64,
245    pub selector_weight: f32,
246}
247
248#[derive(Clone, Debug, Serialize, Deserialize)]
249pub struct ValidationSettlement {
250    pub publisher_delta: i64,
251    pub validator_delta: i64,
252    pub reason: String,
253}
254
255#[derive(Clone, Debug, Default, Serialize, Deserialize)]
256pub struct EvuLedger {
257    pub accounts: Vec<EvuAccount>,
258    pub reputations: Vec<ReputationRecord>,
259}
260
261impl EvuLedger {
262    pub fn can_publish(&self, node_id: &str, policy: &StakePolicy) -> bool {
263        self.accounts
264            .iter()
265            .find(|account| account.node_id == node_id)
266            .map(|account| account.balance >= policy.publish_cost)
267            .unwrap_or(false)
268    }
269
270    pub fn available_balance(&self, node_id: &str) -> Option<i64> {
271        self.accounts
272            .iter()
273            .find(|account| account.node_id == node_id)
274            .map(|account| account.balance)
275    }
276
277    pub fn reserve_publish_stake(
278        &mut self,
279        node_id: &str,
280        policy: &StakePolicy,
281    ) -> Option<ValidationSettlement> {
282        if !self.can_publish(node_id, policy) {
283            return None;
284        }
285        let account = self.account_mut(node_id);
286        account.balance -= policy.publish_cost;
287        Some(ValidationSettlement {
288            publisher_delta: -policy.publish_cost,
289            validator_delta: 0,
290            reason: "publish stake reserved".into(),
291        })
292    }
293
294    pub fn settle_remote_reuse(
295        &mut self,
296        publisher_id: &str,
297        success: bool,
298        policy: &StakePolicy,
299    ) -> ValidationSettlement {
300        if success {
301            {
302                let account = self.account_mut(publisher_id);
303                account.balance += policy.reuse_reward;
304            }
305            {
306                let reputation = self.reputation_mut(publisher_id);
307                reputation.publish_success_rate =
308                    blend_metric(reputation.publish_success_rate, 1.0);
309                reputation.reuse_impact = reputation.reuse_impact.saturating_add(1);
310            }
311            ValidationSettlement {
312                publisher_delta: policy.reuse_reward,
313                validator_delta: 0,
314                reason: "remote reuse succeeded".into(),
315            }
316        } else {
317            let reputation = self.reputation_mut(publisher_id);
318            reputation.publish_success_rate = blend_metric(reputation.publish_success_rate, 0.0);
319            reputation.validator_accuracy = blend_metric(reputation.validator_accuracy, 0.0);
320            ValidationSettlement {
321                publisher_delta: 0,
322                validator_delta: -policy.validator_penalty,
323                reason: "remote reuse failed local validation".into(),
324            }
325        }
326    }
327
328    pub fn penalize_validator_divergence(
329        &mut self,
330        validator_id: &str,
331        policy: &StakePolicy,
332    ) -> ValidationSettlement {
333        let reputation = self.reputation_mut(validator_id);
334        reputation.validator_accuracy = blend_metric(reputation.validator_accuracy, 0.0);
335        ValidationSettlement {
336            publisher_delta: 0,
337            validator_delta: -policy.validator_penalty,
338            reason: "validator report diverged from local final validation".into(),
339        }
340    }
341
342    pub fn selector_reputation_bias(&self) -> BTreeMap<String, f32> {
343        self.reputations
344            .iter()
345            .map(|record| {
346                let reuse_bonus = ((record.reuse_impact as f32).ln_1p() / 4.0).min(0.25);
347                let weight = (record.publish_success_rate * 0.55)
348                    + (record.validator_accuracy * 0.35)
349                    + reuse_bonus;
350                (record.node_id.clone(), weight.clamp(0.0, 1.0))
351            })
352            .collect()
353    }
354
355    pub fn governor_signal(&self, node_id: &str) -> Option<EconomicsSignal> {
356        let balance = self.available_balance(node_id).unwrap_or(0);
357        let reputation = self
358            .reputations
359            .iter()
360            .find(|record| record.node_id == node_id)
361            .cloned()
362            .or_else(|| {
363                self.accounts
364                    .iter()
365                    .find(|record| record.node_id == node_id)
366                    .map(|_| ReputationRecord {
367                        node_id: node_id.to_string(),
368                        publish_success_rate: 0.5,
369                        validator_accuracy: 0.5,
370                        reuse_impact: 0,
371                    })
372            })?;
373        let selector_weight = self
374            .selector_reputation_bias()
375            .get(node_id)
376            .copied()
377            .unwrap_or(0.0);
378        Some(EconomicsSignal {
379            available_evu: balance,
380            publish_success_rate: reputation.publish_success_rate,
381            validator_accuracy: reputation.validator_accuracy,
382            reuse_impact: reputation.reuse_impact,
383            selector_weight,
384        })
385    }
386
387    fn account_mut(&mut self, node_id: &str) -> &mut EvuAccount {
388        if let Some(index) = self
389            .accounts
390            .iter()
391            .position(|item| item.node_id == node_id)
392        {
393            return &mut self.accounts[index];
394        }
395        self.accounts.push(EvuAccount {
396            node_id: node_id.to_string(),
397            balance: 0,
398        });
399        self.accounts.last_mut().expect("account just inserted")
400    }
401
402    fn reputation_mut(&mut self, node_id: &str) -> &mut ReputationRecord {
403        if let Some(index) = self
404            .reputations
405            .iter()
406            .position(|item| item.node_id == node_id)
407        {
408            return &mut self.reputations[index];
409        }
410        self.reputations.push(ReputationRecord {
411            node_id: node_id.to_string(),
412            publish_success_rate: 0.5,
413            validator_accuracy: 0.5,
414            reuse_impact: 0,
415        });
416        self.reputations
417            .last_mut()
418            .expect("reputation just inserted")
419    }
420}
421
422fn blend_metric(current: f32, observation: f32) -> f32 {
423    ((current * 0.7) + (observation * 0.3)).clamp(0.0, 1.0)
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_evu_ledger_can_publish_with_sufficient_balance() {
432        let ledger = EvuLedger {
433            accounts: vec![EvuAccount {
434                node_id: "node1".into(),
435                balance: 10,
436            }],
437            reputations: vec![],
438        };
439        let policy = StakePolicy {
440            publish_cost: 5,
441            ..Default::default()
442        };
443        assert!(ledger.can_publish("node1", &policy));
444    }
445
446    #[test]
447    fn test_evu_ledger_cannot_publish_with_insufficient_balance() {
448        let ledger = EvuLedger {
449            accounts: vec![EvuAccount {
450                node_id: "node1".into(),
451                balance: 3,
452            }],
453            reputations: vec![],
454        };
455        let policy = StakePolicy {
456            publish_cost: 5,
457            ..Default::default()
458        };
459        assert!(!ledger.can_publish("node1", &policy));
460    }
461
462    #[test]
463    fn test_evu_ledger_cannot_publish_unknown_node() {
464        let ledger = EvuLedger::default();
465        let policy = StakePolicy {
466            publish_cost: 5,
467            ..Default::default()
468        };
469        assert!(!ledger.can_publish("unknown_node", &policy));
470    }
471
472    #[test]
473    fn test_default_stake_policy() {
474        let policy = StakePolicy::default();
475        assert_eq!(policy.publish_cost, 1);
476        assert_eq!(policy.reuse_reward, 2);
477        assert_eq!(policy.validator_penalty, 1);
478    }
479
480    #[test]
481    fn test_reputation_record() {
482        let reputation = ReputationRecord {
483            node_id: "node1".into(),
484            publish_success_rate: 0.95,
485            validator_accuracy: 0.88,
486            reuse_impact: 100,
487        };
488        assert_eq!(reputation.node_id, "node1");
489        assert!(reputation.publish_success_rate > 0.9);
490    }
491
492    #[test]
493    fn test_validation_settlement() {
494        let settlement = ValidationSettlement {
495            publisher_delta: 10,
496            validator_delta: 5,
497            reason: "successful validation".into(),
498        };
499        assert_eq!(settlement.publisher_delta, 10);
500        assert_eq!(settlement.validator_delta, 5);
501    }
502
503    #[test]
504    fn reserve_publish_stake_deducts_balance() {
505        let mut ledger = EvuLedger {
506            accounts: vec![EvuAccount {
507                node_id: "node1".into(),
508                balance: 10,
509            }],
510            reputations: vec![],
511        };
512        let policy = StakePolicy::default();
513        let settlement = ledger.reserve_publish_stake("node1", &policy).unwrap();
514        assert_eq!(settlement.publisher_delta, -1);
515        assert_eq!(ledger.available_balance("node1"), Some(9));
516    }
517
518    #[test]
519    fn remote_reuse_success_rewards_balance_and_reputation() {
520        let mut ledger = EvuLedger {
521            accounts: vec![EvuAccount {
522                node_id: "node1".into(),
523                balance: 3,
524            }],
525            reputations: vec![ReputationRecord {
526                node_id: "node1".into(),
527                publish_success_rate: 0.5,
528                validator_accuracy: 0.5,
529                reuse_impact: 0,
530            }],
531        };
532        let settlement = ledger.settle_remote_reuse("node1", true, &StakePolicy::default());
533        assert_eq!(settlement.publisher_delta, 2);
534        assert_eq!(ledger.available_balance("node1"), Some(5));
535        assert!(ledger.reputations[0].publish_success_rate > 0.5);
536        assert_eq!(ledger.reputations[0].reuse_impact, 1);
537    }
538
539    #[test]
540    fn remote_reuse_failure_penalizes_reputation() {
541        let mut ledger = EvuLedger {
542            accounts: vec![EvuAccount {
543                node_id: "node1".into(),
544                balance: 3,
545            }],
546            reputations: vec![ReputationRecord {
547                node_id: "node1".into(),
548                publish_success_rate: 0.8,
549                validator_accuracy: 0.9,
550                reuse_impact: 2,
551            }],
552        };
553        let settlement = ledger.settle_remote_reuse("node1", false, &StakePolicy::default());
554        assert_eq!(settlement.publisher_delta, 0);
555        assert!(settlement.validator_delta < 0);
556        assert!(ledger.reputations[0].publish_success_rate < 0.8);
557        assert!(ledger.reputations[0].validator_accuracy < 0.9);
558        assert_eq!(ledger.available_balance("node1"), Some(3));
559    }
560
561    #[test]
562    fn selector_reputation_bias_prefers_stronger_reputation() {
563        let ledger = EvuLedger {
564            accounts: vec![],
565            reputations: vec![
566                ReputationRecord {
567                    node_id: "node-a".into(),
568                    publish_success_rate: 0.4,
569                    validator_accuracy: 0.4,
570                    reuse_impact: 0,
571                },
572                ReputationRecord {
573                    node_id: "node-b".into(),
574                    publish_success_rate: 0.9,
575                    validator_accuracy: 0.9,
576                    reuse_impact: 10,
577                },
578            ],
579        };
580        let bias = ledger.selector_reputation_bias();
581        assert!(bias["node-b"] > bias["node-a"]);
582    }
583
584    #[test]
585    fn governor_signal_exposes_balance_and_reputation() {
586        let ledger = EvuLedger {
587            accounts: vec![EvuAccount {
588                node_id: "node1".into(),
589                balance: 7,
590            }],
591            reputations: vec![ReputationRecord {
592                node_id: "node1".into(),
593                publish_success_rate: 0.75,
594                validator_accuracy: 0.5,
595                reuse_impact: 4,
596            }],
597        };
598        let signal = ledger.governor_signal("node1").unwrap();
599        assert_eq!(signal.available_evu, 7);
600        assert_eq!(signal.reuse_impact, 4);
601        assert!(signal.selector_weight > 0.0);
602    }
603
604    // ── LedgerJournal / EVU calculation tests ─────────────────────────────
605
606    #[test]
607    fn compute_replay_evu_proportional_to_latency() {
608        let policy = RoiPolicy {
609            evu_per_window: 1,
610            roi_window_ms: 200,
611            max_reward_per_replay: 10,
612            ..RoiPolicy::default()
613        };
614        // 400 ms saved → 2 windows → 2 EVU
615        assert_eq!(compute_replay_evu(400, &policy), 2);
616        // 199 ms saved → 0 complete windows → 0 EVU
617        assert_eq!(compute_replay_evu(199, &policy), 0);
618        // 1_000 ms → 5 EVU
619        assert_eq!(compute_replay_evu(1_000, &policy), 5);
620    }
621
622    #[test]
623    fn compute_replay_evu_capped_by_max() {
624        let policy = RoiPolicy {
625            evu_per_window: 3,
626            roi_window_ms: 200,
627            max_reward_per_replay: 5,
628            ..RoiPolicy::default()
629        };
630        // 1_000 ms → 5 windows × 3 = 15, but capped at 5
631        assert_eq!(compute_replay_evu(1_000, &policy), 5);
632    }
633
634    #[test]
635    fn journal_records_replay_success_and_accumulates_balance() {
636        let mut journal = LedgerJournal::new("node-x");
637        let policy = RoiPolicy::default(); // 1 EVU per 200 ms
638        let e1 = journal.record_replay_success("gene-1", 400, 1_000, &policy);
639        let e2 = journal.record_replay_success("gene-2", 600, 2_000, &policy);
640
641        assert_eq!(e1.delta, 2);
642        assert_eq!(e1.seq, 1);
643        assert_eq!(e1.cumulative_balance, 2);
644
645        assert_eq!(e2.delta, 3);
646        assert_eq!(e2.seq, 2);
647        assert_eq!(e2.cumulative_balance, 5);
648
649        assert_eq!(journal.balance(), 5);
650        assert_eq!(journal.len(), 2);
651    }
652
653    #[test]
654    fn journal_replay_balance_matches_primary_balance() {
655        let mut journal = LedgerJournal::new("node-y");
656        let policy = RoiPolicy::default();
657        journal.record_replay_success("gene-a", 400, 1_000, &policy);
658        journal.record_replay_success("gene-b", 800, 2_000, &policy);
659        journal.record_replay_success("gene-c", 200, 3_000, &policy);
660
661        // replay_balance() re-derives from entry deltas; must equal balance()
662        assert_eq!(journal.replay_balance(), journal.balance());
663    }
664
665    #[test]
666    fn journal_restore_from_snapshot_recovers_balance() {
667        let mut source = LedgerJournal::new("node-z");
668        let policy = RoiPolicy::default();
669        source.record_replay_success("gene-1", 400, 1_000, &policy);
670        source.record_replay_success("gene-2", 600, 2_000, &policy);
671
672        // Serialise entries to JSON (simulating persistence)
673        let snapshot_json = serde_json::to_string(source.entries()).unwrap();
674        let restored_entries: Vec<LedgerEntry> = serde_json::from_str(&snapshot_json).unwrap();
675
676        // Restore from snapshot → balance must match original
677        let restored = journal_from_snapshot("node-z", restored_entries);
678        assert_eq!(restored.balance(), source.balance());
679        assert_eq!(restored.len(), source.len());
680    }
681
682    #[test]
683    fn journal_anti_inflation_cap_prevents_overflow() {
684        let policy = RoiPolicy {
685            evu_per_window: 5,
686            roi_window_ms: 100,
687            max_reward_per_replay: 50,
688            balance_cap: 10,
689        };
690        let mut journal = LedgerJournal::new("node-inflate");
691        // 5_000 ms → 50 EVU, but cap is 10
692        let e = journal.record_replay_success("gene-big", 5_000, 1, &policy);
693        assert_eq!(e.delta, 10);
694        assert_eq!(journal.balance(), 10);
695        assert_eq!(e.event_type, LedgerEventType::AntiInflationCap);
696
697        // Second replay → balance already at cap, delta must be 0
698        let e2 = journal.record_replay_success("gene-extra", 5_000, 2, &policy);
699        assert_eq!(e2.delta, 0);
700        assert_eq!(journal.balance(), 10);
701    }
702
703    #[test]
704    fn roi_stable_within_five_percent_tolerance() {
705        // baseline: 2 EVU/replay (400 ms / 200 ms/window × 1 EVU)
706        let policy = RoiPolicy::default();
707        let mut journal = LedgerJournal::new("roi-node");
708        for i in 0..20u64 {
709            journal.record_replay_success("gene", 400, i * 100, &policy);
710        }
711        let result = journal.roi_stable(2.0, 0.05);
712        assert!(result.is_ok(), "ROI should be stable: {:?}", result);
713        let observed = result.unwrap();
714        assert!((observed - 2.0).abs() / 2.0 <= 0.05);
715    }
716
717    #[test]
718    fn roi_stable_detects_inflated_roi() {
719        // baseline: 1 EVU/replay but we use 400 ms (2 EVU).  With baseline=1.0
720        // the deviation is 100%, far outside 5%.
721        let policy = RoiPolicy::default();
722        let mut journal = LedgerJournal::new("roi-node-b");
723        for i in 0..10u64 {
724            journal.record_replay_success("gene", 400, i * 100, &policy);
725        }
726        let result = journal.roi_stable(1.0, 0.05);
727        assert!(result.is_err(), "Should detect deviation from baseline");
728    }
729}