Skip to main content

llmtxt_core/
consensus.rs

1//! Consensus and approval workflow evaluation.
2//!
3//! Pure functions for multi-agent review/approval workflows.
4//! No storage, no side effects -- just evaluation logic.
5//!
6//! Accepts and returns JSON for WASM compatibility. Native callers
7//! can use the struct-based variants directly.
8
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12#[cfg(feature = "wasm")]
13use wasm_bindgen::prelude::*;
14
15/// A single review from an agent.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct Review {
19    /// Agent that submitted the review.
20    pub reviewer_id: String,
21    /// Current status: PENDING, APPROVED, REJECTED, STALE.
22    pub status: String,
23    /// Timestamp of the review action (ms since epoch).
24    pub timestamp: f64,
25    /// Reason or comment provided with the review.
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub reason: Option<String>,
28    /// Version number the review applies to.
29    pub at_version: u32,
30}
31
32/// Policy governing how approvals are evaluated.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct ApprovalPolicy {
36    /// Minimum number of approvals required (absolute count).
37    /// Ignored when `required_percentage` is set (> 0).
38    pub required_count: u32,
39    /// If true, all allowed reviewers must approve (overrides count/percentage).
40    pub require_unanimous: bool,
41    /// Agent IDs allowed to review. Empty means anyone can review.
42    pub allowed_reviewer_ids: Vec<String>,
43    /// Auto-expire reviews older than this (ms). 0 means no timeout.
44    pub timeout_ms: f64,
45    /// Percentage of effective reviewers required to approve (0-100).
46    /// 0 means use `required_count` instead. When > 0, the required count
47    /// is computed as `ceil(percentage * effective_reviewer_count / 100)`.
48    #[serde(default)]
49    pub required_percentage: u32,
50}
51
52/// Result of evaluating reviews against a policy.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55pub struct ApprovalResult {
56    /// Whether the approval threshold is met.
57    pub approved: bool,
58    /// Reviewers that have approved.
59    pub approved_by: Vec<String>,
60    /// Reviewers that have rejected.
61    pub rejected_by: Vec<String>,
62    /// Reviewers that are still pending.
63    pub pending_from: Vec<String>,
64    /// Reviewers whose reviews are stale.
65    pub stale_from: Vec<String>,
66    /// Human-readable summary.
67    pub reason: String,
68}
69
70/// Evaluate reviews against an approval policy (native API).
71///
72/// Filters out stale and timed-out reviews, then checks whether the
73/// remaining approvals meet the policy threshold.
74pub fn evaluate_approvals_native(
75    reviews: &[Review],
76    policy: &ApprovalPolicy,
77    current_version: u32,
78    now: f64,
79) -> ApprovalResult {
80    let mut approved_by = Vec::new();
81    let mut rejected_by = Vec::new();
82    let mut pending_from = Vec::new();
83    let mut stale_from = Vec::new();
84
85    // Determine effective reviewers
86    let effective_reviewers: Vec<String> = if !policy.allowed_reviewer_ids.is_empty() {
87        policy.allowed_reviewer_ids.clone()
88    } else {
89        let mut seen = HashSet::new();
90        reviews
91            .iter()
92            .filter(|r| seen.insert(r.reviewer_id.clone()))
93            .map(|r| r.reviewer_id.clone())
94            .collect()
95    };
96
97    // Keep latest review per reviewer
98    let mut review_map: HashMap<&str, &Review> = HashMap::new();
99    for review in reviews {
100        let dominated = match review_map.get(review.reviewer_id.as_str()) {
101            None => true,
102            Some(existing) => review.timestamp > existing.timestamp,
103        };
104        if dominated {
105            review_map.insert(&review.reviewer_id, review);
106        }
107    }
108
109    for reviewer_id in &effective_reviewers {
110        let Some(review) = review_map.get(reviewer_id.as_str()) else {
111            pending_from.push(reviewer_id.clone());
112            continue;
113        };
114
115        // Stale if review was for an older version
116        if review.at_version < current_version {
117            stale_from.push(reviewer_id.clone());
118            continue;
119        }
120
121        // Stale if review timed out
122        if policy.timeout_ms > 0.0 && (now - review.timestamp) > policy.timeout_ms {
123            stale_from.push(reviewer_id.clone());
124            continue;
125        }
126
127        match review.status.to_uppercase().as_str() {
128            "APPROVED" => approved_by.push(reviewer_id.clone()),
129            "REJECTED" => rejected_by.push(reviewer_id.clone()),
130            "STALE" => stale_from.push(reviewer_id.clone()),
131            _ => pending_from.push(reviewer_id.clone()),
132        }
133    }
134
135    // Evaluate threshold
136    let (approved, reason) = if !rejected_by.is_empty() {
137        (false, format!("Rejected by {}", rejected_by.join(", ")))
138    } else if policy.require_unanimous {
139        let all_approved = approved_by.len() == effective_reviewers.len()
140            && pending_from.is_empty()
141            && stale_from.is_empty();
142        let reason = if all_approved {
143            format!(
144                "Unanimous approval ({}/{})",
145                approved_by.len(),
146                effective_reviewers.len()
147            )
148        } else {
149            format!(
150                "Awaiting unanimous approval ({}/{})",
151                approved_by.len(),
152                effective_reviewers.len()
153            )
154        };
155        (all_approved, reason)
156    } else {
157        // Compute effective threshold: percentage overrides count when > 0
158        let threshold = if policy.required_percentage > 0 {
159            let pct = policy.required_percentage.min(100) as f64 / 100.0;
160            (pct * effective_reviewers.len() as f64).ceil() as u32
161        } else {
162            policy.required_count
163        };
164        let met = approved_by.len() as u32 >= threshold;
165        let threshold_label = if policy.required_percentage > 0 {
166            format!("{}% = {}", policy.required_percentage, threshold)
167        } else {
168            format!("{}", threshold)
169        };
170        let reason = if met {
171            format!(
172                "Approved ({}/{} required)",
173                approved_by.len(),
174                threshold_label
175            )
176        } else {
177            let remaining = threshold.saturating_sub(approved_by.len() as u32);
178            format!(
179                "Needs {} more approval(s) ({}/{} required)",
180                remaining,
181                approved_by.len(),
182                threshold_label
183            )
184        };
185        (met, reason)
186    };
187
188    ApprovalResult {
189        approved,
190        approved_by,
191        rejected_by,
192        pending_from,
193        stale_from,
194        reason,
195    }
196}
197
198/// Mark reviews as stale when a document version changes.
199///
200/// Returns a new vector with updated review statuses.
201pub fn mark_stale_reviews_native(reviews: &[Review], current_version: u32) -> Vec<Review> {
202    reviews
203        .iter()
204        .map(|r| {
205            if r.at_version < current_version && r.status.to_uppercase() != "STALE" {
206                Review {
207                    status: "STALE".to_string(),
208                    ..r.clone()
209                }
210            } else {
211                r.clone()
212            }
213        })
214        .collect()
215}
216
217// ── WASM entry points (JSON I/O) ──────────────────────────────
218
219/// Evaluate reviews against a policy. All inputs and output are JSON strings.
220///
221/// Input `reviews_json`: `[{"reviewerId":"...","status":"APPROVED","timestamp":123,"atVersion":1}]`
222/// Input `policy_json`: `{"requiredCount":1,"requireUnanimous":false,"allowedReviewerIds":[],"timeoutMs":0}`
223///
224/// Returns a JSON string matching the TypeScript `ApprovalResult` interface.
225#[cfg_attr(feature = "wasm", wasm_bindgen)]
226pub fn evaluate_approvals(
227    reviews_json: &str,
228    policy_json: &str,
229    current_version: u32,
230    now_ms: f64,
231) -> Result<String, String> {
232    let reviews: Vec<Review> =
233        serde_json::from_str(reviews_json).map_err(|e| format!("Invalid reviews JSON: {e}"))?;
234    let policy: ApprovalPolicy =
235        serde_json::from_str(policy_json).map_err(|e| format!("Invalid policy JSON: {e}"))?;
236
237    let result = evaluate_approvals_native(&reviews, &policy, current_version, now_ms);
238    serde_json::to_string(&result).map_err(|e| format!("Serialization failed: {e}"))
239}
240
241/// Mark reviews as stale for the given version. JSON I/O for WASM.
242///
243/// Returns a JSON array of updated reviews.
244#[cfg_attr(feature = "wasm", wasm_bindgen)]
245pub fn mark_stale_reviews(reviews_json: &str, current_version: u32) -> Result<String, String> {
246    let reviews: Vec<Review> =
247        serde_json::from_str(reviews_json).map_err(|e| format!("Invalid reviews JSON: {e}"))?;
248
249    let result = mark_stale_reviews_native(&reviews, current_version);
250    serde_json::to_string(&result).map_err(|e| format!("Serialization failed: {e}"))
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    fn default_policy() -> ApprovalPolicy {
258        ApprovalPolicy {
259            required_count: 1,
260            require_unanimous: false,
261            allowed_reviewer_ids: vec![],
262            timeout_ms: 0.0,
263            required_percentage: 0,
264        }
265    }
266
267    fn review(id: &str, status: &str, version: u32) -> Review {
268        Review {
269            reviewer_id: id.to_string(),
270            status: status.to_string(),
271            timestamp: 1_000_000.0,
272            reason: None,
273            at_version: version,
274        }
275    }
276
277    #[test]
278    fn test_single_approval_meets_default_policy() {
279        let reviews = vec![review("agent-1", "APPROVED", 1)];
280        let result = evaluate_approvals_native(&reviews, &default_policy(), 1, 2_000_000.0);
281        assert!(result.approved);
282        assert_eq!(result.approved_by, vec!["agent-1"]);
283    }
284
285    #[test]
286    fn test_no_reviews_pending() {
287        let policy = ApprovalPolicy {
288            allowed_reviewer_ids: vec!["agent-1".to_string()],
289            ..default_policy()
290        };
291        let result = evaluate_approvals_native(&[], &policy, 1, 2_000_000.0);
292        assert!(!result.approved);
293        assert_eq!(result.pending_from, vec!["agent-1"]);
294    }
295
296    #[test]
297    fn test_rejection_overrides_approval() {
298        let reviews = vec![
299            review("agent-1", "APPROVED", 1),
300            review("agent-2", "REJECTED", 1),
301        ];
302        let policy = ApprovalPolicy {
303            required_count: 1,
304            ..default_policy()
305        };
306        let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
307        assert!(!result.approved);
308        assert!(result.reason.contains("Rejected"));
309    }
310
311    #[test]
312    fn test_stale_review_for_old_version() {
313        let reviews = vec![review("agent-1", "APPROVED", 1)];
314        let result = evaluate_approvals_native(&reviews, &default_policy(), 2, 2_000_000.0);
315        assert!(!result.approved);
316        assert_eq!(result.stale_from, vec!["agent-1"]);
317    }
318
319    #[test]
320    fn test_timed_out_review() {
321        let reviews = vec![Review {
322            reviewer_id: "agent-1".to_string(),
323            status: "APPROVED".to_string(),
324            timestamp: 1_000_000.0,
325            reason: None,
326            at_version: 1,
327        }];
328        let policy = ApprovalPolicy {
329            timeout_ms: 500_000.0,
330            ..default_policy()
331        };
332        // now is 2_000_000, review was at 1_000_000, timeout is 500_000
333        let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
334        assert!(!result.approved);
335        assert_eq!(result.stale_from, vec!["agent-1"]);
336    }
337
338    #[test]
339    fn test_unanimous_policy() {
340        let policy = ApprovalPolicy {
341            required_count: 2,
342            require_unanimous: true,
343            allowed_reviewer_ids: vec!["agent-1".to_string(), "agent-2".to_string()],
344            timeout_ms: 0.0,
345            required_percentage: 0,
346        };
347
348        // Only one approved
349        let reviews = vec![review("agent-1", "APPROVED", 1)];
350        let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
351        assert!(!result.approved);
352        assert!(result.reason.contains("Awaiting unanimous"));
353
354        // Both approved
355        let reviews = vec![
356            review("agent-1", "APPROVED", 1),
357            review("agent-2", "APPROVED", 1),
358        ];
359        let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
360        assert!(result.approved);
361        assert!(result.reason.contains("Unanimous"));
362    }
363
364    #[test]
365    fn test_latest_review_wins() {
366        let reviews = vec![
367            Review {
368                reviewer_id: "agent-1".to_string(),
369                status: "REJECTED".to_string(),
370                timestamp: 1_000_000.0,
371                reason: None,
372                at_version: 1,
373            },
374            Review {
375                reviewer_id: "agent-1".to_string(),
376                status: "APPROVED".to_string(),
377                timestamp: 2_000_000.0,
378                reason: None,
379                at_version: 1,
380            },
381        ];
382        let result = evaluate_approvals_native(&reviews, &default_policy(), 1, 3_000_000.0);
383        assert!(result.approved);
384        assert_eq!(result.approved_by, vec!["agent-1"]);
385    }
386
387    #[test]
388    fn test_mark_stale_reviews() {
389        let reviews = vec![
390            review("agent-1", "APPROVED", 1),
391            review("agent-2", "APPROVED", 2),
392        ];
393        let result = mark_stale_reviews_native(&reviews, 2);
394        assert_eq!(result[0].status, "STALE");
395        assert_eq!(result[1].status, "APPROVED");
396    }
397
398    #[test]
399    fn test_wasm_evaluate_approvals() {
400        let reviews_json =
401            r#"[{"reviewerId":"agent-1","status":"APPROVED","timestamp":1000000,"atVersion":1}]"#;
402        let policy_json =
403            r#"{"requiredCount":1,"requireUnanimous":false,"allowedReviewerIds":[],"timeoutMs":0}"#;
404        let result_json = evaluate_approvals(reviews_json, policy_json, 1, 2_000_000.0).unwrap();
405        let result: ApprovalResult = serde_json::from_str(&result_json).unwrap();
406        assert!(result.approved);
407    }
408
409    #[test]
410    fn test_wasm_mark_stale() {
411        let reviews_json =
412            r#"[{"reviewerId":"agent-1","status":"APPROVED","timestamp":1000000,"atVersion":1}]"#;
413        let result_json = mark_stale_reviews(reviews_json, 2).unwrap();
414        let result: Vec<Review> = serde_json::from_str(&result_json).unwrap();
415        assert_eq!(result[0].status, "STALE");
416    }
417
418    #[test]
419    fn test_percentage_threshold_51_percent() {
420        // 51% of 10 reviewers = ceil(5.1) = 6 required
421        let ids: Vec<String> = (1..=10).map(|i| format!("agent-{i}")).collect();
422        let policy = ApprovalPolicy {
423            required_count: 0,
424            require_unanimous: false,
425            allowed_reviewer_ids: ids,
426            timeout_ms: 0.0,
427            required_percentage: 51,
428        };
429
430        // 5 approved — not enough (need 6)
431        let reviews: Vec<Review> = (1..=5)
432            .map(|i| review(&format!("agent-{i}"), "APPROVED", 1))
433            .collect();
434        let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
435        assert!(!result.approved, "5/10 should not meet 51%");
436        assert!(result.reason.contains("51%"));
437
438        // 6 approved — enough
439        let reviews: Vec<Review> = (1..=6)
440            .map(|i| review(&format!("agent-{i}"), "APPROVED", 1))
441            .collect();
442        let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
443        assert!(result.approved, "6/10 should meet 51%");
444    }
445
446    #[test]
447    fn test_percentage_threshold_20_percent() {
448        // 20% of 5 reviewers = ceil(1.0) = 1 required
449        let policy = ApprovalPolicy {
450            required_count: 0,
451            require_unanimous: false,
452            allowed_reviewer_ids: vec![
453                "a1".into(),
454                "a2".into(),
455                "a3".into(),
456                "a4".into(),
457                "a5".into(),
458            ],
459            timeout_ms: 0.0,
460            required_percentage: 20,
461        };
462
463        let reviews = vec![review("a1", "APPROVED", 1)];
464        let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
465        assert!(result.approved, "1/5 should meet 20%");
466    }
467
468    #[test]
469    fn test_percentage_overrides_count() {
470        // required_count is 10 but percentage says 20% of 5 = 1
471        let policy = ApprovalPolicy {
472            required_count: 10,
473            require_unanimous: false,
474            allowed_reviewer_ids: vec![
475                "a1".into(),
476                "a2".into(),
477                "a3".into(),
478                "a4".into(),
479                "a5".into(),
480            ],
481            timeout_ms: 0.0,
482            required_percentage: 20,
483        };
484
485        let reviews = vec![review("a1", "APPROVED", 1)];
486        let result = evaluate_approvals_native(&reviews, &policy, 1, 2_000_000.0);
487        assert!(result.approved, "percentage should override count");
488    }
489}