1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
13pub struct LedgerEntry {
14 pub seq: u64,
16 pub gene_id: String,
18 pub node_id: String,
20 pub delta: i64,
22 pub latency_saved_ms: u64,
24 pub event_type: LedgerEventType,
26 pub recorded_at_ms: u64,
28 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#[derive(Clone, Debug, Serialize, Deserialize)]
46pub struct RoiPolicy {
47 pub evu_per_window: i64,
49 pub roi_window_ms: u64,
51 pub max_reward_per_replay: i64,
53 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
68pub 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#[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 pub fn balance(&self) -> i64 {
99 self.entries
100 .last()
101 .map(|e| e.cumulative_balance)
102 .unwrap_or(0)
103 }
104
105 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 pub fn entries(&self) -> &[LedgerEntry] {
116 &self.entries
117 }
118
119 pub fn replay_balance(&self) -> i64 {
122 self.entries.iter().fold(0i64, |acc, e| acc + e.delta)
123 }
124
125 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 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 pub fn restore_entry(&mut self, entry: LedgerEntry) {
163 self.entries.push(entry);
164 }
165
166 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
194pub 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 #[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 assert_eq!(compute_replay_evu(400, &policy), 2);
616 assert_eq!(compute_replay_evu(199, &policy), 0);
618 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 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(); 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 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 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 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 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 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 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 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}