Skip to main content

mnemo_deal/
reputation.rs

1//! Advisory reputation score (v0.4.1 P1-5).
2//!
3//! Reputation is gameable; the README's threat-model section spells
4//! that out — the score is **advisory**, not a gate. The shape:
5//!
6//! ```text
7//! score = (completed_weighted - dispute_penalty) / total_weighted
8//! ```
9//!
10//! Older completed deals decay with a 90-day half-life so a long-
11//! dormant reputation eventually falls back to neutral. One verified
12//! [`crate::DisputeReport`] drops the score by ≥ 10%.
13
14use std::time::{Duration, SystemTime};
15
16use serde::{Deserialize, Serialize};
17
18use crate::dispute::DisputeReport;
19use crate::envelope::DealEnvelope;
20
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct ReputationScore {
23    pub agent: String,
24    pub completed: u32,
25    pub disputed: u32,
26    pub mean_settlement_ms: u64,
27    pub score: f32,
28}
29
30impl ReputationScore {
31    pub fn empty(agent: impl Into<String>) -> Self {
32        Self {
33            agent: agent.into(),
34            completed: 0,
35            disputed: 0,
36            mean_settlement_ms: 0,
37            score: 0.5, // neutral
38        }
39    }
40}
41
42const HALF_LIFE_SECS: f32 = 90.0 * 24.0 * 3600.0;
43
44fn decay_weight(now: SystemTime, signed_at: SystemTime) -> f32 {
45    let age_secs = now
46        .duration_since(signed_at)
47        .map(|d| d.as_secs())
48        .unwrap_or(0) as f32;
49    0.5_f32.powf(age_secs / HALF_LIFE_SECS)
50}
51
52/// Compute the reputation score for an agent given their history of
53/// completed envelopes + the disputes filed against them.
54pub fn compute_reputation(
55    agent: &str,
56    history: &[DealEnvelope],
57    disputes: &[DisputeReport],
58) -> ReputationScore {
59    let now = SystemTime::now();
60    let agent_envelopes: Vec<&DealEnvelope> = history
61        .iter()
62        .filter(|e| e.seller == agent || e.buyer == agent)
63        .collect();
64    let completed = agent_envelopes.len() as u32;
65    let disputed = disputes.len() as u32;
66
67    if completed == 0 {
68        return ReputationScore::empty(agent);
69    }
70
71    let mut weighted_completed = 0.0f32;
72    let mut weighted_total = 0.0f32;
73    let mut settle_total = 0u64;
74    for e in &agent_envelopes {
75        let w = decay_weight(now, e.signed_at);
76        weighted_completed += w;
77        weighted_total += w;
78        settle_total += now
79            .duration_since(e.signed_at)
80            .unwrap_or(Duration::ZERO)
81            .as_millis() as u64;
82    }
83
84    // Dispute penalty: 10% of weighted_completed per dispute, floored at 0.
85    let dispute_penalty = disputed as f32 * 0.10 * weighted_completed;
86    // If every deal has decayed to near-zero weight, fall back to
87    // neutral rather than dividing by ~0 (which produces NaN). The
88    // floor matches the "empty history → 0.5" rule.
89    let score = if weighted_total < 1e-6 {
90        0.5
91    } else {
92        ((weighted_completed - dispute_penalty) / weighted_total).clamp(0.0, 1.0)
93    };
94
95    ReputationScore {
96        agent: agent.to_string(),
97        completed,
98        disputed,
99        mean_settlement_ms: settle_total / completed as u64,
100        score,
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::envelope::DealEnvelope;
108
109    fn key() -> [u8; 32] {
110        [33u8; 32]
111    }
112
113    fn envelope(buyer: &str, seller: &str, signed_at: SystemTime) -> DealEnvelope {
114        DealEnvelope::sign(
115            buyer,
116            seller,
117            serde_json::json!({"price": "10 USDC"}),
118            [0u8; 32],
119            signed_at,
120            &key(),
121        )
122        .unwrap()
123    }
124
125    #[test]
126    fn empty_history_yields_neutral_score() {
127        let r = compute_reputation("a", &[], &[]);
128        assert_eq!(r.completed, 0);
129        assert!((r.score - 0.5).abs() < 1e-3);
130    }
131
132    #[test]
133    fn fresh_completed_deals_score_near_one() {
134        let now = SystemTime::now();
135        let history: Vec<DealEnvelope> = (0..5).map(|_| envelope("buyer", "seller", now)).collect();
136        let r = compute_reputation("seller", &history, &[]);
137        assert!(
138            r.score > 0.95,
139            "fresh agent should score >0.95, got {}",
140            r.score
141        );
142        assert_eq!(r.completed, 5);
143    }
144
145    #[test]
146    fn old_deals_decay_to_neutral_when_weight_is_tiny() {
147        // Very old deal (decades ago): weight underflows to ~0,
148        // score falls back to neutral 0.5 instead of dividing 0/0.
149        let very_old = SystemTime::UNIX_EPOCH;
150        let now = SystemTime::now();
151        let new_history = vec![envelope("buyer", "seller", now)];
152        let old_history = vec![envelope("buyer", "seller", very_old)];
153        let r_new = compute_reputation("seller", &new_history, &[]);
154        let r_old = compute_reputation("seller", &old_history, &[]);
155        // Fresh deal scores 1.0, very-old falls back to neutral.
156        assert_eq!(r_new.score, 1.0);
157        assert!(
158            (r_old.score - 0.5).abs() < 1e-3,
159            "very-old deal should fall back to neutral 0.5, got {}",
160            r_old.score
161        );
162    }
163
164    #[test]
165    fn dispute_drops_score_by_at_least_ten_percent() {
166        let now = SystemTime::now();
167        let history: Vec<DealEnvelope> = (0..5).map(|_| envelope("buyer", "seller", now)).collect();
168        let no_dispute = compute_reputation("seller", &history, &[]);
169        let dispute_report = DisputeReport {
170            divergent_offset: crate::ledger::LedgerOffset(0),
171            expected_hash: [0u8; 32],
172            actual_hash: [1u8; 32],
173        };
174        let with_one = compute_reputation("seller", &history, &[dispute_report]);
175        let drop = no_dispute.score - with_one.score;
176        assert!(
177            drop >= 0.09,
178            "expected >=10% drop, got {drop} (no_dispute={}, with_one={})",
179            no_dispute.score,
180            with_one.score
181        );
182    }
183}