1use crate::error::{OptimError, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PeerReviewSystem {
14 pub sessions: HashMap<String, ReviewSession>,
16 pub reviewers: HashMap<String, Reviewer>,
18 pub assignments: Vec<ReviewAssignment>,
20 pub quality_metrics: Vec<ReviewQualityMetric>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ReviewSession {
27 pub id: String,
29 pub submission_id: String,
31 pub review_type: ReviewType,
33 pub status: ReviewSessionStatus,
35 pub criteria: Vec<ReviewCriterion>,
37 pub deadline: DateTime<Utc>,
39 pub reviews: Vec<PeerReview>,
41 pub meta_review: Option<MetaReview>,
43 pub discussion: Vec<ReviewDiscussion>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub enum ReviewType {
50 SingleBlind,
52 DoubleBlind,
54 Open,
56 PostPublication,
58 Internal,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
64pub enum ReviewSessionStatus {
65 WaitingForReviewers,
67 InProgress,
69 ReviewsComplete,
71 MetaReviewInProgress,
73 Complete,
75 Cancelled,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ReviewCriterion {
82 pub name: String,
84 pub description: String,
86 pub score_range: (f64, f64),
88 pub weight: f64,
90 pub required: bool,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct PeerReview {
97 pub id: String,
99 pub reviewer_id: String,
101 pub recommendation: ReviewRecommendation,
103 pub criterion_scores: HashMap<String, f64>,
105 pub overall_score: f64,
107 pub confidence: f64,
109 pub written_review: WrittenReview,
111 pub status: ReviewStatus,
113 pub submitted_at: Option<DateTime<Utc>>,
115 pub time_spent_minutes: Option<u32>,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
121pub enum ReviewRecommendation {
122 StrongAccept,
124 Accept,
126 WeakAccept,
128 BorderlineAccept,
130 BorderlineReject,
132 WeakReject,
134 Reject,
136 StrongReject,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct WrittenReview {
143 pub summary: String,
145 pub strengths: Vec<String>,
147 pub weaknesses: Vec<String>,
149 pub detailed_comments: String,
151 pub questions: Vec<String>,
153 pub minor_issues: Vec<String>,
155 pub suggestions: Vec<String>,
157 pub committee_comments: Option<String>,
159}
160
161#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
163pub enum ReviewStatus {
164 Assigned,
166 InProgress,
168 Draft,
170 Submitted,
172 RevisionRequested,
174 Declined,
176 Overdue,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct MetaReview {
183 pub meta_reviewer_id: String,
185 pub review_summary: String,
187 pub final_recommendation: ReviewRecommendation,
189 pub justification: String,
191 pub review_quality: Vec<ReviewQualityAssessment>,
193 pub areas_of_agreement: Vec<String>,
195 pub areas_of_disagreement: Vec<String>,
197 pub decision_rationale: String,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct ReviewQualityAssessment {
204 pub review_id: String,
206 pub quality_scores: HashMap<String, f64>,
208 pub overall_quality: f64,
210 pub helpfulness: f64,
212 pub comments: String,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct ReviewDiscussion {
219 pub id: String,
221 pub author: String,
223 pub content: String,
225 pub reply_to: Option<String>,
227 pub posted_at: DateTime<Utc>,
229 pub post_type: DiscussionPostType,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
235pub enum DiscussionPostType {
236 Question,
238 Answer,
240 Clarification,
242 Disagreement,
244 Consensus,
246 ModeratorMessage,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct Reviewer {
253 pub id: String,
255 pub expertise_areas: Vec<String>,
257 pub experience_level: ExperienceLevel,
259 pub review_history: ReviewerHistory,
261 pub availability: ReviewerAvailability,
263 pub quality_metrics: ReviewerQualityMetrics,
265 pub preferences: ReviewerPreferences,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
271pub enum ExperienceLevel {
272 Expert,
274 Senior,
276 Experienced,
278 Junior,
280 Novice,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct ReviewerHistory {
287 pub total_reviews: usize,
289 pub reviews_last_year: usize,
291 pub avg_review_time_days: f64,
293 pub on_time_rate: f64,
295 pub avg_quality_score: f64,
297 pub review_acceptance_rate: f64,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct ReviewerAvailability {
304 pub available: bool,
306 pub max_reviews_per_month: u32,
308 pub current_load: u32,
310 pub unavailable_periods: Vec<(DateTime<Utc>, DateTime<Utc>)>,
312 pub preferred_types: Vec<ReviewType>,
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct ReviewerQualityMetrics {
319 pub thoroughness: f64,
321 pub constructiveness: f64,
323 pub timeliness: f64,
325 pub expertise_match: f64,
327 pub overall_score: f64,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct ReviewerPreferences {
334 pub preferred_paper_types: Vec<String>,
336 pub avoid_paper_types: Vec<String>,
338 pub max_review_length: Option<u32>,
340 pub anonymous_preference: bool,
342 pub notification_preferences: NotificationPreferences,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct NotificationPreferences {
349 pub email: bool,
351 pub reminder_frequency: u32,
353 pub deadline_notifications: bool,
355 pub discussion_notifications: bool,
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct ReviewAssignment {
362 pub id: String,
364 pub session_id: String,
366 pub reviewer_id: String,
368 pub assigned_at: DateTime<Utc>,
370 pub due_date: DateTime<Utc>,
372 pub status: AssignmentStatus,
374 pub assignment_method: AssignmentMethod,
376 pub expertise_match: f64,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
382pub enum AssignmentStatus {
383 Pending,
385 Accepted,
387 Declined,
389 Completed,
391 Overdue,
393 Cancelled,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
399pub enum AssignmentMethod {
400 Manual,
402 AutomaticExpertise,
404 AutomaticLoadBalancing,
406 Hybrid,
408 SelfAssignment,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
414pub struct ReviewQualityMetric {
415 pub name: String,
417 pub description: String,
419 pub value_range: (f64, f64),
421 pub higher_is_better: bool,
423 pub calculation_method: String,
425}
426
427impl Default for PeerReviewSystem {
428 fn default() -> Self {
429 Self::new()
430 }
431}
432
433impl PeerReviewSystem {
434 pub fn new() -> Self {
436 Self {
437 sessions: HashMap::new(),
438 reviewers: HashMap::new(),
439 assignments: Vec::new(),
440 quality_metrics: Self::create_default_quality_metrics(),
441 }
442 }
443
444 pub fn create_review_session(
446 &mut self,
447 submission_id: &str,
448 review_type: ReviewType,
449 criteria: Vec<ReviewCriterion>,
450 deadline: DateTime<Utc>,
451 ) -> String {
452 let session_id = uuid::Uuid::new_v4().to_string();
453 let session = ReviewSession {
454 id: session_id.clone(),
455 submission_id: submission_id.to_string(),
456 review_type,
457 status: ReviewSessionStatus::WaitingForReviewers,
458 criteria,
459 deadline,
460 reviews: Vec::new(),
461 meta_review: None,
462 discussion: Vec::new(),
463 };
464
465 self.sessions.insert(session_id.clone(), session);
466 session_id
467 }
468
469 pub fn assign_reviewers(
478 &mut self,
479 session_id: &str,
480 reviewer_ids: &[String],
481 assignment_method: AssignmentMethod,
482 ) -> Result<Vec<String>> {
483 if !self.sessions.contains_key(session_id) {
484 return Err(OptimError::InvalidConfig("Session not found".to_string()));
485 }
486
487 let mut assignment_ids = Vec::new();
488 let now = Utc::now();
489 let deadline = self
490 .sessions
491 .get(session_id)
492 .ok_or_else(|| {
493 OptimError::InvalidState(format!("session '{session_id}' vanished during lookup"))
494 })?
495 .deadline;
496
497 for reviewer_id in reviewer_ids {
498 if !self.reviewers.contains_key(reviewer_id) {
499 continue; }
501
502 let already_assigned = self
503 .assignments
504 .iter()
505 .any(|a| a.session_id == session_id && a.reviewer_id == *reviewer_id);
506 if already_assigned {
507 continue;
508 }
509
510 let assignment_id = uuid::Uuid::new_v4().to_string();
511 let assignment = ReviewAssignment {
512 id: assignment_id.clone(),
513 session_id: session_id.to_string(),
514 reviewer_id: reviewer_id.clone(),
515 assigned_at: now,
516 due_date: deadline,
517 status: AssignmentStatus::Pending,
518 assignment_method: assignment_method.clone(),
519 expertise_match: self.calculate_expertise_match(reviewer_id, session_id),
520 };
521
522 self.assignments.push(assignment);
523 assignment_ids.push(assignment_id);
524
525 if let Some(reviewer) = self.reviewers.get_mut(reviewer_id) {
529 reviewer.availability.current_load += 1;
530 }
531 }
532
533 if let Some(session) = self.sessions.get_mut(session_id) {
535 session.status = ReviewSessionStatus::InProgress;
536 }
537
538 Ok(assignment_ids)
539 }
540
541 pub fn submit_review(
551 &mut self,
552 session_id: &str,
553 reviewer_id: &str,
554 review: PeerReview,
555 ) -> Result<()> {
556 let session = self
557 .sessions
558 .get_mut(session_id)
559 .ok_or_else(|| OptimError::InvalidConfig("Session not found".to_string()))?;
560
561 let assignment = self
563 .assignments
564 .iter_mut()
565 .find(|a| a.session_id == session_id && a.reviewer_id == reviewer_id)
566 .ok_or_else(|| {
567 OptimError::InvalidConfig("Reviewer not assigned to this session".to_string())
568 })?;
569
570 if assignment.status == AssignmentStatus::Completed {
571 return Err(OptimError::InvalidConfig(format!(
572 "reviewer '{reviewer_id}' already submitted a review for session '{session_id}'"
573 )));
574 }
575
576 assignment.status = AssignmentStatus::Completed;
578
579 session.reviews.push(review);
581
582 let total_assignments = self
584 .assignments
585 .iter()
586 .filter(|a| a.session_id == session_id)
587 .count();
588
589 if session.reviews.len() == total_assignments {
590 session.status = ReviewSessionStatus::ReviewsComplete;
591 }
592
593 if let Some(reviewer) = self.reviewers.get_mut(reviewer_id) {
598 reviewer.availability.current_load =
599 reviewer.availability.current_load.saturating_sub(1);
600 }
601
602 Ok(())
603 }
604
605 pub fn generate_meta_review(&mut self, session_id: &str, meta_reviewer_id: &str) -> Result<()> {
607 let meta_review = {
609 let session = self
610 .sessions
611 .get(session_id)
612 .ok_or_else(|| OptimError::InvalidConfig("Session not found".to_string()))?;
613
614 if session.status != ReviewSessionStatus::ReviewsComplete {
615 return Err(OptimError::InvalidConfig(
616 "Not all reviews are complete".to_string(),
617 ));
618 }
619
620 self.create_meta_review(session, meta_reviewer_id)
621 };
622
623 let session = self.sessions.get_mut(session_id).ok_or_else(|| {
625 OptimError::InvalidState(format!("session '{session_id}' vanished during lookup"))
626 })?;
627 session.meta_review = Some(meta_review);
628 session.status = ReviewSessionStatus::Complete;
629
630 Ok(())
631 }
632
633 pub fn calculate_reviewer_workload(&self, reviewer_id: &str) -> u32 {
635 self.assignments
636 .iter()
637 .filter(|a| {
638 a.reviewer_id == reviewer_id
639 && matches!(
640 a.status,
641 AssignmentStatus::Pending | AssignmentStatus::Accepted
642 )
643 })
644 .count() as u32
645 }
646
647 pub fn get_available_reviewers(&self, expertise_area: &str) -> Vec<&Reviewer> {
649 self.reviewers
650 .values()
651 .filter(|r| {
652 r.availability.available
653 && r.expertise_areas
654 .iter()
655 .any(|area| area.to_lowercase().contains(&expertise_area.to_lowercase()))
656 && r.availability.current_load < r.availability.max_reviews_per_month
657 })
658 .collect()
659 }
660
661 pub fn calculate_review_quality(&self, review: &PeerReview) -> f64 {
663 let mut quality_score = 0.0;
664 let mut total_weight = 0.0;
665
666 let review_length = review.written_review.detailed_comments.len()
668 + review
669 .written_review
670 .strengths
671 .iter()
672 .map(|s| s.len())
673 .sum::<usize>()
674 + review
675 .written_review
676 .weaknesses
677 .iter()
678 .map(|s| s.len())
679 .sum::<usize>();
680
681 let length_score = ((review_length as f64).ln_1p() / 10.0).clamp(0.0, 1.0);
686 quality_score += length_score * 0.3;
687 total_weight += 0.3;
688
689 let specific_points = review.written_review.strengths.len()
691 + review.written_review.weaknesses.len()
692 + review.written_review.suggestions.len();
693
694 let specificity_score = (specific_points as f64 / 10.0).min(1.0);
695 quality_score += specificity_score * 0.4;
696 total_weight += 0.4;
697
698 quality_score += review.confidence * 0.3;
700 total_weight += 0.3;
701
702 quality_score / total_weight
703 }
704
705 fn calculate_expertise_match(&self, reviewer_id: &str, sessionid: &str) -> f64 {
720 let Some(reviewer) = self.reviewers.get(reviewer_id) else {
721 return 0.0;
722 };
723 if reviewer.expertise_areas.is_empty() {
724 return 0.0;
725 }
726 let Some(session) = self.sessions.get(sessionid) else {
727 return 0.5;
728 };
729 if session.criteria.is_empty() {
730 return 0.5;
731 }
732
733 let areas: Vec<String> = reviewer
734 .expertise_areas
735 .iter()
736 .map(|area| area.to_ascii_lowercase())
737 .filter(|area| !area.is_empty())
738 .collect();
739 if areas.is_empty() {
740 return 0.0;
741 }
742
743 let matched = session
744 .criteria
745 .iter()
746 .filter(|criterion| {
747 let haystack =
748 format!("{} {}", criterion.name, criterion.description).to_ascii_lowercase();
749 areas.iter().any(|area| haystack.contains(area))
750 })
751 .count();
752 matched as f64 / session.criteria.len() as f64
753 }
754
755 fn create_meta_review(&self, session: &ReviewSession, meta_reviewer_id: &str) -> MetaReview {
756 let review_summary = format!("Meta-review of {} reviews", session.reviews.len());
757
758 let recommendations: Vec<_> = session.reviews.iter().map(|r| &r.recommendation).collect();
760
761 let final_recommendation = self.determine_consensus_recommendation(&recommendations);
762
763 let review_quality: Vec<_> = session
765 .reviews
766 .iter()
767 .map(|review| {
768 let quality_score = self.calculate_review_quality(review);
769 ReviewQualityAssessment {
770 review_id: review.id.clone(),
771 quality_scores: HashMap::new(),
772 overall_quality: quality_score,
773 helpfulness: quality_score * 0.9, comments: if quality_score > 0.7 {
775 "High quality review".to_string()
776 } else {
777 "Review could be more detailed".to_string()
778 },
779 }
780 })
781 .collect();
782
783 MetaReview {
784 meta_reviewer_id: meta_reviewer_id.to_string(),
785 review_summary,
786 final_recommendation,
787 justification: "Based on consensus of reviewer recommendations".to_string(),
788 review_quality,
789 areas_of_agreement: vec!["Technical quality assessment".to_string()],
790 areas_of_disagreement: vec!["Significance of contribution".to_string()],
791 decision_rationale: "Decision based on majority reviewer consensus".to_string(),
792 }
793 }
794
795 fn determine_consensus_recommendation(
805 &self,
806 recommendations: &[&ReviewRecommendation],
807 ) -> ReviewRecommendation {
808 if recommendations.is_empty() {
809 return ReviewRecommendation::BorderlineReject;
810 }
811
812 let mut ranks: Vec<u8> = recommendations
813 .iter()
814 .map(|rec| Self::recommendation_rank(rec))
815 .collect();
816 ranks.sort_unstable();
817
818 let mid = ranks.len() / 2;
819 let median_rank = if ranks.len().is_multiple_of(2) {
820 let lower = u16::from(ranks[mid - 1]);
824 let upper = u16::from(ranks[mid]);
825 (lower + upper).div_ceil(2) as u8
826 } else {
827 ranks[mid]
828 };
829
830 Self::recommendation_from_rank(median_rank)
831 }
832
833 fn recommendation_rank(rec: &ReviewRecommendation) -> u8 {
836 match rec {
837 ReviewRecommendation::StrongAccept => 0,
838 ReviewRecommendation::Accept => 1,
839 ReviewRecommendation::WeakAccept => 2,
840 ReviewRecommendation::BorderlineAccept => 3,
841 ReviewRecommendation::BorderlineReject => 4,
842 ReviewRecommendation::WeakReject => 5,
843 ReviewRecommendation::Reject => 6,
844 ReviewRecommendation::StrongReject => 7,
845 }
846 }
847
848 fn recommendation_from_rank(rank: u8) -> ReviewRecommendation {
850 match rank {
851 0 => ReviewRecommendation::StrongAccept,
852 1 => ReviewRecommendation::Accept,
853 2 => ReviewRecommendation::WeakAccept,
854 3 => ReviewRecommendation::BorderlineAccept,
855 4 => ReviewRecommendation::BorderlineReject,
856 5 => ReviewRecommendation::WeakReject,
857 6 => ReviewRecommendation::Reject,
858 _ => ReviewRecommendation::StrongReject,
859 }
860 }
861
862 fn create_default_quality_metrics() -> Vec<ReviewQualityMetric> {
863 vec![
864 ReviewQualityMetric {
865 name: "Thoroughness".to_string(),
866 description: "How comprehensive and detailed the review is".to_string(),
867 value_range: (0.0, 1.0),
868 higher_is_better: true,
869 calculation_method: "Based on review length and number of specific points"
870 .to_string(),
871 },
872 ReviewQualityMetric {
873 name: "Constructiveness".to_string(),
874 description: "How helpful the review is for improving the work".to_string(),
875 value_range: (0.0, 1.0),
876 higher_is_better: true,
877 calculation_method: "Based on number of suggestions and actionable feedback"
878 .to_string(),
879 },
880 ReviewQualityMetric {
881 name: "Timeliness".to_string(),
882 description: "How promptly the review was submitted".to_string(),
883 value_range: (0.0, 1.0),
884 higher_is_better: true,
885 calculation_method: "Based on submission time relative to deadline".to_string(),
886 },
887 ]
888 }
889}
890
891impl Default for ReviewerHistory {
892 fn default() -> Self {
893 Self {
894 total_reviews: 0,
895 reviews_last_year: 0,
896 avg_review_time_days: 14.0,
897 on_time_rate: 1.0,
898 avg_quality_score: 0.7,
899 review_acceptance_rate: 0.9,
900 }
901 }
902}
903
904impl Default for ReviewerAvailability {
905 fn default() -> Self {
906 Self {
907 available: true,
908 max_reviews_per_month: 5,
909 current_load: 0,
910 unavailable_periods: Vec::new(),
911 preferred_types: vec![ReviewType::DoubleBlind],
912 }
913 }
914}
915
916impl Default for ReviewerQualityMetrics {
917 fn default() -> Self {
918 Self {
919 thoroughness: 0.7,
920 constructiveness: 0.7,
921 timeliness: 0.8,
922 expertise_match: 0.7,
923 overall_score: 0.7,
924 }
925 }
926}
927
928impl Default for NotificationPreferences {
929 fn default() -> Self {
930 Self {
931 email: true,
932 reminder_frequency: 7,
933 deadline_notifications: true,
934 discussion_notifications: false,
935 }
936 }
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942
943 #[test]
944 fn test_peer_review_system_creation() {
945 let system = PeerReviewSystem::new();
946 assert!(system.sessions.is_empty());
947 assert!(system.reviewers.is_empty());
948 assert!(!system.quality_metrics.is_empty());
949 }
950
951 #[test]
952 fn test_create_review_session() {
953 let mut system = PeerReviewSystem::new();
954
955 let criteria = vec![ReviewCriterion {
956 name: "Technical Quality".to_string(),
957 description: "Assessment of technical merit".to_string(),
958 score_range: (1.0, 5.0),
959 weight: 0.4,
960 required: true,
961 }];
962
963 let deadline = Utc::now() + chrono::Duration::days(14);
964 let session_id =
965 system.create_review_session("paper123", ReviewType::DoubleBlind, criteria, deadline);
966
967 assert!(system.sessions.contains_key(&session_id));
968 let session = &system.sessions[&session_id];
969 assert_eq!(session.submission_id, "paper123");
970 assert_eq!(session.review_type, ReviewType::DoubleBlind);
971 }
972
973 #[test]
974 fn test_reviewer_workload_calculation() {
975 let mut system = PeerReviewSystem::new();
976
977 system.assignments.push(ReviewAssignment {
979 id: "assign1".to_string(),
980 session_id: "session1".to_string(),
981 reviewer_id: "reviewer1".to_string(),
982 assigned_at: Utc::now(),
983 due_date: Utc::now() + chrono::Duration::days(14),
984 status: AssignmentStatus::Pending,
985 assignment_method: AssignmentMethod::Manual,
986 expertise_match: 0.8,
987 });
988
989 let workload = system.calculate_reviewer_workload("reviewer1");
990 assert_eq!(workload, 1);
991
992 let workload = system.calculate_reviewer_workload("reviewer2");
993 assert_eq!(workload, 0);
994 }
995
996 fn make_reviewer(id: &str) -> Reviewer {
997 Reviewer {
998 id: id.to_string(),
999 expertise_areas: vec!["optimization".to_string()],
1000 experience_level: ExperienceLevel::Senior,
1001 review_history: ReviewerHistory::default(),
1002 availability: ReviewerAvailability::default(),
1003 quality_metrics: ReviewerQualityMetrics::default(),
1004 preferences: ReviewerPreferences {
1005 preferred_paper_types: Vec::new(),
1006 avoid_paper_types: Vec::new(),
1007 max_review_length: None,
1008 anonymous_preference: true,
1009 notification_preferences: NotificationPreferences::default(),
1010 },
1011 }
1012 }
1013
1014 fn make_review(reviewer_id: &str, recommendation: ReviewRecommendation) -> PeerReview {
1015 PeerReview {
1016 id: uuid::Uuid::new_v4().to_string(),
1017 reviewer_id: reviewer_id.to_string(),
1018 recommendation,
1019 criterion_scores: HashMap::new(),
1020 overall_score: 3.0,
1021 confidence: 0.5,
1022 written_review: WrittenReview {
1023 summary: String::new(),
1024 strengths: Vec::new(),
1025 weaknesses: Vec::new(),
1026 detailed_comments: String::new(),
1027 questions: Vec::new(),
1028 minor_issues: Vec::new(),
1029 suggestions: Vec::new(),
1030 committee_comments: None,
1031 },
1032 status: ReviewStatus::Submitted,
1033 submitted_at: Some(Utc::now()),
1034 time_spent_minutes: Some(30),
1035 }
1036 }
1037
1038 #[test]
1044 fn test_calculate_review_quality_handles_empty_review() {
1045 let system = PeerReviewSystem::new();
1046 let review = make_review("reviewer1", ReviewRecommendation::BorderlineAccept);
1047
1048 let quality = system.calculate_review_quality(&review);
1049
1050 assert!(
1051 quality.is_finite(),
1052 "quality score must be finite, got {quality}"
1053 );
1054 assert!(
1055 (0.0..=1.0).contains(&quality),
1056 "quality score must be in [0, 1], got {quality}"
1057 );
1058 }
1059
1060 #[test]
1066 fn test_determine_consensus_recommendation_is_deterministic_median() {
1067 let system = PeerReviewSystem::new();
1068
1069 let accept = ReviewRecommendation::Accept;
1073 let reject = ReviewRecommendation::Reject;
1074 let order_a = system.determine_consensus_recommendation(&[&accept, &reject]);
1075 let order_b = system.determine_consensus_recommendation(&[&reject, &accept]);
1076 assert_eq!(order_a, ReviewRecommendation::BorderlineReject);
1077 assert_eq!(order_a, order_b, "consensus must not depend on input order");
1078
1079 let strong_accept = ReviewRecommendation::StrongAccept;
1082 let median = system.determine_consensus_recommendation(&[&reject, &strong_accept, &accept]);
1083 assert_eq!(median, ReviewRecommendation::Accept);
1084 }
1085
1086 #[test]
1093 fn test_assign_reviewers_tracks_load_and_rejects_duplicates() {
1094 let mut system = PeerReviewSystem::new();
1095 system
1096 .reviewers
1097 .insert("reviewer1".to_string(), make_reviewer("reviewer1"));
1098
1099 let deadline = Utc::now() + chrono::Duration::days(14);
1100 let session_id =
1101 system.create_review_session("paper1", ReviewType::DoubleBlind, vec![], deadline);
1102
1103 let ids = system
1105 .assign_reviewers(
1106 &session_id,
1107 &["reviewer1".to_string(), "reviewer1".to_string()],
1108 AssignmentMethod::Manual,
1109 )
1110 .expect("assignment should succeed");
1111 assert_eq!(ids.len(), 1);
1112 assert_eq!(system.reviewers["reviewer1"].availability.current_load, 1);
1113
1114 let ids_again = system
1116 .assign_reviewers(
1117 &session_id,
1118 &["reviewer1".to_string()],
1119 AssignmentMethod::Manual,
1120 )
1121 .expect("assignment should succeed");
1122 assert!(ids_again.is_empty());
1123 assert_eq!(system.reviewers["reviewer1"].availability.current_load, 1);
1124 assert_eq!(system.calculate_reviewer_workload("reviewer1"), 1);
1125
1126 let review = make_review("reviewer1", ReviewRecommendation::Accept);
1128 system
1129 .submit_review(&session_id, "reviewer1", review)
1130 .expect("first submission should succeed");
1131 assert_eq!(system.reviewers["reviewer1"].availability.current_load, 0);
1132
1133 let duplicate_review = make_review("reviewer1", ReviewRecommendation::Reject);
1135 let result = system.submit_review(&session_id, "reviewer1", duplicate_review);
1136 assert!(result.is_err(), "duplicate submission must be rejected");
1137 assert_eq!(
1138 system.sessions[&session_id].reviews.len(),
1139 1,
1140 "duplicate submission must not be recorded"
1141 );
1142 }
1143}