Skip to main content

de_mls/core/peer_scoring/
helpers.rs

1//! Pure helpers: scoring/member-roster diff and ECP score-op derivation.
2//! No state, no I/O.
3
4use std::collections::HashSet;
5
6use prost::Message;
7
8use crate::protos::de_mls::messages::v1::{
9    ConversationUpdateRequest, ViolationEvidence, conversation_update_request::Payload,
10};
11
12use crate::core::{ScoreEvent, ScoreOp, ScoringMemberDiff};
13
14/// Diff between a scoring table snapshot and an MLS member roster.
15/// Caller applies the diff to its own [`super::PeerScoringPlugin`].
16pub fn scoring_member_diff(scored: &[Vec<u8>], mls_members: &[Vec<u8>]) -> ScoringMemberDiff {
17    let scored_set: HashSet<&[u8]> = scored.iter().map(Vec::as_slice).collect();
18    let mls_set: HashSet<&[u8]> = mls_members.iter().map(Vec::as_slice).collect();
19
20    let to_add = mls_members
21        .iter()
22        .filter(|m| !scored_set.contains(m.as_slice()))
23        .cloned()
24        .collect();
25    let to_remove = scored
26        .iter()
27        .filter(|m| !mls_set.contains(m.as_slice()))
28        .cloned()
29        .collect();
30    ScoringMemberDiff { to_add, to_remove }
31}
32
33/// Score ops to apply when an emergency proposal resolves. Returns an
34/// empty vector when the payload isn't an ECP or has no evidence.
35///
36/// - accepted target-bearing emergency → target penalty + creator reward.
37/// - accepted `SCORE_BELOW_THRESHOLD` or `DEADLOCK` → creator reward only.
38/// - rejected emergency → creator penalty.
39pub fn emergency_score_ops(payload: &[u8], approved: bool) -> Vec<ScoreOp> {
40    let Ok(req) = ConversationUpdateRequest::decode(payload) else {
41        return Vec::new();
42    };
43    let Some(Payload::EmergencyCriteria(ec)) = req.payload else {
44        return Vec::new();
45    };
46    let Some(evidence) = ec.evidence else {
47        return Vec::new();
48    };
49
50    if approved {
51        let mut ops = vec![creator_reward(&evidence)];
52        if let Some(target_op) = evidence.target_score_op() {
53            ops.push(target_op);
54        }
55        ops
56    } else {
57        vec![creator_penalty(&evidence)]
58    }
59}
60
61fn creator_reward(ev: &ViolationEvidence) -> ScoreOp {
62    ScoreOp {
63        member_id: ev.creator_member_id.clone(),
64        event: ScoreEvent::EmergencyYesCreator,
65    }
66}
67
68fn creator_penalty(ev: &ViolationEvidence) -> ScoreOp {
69    ScoreOp {
70        member_id: ev.creator_member_id.clone(),
71        event: ScoreEvent::EmergencyNoCreator,
72    }
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use crate::protos::de_mls::messages::v1::{EmergencyCriteriaProposal, ViolationType};
79
80    fn ecp_payload(violation_type: i32, target: Vec<u8>, creator: Vec<u8>) -> Vec<u8> {
81        let evidence = ViolationEvidence {
82            violation_type,
83            target_member_id: target,
84            evidence_payload: Vec::new(),
85            epoch: 0,
86            creator_member_id: creator,
87        };
88        let req = ConversationUpdateRequest {
89            payload: Some(Payload::EmergencyCriteria(EmergencyCriteriaProposal {
90                evidence: Some(evidence),
91            })),
92        };
93        req.encode_to_vec()
94    }
95
96    /// Approved + target-mappable violation → creator reward + target penalty.
97    #[test]
98    fn approved_broken_commit_emits_reward_and_target_penalty() {
99        let payload = ecp_payload(ViolationType::BrokenCommit as i32, vec![0xAA], vec![0xBB]);
100        let ops = emergency_score_ops(&payload, true);
101        assert_eq!(ops.len(), 2);
102        assert_eq!(ops[0].event, ScoreEvent::EmergencyYesCreator);
103        assert_eq!(ops[0].member_id, vec![0xBB]);
104        assert_eq!(ops[1].event, ScoreEvent::BrokenCommit);
105        assert_eq!(ops[1].member_id, vec![0xAA]);
106    }
107
108    /// Approved + non-target violation (`Deadlock`) → creator reward only.
109    #[test]
110    fn approved_deadlock_emits_reward_only() {
111        let payload = ecp_payload(ViolationType::Deadlock as i32, Vec::new(), vec![0xBB]);
112        let ops = emergency_score_ops(&payload, true);
113        assert_eq!(ops.len(), 1);
114        assert_eq!(ops[0].event, ScoreEvent::EmergencyYesCreator);
115    }
116
117    /// Approved `ScoreBelowThreshold` is the trigger for removal — no
118    /// target-side score op (the target gets removed instead).
119    #[test]
120    fn approved_score_below_threshold_emits_reward_only() {
121        let payload = ecp_payload(
122            ViolationType::ScoreBelowThreshold as i32,
123            vec![0xAA],
124            vec![0xBB],
125        );
126        let ops = emergency_score_ops(&payload, true);
127        assert_eq!(ops.len(), 1);
128        assert_eq!(ops[0].event, ScoreEvent::EmergencyYesCreator);
129    }
130
131    /// Wire-malformed `Unspecified` violation type — emit creator reward,
132    /// drop the malformed target op silently.
133    #[test]
134    fn approved_unspecified_emits_reward_only() {
135        let payload = ecp_payload(0, vec![0xAA], vec![0xBB]);
136        let ops = emergency_score_ops(&payload, true);
137        assert_eq!(ops.len(), 1);
138        assert_eq!(ops[0].event, ScoreEvent::EmergencyYesCreator);
139    }
140
141    /// Rejected → creator penalty regardless of violation type.
142    #[test]
143    fn rejected_emits_creator_penalty() {
144        for vt in [
145            ViolationType::BrokenCommit,
146            ViolationType::Deadlock,
147            ViolationType::ScoreBelowThreshold,
148        ] {
149            let payload = ecp_payload(vt as i32, vec![0xAA], vec![0xBB]);
150            let ops = emergency_score_ops(&payload, false);
151            assert_eq!(ops.len(), 1);
152            assert_eq!(ops[0].event, ScoreEvent::EmergencyNoCreator);
153            assert_eq!(ops[0].member_id, vec![0xBB]);
154        }
155    }
156}