1pub mod cite;
2pub mod claude_recovery;
3pub mod config;
4pub mod exec_agent;
5pub mod mcp_agent;
6pub mod mcp_tools;
7pub mod nsed_agent;
8pub mod output_guard;
9pub mod session_store;
10pub mod user_tools;
11
12pub use nsed_agent::{AgentResponse, ProposerEvaluatorAgent};
13pub use output_guard::{OutputLeakDetector, OutputScanResult};
14pub use user_tools::{NatsUserToolHandlerFactory, UserToolHandler, toolcalls_bucket_name};
15
16use anyhow::Result;
17use async_trait::async_trait;
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20use std::fmt::Debug;
21use std::hash::{Hash, Hasher};
22use std::sync::Arc;
23use utoipa::ToSchema;
24
25pub use config::{AgentConfig, TaskPrecision};
26pub use config::{default_context_window, default_scratchpad_limit};
28
29#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
30pub struct AgentContext {
31 pub task_description: String,
32 pub round_number: u32,
33 pub total_rounds: u32,
34 pub phase: DeliberationPhase,
35 pub target_proposal: Option<Proposal>,
36 pub competitor_summaries: Vec<String>,
37 pub previous_round_matrix: Option<String>,
38 pub previous_own_proposal: Option<Proposal>,
39 pub previous_own_score: Option<f32>,
40 pub previous_critiques: Vec<String>,
41 pub scratchpad: Option<String>,
42 #[serde(skip)]
43 #[schema(ignore)]
44 #[schemars(skip)]
45 pub store: Option<Arc<dyn PersistenceStore>>,
46 #[serde(default)]
47 pub candidates: Vec<CandidateProposal>,
48 #[serde(default)]
49 pub user_injections: Vec<UserInjection>,
50 #[serde(default)]
52 pub user_tools: Vec<UserToolDefinition>,
53 #[serde(default)]
56 pub phase_budget_remaining_secs: f64,
57 #[serde(default)]
59 pub session_id: Option<String>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub conversation_id: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub new_turn: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub structured_feedback: Option<StructuredFeedback>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
83 #[schema(ignore)]
84 #[schemars(skip)]
85 pub forced_proposal_schema: Option<serde_json::Value>,
86 #[serde(default, skip_serializing_if = "Option::is_none")]
92 #[schema(ignore)]
93 #[schemars(skip)]
94 pub working_dir_override: Option<std::path::PathBuf>,
95 #[serde(skip)]
99 #[schema(ignore)]
100 #[schemars(skip)]
101 pub user_tool_handler: Option<Arc<dyn UserToolHandlerTrait>>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub role: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub role_context: Option<String>,
110 #[serde(default)]
118 pub agent_id: String,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub task_publish_ts: Option<i64>,
125 #[serde(skip)]
135 #[schema(ignore)]
136 #[schemars(skip)]
137 pub telemetry: Option<crate::telemetry::TelemetryEmitterMux>,
138 #[serde(skip)]
144 #[schema(ignore)]
145 #[schemars(skip)]
146 pub submission_validator: Option<Arc<dyn SubmissionValidator>>,
147 #[serde(skip)]
152 #[schema(ignore)]
153 #[schemars(skip)]
154 pub event_store: Option<crate::status::agent_events::AgentEventStore>,
155}
156
157impl AgentContext {
158 pub fn claude_session_key(&self) -> Option<&str> {
163 self.conversation_id
164 .as_deref()
165 .or(self.session_id.as_deref())
166 }
167
168 pub fn delta_task(&self) -> &str {
173 self.new_turn.as_deref().unwrap_or(&self.task_description)
174 }
175
176 pub fn telemetry_for(&self) -> crate::telemetry::TelemetryContext {
199 let session_id = self.session_id.as_deref().filter(|s| !s.is_empty()).expect(
200 "AgentContext::telemetry_for requires session_id; \
201 orchestrator must populate it at dispatch and tests \
202 must set it before emitting telemetry events",
203 );
204 crate::telemetry::TelemetryContext::new(
205 &self.agent_id,
206 Some(session_id),
207 Some(self.round_number),
208 Some(self.phase),
209 )
210 }
211}
212
213#[derive(
214 Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema, ToSchema, Default,
215)]
216pub enum DeliberationPhase {
217 #[default]
218 Proposing,
219 Evaluating,
220 ConsensusCheck,
221}
222
223impl DeliberationPhase {
224 pub fn as_str(&self) -> &'static str {
226 match self {
227 DeliberationPhase::Proposing => "propose",
228 DeliberationPhase::Evaluating => "evaluate",
229 DeliberationPhase::ConsensusCheck => "consensus_check",
230 }
231 }
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, ToSchema, Default)]
240#[serde(rename_all = "snake_case")]
241pub enum AnnotationType {
242 #[default]
244 Comment,
245 Edit,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, ToSchema, Default)]
258pub struct OperatorAnnotation {
259 pub annotation_type: AnnotationType,
261 pub comment: String,
263 pub timestamp: String,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub original_content_hash: Option<String>,
269}
270
271impl OperatorAnnotation {
272 pub fn validate(&self) -> Result<(), String> {
280 if self.annotation_type == AnnotationType::Edit {
281 match &self.original_content_hash {
282 Some(hash) if !hash.is_empty() => Ok(()),
283 _ => Err(
284 "Edit annotations must include a non-empty original_content_hash".to_string(),
285 ),
286 }
287 } else {
288 Ok(())
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
294pub struct Proposal {
295 pub thought_process: String,
296 pub content: String,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub final_scratchpad: Option<String>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub token_usage_stats: Option<TokenUsage>,
302 #[serde(default, skip_serializing_if = "Vec::is_empty")]
304 pub operator_annotations: Vec<OperatorAnnotation>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub edited_by: Option<String>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub finish_reason: Option<String>,
316 #[serde(default)]
322 pub published_at_ms: i64,
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
326pub struct TokenUsage {
327 pub input_tokens: u32,
328 pub output_tokens: u32,
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, ToSchema, Default)]
338#[serde(rename_all = "snake_case")]
339pub enum ClaimVerdict {
340 Verified,
341 Contested,
342 Unverified,
343 Wrong,
344 #[default]
345 Unknown,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, ToSchema, Default)]
350#[serde(rename_all = "snake_case")]
351pub enum Stance {
352 StrongAgree,
353 Agree,
354 #[default]
355 Neutral,
356 Disagree,
357 StrongDisagree,
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, ToSchema, Default)]
362#[serde(rename_all = "snake_case")]
363pub enum Confidence {
364 High,
365 #[default]
366 Medium,
367 Low,
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
372pub struct ClaimAssessment {
373 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub claim_id: Option<String>,
378 #[serde(
393 default,
394 alias = "cite",
395 alias = "quote",
396 alias = "content",
397 alias = "text",
398 alias = "claim_text",
399 alias = "description",
400 alias = "summary"
401 )]
402 pub claim: String,
403 pub verdict: ClaimVerdict,
405 #[serde(
409 default,
410 skip_serializing_if = "Option::is_none",
411 alias = "disagreement",
412 alias = "explanation",
413 alias = "reasoning"
414 )]
415 pub reason: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
424 pub anchor: Option<ClaimAnchor>,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
437#[serde(tag = "in", rename_all = "snake_case")]
438pub enum ClaimAnchor {
439 AnswerBody {
454 start_utf16: usize,
456 end_utf16: usize,
458 },
459 ThoughtWindow,
464}
465
466#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
468pub struct DisagreementPoint {
469 #[serde(default, skip_serializing_if = "Option::is_none")]
471 pub claim_id: Option<String>,
472 #[serde(
477 default,
478 alias = "contested_claim",
479 alias = "claim",
480 alias = "claim_text",
481 alias = "proposal",
482 alias = "what_they_claimed"
483 )]
484 pub proposal_claims: String,
485 #[serde(
491 default,
492 alias = "belief",
493 alias = "details",
494 alias = "counter_position",
495 alias = "position",
496 alias = "explanation",
497 alias = "analysis",
498 alias = "counter",
499 alias = "our_position",
500 alias = "your_view",
501 alias = "what_i_believe"
502 )]
503 pub evaluator_position: String,
504 pub confidence: Confidence,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
513pub struct CategoryScores {
514 pub correctness: f32,
515 pub completeness: f32,
516 pub novelty: f32,
517 pub feasibility: f32,
518 pub evidence_quality: f32,
519}
520
521#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
522pub struct Evaluation {
523 pub score: f32,
530 #[serde(default)]
531 pub justification: String,
532 #[serde(default, skip_serializing_if = "Option::is_none")]
541 pub token_usage: Option<TokenUsage>,
542 #[serde(default)]
544 pub claim_assessments: Vec<ClaimAssessment>,
545 #[serde(default)]
547 pub disagreements: Vec<DisagreementPoint>,
548 #[serde(default, skip_serializing_if = "Option::is_none")]
550 pub stance: Option<Stance>,
551 #[serde(default)]
553 pub is_final_solution: bool,
554 #[serde(default, skip_serializing_if = "Option::is_none")]
556 pub category_scores: Option<CategoryScores>,
557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
559 pub operator_annotations: Vec<OperatorAnnotation>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub edited_by: Option<String>,
563 #[serde(default, skip_serializing_if = "Option::is_none")]
568 pub finish_reason: Option<String>,
569 #[serde(default)]
574 pub published_at_ms: i64,
575}
576
577pub fn generate_claim_id(target_id: &str, claim_text: &str, round: u32) -> String {
582 let mut hasher = std::collections::hash_map::DefaultHasher::new();
583 (target_id, claim_text.to_lowercase().trim(), round).hash(&mut hasher);
584 format!("{:06x}", hasher.finish() & 0xFFFFFF)
585}
586
587#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
594pub struct StructuredFeedback {
595 pub contested_claims: Vec<ContestedClaim>,
597 pub verified_claims: Vec<String>,
599 pub mean_stance: f32,
601 pub evaluator_count: u32,
603 #[serde(default, skip_serializing_if = "Option::is_none")]
605 pub category_breakdown: Option<CategoryScores>,
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
610pub struct ContestedClaim {
611 pub claim_id: String,
613 pub what_you_claimed: String,
615 pub counter_position: String,
617 pub evaluator: String,
619 pub confidence: Confidence,
621}
622
623pub fn build_structured_feedback(evaluations: &[EvaluationRecord]) -> StructuredFeedback {
629 let mut contested_claims = Vec::new();
630 let mut verified_claims = Vec::new();
631 let mut stance_sum = 0.0f32;
632 let mut stance_count = 0u32;
633 let mut cat_totals = CategoryScores::default();
634 let mut cat_count = 0u32;
635
636 for er in evaluations {
637 let eval = &er.evaluation;
638
639 if let Some(ref s) = eval.stance {
641 stance_sum += match s {
642 Stance::StrongAgree => 2.0,
643 Stance::Agree => 1.0,
644 Stance::Neutral => 0.0,
645 Stance::Disagree => -1.0,
646 Stance::StrongDisagree => -2.0,
647 };
648 stance_count += 1;
649 }
650
651 if let Some(ref cs) = eval.category_scores {
653 cat_totals.correctness += cs.correctness;
654 cat_totals.completeness += cs.completeness;
655 cat_totals.novelty += cs.novelty;
656 cat_totals.feasibility += cs.feasibility;
657 cat_totals.evidence_quality += cs.evidence_quality;
658 cat_count += 1;
659 }
660
661 for ca in &eval.claim_assessments {
663 match ca.verdict {
664 ClaimVerdict::Verified => {
665 verified_claims.push(ca.claim.clone());
666 }
667 ClaimVerdict::Contested | ClaimVerdict::Wrong => {
668 let claim_id = ca.claim_id.clone().unwrap_or_else(|| {
669 format!("auto_{:04x}", {
670 let mut h = std::collections::hash_map::DefaultHasher::new();
671 ca.claim.hash(&mut h);
672 h.finish() & 0xFFFF
673 })
674 });
675 contested_claims.push(ContestedClaim {
676 claim_id,
677 what_you_claimed: ca.claim.clone(),
678 counter_position: ca.reason.clone().unwrap_or_default(),
679 evaluator: er.evaluator_agent_id.clone(),
680 confidence: Confidence::Medium,
681 });
682 }
683 _ => {}
684 }
685 }
686
687 for dp in &eval.disagreements {
689 let claim_id = dp.claim_id.clone().unwrap_or_else(|| {
690 format!("disp_{:04x}", {
691 let mut h = std::collections::hash_map::DefaultHasher::new();
692 dp.proposal_claims.hash(&mut h);
693 h.finish() & 0xFFFF
694 })
695 });
696 contested_claims.push(ContestedClaim {
697 claim_id,
698 what_you_claimed: dp.proposal_claims.clone(),
699 counter_position: dp.evaluator_position.clone(),
700 evaluator: er.evaluator_agent_id.clone(),
701 confidence: dp.confidence.clone(),
702 });
703 }
704 }
705
706 verified_claims.sort();
708 verified_claims.dedup();
709
710 let category_breakdown = if cat_count > 0 {
711 Some(CategoryScores {
712 correctness: cat_totals.correctness / cat_count as f32,
713 completeness: cat_totals.completeness / cat_count as f32,
714 novelty: cat_totals.novelty / cat_count as f32,
715 feasibility: cat_totals.feasibility / cat_count as f32,
716 evidence_quality: cat_totals.evidence_quality / cat_count as f32,
717 })
718 } else {
719 None
720 };
721
722 StructuredFeedback {
723 contested_claims,
724 verified_claims,
725 mean_stance: if stance_count > 0 {
726 stance_sum / stance_count as f32
727 } else {
728 0.0
729 },
730 evaluator_count: evaluations.len() as u32,
731 category_breakdown,
732 }
733}
734
735pub trait TokenEstimator: Send + Sync {
738 fn estimate_tokens(&self, text: &str) -> u32;
739}
740
741#[derive(Debug, Clone)]
743pub struct HeuristicTokenEstimator {
744 pub chars_per_token: f64,
745}
746
747impl Default for HeuristicTokenEstimator {
748 fn default() -> Self {
749 Self {
750 chars_per_token: 4.0,
751 }
752 }
753}
754
755impl TokenEstimator for HeuristicTokenEstimator {
756 fn estimate_tokens(&self, text: &str) -> u32 {
757 if self.chars_per_token <= 0.0 || text.is_empty() {
758 return 0;
759 }
760 (text.chars().count() as f64 / self.chars_per_token).ceil() as u32
763 }
764}
765
766#[derive(Debug, Clone, Default, Serialize, Deserialize)]
768pub struct AgentPricingInfo {
769 pub input_price_per_mtok: f64,
771 pub output_price_per_mtok: f64,
773}
774
775impl AgentPricingInfo {
776 pub fn compute_cost(&self, input_tokens: u32, output_tokens: u32) -> f64 {
778 (input_tokens as f64 * self.input_price_per_mtok
779 + output_tokens as f64 * self.output_price_per_mtok)
780 / 1_000_000.0
781 }
782}
783
784#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
785pub struct ProposalRecord {
786 pub round: u32,
787 pub author_agent_id: String,
788 pub proposal: Proposal,
789 pub evaluations: Vec<EvaluationRecord>,
790 pub aggregated_score: f32,
796}
797
798#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, Default)]
799pub struct EvaluationRecord {
800 pub evaluator_agent_id: String,
801 pub evaluation: Evaluation,
802 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
812 pub synthetic: bool,
813}
814
815#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
816pub struct CandidateProposal {
817 pub id: String,
818 pub proposal: Proposal,
819}
820
821#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
822pub struct UserInjection {
823 pub message: String,
824 pub injected_at_round: u32,
825 pub timestamp: u64,
826 #[serde(default)]
827 pub priority: InjectionPriority,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub tool_changes: Option<ToolChanges>,
831}
832
833#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default, PartialEq)]
834pub enum InjectionPriority {
835 #[default]
836 Normal,
837 Urgent,
838}
839
840#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
842pub struct ToolChanges {
843 #[serde(default)]
845 pub add: Vec<UserToolDefinition>,
846 #[serde(default)]
848 pub remove: Vec<String>,
849}
850
851#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
854pub struct UserToolDefinition {
855 pub name: String,
858 pub description: String,
860 #[serde(default, skip_serializing_if = "Option::is_none")]
863 pub parameters: Option<serde_json::Value>,
864 #[serde(default, skip_serializing_if = "Option::is_none")]
866 pub strict: Option<bool>,
867}
868
869#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
871pub struct PendingToolCall {
872 pub call_id: String,
873 pub job_id: String,
874 pub agent_id: String,
875 pub tool_name: String,
877 pub arguments: serde_json::Value,
878 pub round: u32,
879 pub phase: DeliberationPhase,
880 pub status: ToolCallStatus,
881 pub created_at: u64,
883 #[serde(default, skip_serializing_if = "Option::is_none")]
885 pub responded_at: Option<u64>,
886 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub result: Option<String>,
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default, PartialEq)]
892pub enum ToolCallStatus {
893 #[default]
894 Pending,
895 Responded,
896 Expired,
897}
898
899#[async_trait]
900pub trait PersistenceStore: Debug + Send + Sync {
901 async fn get(&self, key: &str) -> Result<Option<String>>;
902 async fn append(&self, key: &str, content: &str) -> Result<()>;
903 async fn set(&self, key: &str, content: &str) -> Result<()>;
904 async fn get_round_history(&self, round: u32) -> Result<Option<Vec<ProposalRecord>>>;
905}
906
907#[async_trait]
908pub trait NsedAgent: Send + Sync + Debug + dyn_clone::DynClone {
909 async fn propose(&self, context: &AgentContext) -> Result<Proposal>;
910 async fn evaluate(&self, context: &AgentContext) -> Result<Vec<(String, Evaluation)>>;
911 fn name(&self) -> String;
912}
913
914dyn_clone::clone_trait_object!(NsedAgent);
915
916#[async_trait]
921pub trait ChatCapable: Send + Sync {
922 async fn chat(
925 &self,
926 messages: Vec<async_openai::types::ChatCompletionRequestMessage>,
927 ) -> Result<String>;
928}
929
930#[async_trait]
939pub trait SubmissionValidator: Send + Sync + Debug {
940 async fn validate(&self, content: &str) -> Option<String>;
942}
943
944#[async_trait]
945pub trait UserToolHandlerTrait: Send + Sync + Debug {
946 async fn handle_call(
948 &self,
949 tool_name: &str,
950 arguments_json: &str,
951 round: u32,
952 phase: DeliberationPhase,
953 ) -> String;
954}
955
956#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Default)]
962#[serde(rename_all = "lowercase")]
963pub enum AgentLiveStatus {
964 #[default]
966 Idle,
967 Busy,
969}
970
971#[derive(
974 Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema, Default,
975)]
976#[serde(rename_all = "snake_case")]
977pub enum AgentHealthState {
978 #[default]
980 Healthy,
981 Degraded,
984 Down,
986}
987
988#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
990pub struct AgentHealth {
991 #[serde(default)]
992 pub state: AgentHealthState,
993 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub reason: Option<String>,
997}
998
999impl AgentHealth {
1000 pub fn is_down(&self) -> bool {
1002 self.state == AgentHealthState::Down
1003 }
1004}
1005
1006pub fn compute_agent_health(model_down: bool, paused: bool) -> AgentHealth {
1011 if model_down {
1012 AgentHealth {
1013 state: AgentHealthState::Down,
1014 reason: Some("remote model unavailable".to_string()),
1015 }
1016 } else if paused {
1017 AgentHealth {
1018 state: AgentHealthState::Degraded,
1019 reason: Some("paused".to_string()),
1020 }
1021 } else {
1022 AgentHealth::default()
1023 }
1024}
1025
1026#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
1031pub struct AgentHeartbeat {
1032 pub agent_id: String,
1033 pub status: AgentLiveStatus,
1034 pub model_name: String,
1035 pub provider_id: String,
1036 #[serde(default)]
1043 pub model_down: bool,
1044 #[serde(default)]
1052 pub health: AgentHealth,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1055 pub current_job: Option<String>,
1056 pub uptime_secs: u64,
1058 pub timestamp: String,
1060 #[serde(default, skip_serializing_if = "Option::is_none")]
1062 pub input_price_per_mtok: Option<f64>,
1063 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub output_price_per_mtok: Option<f64>,
1066 #[serde(default, skip_serializing_if = "Option::is_none")]
1068 pub chars_per_token: Option<f64>,
1069
1070 #[serde(default, skip_serializing_if = "Option::is_none")]
1073 pub response_sla_secs: Option<u64>,
1074
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1078 pub temperature: Option<f32>,
1079 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub frequency_penalty: Option<f32>,
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1084 pub presence_penalty: Option<f32>,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1087 pub max_tokens: Option<i32>,
1088 #[serde(default, skip_serializing_if = "Option::is_none")]
1090 pub context_window: Option<i32>,
1091
1092 #[serde(default)]
1095 pub tasks_completed: u64,
1096 #[serde(default)]
1098 pub tasks_failed: u64,
1099 #[serde(default, skip_serializing_if = "Option::is_none")]
1101 pub last_error: Option<String>,
1102
1103 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1106 pub capability_tags: Vec<String>,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1109 pub description: Option<String>,
1110 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1112 pub signing_schemes: Vec<String>,
1113}
1114
1115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
1119pub struct OrchestratorPing {
1120 pub orchestrator_id: String,
1121 pub timestamp: String,
1122 pub uptime_secs: u64,
1123}
1124
1125pub fn normalize_score(raw_weight: f32, total_abs_weight: f32) -> f32 {
1132 if !raw_weight.is_finite() || !total_abs_weight.is_finite() {
1138 return 0.0;
1139 }
1140 if total_abs_weight > f32::EPSILON {
1141 (raw_weight / total_abs_weight).clamp(-1.0, 1.0)
1142 } else {
1143 0.0
1144 }
1145}
1146
1147pub fn calculate_qv_from_fraction(fraction: f32) -> f32 {
1154 if !fraction.is_finite() {
1157 return 0.0;
1158 }
1159 let f = fraction.clamp(-1.0, 1.0);
1160 if f.abs() <= f32::EPSILON {
1161 return 0.0;
1162 }
1163 let magnitude = (f.abs() * 100.0).sqrt() / 10.0;
1164 f.signum() * magnitude
1165}
1166
1167pub fn calculate_qv_score(raw_weight: f32, total_weight: f32) -> (f32, f32) {
1188 let normalized_tokens = if total_weight > 100.0 {
1189 ((raw_weight / total_weight) * 100.0).clamp(0.0, 100.0)
1190 } else {
1191 raw_weight.clamp(0.0, 100.0)
1192 };
1193 let strength = normalized_tokens.sqrt();
1194 let display_influence = strength / 10.0;
1195 (display_influence, normalized_tokens)
1196}
1197
1198#[cfg(test)]
1199mod tests {
1200 use super::*;
1201
1202 #[test]
1203 fn compute_agent_health_precedence_and_reasons() {
1204 let both = compute_agent_health(true, true);
1206 assert_eq!(both.state, AgentHealthState::Down);
1207 assert!(both.is_down());
1208 assert_eq!(both.reason.as_deref(), Some("remote model unavailable"));
1209
1210 let down = compute_agent_health(true, false);
1211 assert_eq!(down.state, AgentHealthState::Down);
1212
1213 let degraded = compute_agent_health(false, true);
1214 assert_eq!(degraded.state, AgentHealthState::Degraded);
1215 assert!(!degraded.is_down());
1216 assert_eq!(degraded.reason.as_deref(), Some("paused"));
1217
1218 let healthy = compute_agent_health(false, false);
1219 assert_eq!(healthy.state, AgentHealthState::Healthy);
1220 assert!(healthy.reason.is_none());
1221 assert_eq!(healthy, AgentHealth::default());
1222 }
1223
1224 #[test]
1225 fn heartbeat_without_health_defaults_to_healthy() {
1226 let json = r#"{"agent_id":"a","status":"idle","model_name":"m","provider_id":"p","uptime_secs":1,"timestamp":"t"}"#;
1229 let hb: AgentHeartbeat = serde_json::from_str(json).unwrap();
1230 assert_eq!(hb.health.state, AgentHealthState::Healthy);
1231 assert!(!hb.model_down);
1232 }
1233
1234 #[test]
1235 fn agent_health_serde_roundtrip() {
1236 let h = compute_agent_health(true, false);
1237 let s = serde_json::to_string(&h).unwrap();
1238 assert!(s.contains("\"down\""), "state serializes snake_case: {s}");
1239 assert_eq!(serde_json::from_str::<AgentHealth>(&s).unwrap(), h);
1240 }
1241
1242 #[test]
1247 fn claim_verdict_serde_roundtrip() {
1248 for variant in [
1249 ClaimVerdict::Verified,
1250 ClaimVerdict::Contested,
1251 ClaimVerdict::Unverified,
1252 ClaimVerdict::Wrong,
1253 ClaimVerdict::Unknown,
1254 ] {
1255 let json = serde_json::to_string(&variant).unwrap();
1256 let deserialized: ClaimVerdict = serde_json::from_str(&json).unwrap();
1257 assert_eq!(deserialized, variant);
1258 }
1259 }
1260
1261 #[test]
1262 fn claim_verdict_snake_case_serialization() {
1263 assert_eq!(
1264 serde_json::to_string(&ClaimVerdict::Verified).unwrap(),
1265 "\"verified\""
1266 );
1267 assert_eq!(
1268 serde_json::to_string(&ClaimVerdict::Wrong).unwrap(),
1269 "\"wrong\""
1270 );
1271 assert_eq!(
1272 serde_json::to_string(&Stance::StrongAgree).unwrap(),
1273 "\"strong_agree\""
1274 );
1275 assert_eq!(
1276 serde_json::to_string(&Stance::StrongDisagree).unwrap(),
1277 "\"strong_disagree\""
1278 );
1279 assert_eq!(
1280 serde_json::to_string(&Confidence::High).unwrap(),
1281 "\"high\""
1282 );
1283 assert_eq!(
1284 serde_json::to_string(&Confidence::Medium).unwrap(),
1285 "\"medium\""
1286 );
1287 assert_eq!(serde_json::to_string(&Confidence::Low).unwrap(), "\"low\"");
1288 }
1289
1290 #[test]
1291 fn stance_serde_roundtrip() {
1292 for variant in [
1293 Stance::StrongAgree,
1294 Stance::Agree,
1295 Stance::Neutral,
1296 Stance::Disagree,
1297 Stance::StrongDisagree,
1298 ] {
1299 let json = serde_json::to_string(&variant).unwrap();
1300 let deserialized: Stance = serde_json::from_str(&json).unwrap();
1301 assert_eq!(deserialized, variant);
1302 }
1303 }
1304
1305 #[test]
1306 fn confidence_serde_roundtrip() {
1307 for variant in [Confidence::High, Confidence::Medium, Confidence::Low] {
1308 let json = serde_json::to_string(&variant).unwrap();
1309 let deserialized: Confidence = serde_json::from_str(&json).unwrap();
1310 assert_eq!(deserialized, variant);
1311 }
1312 }
1313
1314 #[test]
1315 fn evaluation_backward_compat_minimal_json() {
1316 let json = r#"{"score": 0.75, "justification": "Looks good"}"#;
1318 let eval: Evaluation = serde_json::from_str(json).unwrap();
1319 assert!((eval.score - 0.75).abs() < f32::EPSILON);
1320 assert_eq!(eval.justification, "Looks good");
1321 assert!(eval.claim_assessments.is_empty());
1322 assert!(eval.disagreements.is_empty());
1323 assert!(eval.stance.is_none());
1324 assert!(!eval.is_final_solution);
1325 assert!(eval.category_scores.is_none());
1326 assert!(eval.token_usage.is_none());
1327 }
1328
1329 #[test]
1330 fn evaluation_full_structured_roundtrip() {
1331 let eval = Evaluation {
1332 score: 0.82,
1333 justification: "Well-reasoned".to_string(),
1334 token_usage: Some(TokenUsage {
1335 input_tokens: 1500,
1336 output_tokens: 300,
1337 }),
1338 claim_assessments: vec![
1339 ClaimAssessment {
1340 claim_id: Some("abc123".to_string()),
1341 claim: "O(n log n) complexity".to_string(),
1342 verdict: ClaimVerdict::Verified,
1343 reason: Some("Confirmed via analysis".to_string()),
1344 anchor: None,
1345 },
1346 ClaimAssessment {
1347 claim_id: None,
1348 claim: "Thread safety guaranteed".to_string(),
1349 verdict: ClaimVerdict::Wrong,
1350 reason: Some("Missing lock in critical section".to_string()),
1351 anchor: None,
1352 },
1353 ],
1354 disagreements: vec![DisagreementPoint {
1355 claim_id: Some("abc123".to_string()),
1356 proposal_claims: "No race condition".to_string(),
1357 evaluator_position: "Race condition on shared state".to_string(),
1358 confidence: Confidence::High,
1359 }],
1360 stance: Some(Stance::Disagree),
1361 is_final_solution: false,
1362 category_scores: Some(CategoryScores {
1363 correctness: 60.0,
1364 completeness: 80.0,
1365 novelty: 40.0,
1366 feasibility: 90.0,
1367 evidence_quality: 55.0,
1368 }),
1369 ..Default::default()
1370 };
1371
1372 let json = serde_json::to_string(&eval).unwrap();
1373 let deserialized: Evaluation = serde_json::from_str(&json).unwrap();
1374
1375 assert!((deserialized.score - 0.82).abs() < f32::EPSILON);
1376 assert_eq!(deserialized.claim_assessments.len(), 2);
1377 assert_eq!(
1378 deserialized.claim_assessments[0].verdict,
1379 ClaimVerdict::Verified
1380 );
1381 assert_eq!(
1382 deserialized.claim_assessments[1].verdict,
1383 ClaimVerdict::Wrong
1384 );
1385 assert_eq!(deserialized.disagreements.len(), 1);
1386 assert_eq!(deserialized.disagreements[0].confidence, Confidence::High);
1387 assert_eq!(deserialized.stance, Some(Stance::Disagree));
1388 assert!(!deserialized.is_final_solution);
1389 let cs = deserialized.category_scores.unwrap();
1390 assert!((cs.correctness - 60.0).abs() < f32::EPSILON);
1391 assert!((cs.evidence_quality - 55.0).abs() < f32::EPSILON);
1392 }
1393
1394 #[test]
1395 fn evaluation_skip_serializing_none_fields() {
1396 let eval = Evaluation {
1397 score: 0.5,
1398 justification: "Ok".to_string(),
1399 ..Default::default()
1400 };
1401 let json = serde_json::to_string(&eval).unwrap();
1402 assert!(!json.contains("token_usage"));
1404 assert!(!json.contains("stance"));
1405 assert!(!json.contains("category_scores"));
1406 }
1409
1410 #[test]
1411 fn structured_feedback_serde_roundtrip() {
1412 let sf = StructuredFeedback {
1413 contested_claims: vec![ContestedClaim {
1414 claim_id: "abc123".to_string(),
1415 what_you_claimed: "X is true".to_string(),
1416 counter_position: "X is false because Y".to_string(),
1417 evaluator: "eval_1".to_string(),
1418 confidence: Confidence::High,
1419 }],
1420 verified_claims: vec!["Claim A is correct".to_string()],
1421 mean_stance: -0.5,
1422 evaluator_count: 3,
1423 category_breakdown: Some(CategoryScores {
1424 correctness: 70.0,
1425 completeness: 80.0,
1426 novelty: 50.0,
1427 feasibility: 90.0,
1428 evidence_quality: 60.0,
1429 }),
1430 };
1431 let json = serde_json::to_string(&sf).unwrap();
1432 let deserialized: StructuredFeedback = serde_json::from_str(&json).unwrap();
1433 assert_eq!(deserialized.contested_claims.len(), 1);
1434 assert_eq!(deserialized.verified_claims.len(), 1);
1435 assert!((deserialized.mean_stance - (-0.5)).abs() < f32::EPSILON);
1436 assert_eq!(deserialized.evaluator_count, 3);
1437 assert!(deserialized.category_breakdown.is_some());
1438 }
1439
1440 #[test]
1445 fn generate_claim_id_is_deterministic() {
1446 let id1 = generate_claim_id("agent_1", "O(n log n) proof", 1);
1447 let id2 = generate_claim_id("agent_1", "O(n log n) proof", 1);
1448 assert_eq!(id1, id2, "Same inputs must produce identical claim IDs");
1449 }
1450
1451 #[test]
1452 fn generate_claim_id_is_6_hex_chars() {
1453 let id = generate_claim_id("agent_1", "some claim", 3);
1454 assert_eq!(id.len(), 6);
1455 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1456 }
1457
1458 #[test]
1459 fn generate_claim_id_differs_for_different_rounds() {
1460 let r1 = generate_claim_id("agent_1", "same claim", 1);
1461 let r2 = generate_claim_id("agent_1", "same claim", 2);
1462 assert_ne!(r1, r2, "Different rounds should produce different IDs");
1463 }
1464
1465 #[test]
1466 fn generate_claim_id_differs_for_different_targets() {
1467 let a = generate_claim_id("agent_1", "same claim", 1);
1468 let b = generate_claim_id("agent_2", "same claim", 1);
1469 assert_ne!(a, b, "Different targets should produce different IDs");
1470 }
1471
1472 #[test]
1473 fn generate_claim_id_case_insensitive_on_claim_text() {
1474 let lower = generate_claim_id("agent_1", "this is a claim", 1);
1475 let upper = generate_claim_id("agent_1", "THIS IS A CLAIM", 1);
1476 assert_eq!(lower, upper, "Claim text should be normalized to lowercase");
1477 }
1478
1479 fn make_eval_record(
1484 evaluator: &str,
1485 score: f32,
1486 stance: Option<Stance>,
1487 claims: Vec<ClaimAssessment>,
1488 disagreements: Vec<DisagreementPoint>,
1489 category_scores: Option<CategoryScores>,
1490 ) -> EvaluationRecord {
1491 EvaluationRecord {
1492 evaluator_agent_id: evaluator.to_string(),
1493 evaluation: Evaluation {
1494 score,
1495 justification: format!("Evaluation by {evaluator}"),
1496 stance,
1497 claim_assessments: claims,
1498 disagreements,
1499 category_scores,
1500 ..Default::default()
1501 },
1502 synthetic: false,
1503 }
1504 }
1505
1506 #[test]
1507 fn build_structured_feedback_empty_evaluations() {
1508 let sf = build_structured_feedback(&[]);
1509 assert!(sf.contested_claims.is_empty());
1510 assert!(sf.verified_claims.is_empty());
1511 assert!((sf.mean_stance - 0.0).abs() < f32::EPSILON);
1512 assert_eq!(sf.evaluator_count, 0);
1513 assert!(sf.category_breakdown.is_none());
1514 }
1515
1516 #[test]
1517 fn build_structured_feedback_single_verified_claim() {
1518 let evals = vec![make_eval_record(
1519 "eval_1",
1520 0.8,
1521 Some(Stance::Agree),
1522 vec![ClaimAssessment {
1523 claim_id: Some("c1".to_string()),
1524 claim: "Algorithm is correct".to_string(),
1525 verdict: ClaimVerdict::Verified,
1526 reason: Some("Confirmed".to_string()),
1527 anchor: None,
1528 }],
1529 vec![],
1530 None,
1531 )];
1532
1533 let sf = build_structured_feedback(&evals);
1534 assert!(sf.contested_claims.is_empty());
1535 assert_eq!(sf.verified_claims, vec!["Algorithm is correct"]);
1536 assert!((sf.mean_stance - 1.0).abs() < f32::EPSILON); assert_eq!(sf.evaluator_count, 1);
1538 }
1539
1540 #[test]
1541 fn build_structured_feedback_contested_and_wrong_claims() {
1542 let evals = vec![make_eval_record(
1543 "eval_1",
1544 0.3,
1545 Some(Stance::Disagree),
1546 vec![
1547 ClaimAssessment {
1548 claim_id: Some("c1".to_string()),
1549 claim: "Thread safe".to_string(),
1550 verdict: ClaimVerdict::Contested,
1551 reason: Some("Missing mutex".to_string()),
1552 anchor: None,
1553 },
1554 ClaimAssessment {
1555 claim_id: None,
1556 claim: "O(1) lookup".to_string(),
1557 verdict: ClaimVerdict::Wrong,
1558 reason: Some("Actually O(n)".to_string()),
1559 anchor: None,
1560 },
1561 ],
1562 vec![],
1563 None,
1564 )];
1565
1566 let sf = build_structured_feedback(&evals);
1567 assert_eq!(sf.contested_claims.len(), 2);
1568 assert_eq!(sf.contested_claims[0].claim_id, "c1");
1570 assert_eq!(sf.contested_claims[0].what_you_claimed, "Thread safe");
1571 assert!(sf.contested_claims[1].claim_id.starts_with("auto_"));
1573 assert_eq!(sf.contested_claims[1].what_you_claimed, "O(1) lookup");
1574 }
1575
1576 #[test]
1577 fn build_structured_feedback_disagreement_points() {
1578 let evals = vec![make_eval_record(
1579 "eval_1",
1580 0.4,
1581 None,
1582 vec![],
1583 vec![DisagreementPoint {
1584 claim_id: None,
1585 proposal_claims: "Uses quicksort".to_string(),
1586 evaluator_position: "Mergesort is better for stability".to_string(),
1587 confidence: Confidence::High,
1588 }],
1589 None,
1590 )];
1591
1592 let sf = build_structured_feedback(&evals);
1593 assert_eq!(sf.contested_claims.len(), 1);
1594 assert!(sf.contested_claims[0].claim_id.starts_with("disp_"));
1595 assert_eq!(sf.contested_claims[0].confidence, Confidence::High);
1596 }
1597
1598 #[test]
1599 fn build_structured_feedback_stance_aggregation() {
1600 let evals = vec![
1601 make_eval_record("e1", 0.7, Some(Stance::StrongAgree), vec![], vec![], None),
1602 make_eval_record("e2", 0.3, Some(Stance::Disagree), vec![], vec![], None),
1603 make_eval_record("e3", 0.5, Some(Stance::Neutral), vec![], vec![], None),
1604 ];
1605
1606 let sf = build_structured_feedback(&evals);
1607 assert!((sf.mean_stance - (1.0 / 3.0)).abs() < 0.01);
1609 assert_eq!(sf.evaluator_count, 3);
1610 }
1611
1612 #[test]
1613 fn build_structured_feedback_stance_strong_disagree() {
1614 let evals = vec![
1615 make_eval_record(
1616 "e1",
1617 0.2,
1618 Some(Stance::StrongDisagree),
1619 vec![],
1620 vec![],
1621 None,
1622 ),
1623 make_eval_record("e2", 0.9, Some(Stance::Agree), vec![], vec![], None),
1624 ];
1625
1626 let sf = build_structured_feedback(&evals);
1627 assert!((sf.mean_stance - (-0.5)).abs() < f32::EPSILON);
1629 assert_eq!(sf.evaluator_count, 2);
1630 }
1631
1632 #[test]
1633 fn build_structured_feedback_stance_ignores_none() {
1634 let evals = vec![
1635 make_eval_record("e1", 0.8, Some(Stance::Agree), vec![], vec![], None),
1636 make_eval_record("e2", 0.5, None, vec![], vec![], None), ];
1638
1639 let sf = build_structured_feedback(&evals);
1640 assert!((sf.mean_stance - 1.0).abs() < f32::EPSILON);
1642 assert_eq!(sf.evaluator_count, 2); }
1644
1645 #[test]
1646 fn build_structured_feedback_category_score_averaging() {
1647 let cs1 = CategoryScores {
1648 correctness: 80.0,
1649 completeness: 60.0,
1650 novelty: 40.0,
1651 feasibility: 90.0,
1652 evidence_quality: 70.0,
1653 };
1654 let cs2 = CategoryScores {
1655 correctness: 60.0,
1656 completeness: 80.0,
1657 novelty: 60.0,
1658 feasibility: 70.0,
1659 evidence_quality: 50.0,
1660 };
1661
1662 let evals = vec![
1663 make_eval_record("e1", 0.7, None, vec![], vec![], Some(cs1)),
1664 make_eval_record("e2", 0.6, None, vec![], vec![], Some(cs2)),
1665 ];
1666
1667 let sf = build_structured_feedback(&evals);
1668 let cat = sf
1669 .category_breakdown
1670 .expect("Should have category breakdown");
1671 assert!((cat.correctness - 70.0).abs() < f32::EPSILON);
1672 assert!((cat.completeness - 70.0).abs() < f32::EPSILON);
1673 assert!((cat.novelty - 50.0).abs() < f32::EPSILON);
1674 assert!((cat.feasibility - 80.0).abs() < f32::EPSILON);
1675 assert!((cat.evidence_quality - 60.0).abs() < f32::EPSILON);
1676 }
1677
1678 #[test]
1679 fn build_structured_feedback_category_scores_skipped_when_none() {
1680 let evals = vec![make_eval_record("e1", 0.5, None, vec![], vec![], None)];
1681 let sf = build_structured_feedback(&evals);
1682 assert!(sf.category_breakdown.is_none());
1683 }
1684
1685 #[test]
1686 fn build_structured_feedback_verified_claims_deduplicated() {
1687 let evals = vec![
1688 make_eval_record(
1689 "e1",
1690 0.8,
1691 None,
1692 vec![ClaimAssessment {
1693 claim_id: None,
1694 claim: "Earth is round".to_string(),
1695 verdict: ClaimVerdict::Verified,
1696 reason: None,
1697 anchor: None,
1698 }],
1699 vec![],
1700 None,
1701 ),
1702 make_eval_record(
1703 "e2",
1704 0.9,
1705 None,
1706 vec![ClaimAssessment {
1707 claim_id: None,
1708 claim: "Earth is round".to_string(),
1709 verdict: ClaimVerdict::Verified,
1710 reason: None,
1711 anchor: None,
1712 }],
1713 vec![],
1714 None,
1715 ),
1716 ];
1717
1718 let sf = build_structured_feedback(&evals);
1719 assert_eq!(sf.verified_claims.len(), 1);
1721 assert_eq!(sf.verified_claims[0], "Earth is round");
1722 }
1723
1724 #[test]
1725 fn build_structured_feedback_unverified_claims_ignored() {
1726 let evals = vec![make_eval_record(
1727 "e1",
1728 0.5,
1729 None,
1730 vec![ClaimAssessment {
1731 claim_id: None,
1732 claim: "Might be true".to_string(),
1733 verdict: ClaimVerdict::Unverified,
1734 reason: None,
1735 anchor: None,
1736 }],
1737 vec![],
1738 None,
1739 )];
1740
1741 let sf = build_structured_feedback(&evals);
1742 assert!(sf.contested_claims.is_empty());
1743 assert!(sf.verified_claims.is_empty());
1744 }
1745
1746 #[test]
1751 fn default_enums_have_expected_defaults() {
1752 assert_eq!(ClaimVerdict::default(), ClaimVerdict::Unknown);
1753 assert_eq!(Stance::default(), Stance::Neutral);
1754 assert_eq!(Confidence::default(), Confidence::Medium);
1755 }
1756
1757 #[test]
1758 fn default_evaluation_is_empty() {
1759 let eval = Evaluation::default();
1760 assert!((eval.score - 0.0).abs() < f32::EPSILON);
1761 assert!(eval.justification.is_empty());
1762 assert!(eval.claim_assessments.is_empty());
1763 assert!(eval.disagreements.is_empty());
1764 assert!(eval.stance.is_none());
1765 assert!(!eval.is_final_solution);
1766 assert!(eval.category_scores.is_none());
1767 }
1768
1769 #[test]
1779 fn disagreement_point_alias_contested_claim_and_belief() {
1780 let json = serde_json::json!({
1781 "contested_claim": "The 38% equity allocation is optimal",
1782 "belief": "40% equity is more appropriate given historical returns",
1783 "confidence": "medium"
1784 });
1785 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
1786 assert_eq!(dp.proposal_claims, "The 38% equity allocation is optimal");
1787 assert_eq!(
1788 dp.evaluator_position,
1789 "40% equity is more appropriate given historical returns"
1790 );
1791 assert_eq!(dp.confidence, Confidence::Medium);
1792 }
1793
1794 #[test]
1797 fn disagreement_point_alias_claim_and_details() {
1798 let json = serde_json::json!({
1799 "claim": "1% hedge provides sufficient protection",
1800 "details": "A 1% hedge yields at most 0.6% portfolio gain, insufficient to offset losses.",
1801 "confidence": "high"
1802 });
1803 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
1804 assert_eq!(
1805 dp.proposal_claims,
1806 "1% hedge provides sufficient protection"
1807 );
1808 assert!(dp.evaluator_position.contains("0.6% portfolio gain"));
1809 assert_eq!(dp.confidence, Confidence::High);
1810 }
1811
1812 #[test]
1814 fn disagreement_point_alias_counter_position() {
1815 let json = serde_json::json!({
1816 "proposal_claims": "Equities should be 50%",
1817 "counter_position": "40% is safer given volatility",
1818 "confidence": "low"
1819 });
1820 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
1821 assert_eq!(dp.evaluator_position, "40% is safer given volatility");
1822 }
1823
1824 #[test]
1826 fn disagreement_point_canonical_fields_still_work() {
1827 let json = serde_json::json!({
1828 "claim_id": "abc123",
1829 "proposal_claims": "The algorithm is O(n)",
1830 "evaluator_position": "It is O(n^2) due to nested loop",
1831 "confidence": "high"
1832 });
1833 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
1834 assert_eq!(dp.claim_id, Some("abc123".to_string()));
1835 assert_eq!(dp.proposal_claims, "The algorithm is O(n)");
1836 assert_eq!(dp.evaluator_position, "It is O(n^2) due to nested loop");
1837 }
1838
1839 #[test]
1842 fn regression_macro_evaluation_with_aliased_disagreements() {
1843 let json = serde_json::json!({
1844 "evaluations": [{
1845 "agent_id": "Candidate_B",
1846 "stance": "strong_disagree",
1847 "claim_assessments": [
1848 {"claim": "38% equity allocation", "verdict": "contested"},
1849 {"claim": "Tail risk hedge costs ~45bps", "verdict": "contested"}
1850 ],
1851 "disagreements": [
1852 {
1853 "contested_claim": "38% equity allocation due to elevated valuations",
1854 "belief": "40% equity is more appropriate given historical returns",
1855 "confidence": "medium"
1856 },
1857 {
1858 "contested_claim": "SPX puts at 100% of equity sleeve costs 45bps",
1859 "belief": "5% notional put-spread is more cost-effective",
1860 "confidence": "high"
1861 }
1862 ],
1863 "category_scores": {
1864 "correctness": 50, "completeness": 60, "novelty": 70,
1865 "feasibility": 60, "evidence_quality": 55
1866 },
1867 "endorsement_weight": 55
1868 }]
1869 });
1870
1871 #[derive(Debug, serde::Deserialize)]
1873 #[allow(dead_code)]
1874 struct BatchResponse {
1875 evaluations: Vec<BatchItem>,
1876 }
1877 #[derive(Debug, serde::Deserialize)]
1878 #[allow(dead_code)]
1879 struct BatchItem {
1880 agent_id: String,
1881 #[serde(default)]
1882 stance: Option<Stance>,
1883 #[serde(default)]
1884 claim_assessments: Vec<ClaimAssessment>,
1885 #[serde(default)]
1886 disagreements: Vec<DisagreementPoint>,
1887 #[serde(default)]
1888 category_scores: Option<CategoryScores>,
1889 endorsement_weight: f32,
1890 }
1891
1892 let resp: BatchResponse = serde_json::from_value(json).unwrap();
1893 assert_eq!(resp.evaluations.len(), 1);
1894 let item = &resp.evaluations[0];
1895 assert_eq!(item.agent_id, "Candidate_B");
1896 assert_eq!(item.stance, Some(Stance::StrongDisagree));
1897 assert_eq!(item.disagreements.len(), 2);
1898 assert_eq!(
1899 item.disagreements[0].proposal_claims,
1900 "38% equity allocation due to elevated valuations"
1901 );
1902 assert_eq!(
1903 item.disagreements[0].evaluator_position,
1904 "40% equity is more appropriate given historical returns"
1905 );
1906 assert_eq!(
1907 item.disagreements[1].proposal_claims,
1908 "SPX puts at 100% of equity sleeve costs 45bps"
1909 );
1910 assert!((item.endorsement_weight - 55.0).abs() < f32::EPSILON);
1911 }
1912
1913 #[test]
1917 fn claim_assessment_alias_content() {
1918 let json = serde_json::json!({
1919 "content": "The allocation strategy meets the fund's return targets.",
1920 "verdict": "verified"
1921 });
1922 let ca: ClaimAssessment = serde_json::from_value(json).unwrap();
1923 assert_eq!(
1924 ca.claim,
1925 "The allocation strategy meets the fund's return targets."
1926 );
1927 assert_eq!(ca.verdict, ClaimVerdict::Verified);
1928 }
1929
1930 #[test]
1934 fn claim_assessment_alias_disagreement_as_reason() {
1935 let json = serde_json::json!({
1936 "claim": "Alternative allocation is too high",
1937 "verdict": "contested",
1938 "disagreement": "I believe allocating 10% to alternatives is more appropriate."
1939 });
1940 let ca: ClaimAssessment = serde_json::from_value(json).unwrap();
1941 assert_eq!(
1942 ca.reason,
1943 Some("I believe allocating 10% to alternatives is more appropriate.".to_string())
1944 );
1945 }
1946
1947 #[test]
1950 fn regression_macro_evaluation_with_content_alias_claims() {
1951 let json = serde_json::json!({
1952 "evaluations": [{
1953 "agent_id": "Candidate_A",
1954 "endorsement_weight": 78,
1955 "stance": "agree",
1956 "claim_assessments": [
1957 {"content": "The allocation strategy meets targets.", "verdict": "verified"},
1958 {"content": "Momentum-driven framework is ideal.", "verdict": "verified"},
1959 {"content": "OTM put spread is cost-effective.", "verdict": "verified"}
1960 ],
1961 "disagreements": [],
1962 "category_scores": {
1963 "correctness": 85, "completeness": 75, "novelty": 80,
1964 "feasibility": 80, "evidence_quality": 80
1965 }
1966 }]
1967 });
1968
1969 #[derive(Debug, serde::Deserialize)]
1970 #[allow(dead_code)]
1971 struct Batch {
1972 evaluations: Vec<Item>,
1973 }
1974 #[derive(Debug, serde::Deserialize)]
1975 #[allow(dead_code)]
1976 struct Item {
1977 agent_id: String,
1978 #[serde(default)]
1979 claim_assessments: Vec<ClaimAssessment>,
1980 endorsement_weight: f32,
1981 }
1982
1983 let resp: Batch = serde_json::from_value(json).unwrap();
1984 let item = &resp.evaluations[0];
1985 assert_eq!(item.claim_assessments.len(), 3);
1986 assert_eq!(
1987 item.claim_assessments[0].claim,
1988 "The allocation strategy meets targets."
1989 );
1990 assert_eq!(item.claim_assessments[0].verdict, ClaimVerdict::Verified);
1991 }
1992
1993 #[test]
1999 fn test_disagreement_alias_explanation() {
2000 let json = serde_json::json!({
2001 "claim": "Equities 50% allocation will meet targets.",
2002 "explanation": "A 50% equity exposure is too high for the -8% drawdown limit.",
2003 "confidence": "high"
2004 });
2005 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
2006 assert_eq!(
2007 dp.evaluator_position,
2008 "A 50% equity exposure is too high for the -8% drawdown limit."
2009 );
2010 assert_eq!(
2011 dp.proposal_claims,
2012 "Equities 50% allocation will meet targets."
2013 );
2014 }
2015
2016 #[test]
2018 fn test_disagreement_alias_analysis() {
2019 let json = serde_json::json!({
2020 "claim": "Value factor is appropriate.",
2021 "analysis": "Current P/E ratios are above average, value tilt is risky.",
2022 "confidence": "medium"
2023 });
2024 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
2025 assert_eq!(
2026 dp.evaluator_position,
2027 "Current P/E ratios are above average, value tilt is risky."
2028 );
2029 }
2030
2031 #[test]
2033 fn test_disagreement_alias_counter_and_proposal() {
2034 let json = serde_json::json!({
2035 "claim_id": "C1",
2036 "proposal": "Equity allocation of 40% of total AUM",
2037 "counter": "Our analysis indicates 40% equity exceeds the drawdown limit.",
2038 "confidence": "high"
2039 });
2040 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
2041 assert_eq!(dp.proposal_claims, "Equity allocation of 40% of total AUM");
2042 assert_eq!(
2043 dp.evaluator_position,
2044 "Our analysis indicates 40% equity exceeds the drawdown limit."
2045 );
2046 }
2047
2048 #[test]
2050 fn test_disagreement_alias_our_position() {
2051 let json = serde_json::json!({
2052 "proposal": "Provides allocation percentages and strategy.",
2053 "our_position": "Cannot assess due to missing content.",
2054 "confidence": "high"
2055 });
2056 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
2057 assert_eq!(
2058 dp.evaluator_position,
2059 "Cannot assess due to missing content."
2060 );
2061 }
2062
2063 #[test]
2065 fn test_disagreement_alias_your_view() {
2066 let json = serde_json::json!({
2067 "claim_id": "C_value",
2068 "proposal": "Value factor exposure of 20%.",
2069 "your_view": "Elevated P/E ratios make value tilt unsupported.",
2070 "confidence": "medium"
2071 });
2072 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
2073 assert_eq!(
2074 dp.evaluator_position,
2075 "Elevated P/E ratios make value tilt unsupported."
2076 );
2077 }
2078
2079 #[test]
2081 fn test_disagreement_alias_what_they_what_i() {
2082 let json = serde_json::json!({
2083 "what_they_claimed": "Mean-reversion overlay provides superior risk management.",
2084 "what_i_believe": "The overlay adds unnecessary complexity.",
2085 "confidence": "high"
2086 });
2087 let dp: DisagreementPoint = serde_json::from_value(json).unwrap();
2088 assert_eq!(
2089 dp.proposal_claims,
2090 "Mean-reversion overlay provides superior risk management."
2091 );
2092 assert_eq!(
2093 dp.evaluator_position,
2094 "The overlay adds unnecessary complexity."
2095 );
2096 }
2097
2098 #[test]
2100 fn test_claim_assessment_alias_description() {
2101 let json = serde_json::json!({
2102 "description": "Proposal content is incomplete, preventing verification.",
2103 "verdict": "unverified"
2104 });
2105 let ca: ClaimAssessment = serde_json::from_value(json).unwrap();
2106 assert_eq!(
2107 ca.claim,
2108 "Proposal content is incomplete, preventing verification."
2109 );
2110 assert_eq!(ca.verdict, ClaimVerdict::Unverified);
2111 }
2112
2113 #[test]
2115 fn test_claim_assessment_alias_summary() {
2116 let json = serde_json::json!({
2117 "claim_id": "C1",
2118 "summary": "Allocation (40/40/15/5) will achieve 12-15% return.",
2119 "verdict": "unverified",
2120 "reasoning": "No backtest evidence provided."
2121 });
2122 let ca: ClaimAssessment = serde_json::from_value(json).unwrap();
2123 assert_eq!(
2124 ca.claim,
2125 "Allocation (40/40/15/5) will achieve 12-15% return."
2126 );
2127 assert_eq!(ca.reason.unwrap(), "No backtest evidence provided.");
2128 }
2129
2130 #[test]
2132 fn test_claim_assessment_alias_reasoning() {
2133 let json = serde_json::json!({
2134 "claim": "Hedge cost is 35bps.",
2135 "verdict": "verified",
2136 "reasoning": "Consistent with our own hedge design."
2137 });
2138 let ca: ClaimAssessment = serde_json::from_value(json).unwrap();
2139 assert_eq!(ca.reason.unwrap(), "Consistent with our own hedge design.");
2140 }
2141
2142 #[test]
2144 fn test_gpt_oss_full_eval_payload_mixed_aliases() {
2145 let json = serde_json::json!({
2146 "evaluations": [{
2147 "candidate_id": "Candidate_C",
2148 "endorsement_weight": 45.0,
2149 "stance": "disagree",
2150 "claim_assessments": [
2151 {"claim_id": "C1", "claim_text": "36% equity yields drawdown <8%", "verdict": "wrong", "reason": "Backtest shows 35% is optimal."},
2152 {"claim_id": "C2", "claim_text": "Put-spread costs 35bps", "verdict": "verified", "reason": "Consistent with our design."},
2153 ],
2154 "disagreements": [
2155 {"claim_id": "C1", "our_position": "Equity at 36% breaches beta cap.", "confidence": "high"}
2156 ],
2157 "category_scores": {"correctness": 45, "completeness": 50, "novelty": 60, "feasibility": 55, "evidence_quality": 40}
2158 }, {
2159 "candidate_id": "Candidate_B",
2160 "endorsement_weight": 78.0,
2161 "stance": "agree",
2162 "claim_assessments": [
2163 {"claim_id": "B1", "claim_text": "36% equity, beta 0.43, max dd <8%", "verdict": "verified", "reason": "Results consistent."},
2164 ],
2165 "disagreements": [],
2166 "category_scores": {"correctness": 80, "completeness": 78, "novelty": 85, "feasibility": 80, "evidence_quality": 78}
2167 }]
2168 });
2169
2170 #[derive(Debug, serde::Deserialize)]
2174 #[allow(dead_code)]
2175 struct Batch {
2176 evaluations: Vec<Item>,
2177 }
2178 #[derive(Debug, serde::Deserialize)]
2179 #[allow(dead_code)]
2180 struct Item {
2181 #[serde(alias = "candidate_id")]
2182 agent_id: String,
2183 endorsement_weight: f32,
2184 #[serde(default)]
2185 claim_assessments: Vec<ClaimAssessment>,
2186 #[serde(default)]
2187 disagreements: Vec<DisagreementPoint>,
2188 }
2189
2190 let resp: Batch = serde_json::from_value(json).unwrap();
2191 assert_eq!(resp.evaluations.len(), 2);
2192 assert_eq!(resp.evaluations[0].agent_id, "Candidate_C");
2193 assert_eq!(resp.evaluations[0].claim_assessments.len(), 2);
2194 assert_eq!(resp.evaluations[0].disagreements.len(), 1);
2195 assert_eq!(
2196 resp.evaluations[0].disagreements[0].evaluator_position,
2197 "Equity at 36% breaches beta cap."
2198 );
2199 assert_eq!(resp.evaluations[1].agent_id, "Candidate_B");
2200 assert_eq!(resp.evaluations[1].endorsement_weight, 78.0);
2201 }
2202
2203 #[test]
2208 fn agent_context_serde_roundtrip() {
2209 let ctx = AgentContext {
2210 task_description: "Solve the halting problem".to_string(),
2211 round_number: 3,
2212 total_rounds: 5,
2213 phase: DeliberationPhase::Evaluating,
2214 target_proposal: Some(Proposal {
2215 thought_process: "Think hard".to_string(),
2216 content: "My proposal".to_string(),
2217 final_scratchpad: Some("notes".to_string()),
2218 token_usage_stats: Some(TokenUsage {
2219 input_tokens: 100,
2220 output_tokens: 50,
2221 }),
2222 ..Default::default()
2223 }),
2224 competitor_summaries: vec!["Agent A did X".to_string(), "Agent B did Y".to_string()],
2225 previous_round_matrix: Some("matrix data".to_string()),
2226 previous_own_proposal: Some(Proposal {
2227 thought_process: "Previous thought".to_string(),
2228 content: "Previous content".to_string(),
2229 final_scratchpad: None,
2230 token_usage_stats: None,
2231 ..Default::default()
2232 }),
2233 previous_own_score: Some(0.85),
2234 previous_critiques: vec!["Needs more evidence".to_string()],
2235 scratchpad: Some("my scratchpad".to_string()),
2236 store: None, candidates: vec![CandidateProposal {
2238 id: "c1".to_string(),
2239 proposal: Proposal {
2240 thought_process: "candidate thought".to_string(),
2241 content: "candidate content".to_string(),
2242 final_scratchpad: None,
2243 token_usage_stats: None,
2244 ..Default::default()
2245 },
2246 }],
2247 user_injections: vec![UserInjection {
2248 message: "Focus on feasibility".to_string(),
2249 injected_at_round: 2,
2250 timestamp: 1700000000,
2251 priority: InjectionPriority::Urgent,
2252 tool_changes: None,
2253 }],
2254 user_tools: vec![UserToolDefinition {
2255 name: "dm_user".to_string(),
2256 description: "Send a DM".to_string(),
2257 parameters: Some(serde_json::json!({"type": "object", "properties": {}})),
2258 strict: Some(true),
2259 }],
2260 phase_budget_remaining_secs: 42.5,
2261 session_id: Some("sess-123".to_string()),
2262 conversation_id: None,
2263 new_turn: None,
2264 structured_feedback: Some(StructuredFeedback {
2265 contested_claims: vec![],
2266 verified_claims: vec!["claim A".to_string()],
2267 mean_stance: 0.5,
2268 evaluator_count: 2,
2269 category_breakdown: None,
2270 }),
2271 forced_proposal_schema: None,
2272 working_dir_override: None,
2273 user_tool_handler: None, role: Some("security".to_string()),
2275 role_context: Some("Per-role context content".to_string()),
2276 telemetry: None, submission_validator: None, event_store: None, agent_id: String::new(),
2280 task_publish_ts: Some(1_776_790_000_000),
2281 };
2282
2283 let json = serde_json::to_string(&ctx).unwrap();
2284 let deserialized: AgentContext = serde_json::from_str(&json).unwrap();
2285
2286 assert_eq!(deserialized.task_description, "Solve the halting problem");
2287 assert_eq!(deserialized.round_number, 3);
2288 assert_eq!(deserialized.total_rounds, 5);
2289 assert_eq!(deserialized.phase, DeliberationPhase::Evaluating);
2290 assert!(deserialized.target_proposal.is_some());
2291 assert_eq!(
2292 deserialized.target_proposal.as_ref().unwrap().content,
2293 "My proposal"
2294 );
2295 assert_eq!(deserialized.competitor_summaries.len(), 2);
2296 assert_eq!(
2297 deserialized.previous_round_matrix,
2298 Some("matrix data".to_string())
2299 );
2300 assert!(deserialized.previous_own_proposal.is_some());
2301 assert!((deserialized.previous_own_score.unwrap() - 0.85).abs() < f32::EPSILON);
2302 assert_eq!(deserialized.previous_critiques.len(), 1);
2303 assert_eq!(deserialized.scratchpad, Some("my scratchpad".to_string()));
2304 assert_eq!(deserialized.candidates.len(), 1);
2305 assert_eq!(deserialized.user_injections.len(), 1);
2306 assert_eq!(deserialized.user_tools.len(), 1);
2307 assert!((deserialized.phase_budget_remaining_secs - 42.5).abs() < f64::EPSILON);
2308 assert_eq!(deserialized.session_id, Some("sess-123".to_string()));
2309 assert!(deserialized.structured_feedback.is_some());
2310 assert_eq!(
2311 deserialized
2312 .structured_feedback
2313 .as_ref()
2314 .unwrap()
2315 .evaluator_count,
2316 2
2317 );
2318 assert!(deserialized.store.is_none());
2320 assert!(deserialized.user_tool_handler.is_none());
2321 assert_eq!(deserialized.role, Some("security".to_string()));
2323 assert_eq!(
2324 deserialized.role_context,
2325 Some("Per-role context content".to_string())
2326 );
2327 assert_eq!(deserialized.task_publish_ts, Some(1_776_790_000_000));
2328 }
2329
2330 #[test]
2331 fn agent_context_with_defaults() {
2332 let json = r#"{
2336 "task_description": "",
2337 "round_number": 0,
2338 "total_rounds": 0,
2339 "phase": "Proposing",
2340 "target_proposal": null,
2341 "competitor_summaries": [],
2342 "previous_round_matrix": null,
2343 "previous_own_proposal": null,
2344 "previous_own_score": null,
2345 "previous_critiques": [],
2346 "scratchpad": null
2347 }"#;
2348 let ctx: AgentContext = serde_json::from_str(json).unwrap();
2349 assert_eq!(ctx.task_description, "");
2350 assert_eq!(ctx.round_number, 0);
2351 assert_eq!(ctx.total_rounds, 0);
2352 assert_eq!(ctx.phase, DeliberationPhase::Proposing);
2353 assert!(ctx.target_proposal.is_none());
2354 assert!(ctx.competitor_summaries.is_empty());
2355 assert!(ctx.previous_round_matrix.is_none());
2356 assert!(ctx.previous_own_proposal.is_none());
2357 assert!(ctx.previous_own_score.is_none());
2358 assert!(ctx.previous_critiques.is_empty());
2359 assert!(ctx.scratchpad.is_none());
2360 assert!(ctx.store.is_none());
2361 assert!(ctx.candidates.is_empty());
2363 assert!(ctx.user_injections.is_empty());
2364 assert!(ctx.user_tools.is_empty());
2365 assert!((ctx.phase_budget_remaining_secs - 0.0).abs() < f64::EPSILON);
2366 assert!(ctx.session_id.is_none());
2367 assert!(ctx.structured_feedback.is_none());
2368 assert!(ctx.user_tool_handler.is_none());
2369 assert!(ctx.task_publish_ts.is_none());
2370 }
2371
2372 #[test]
2377 fn candidate_proposal_serde_roundtrip() {
2378 let cp = CandidateProposal {
2379 id: "agent-42".to_string(),
2380 proposal: Proposal {
2381 thought_process: "I considered many options".to_string(),
2382 content: "Use approach X".to_string(),
2383 final_scratchpad: Some("final notes".to_string()),
2384 token_usage_stats: Some(TokenUsage {
2385 input_tokens: 200,
2386 output_tokens: 80,
2387 }),
2388 ..Default::default()
2389 },
2390 };
2391 let json = serde_json::to_string(&cp).unwrap();
2392 let deserialized: CandidateProposal = serde_json::from_str(&json).unwrap();
2393 assert_eq!(deserialized.id, "agent-42");
2394 assert_eq!(deserialized.proposal.content, "Use approach X");
2395 assert_eq!(
2396 deserialized.proposal.thought_process,
2397 "I considered many options"
2398 );
2399 assert_eq!(
2400 deserialized.proposal.final_scratchpad,
2401 Some("final notes".to_string())
2402 );
2403 assert_eq!(
2404 deserialized
2405 .proposal
2406 .token_usage_stats
2407 .as_ref()
2408 .unwrap()
2409 .input_tokens,
2410 200
2411 );
2412 }
2413
2414 #[test]
2419 fn proposal_with_all_fields_roundtrip() {
2420 let p = Proposal {
2421 thought_process: "Deep analysis".to_string(),
2422 content: "The solution is 42".to_string(),
2423 final_scratchpad: Some("scratch notes".to_string()),
2424 token_usage_stats: Some(TokenUsage {
2425 input_tokens: 500,
2426 output_tokens: 150,
2427 }),
2428 ..Default::default()
2429 };
2430 let json = serde_json::to_string(&p).unwrap();
2431 let deserialized: Proposal = serde_json::from_str(&json).unwrap();
2432 assert_eq!(deserialized.thought_process, "Deep analysis");
2433 assert_eq!(deserialized.content, "The solution is 42");
2434 assert_eq!(
2435 deserialized.final_scratchpad,
2436 Some("scratch notes".to_string())
2437 );
2438 let tu = deserialized.token_usage_stats.unwrap();
2439 assert_eq!(tu.input_tokens, 500);
2440 assert_eq!(tu.output_tokens, 150);
2441 }
2442
2443 #[test]
2444 fn proposal_defaults_and_skip_serializing() {
2445 let p = Proposal::default();
2446 assert_eq!(p.thought_process, "");
2447 assert_eq!(p.content, "");
2448 assert!(p.final_scratchpad.is_none());
2449 assert!(p.token_usage_stats.is_none());
2450 assert_eq!(p.published_at_ms, 0);
2451
2452 let json = serde_json::to_string(&p).unwrap();
2453 let val: serde_json::Value = serde_json::from_str(&json).unwrap();
2454 assert!(val.get("final_scratchpad").is_none());
2456 assert!(val.get("token_usage_stats").is_none());
2457 }
2458
2459 #[test]
2463 fn proposal_published_at_ms_defaults_to_zero_for_legacy_payload() {
2464 let legacy = r#"{"thought_process":"old","content":"old"}"#;
2465 let p: Proposal = serde_json::from_str(legacy).unwrap();
2466 assert_eq!(p.published_at_ms, 0);
2467 }
2468
2469 #[test]
2473 fn proposal_published_at_ms_roundtrips_nonzero() {
2474 let p = Proposal {
2475 published_at_ms: 1_776_790_692_747,
2476 ..Default::default()
2477 };
2478 let json = serde_json::to_string(&p).unwrap();
2479 assert!(json.contains("\"published_at_ms\":1776790692747"));
2480 let back: Proposal = serde_json::from_str(&json).unwrap();
2481 assert_eq!(back.published_at_ms, 1_776_790_692_747);
2482 }
2483
2484 #[test]
2486 fn evaluation_published_at_ms_defaults_to_zero_for_legacy_payload() {
2487 let legacy = r#"{"score":0.5,"justification":"old"}"#;
2488 let e: Evaluation = serde_json::from_str(legacy).unwrap();
2489 assert_eq!(e.published_at_ms, 0);
2490 }
2491
2492 #[test]
2493 fn evaluation_published_at_ms_roundtrips_nonzero() {
2494 let e = Evaluation {
2495 published_at_ms: 1_776_790_692_999,
2496 ..Default::default()
2497 };
2498 let json = serde_json::to_string(&e).unwrap();
2499 assert!(json.contains("\"published_at_ms\":1776790692999"));
2500 let back: Evaluation = serde_json::from_str(&json).unwrap();
2501 assert_eq!(back.published_at_ms, 1_776_790_692_999);
2502 }
2503
2504 #[test]
2509 fn token_usage_serde_and_defaults() {
2510 let tu = TokenUsage {
2511 input_tokens: 1234,
2512 output_tokens: 567,
2513 };
2514 let json = serde_json::to_string(&tu).unwrap();
2515 let deserialized: TokenUsage = serde_json::from_str(&json).unwrap();
2516 assert_eq!(deserialized.input_tokens, 1234);
2517 assert_eq!(deserialized.output_tokens, 567);
2518
2519 let default_tu = TokenUsage::default();
2520 assert_eq!(default_tu.input_tokens, 0);
2521 assert_eq!(default_tu.output_tokens, 0);
2522 }
2523
2524 #[test]
2529 fn heuristic_estimator_default_chars_per_token() {
2530 let estimator = HeuristicTokenEstimator::default();
2531 assert!((estimator.chars_per_token - 4.0).abs() < f64::EPSILON);
2532 }
2533
2534 #[test]
2535 fn heuristic_estimator_empty_string() {
2536 let estimator = HeuristicTokenEstimator::default();
2537 assert_eq!(estimator.estimate_tokens(""), 0);
2538 }
2539
2540 #[test]
2541 fn heuristic_estimator_ascii_text() {
2542 let estimator = HeuristicTokenEstimator::default();
2543 assert_eq!(estimator.estimate_tokens("hello world"), 3);
2545 }
2546
2547 #[test]
2548 fn heuristic_estimator_cjk_text() {
2549 let estimator = HeuristicTokenEstimator::default();
2550 assert_eq!(estimator.estimate_tokens("你好世界"), 1);
2552 }
2553
2554 #[test]
2555 fn heuristic_estimator_emoji() {
2556 let estimator = HeuristicTokenEstimator::default();
2557 assert_eq!(estimator.estimate_tokens("\u{1F389}\u{1F38A}"), 1);
2559 }
2560
2561 #[test]
2562 fn heuristic_estimator_custom_chars_per_token() {
2563 let estimator = HeuristicTokenEstimator {
2564 chars_per_token: 1.5,
2565 };
2566 assert_eq!(estimator.estimate_tokens("hello"), 4);
2568 }
2569
2570 #[test]
2571 fn heuristic_estimator_zero_chars_per_token() {
2572 let estimator = HeuristicTokenEstimator {
2573 chars_per_token: 0.0,
2574 };
2575 assert_eq!(estimator.estimate_tokens("hello"), 0);
2576 }
2577
2578 #[test]
2579 fn heuristic_estimator_negative_chars_per_token() {
2580 let estimator = HeuristicTokenEstimator {
2581 chars_per_token: -2.0,
2582 };
2583 assert_eq!(estimator.estimate_tokens("hello"), 0);
2584 }
2585
2586 #[test]
2591 fn pricing_zero_tokens() {
2592 let pricing = AgentPricingInfo {
2593 input_price_per_mtok: 10.0,
2594 output_price_per_mtok: 30.0,
2595 };
2596 assert!((pricing.compute_cost(0, 0) - 0.0).abs() < f64::EPSILON);
2597 }
2598
2599 #[test]
2600 fn pricing_standard_calculation() {
2601 let pricing = AgentPricingInfo {
2602 input_price_per_mtok: 10.0,
2603 output_price_per_mtok: 30.0,
2604 };
2605 let cost = pricing.compute_cost(1000, 500);
2607 assert!((cost - 0.025).abs() < 1e-10);
2608 }
2609
2610 #[test]
2611 fn pricing_zero_prices() {
2612 let pricing = AgentPricingInfo {
2613 input_price_per_mtok: 0.0,
2614 output_price_per_mtok: 0.0,
2615 };
2616 assert!((pricing.compute_cost(1000, 500) - 0.0).abs() < f64::EPSILON);
2617 }
2618
2619 #[test]
2620 fn pricing_large_token_counts() {
2621 let pricing = AgentPricingInfo {
2622 input_price_per_mtok: 15.0,
2623 output_price_per_mtok: 60.0,
2624 };
2625 let cost = pricing.compute_cost(1_000_000, 500_000);
2627 assert!((cost - 45.0).abs() < 1e-10);
2628 }
2629
2630 #[test]
2631 fn pricing_default_is_zero() {
2632 let pricing = AgentPricingInfo::default();
2633 assert!((pricing.input_price_per_mtok - 0.0).abs() < f64::EPSILON);
2634 assert!((pricing.output_price_per_mtok - 0.0).abs() < f64::EPSILON);
2635 assert!((pricing.compute_cost(1000, 1000) - 0.0).abs() < f64::EPSILON);
2636 }
2637
2638 #[test]
2643 fn qv_from_fraction_full() {
2644 assert!((calculate_qv_from_fraction(1.0) - 1.0).abs() < f32::EPSILON);
2645 }
2646
2647 #[test]
2648 fn qv_from_fraction_quarter() {
2649 assert!((calculate_qv_from_fraction(0.25) - 0.5).abs() < f32::EPSILON);
2651 }
2652
2653 #[test]
2654 fn qv_from_fraction_zero() {
2655 assert!((calculate_qv_from_fraction(0.0) - 0.0).abs() < f32::EPSILON);
2656 }
2657
2658 #[test]
2659 fn qv_from_fraction_clamps_above_one() {
2660 assert!((calculate_qv_from_fraction(2.0) - 1.0).abs() < f32::EPSILON);
2661 }
2662
2663 #[test]
2664 fn qv_from_fraction_full_negative() {
2665 assert!((calculate_qv_from_fraction(-1.0) - (-1.0)).abs() < f32::EPSILON);
2667 }
2668
2669 #[test]
2670 fn qv_from_fraction_negative_quarter() {
2671 assert!((calculate_qv_from_fraction(-0.25) - (-0.5)).abs() < f32::EPSILON);
2673 }
2674
2675 #[test]
2676 fn qv_from_fraction_clamps_below_minus_one() {
2677 assert!((calculate_qv_from_fraction(-2.0) - (-1.0)).abs() < f32::EPSILON);
2679 }
2680
2681 #[test]
2686 fn qv_score_full_weight() {
2687 let (influence, normalized) = calculate_qv_score(100.0, 100.0);
2690 assert!((normalized - 100.0).abs() < f32::EPSILON);
2691 assert!((influence - 1.0).abs() < f32::EPSILON);
2692 }
2693
2694 #[test]
2695 fn qv_score_quarter_weight() {
2696 let (influence, normalized) = calculate_qv_score(25.0, 100.0);
2699 assert!((normalized - 25.0).abs() < f32::EPSILON);
2700 assert!((influence - 0.5).abs() < f32::EPSILON);
2701 }
2702
2703 #[test]
2704 fn qv_score_zero_weight() {
2705 let (influence, normalized) = calculate_qv_score(0.0, 100.0);
2707 assert!((normalized - 0.0).abs() < f32::EPSILON);
2708 assert!((influence - 0.0).abs() < f32::EPSILON);
2709 }
2710
2711 #[test]
2712 fn qv_score_total_equals_raw() {
2713 let (influence, normalized) = calculate_qv_score(50.0, 50.0);
2716 assert!((normalized - 50.0).abs() < f32::EPSILON);
2717 let expected_influence = (50.0f32).sqrt() / 10.0;
2718 assert!((influence - expected_influence).abs() < 1e-6);
2719 }
2720
2721 #[test]
2722 fn qv_score_total_over_100_normalizes() {
2723 let (influence, normalized) = calculate_qv_score(200.0, 200.0);
2726 assert!((normalized - 100.0).abs() < f32::EPSILON);
2727 assert!((influence - 1.0).abs() < f32::EPSILON);
2728 }
2729
2730 #[test]
2731 fn qv_score_raw_exceeds_total_when_total_lte_100() {
2732 let (influence, normalized) = calculate_qv_score(200.0, 100.0);
2735 assert!((normalized - 100.0).abs() < f32::EPSILON);
2736 assert!((influence - 1.0).abs() < f32::EPSILON);
2737 }
2738
2739 #[test]
2740 fn qv_score_negative_raw_clamped() {
2741 let (influence, normalized) = calculate_qv_score(-50.0, 100.0);
2744 assert!((normalized - 0.0).abs() < f32::EPSILON);
2745 assert!((influence - 0.0).abs() < f32::EPSILON);
2746 }
2747
2748 #[test]
2753 fn user_injection_serde_roundtrip() {
2754 let inj = UserInjection {
2755 message: "Please focus on edge cases".to_string(),
2756 injected_at_round: 2,
2757 timestamp: 1700000000,
2758 priority: InjectionPriority::Urgent,
2759 tool_changes: Some(ToolChanges {
2760 add: vec![UserToolDefinition {
2761 name: "new_tool".to_string(),
2762 description: "A new tool".to_string(),
2763 parameters: Some(serde_json::json!({"type": "object"})),
2764 strict: None,
2765 }],
2766 remove: vec!["old_tool".to_string()],
2767 }),
2768 };
2769 let json = serde_json::to_string(&inj).unwrap();
2770 let deserialized: UserInjection = serde_json::from_str(&json).unwrap();
2771 assert_eq!(deserialized.message, "Please focus on edge cases");
2772 assert_eq!(deserialized.injected_at_round, 2);
2773 assert_eq!(deserialized.timestamp, 1700000000);
2774 assert_eq!(deserialized.priority, InjectionPriority::Urgent);
2775 let tc = deserialized.tool_changes.unwrap();
2776 assert_eq!(tc.add.len(), 1);
2777 assert_eq!(tc.add[0].name, "new_tool");
2778 assert_eq!(tc.remove, vec!["old_tool"]);
2779 }
2780
2781 #[test]
2786 fn agent_heartbeat_serde_roundtrip_all_fields() {
2787 let hb = AgentHeartbeat {
2788 agent_id: "agent-1".to_string(),
2789 status: AgentLiveStatus::Busy,
2790 model_name: "gpt-4".to_string(),
2791 provider_id: "openai".to_string(),
2792 current_job: Some("job-42".to_string()),
2793 uptime_secs: 3600,
2794 timestamp: "2025-01-01T00:00:00Z".to_string(),
2795 input_price_per_mtok: Some(10.0),
2796 output_price_per_mtok: Some(30.0),
2797 chars_per_token: Some(3.5),
2798 response_sla_secs: Some(120),
2799 temperature: Some(0.7),
2800 frequency_penalty: Some(0.1),
2801 presence_penalty: Some(0.2),
2802 max_tokens: Some(4096),
2803 context_window: Some(128000),
2804 tasks_completed: 50,
2805 tasks_failed: 2,
2806 last_error: Some("timeout".to_string()),
2807 capability_tags: vec!["legal".to_string(), "audit".to_string()],
2808 description: Some("Legal specialist".to_string()),
2809 signing_schemes: vec!["eip712".to_string()],
2810 model_down: true,
2811 health: compute_agent_health(true, false),
2812 };
2813 let json = serde_json::to_string(&hb).unwrap();
2814 let deserialized: AgentHeartbeat = serde_json::from_str(&json).unwrap();
2815 assert!(deserialized.model_down, "model_down round-trips");
2816 assert_eq!(
2817 deserialized.health.state,
2818 AgentHealthState::Down,
2819 "health round-trips"
2820 );
2821 assert_eq!(deserialized.agent_id, "agent-1");
2822 assert_eq!(deserialized.status, AgentLiveStatus::Busy);
2823 assert_eq!(deserialized.model_name, "gpt-4");
2824 assert_eq!(deserialized.provider_id, "openai");
2825 assert_eq!(deserialized.current_job, Some("job-42".to_string()));
2826 assert_eq!(deserialized.uptime_secs, 3600);
2827 assert!((deserialized.input_price_per_mtok.unwrap() - 10.0).abs() < f64::EPSILON);
2828 assert!((deserialized.output_price_per_mtok.unwrap() - 30.0).abs() < f64::EPSILON);
2829 assert!((deserialized.chars_per_token.unwrap() - 3.5).abs() < f64::EPSILON);
2830 assert_eq!(deserialized.response_sla_secs, Some(120));
2831 assert!((deserialized.temperature.unwrap() - 0.7).abs() < f32::EPSILON);
2832 assert!((deserialized.frequency_penalty.unwrap() - 0.1).abs() < f32::EPSILON);
2833 assert!((deserialized.presence_penalty.unwrap() - 0.2).abs() < f32::EPSILON);
2834 assert_eq!(deserialized.max_tokens, Some(4096));
2835 assert_eq!(deserialized.context_window, Some(128000));
2836 assert_eq!(deserialized.tasks_completed, 50);
2837 assert_eq!(deserialized.tasks_failed, 2);
2838 assert_eq!(deserialized.last_error, Some("timeout".to_string()));
2839 assert_eq!(deserialized.capability_tags, vec!["legal", "audit"]);
2841 assert_eq!(
2842 deserialized.description.as_deref(),
2843 Some("Legal specialist")
2844 );
2845 assert_eq!(deserialized.signing_schemes, vec!["eip712"]);
2846 }
2847
2848 #[test]
2849 fn agent_heartbeat_missing_model_down_defaults_to_up() {
2850 let json = r#"{
2854 "agent_id": "legacy",
2855 "status": "idle",
2856 "model_name": "gpt-4",
2857 "provider_id": "openai",
2858 "uptime_secs": 10,
2859 "timestamp": "2025-01-01T00:00:00Z"
2860 }"#;
2861 let hb: AgentHeartbeat = serde_json::from_str(json).unwrap();
2862 assert!(
2863 !hb.model_down,
2864 "an omitted model_down must default to false (up)"
2865 );
2866 }
2867
2868 #[test]
2869 fn agent_heartbeat_skip_serializing_none_fields() {
2870 let hb = AgentHeartbeat::default();
2871 let json = serde_json::to_string(&hb).unwrap();
2872 let val: serde_json::Value = serde_json::from_str(&json).unwrap();
2873 assert!(val.get("current_job").is_none());
2875 assert!(val.get("input_price_per_mtok").is_none());
2876 assert!(val.get("output_price_per_mtok").is_none());
2877 assert!(val.get("chars_per_token").is_none());
2878 assert!(val.get("temperature").is_none());
2879 assert!(val.get("frequency_penalty").is_none());
2880 assert!(val.get("presence_penalty").is_none());
2881 assert!(val.get("max_tokens").is_none());
2882 assert!(val.get("context_window").is_none());
2883 assert!(val.get("last_error").is_none());
2884 assert!(val.get("response_sla_secs").is_none());
2885 }
2886
2887 #[test]
2892 fn orchestrator_ping_serde_roundtrip() {
2893 let ping = OrchestratorPing {
2894 orchestrator_id: "orch-1".to_string(),
2895 timestamp: "2025-06-01T12:00:00Z".to_string(),
2896 uptime_secs: 7200,
2897 };
2898 let json = serde_json::to_string(&ping).unwrap();
2899 let deserialized: OrchestratorPing = serde_json::from_str(&json).unwrap();
2900 assert_eq!(deserialized.orchestrator_id, "orch-1");
2901 assert_eq!(deserialized.timestamp, "2025-06-01T12:00:00Z");
2902 assert_eq!(deserialized.uptime_secs, 7200);
2903 }
2904
2905 #[test]
2910 fn pending_tool_call_serde_roundtrip() {
2911 let ptc = PendingToolCall {
2912 call_id: "call-abc".to_string(),
2913 job_id: "job-xyz".to_string(),
2914 agent_id: "agent-1".to_string(),
2915 tool_name: "user_dm_user".to_string(),
2916 arguments: serde_json::json!({"message": "hello"}),
2917 round: 2,
2918 phase: DeliberationPhase::Proposing,
2919 status: ToolCallStatus::Pending,
2920 created_at: 1700000000000,
2921 responded_at: None,
2922 result: None,
2923 };
2924 let json = serde_json::to_string(&ptc).unwrap();
2925 let deserialized: PendingToolCall = serde_json::from_str(&json).unwrap();
2926 assert_eq!(deserialized.call_id, "call-abc");
2927 assert_eq!(deserialized.job_id, "job-xyz");
2928 assert_eq!(deserialized.agent_id, "agent-1");
2929 assert_eq!(deserialized.tool_name, "user_dm_user");
2930 assert_eq!(deserialized.arguments["message"], "hello");
2931 assert_eq!(deserialized.round, 2);
2932 assert_eq!(deserialized.phase, DeliberationPhase::Proposing);
2933 assert_eq!(deserialized.status, ToolCallStatus::Pending);
2934 assert_eq!(deserialized.created_at, 1700000000000);
2935 assert!(deserialized.responded_at.is_none());
2936 assert!(deserialized.result.is_none());
2937
2938 let ptc_responded = PendingToolCall {
2940 call_id: "call-def".to_string(),
2941 job_id: "job-xyz".to_string(),
2942 agent_id: "agent-2".to_string(),
2943 tool_name: "user_read_file".to_string(),
2944 arguments: serde_json::json!({"path": "/tmp/test"}),
2945 round: 1,
2946 phase: DeliberationPhase::Evaluating,
2947 status: ToolCallStatus::Responded,
2948 created_at: 1700000000000,
2949 responded_at: Some(1700000001000),
2950 result: Some("file contents here".to_string()),
2951 };
2952 let json2 = serde_json::to_string(&ptc_responded).unwrap();
2953 let des2: PendingToolCall = serde_json::from_str(&json2).unwrap();
2954 assert_eq!(des2.status, ToolCallStatus::Responded);
2955 assert_eq!(des2.responded_at, Some(1700000001000));
2956 assert_eq!(des2.result, Some("file contents here".to_string()));
2957 }
2958
2959 #[test]
2964 fn tool_call_status_serde_all_variants() {
2965 for (variant, expected_default) in [
2966 (ToolCallStatus::Pending, true),
2967 (ToolCallStatus::Responded, false),
2968 (ToolCallStatus::Expired, false),
2969 ] {
2970 let json = serde_json::to_string(&variant).unwrap();
2971 let deserialized: ToolCallStatus = serde_json::from_str(&json).unwrap();
2972 assert_eq!(deserialized, variant);
2973 if expected_default {
2974 assert_eq!(ToolCallStatus::default(), variant);
2975 }
2976 }
2977 }
2978
2979 #[test]
2984 fn agent_live_status_serde() {
2985 let idle_json = serde_json::to_string(&AgentLiveStatus::Idle).unwrap();
2987 assert_eq!(idle_json, "\"idle\"");
2988 let idle: AgentLiveStatus = serde_json::from_str(&idle_json).unwrap();
2989 assert_eq!(idle, AgentLiveStatus::Idle);
2990
2991 let busy_json = serde_json::to_string(&AgentLiveStatus::Busy).unwrap();
2993 assert_eq!(busy_json, "\"busy\"");
2994 let busy: AgentLiveStatus = serde_json::from_str(&busy_json).unwrap();
2995 assert_eq!(busy, AgentLiveStatus::Busy);
2996
2997 assert_eq!(AgentLiveStatus::default(), AgentLiveStatus::Idle);
2999 }
3000
3001 #[test]
3006 fn deliberation_phase_serde_all_variants() {
3007 let variants = [
3008 DeliberationPhase::Proposing,
3009 DeliberationPhase::Evaluating,
3010 DeliberationPhase::ConsensusCheck,
3011 ];
3012 for variant in variants {
3013 let json = serde_json::to_string(&variant).unwrap();
3014 let deserialized: DeliberationPhase = serde_json::from_str(&json).unwrap();
3015 assert_eq!(deserialized, variant);
3016 }
3017 }
3018
3019 #[test]
3020 fn deliberation_phase_default() {
3021 assert_eq!(DeliberationPhase::default(), DeliberationPhase::Proposing);
3022 }
3023
3024 #[test]
3029 fn user_tool_definition_with_parameters() {
3030 let tool = UserToolDefinition {
3031 name: "search_db".to_string(),
3032 description: "Search the database".to_string(),
3033 parameters: Some(serde_json::json!({
3034 "type": "object",
3035 "properties": {
3036 "query": { "type": "string" },
3037 "limit": { "type": "integer" }
3038 },
3039 "required": ["query"]
3040 })),
3041 strict: Some(true),
3042 };
3043 let json = serde_json::to_string(&tool).unwrap();
3044 let deserialized: UserToolDefinition = serde_json::from_str(&json).unwrap();
3045 assert_eq!(deserialized.name, "search_db");
3046 assert_eq!(deserialized.description, "Search the database");
3047 assert!(deserialized.parameters.is_some());
3048 let params = deserialized.parameters.unwrap();
3049 assert_eq!(params["type"], "object");
3050 assert_eq!(params["properties"]["query"]["type"], "string");
3051 assert_eq!(deserialized.strict, Some(true));
3052 }
3053
3054 #[test]
3055 fn user_tool_definition_without_parameters() {
3056 let tool = UserToolDefinition {
3057 name: "ping".to_string(),
3058 description: "Ping the server".to_string(),
3059 parameters: None,
3060 strict: None,
3061 };
3062 let json = serde_json::to_string(&tool).unwrap();
3063 let val: serde_json::Value = serde_json::from_str(&json).unwrap();
3064 assert!(val.get("parameters").is_none());
3066 assert!(val.get("strict").is_none());
3067
3068 let deserialized: UserToolDefinition = serde_json::from_str(&json).unwrap();
3069 assert_eq!(deserialized.name, "ping");
3070 assert!(deserialized.parameters.is_none());
3071 assert!(deserialized.strict.is_none());
3072 }
3073
3074 #[test]
3077 fn test_annotation_type_serde_roundtrip() {
3078 for variant in [AnnotationType::Comment, AnnotationType::Edit] {
3079 let json = serde_json::to_string(&variant).unwrap();
3080 let roundtripped: AnnotationType = serde_json::from_str(&json).unwrap();
3081 assert_eq!(variant, roundtripped);
3082 }
3083 assert_eq!(
3085 serde_json::to_string(&AnnotationType::Comment).unwrap(),
3086 "\"comment\""
3087 );
3088 assert_eq!(
3089 serde_json::to_string(&AnnotationType::Edit).unwrap(),
3090 "\"edit\""
3091 );
3092 }
3093
3094 #[test]
3095 fn test_operator_annotation_serde_roundtrip() {
3096 let annotation = OperatorAnnotation {
3097 annotation_type: AnnotationType::Edit,
3098 comment: "Fixed factual error in claim 3".to_string(),
3099 timestamp: "2026-03-07T12:00:00Z".to_string(),
3100 original_content_hash: Some("abc123def456".to_string()),
3101 };
3102 let json = serde_json::to_value(&annotation).unwrap();
3103 let roundtripped: OperatorAnnotation = serde_json::from_value(json).unwrap();
3104 assert_eq!(annotation, roundtripped);
3105 }
3106
3107 #[test]
3108 fn test_operator_annotation_skip_none_hash() {
3109 let annotation = OperatorAnnotation {
3110 annotation_type: AnnotationType::Comment,
3111 comment: "Looks good".to_string(),
3112 timestamp: "2026-03-07T12:00:00Z".to_string(),
3113 original_content_hash: None,
3114 };
3115 let json = serde_json::to_value(&annotation).unwrap();
3116 assert!(
3117 json.get("original_content_hash").is_none(),
3118 "None hash should be skipped"
3119 );
3120 let roundtripped: OperatorAnnotation = serde_json::from_value(json).unwrap();
3121 assert_eq!(annotation, roundtripped);
3122 }
3123
3124 #[test]
3125 fn test_proposal_operator_annotations_roundtrip() {
3126 let proposal = Proposal {
3127 thought_process: "thinking".to_string(),
3128 content: "solution".to_string(),
3129 edited_by: Some("operator".to_string()),
3130 operator_annotations: vec![
3131 OperatorAnnotation {
3132 annotation_type: AnnotationType::Edit,
3133 comment: "Rewrote conclusion".to_string(),
3134 timestamp: "2026-03-07T12:00:00Z".to_string(),
3135 original_content_hash: Some("deadbeef".to_string()),
3136 },
3137 OperatorAnnotation {
3138 annotation_type: AnnotationType::Comment,
3139 comment: "Approved after edit".to_string(),
3140 timestamp: "2026-03-07T12:01:00Z".to_string(),
3141 original_content_hash: None,
3142 },
3143 ],
3144 ..Default::default()
3145 };
3146
3147 let json = serde_json::to_value(&proposal).unwrap();
3148 assert_eq!(json["edited_by"], "operator");
3149 assert_eq!(json["operator_annotations"].as_array().unwrap().len(), 2);
3150
3151 let roundtripped: Proposal = serde_json::from_value(json).unwrap();
3152 assert_eq!(roundtripped.edited_by, Some("operator".to_string()));
3153 assert_eq!(roundtripped.operator_annotations.len(), 2);
3154 assert_eq!(
3155 roundtripped.operator_annotations[0].annotation_type,
3156 AnnotationType::Edit
3157 );
3158 }
3159
3160 #[test]
3161 fn test_proposal_without_annotations_skips_fields() {
3162 let proposal = Proposal::default();
3163 let json = serde_json::to_value(&proposal).unwrap();
3164 assert!(
3165 json.get("operator_annotations").is_none(),
3166 "empty vec should be skipped"
3167 );
3168 assert!(
3169 json.get("edited_by").is_none(),
3170 "None edited_by should be skipped"
3171 );
3172 }
3173
3174 #[test]
3175 fn test_evaluation_operator_annotations_roundtrip() {
3176 let eval = Evaluation {
3177 justification: "Good proposal".to_string(),
3178 score: 0.85,
3179 edited_by: Some("operator".to_string()),
3180 operator_annotations: vec![OperatorAnnotation {
3181 annotation_type: AnnotationType::Comment,
3182 comment: "Score adjusted after review".to_string(),
3183 timestamp: "2026-03-07T14:00:00Z".to_string(),
3184 original_content_hash: None,
3185 }],
3186 ..Default::default()
3187 };
3188
3189 let json = serde_json::to_value(&eval).unwrap();
3190 let roundtripped: Evaluation = serde_json::from_value(json).unwrap();
3191 assert_eq!(roundtripped.edited_by, Some("operator".to_string()));
3192 assert_eq!(roundtripped.operator_annotations.len(), 1);
3193 assert_eq!(
3194 roundtripped.operator_annotations[0].comment,
3195 "Score adjusted after review"
3196 );
3197 }
3198
3199 #[test]
3202 fn test_edit_annotation_with_hash_validates() {
3203 let annotation = OperatorAnnotation {
3204 annotation_type: AnnotationType::Edit,
3205 comment: "Fixed error".to_string(),
3206 timestamp: "2026-03-11T00:00:00Z".to_string(),
3207 original_content_hash: Some("abc123".to_string()),
3208 };
3209 assert!(annotation.validate().is_ok());
3210 }
3211
3212 #[test]
3213 fn test_edit_annotation_without_hash_fails() {
3214 let annotation = OperatorAnnotation {
3215 annotation_type: AnnotationType::Edit,
3216 comment: "Fixed error".to_string(),
3217 timestamp: "2026-03-11T00:00:00Z".to_string(),
3218 original_content_hash: None,
3219 };
3220 let err = annotation.validate().unwrap_err();
3221 assert!(err.contains("original_content_hash"));
3222 }
3223
3224 #[test]
3225 fn test_edit_annotation_with_empty_hash_fails() {
3226 let annotation = OperatorAnnotation {
3227 annotation_type: AnnotationType::Edit,
3228 comment: "Fixed error".to_string(),
3229 timestamp: "2026-03-11T00:00:00Z".to_string(),
3230 original_content_hash: Some(String::new()),
3231 };
3232 assert!(annotation.validate().is_err());
3233 }
3234
3235 #[test]
3236 fn test_comment_annotation_without_hash_validates() {
3237 let annotation = OperatorAnnotation {
3238 annotation_type: AnnotationType::Comment,
3239 comment: "Looks good".to_string(),
3240 timestamp: "2026-03-11T00:00:00Z".to_string(),
3241 original_content_hash: None,
3242 };
3243 assert!(annotation.validate().is_ok());
3244 }
3245
3246 #[test]
3247 fn test_deserialized_edit_without_hash_still_deserializes() {
3248 let json = serde_json::json!({
3250 "annotation_type": "edit",
3251 "comment": "old data",
3252 "timestamp": "2026-01-01T00:00:00Z"
3253 });
3254 let annotation: OperatorAnnotation = serde_json::from_value(json).unwrap();
3255 assert_eq!(annotation.annotation_type, AnnotationType::Edit);
3256 assert!(annotation.original_content_hash.is_none());
3257 assert!(annotation.validate().is_err());
3259 }
3260
3261 #[test]
3266 fn normalize_score_identity_when_total_is_one() {
3267 assert!((normalize_score(0.8, 1.0) - 0.8).abs() < f32::EPSILON);
3268 }
3269
3270 #[test]
3271 fn normalize_score_divides_by_total() {
3272 let result = normalize_score(0.8, 100.0);
3273 assert!((result - 0.008).abs() < f32::EPSILON);
3274 }
3275
3276 #[test]
3277 fn normalize_score_clamps_above_one() {
3278 assert!((normalize_score(2.0, 1.0) - 1.0).abs() < f32::EPSILON);
3279 }
3280
3281 #[test]
3282 fn normalize_score_preserves_negative() {
3283 assert!((normalize_score(-1.0, 1.0) - (-1.0)).abs() < f32::EPSILON);
3285 }
3286
3287 #[test]
3288 fn normalize_score_clamps_below_minus_one() {
3289 assert!((normalize_score(-3.0, 1.0) - (-1.0)).abs() < f32::EPSILON);
3291 }
3292
3293 #[test]
3294 fn normalize_score_zero_total_returns_zero() {
3295 assert!((normalize_score(5.0, 0.0) - 0.0).abs() < f32::EPSILON);
3296 }
3297
3298 #[test]
3299 fn normalize_score_equal_weights() {
3300 assert!((normalize_score(50.0, 100.0) - 0.5).abs() < f32::EPSILON);
3301 }
3302
3303 #[test]
3304 fn normalize_score_negative_half() {
3305 assert!((normalize_score(-50.0, 100.0) - (-0.5)).abs() < f32::EPSILON);
3307 }
3308
3309 #[test]
3310 fn normalize_score_mixed_sign_total_is_abs_sum() {
3311 assert!((normalize_score(60.0, 100.0) - 0.6).abs() < f32::EPSILON);
3314 assert!((normalize_score(-40.0, 100.0) - (-0.4)).abs() < f32::EPSILON);
3315 }
3316
3317 #[test]
3318 fn normalize_score_non_finite_input_is_zero_not_nan() {
3319 assert_eq!(normalize_score(f32::INFINITY, f32::INFINITY), 0.0);
3323 assert_eq!(normalize_score(f32::INFINITY, 100.0), 0.0);
3324 assert_eq!(normalize_score(f32::NAN, 100.0), 0.0);
3325 assert_eq!(normalize_score(50.0, f32::INFINITY), 0.0);
3326 assert!(normalize_score(f32::INFINITY, f32::INFINITY).is_finite());
3327 }
3328
3329 #[test]
3330 fn qv_from_fraction_non_finite_is_zero() {
3331 assert_eq!(calculate_qv_from_fraction(f32::NAN), 0.0);
3332 assert_eq!(calculate_qv_from_fraction(f32::INFINITY), 0.0);
3333 assert_eq!(calculate_qv_from_fraction(f32::NEG_INFINITY), 0.0);
3334 }
3335
3336 fn ctx_with_session(session_id: Option<&str>) -> AgentContext {
3341 AgentContext {
3342 agent_id: "alice".into(),
3343 session_id: session_id.map(|s| s.to_string()),
3344 round_number: 3,
3345 phase: DeliberationPhase::Evaluating,
3346 ..Default::default()
3347 }
3348 }
3349
3350 #[test]
3351 fn delta_task_uses_new_turn_on_resume_and_is_bounded_vs_thread_length() {
3352 let huge_history = "[user] t1\n[assistant] ...\n".repeat(2000); let mut ctx = ctx_with_session(Some("thread-x"));
3356 ctx.task_description = huge_history.clone();
3357 ctx.new_turn = Some("[user] latest turn".into());
3358 assert_eq!(ctx.delta_task(), "[user] latest turn");
3360 assert!(
3361 !ctx.delta_task().contains("t1"),
3362 "prior turns must not re-send"
3363 );
3364 assert!(
3366 ctx.delta_task().len() < 40,
3367 "delta stays small as the thread grows"
3368 );
3369 ctx.new_turn = None;
3371 assert_eq!(ctx.delta_task(), huge_history);
3372 }
3373
3374 #[test]
3375 fn claude_session_key_prefers_conversation_over_session() {
3376 let mut ctx = ctx_with_session(Some("room-turn-1"));
3378 assert_eq!(ctx.claude_session_key(), Some("room-turn-1"));
3379 ctx.conversation_id = Some("thread-abc".into());
3381 assert_eq!(ctx.claude_session_key(), Some("thread-abc"));
3382 let mut turn2 = ctx_with_session(Some("room-turn-2"));
3384 turn2.conversation_id = Some("thread-abc".into());
3385 assert_eq!(ctx.claude_session_key(), turn2.claude_session_key());
3386 }
3387
3388 #[test]
3392 fn telemetry_for_with_session_populates_envelope() {
3393 let context = ctx_with_session(Some("job-abc"));
3394 let tel = context.telemetry_for();
3395 let common = tel.common();
3396 assert_eq!(common.agent_id, "alice");
3397 assert_eq!(common.job_id.as_deref(), Some("job-abc"));
3398 assert_eq!(common.round, Some(3));
3399 assert_eq!(common.phase, Some(DeliberationPhase::Evaluating));
3400 assert_eq!(common.trace_id.len(), 32);
3402 assert!(common.trace_id.chars().all(|c| c.is_ascii_hexdigit()));
3403 }
3404
3405 #[test]
3408 fn telemetry_for_is_deterministic_on_same_session() {
3409 let context = ctx_with_session(Some("job-abc"));
3410 let a = context.telemetry_for().common().trace_id;
3411 let b = context.telemetry_for().common().trace_id;
3412 assert_eq!(a, b);
3413 }
3414
3415 #[test]
3420 #[should_panic(expected = "session_id")]
3421 fn telemetry_for_panics_without_session() {
3422 let context = ctx_with_session(None);
3423 let _ = context.telemetry_for();
3424 }
3425
3426 #[test]
3429 #[should_panic(expected = "session_id")]
3430 fn telemetry_for_panics_on_empty_session() {
3431 let context = ctx_with_session(Some(""));
3432 let _ = context.telemetry_for();
3433 }
3434}