Skip to main content

quorum_rs/agents/
mod.rs

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};
26// Re-export defaults from config to maintain API compatibility
27pub 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    /// User-defined tool definitions for this job. Empty if none registered.
51    #[serde(default)]
52    pub user_tools: Vec<UserToolDefinition>,
53    /// Remaining phase budget in seconds at the time the task was published.
54    /// The agent uses this as the upper bound for user tool call wait times.
55    #[serde(default)]
56    pub phase_budget_remaining_secs: f64,
57    /// The session/job ID for NATS subject construction within the agent worker.
58    #[serde(default)]
59    pub session_id: Option<String>,
60    /// Stable conversation key for the claude-CLI session, when a job belongs to
61    /// a longer-lived thread whose `session_id`/`room_id` is minted fresh per turn
62    /// (the OpenAI-compat path). The deterministic claude session UUID is keyed on
63    /// this so successive turns of one thread `--resume` the same transcript.
64    /// `None` falls back to `session_id` (the thread-TUI path, where the room *is*
65    /// the stable thread id).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub conversation_id: Option<String>,
68    /// The new turn only (this job's incremental user message), when the thread's
69    /// prior turns already live in the resumed claude session. Used as the delta
70    /// prompt's task on a resumed session so we don't re-send the whole flattened
71    /// `task_description` (which the session already holds). `None` → the delta
72    /// falls back to `task_description` (fresh session / non-thread paths).
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub new_turn: Option<String>,
75    /// Structured feedback from previous round's evaluations (Phase 2 context pipeline).
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub structured_feedback: Option<StructuredFeedback>,
78    /// A JSON schema a `before_prompt` middleware declared for the proposal
79    /// submission. When set, the propose tool's `parameters` are constrained to
80    /// it and the terminal tool call is forced (`tool_choice: required`) — the
81    /// model must return a schema-valid structured proposal. Runtime-only.
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    #[schema(ignore)]
84    #[schemars(skip)]
85    pub forced_proposal_schema: Option<serde_json::Value>,
86    /// A per-task working directory a `before_prompt` middleware declared (via the
87    /// `agent_working_dir` key on its content). When set, the agent subprocess runs
88    /// with cwd = this dir instead of the process launch dir, so relative reads/writes
89    /// land where the middleware prepared them. Overrides the agent's static
90    /// `working_dir`. Runtime-only.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    #[schema(ignore)]
93    #[schemars(skip)]
94    pub working_dir_override: Option<std::path::PathBuf>,
95    /// Runtime-only: handler for user tool calls (injected by agent worker, not serialized).
96    /// The concrete type is supplied via [`UserToolHandlerFactory`](crate::workers::UserToolHandlerFactory);
97    /// this field holds an opaque Arc wrapper.
98    #[serde(skip)]
99    #[schema(ignore)]
100    #[schemars(skip)]
101    pub user_tool_handler: Option<Arc<dyn UserToolHandlerTrait>>,
102    /// Role assigned to this agent by the broker (from policy-based scheduling).
103    /// `None` for static agent list mode or legacy payloads.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub role: Option<String>,
106    /// Per-role private context content (not visible to other agents).
107    /// Populated from the role's `context` files by the orchestrator.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub role_context: Option<String>,
110    /// Identity of the agent processing this task. Populated by the
111    /// dispatcher (orchestrator at construction time, worker after
112    /// deserialize) so the agent and any downstream helpers
113    /// (e.g. [`AgentContext::telemetry_for`]) don't need to thread
114    /// the same id through every call site. `#[serde(default)]` on
115    /// the field tolerates payloads serialized before the field
116    /// existed; populated paths keep the value end-to-end.
117    #[serde(default)]
118    pub agent_id: String,
119    /// Unix ms the orchestrator stamped at publish time; `None` on
120    /// pre-stamping payloads, synthetic contexts, and resurrected
121    /// envelopes. Paired with `task_received` to compute
122    /// `TaskAccepted.job_age_at_accept_ms`.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub task_publish_ts: Option<i64>,
125    /// Runtime-only: per-task telemetry mux populated by the agent
126    /// worker. Agents pass `context.telemetry.as_ref()` into
127    /// `generate_structured_output` / `react_loop` so LLM, tool,
128    /// retry, and prompt-exposure events fan out across every
129    /// configured endpoint under the same `(agent_id, job_id,
130    /// round, phase)` envelope as the worker's task-lifecycle
131    /// events. Mirrors the `store` / `user_tool_handler`
132    /// runtime-only pattern: skipped by serde, excluded from
133    /// generated schemas.
134    #[serde(skip)]
135    #[schema(ignore)]
136    #[schemars(skip)]
137    pub telemetry: Option<crate::telemetry::TelemetryEmitterMux>,
138    /// Runtime-only: validates a `submit_proposal` submission inside the react
139    /// loop. Injected by the agent worker from the `provider_response` middleware
140    /// so a reviewer block (e.g. patch-deliberation "applied ZERO changes") feeds
141    /// the reason back through the SAME retry that handles parse failures — no
142    /// separate retry budget. Mirrors the `user_tool_handler` runtime-only pattern.
143    #[serde(skip)]
144    #[schema(ignore)]
145    #[schemars(skip)]
146    pub submission_validator: Option<Arc<dyn SubmissionValidator>>,
147    /// Runtime-only: the agent's own NATS event log. Injected by the worker so
148    /// the react loop can record tool-call start/finish for the operator
149    /// dashboard's 24h history. Mirrors the `telemetry` runtime-only pattern:
150    /// skipped by serde, excluded from generated schemas.
151    #[serde(skip)]
152    #[schema(ignore)]
153    #[schemars(skip)]
154    pub event_store: Option<crate::status::agent_events::AgentEventStore>,
155}
156
157impl AgentContext {
158    /// The key the deterministic claude-CLI session UUID is derived from:
159    /// `conversation_id` (a thread stable across turns) when set, else
160    /// `session_id` (the per-job/room id). This is what makes successive turns
161    /// of one thread resume the same transcript.
162    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    /// The task text a *resumed* session's delta prompt should carry: the new
169    /// turn only when set (the prior turns already live in the session), else the
170    /// full `task_description` (fresh session / non-thread paths). This is what
171    /// stops a resumed thread from re-sending its whole flattened history.
172    pub fn delta_task(&self) -> &str {
173        self.new_turn.as_deref().unwrap_or(&self.task_description)
174    }
175
176    /// Build a [`TelemetryContext`](crate::telemetry::TelemetryContext)
177    /// for telemetry events emitted while processing this task.
178    ///
179    /// Uses the context's own `agent_id`, `session_id`,
180    /// `round_number`, and `phase`. Pair with [`emit_for!`] at the
181    /// call site:
182    ///
183    /// ```ignore
184    /// emit_for!(context, ToolCallExecuted {
185    ///     tool_name: name, latency_ms: 42, success: true,
186    /// });
187    /// ```
188    ///
189    /// # Panics
190    ///
191    /// `session_id` is a dispatch-time invariant: the orchestrator
192    /// populates it on every published task. Emitting telemetry
193    /// from a context with no session is a programmer error,
194    /// typically a test that constructed an `AgentContext` literal
195    /// without setting `session_id`. Panics with a helpful message
196    /// rather than synthesising a fake job_id that would silently
197    /// break trace correlation across the catalog.
198    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    /// Canonical short tag used in NATS subjects, trace IDs, and prompts.
225    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// ---------------------------------------------------------------------------
235// Operator annotations (HITL traceability)
236// ---------------------------------------------------------------------------
237
238/// The type of operator intervention on a buffered response.
239#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, ToSchema, Default)]
240#[serde(rename_all = "snake_case")]
241pub enum AnnotationType {
242    /// Operator added a comment without modifying the content.
243    #[default]
244    Comment,
245    /// Operator edited the response content (becomes "owner").
246    Edit,
247}
248
249/// A record of operator intervention on an agent response.
250///
251/// Attached to [`Proposal`] and [`Evaluation`] payloads when an operator
252/// inspects, edits, or annotates a buffered response before release.
253/// These annotations provide an audit trail for traceability and transparency.
254///
255/// In the future, `Edit` annotations will trigger a digital signature swap:
256/// the agent's signature is replaced by the operator's higher-order public key.
257#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, ToSchema, Default)]
258pub struct OperatorAnnotation {
259    /// Type of intervention.
260    pub annotation_type: AnnotationType,
261    /// Operator's commentary / rationale.
262    pub comment: String,
263    /// ISO-8601 timestamp of when the annotation was made.
264    pub timestamp: String,
265    /// SHA-256 hash of the original payload before editing (audit trail).
266    /// **Required** for `Edit` annotations — enforced by [`Self::validate()`].
267    #[serde(default, skip_serializing_if = "Option::is_none")]
268    pub original_content_hash: Option<String>,
269}
270
271impl OperatorAnnotation {
272    /// Validate the annotation's invariants.
273    ///
274    /// - `Edit` annotations **must** carry a non-empty `original_content_hash`.
275    /// - `Comment` annotations have no additional requirements.
276    ///
277    /// This is enforced at API boundaries (HITL buffer edit handler), not on
278    /// deserialization, to preserve backward compat with `#[serde(default)]`.
279    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    /// Restore scratchpad field to ensure benchmarks can capture it
298    #[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    /// Operator annotations from HITL review (traceability audit trail).
303    #[serde(default, skip_serializing_if = "Vec::is_empty")]
304    pub operator_annotations: Vec<OperatorAnnotation>,
305    /// Set to `"operator"` when the content was edited by a human operator.
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub edited_by: Option<String>,
308    /// Terminal signal from the agent loop. Values:
309    ///   - `"stop"` / `"tool_calls"` — normal LLM-driven termination.
310    ///   - `"max_iterations"` — graceful ceiling hit (react loop capped
311    ///     out without a terminal tool call); content is whatever the
312    ///     last iteration produced. Lets the orchestrator + dashboard
313    ///     distinguish partial fallbacks from full completions.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub finish_reason: Option<String>,
316    /// Wall-clock instant the agent published this proposal, in
317    /// milliseconds since the Unix epoch. Populated by the agent worker
318    /// at publish time so the orchestrator can compute propagation
319    /// latency for the `submission_received` telemetry event. Old
320    /// payloads without the field deserialize as `0`.
321    #[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// =============================================================================
332// Structured Evaluation Types
333// =============================================================================
334
335/// Verdict for a specific claim within a proposal.
336/// JSON Schema `enum` enables logit-biasing in strict mode inference.
337#[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/// Evaluator's overall position toward a proposal.
349#[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/// Confidence level for a disagreement point.
361#[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/// Assessment of a specific claim within a proposal.
371#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
372pub struct ClaimAssessment {
373    /// Stable identifier for cross-round claim tracking. Generated on first
374    /// occurrence; evaluators echo it back in subsequent rounds.
375    /// Format: 6-char hex hash derived from (target_id, claim_text, round).
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub claim_id: Option<String>,
378    /// The claim being assessed, quoted VERBATIM from the proposal — an EXACT,
379    /// character-for-character substring of the proposal text. Do NOT paraphrase,
380    /// summarize, shorten, or reword: the client locates claims by exact substring
381    /// match, so any rephrase breaks highlighting. Copy-paste the span; don't type
382    /// it from memory.
383    ///   WRONG (paraphrase): "the sort is efficient"
384    ///   RIGHT (verbatim):   "sorts in O(n log n) time"
385    /// Models frequently hallucinate "content", "text", "description", or "summary"
386    /// instead of "claim". Some models omit the claim text entirely when using
387    /// claim_id references, so we default to empty string.
388    // `cite` / `quote` are the explicit agent-facing tool field names for the same
389    // internal `claim` — the evaluator quotes the proposal span; it is resolved and
390    // substituted with the exact proposal substring (see [`cite::resolve_cite`]),
391    // so the internal API is unchanged.
392    #[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    /// Evaluator's verdict on this claim.
404    pub verdict: ClaimVerdict,
405    /// Brief reasoning for the verdict.
406    /// Models sometimes use "disagreement", "explanation", or "reasoning" instead
407    /// of "reason".
408    #[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    /// Where this claim's cite was located in the proposal, filled in by
417    /// citation grounding. `None` means the cite resolved to nothing.
418    ///
419    /// Computed once, by the agent that did the matching, against the exact
420    /// proposal string it matched against — never re-derived downstream from a
421    /// copy that may have been re-serialized since. Models do not supply this;
422    /// anything they send is overwritten.
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub anchor: Option<ClaimAnchor>,
425}
426
427/// Where a claim's cite was found, and — when it landed in the answer body —
428/// exactly where, so a client can highlight by offset instead of re-matching
429/// the quote against rendered output.
430///
431/// The two variants are distinct on purpose. A cite may legitimately resolve
432/// into the evaluator's view of the author's *thought process*, which is not
433/// part of the answer a client renders. Emitting an answer-body offset for such
434/// a cite would highlight the wrong span of the wrong string, so those carry no
435/// offsets at all.
436#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema)]
437#[serde(tag = "in", rename_all = "snake_case")]
438pub enum ClaimAnchor {
439    /// Resolved inside the proposal's `content` — the answer body.
440    ///
441    /// Offsets are **UTF-16 code units** into `Proposal.content` exactly as
442    /// shipped: the raw markdown source, NOT the rendered HTML, and NOT
443    /// `content` concatenated with any thought-process window.
444    ///
445    /// UTF-16 because the consumer indexes in it (a browser `Range` over a DOM
446    /// text node, a JS string). Byte offsets would need the client to re-scan
447    /// the string to convert, which is the re-matching this exists to remove,
448    /// and would silently misplace every highlight after the first non-ASCII
449    /// character — real cited prose is full of typographic dashes and quotes.
450    ///
451    /// Invariant: slicing `content` by `[start_utf16, end_utf16)` in UTF-16
452    /// space yields exactly [`ClaimAssessment::claim`].
453    AnswerBody {
454        /// Start offset, in UTF-16 code units, inclusive.
455        start_utf16: usize,
456        /// End offset, in UTF-16 code units, exclusive.
457        end_utf16: usize,
458    },
459    /// Resolved only inside the shown window of the author's `thought_process`.
460    ///
461    /// Deliberately carries no offsets: the answer body does not contain this
462    /// text, so there is nothing for a client to highlight.
463    ThoughtWindow,
464}
465
466/// A specific point of disagreement between the evaluator and a proposal.
467#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
468pub struct DisagreementPoint {
469    /// References the claim_id of the disputed claim when available.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub claim_id: Option<String>,
472    /// What the proposal claims.
473    /// Models frequently hallucinate alternative field names for this field:
474    ///   "contested_claim", "claim", "claim_text", "proposal", "what_they_claimed"
475    /// Some models omit the claim text entirely when referencing by claim_id.
476    #[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    /// The evaluator's counter-position.
486    /// Models frequently hallucinate alternative field names for this field:
487    ///   "belief", "details", "counter_position", "position", "explanation",
488    ///   "analysis", "counter", "our_position", "your_view", "what_i_believe"
489    /// Some models omit this entirely when referencing by claim_id.
490    #[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    /// How confident the evaluator is in their counter-position.
505    pub confidence: Confidence,
506}
507
508/// Per-category signed quality scores (-100 to +100 scale, same as endorsement_weight).
509///
510/// Negative = this dimension actively undermines the proposal;
511/// positive = this dimension supports it. Used for diagnostic breakdown.
512#[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    /// Signed fraction ∈ [-1, +1]: `endorsement_weight / Σ|weights|`.
524    ///
525    /// Positive = endorsement, negative = opposition, zero = neutral.
526    /// The orchestrator applies a single signed QV pipeline for both ranking
527    /// and convergence: `score_q_s = sign(score) × √(|score| × 100) / 10`.
528    /// See `docs/scoring-variables.md`.
529    pub score: f32,
530    #[serde(default)]
531    pub justification: String,
532    /// Token usage for the **evaluator's** LLM call that produced this evaluation.
533    /// This counts the evaluator agent's tokens, not the evaluated proposal's.
534    ///
535    /// **Batch semantics**: One evaluator makes a single LLM call to score ALL
536    /// candidates at once. Every `Evaluation` from that batch receives the
537    /// **same** `token_usage` (the total for the evaluator's batch call, not a
538    /// per-candidate share). The orchestrator deduplicates by `evaluator_agent_id`
539    /// when summing to avoid double-counting.
540    #[serde(default, skip_serializing_if = "Option::is_none")]
541    pub token_usage: Option<TokenUsage>,
542    /// Structured claim-level assessments (2-3 most pivotal claims per candidate).
543    #[serde(default)]
544    pub claim_assessments: Vec<ClaimAssessment>,
545    /// Specific points of disagreement with the proposal.
546    #[serde(default)]
547    pub disagreements: Vec<DisagreementPoint>,
548    /// Evaluator's overall stance toward this proposal.
549    #[serde(default, skip_serializing_if = "Option::is_none")]
550    pub stance: Option<Stance>,
551    /// Whether the evaluator considers this a viable final solution.
552    #[serde(default)]
553    pub is_final_solution: bool,
554    /// Per-category quality breakdown. When present, QV transform is applied per-category.
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub category_scores: Option<CategoryScores>,
557    /// Operator annotations from HITL review (traceability audit trail).
558    #[serde(default, skip_serializing_if = "Vec::is_empty")]
559    pub operator_annotations: Vec<OperatorAnnotation>,
560    /// Set to `"operator"` when the content was edited by a human operator.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub edited_by: Option<String>,
563    /// Terminal signal from the agent loop — see `Proposal::finish_reason`
564    /// for the value taxonomy. `"max_iterations"` here means the
565    /// evaluator's react loop hit the ceiling before emitting a
566    /// terminal tool call; score + text are best-effort partials.
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub finish_reason: Option<String>,
569    /// Wall-clock instant the evaluator published this evaluation, in
570    /// milliseconds since the Unix epoch. Mirror of
571    /// [`Proposal::published_at_ms`] for evaluator submissions; same
572    /// purpose, same default-on-missing semantics.
573    #[serde(default)]
574    pub published_at_ms: i64,
575}
576
577/// Generate a stable 6-char hex claim ID for cross-round tracking.
578///
579/// The ID is derived from (target_id, normalized_claim_text, round), so the same
580/// claim flagged in the same round for the same target always gets the same ID.
581pub 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// =============================================================================
588// Structured Feedback (Context Pipeline)
589// =============================================================================
590
591/// Aggregated structured feedback for a proposal, built from evaluator data.
592/// Placed in the SDK for portability (future crypto-isolated agents build their own).
593#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
594pub struct StructuredFeedback {
595    /// Claims that evaluators contested or marked wrong.
596    pub contested_claims: Vec<ContestedClaim>,
597    /// Claims that all evaluators verified.
598    pub verified_claims: Vec<String>,
599    /// Mean stance across evaluators (mapped: StrongAgree=2, Agree=1, Neutral=0, Disagree=-1, StrongDisagree=-2).
600    pub mean_stance: f32,
601    /// Number of evaluators who provided structured feedback.
602    pub evaluator_count: u32,
603    /// Averaged category breakdown across evaluators (if any provided category_scores).
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub category_breakdown: Option<CategoryScores>,
606}
607
608/// A specific contested claim with evaluator context.
609#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
610pub struct ContestedClaim {
611    /// Stable claim ID for cross-round tracking.
612    pub claim_id: String,
613    /// What the proposal originally claimed.
614    pub what_you_claimed: String,
615    /// The evaluator's counter-position.
616    pub counter_position: String,
617    /// Which evaluator raised this dispute.
618    pub evaluator: String,
619    /// Evaluator's confidence in their counter-position.
620    pub confidence: Confidence,
621}
622
623/// Build structured feedback from evaluation records for a given proposal.
624///
625/// Aggregates claim assessments and disagreements across all evaluators.
626/// Designed as a pure function in the SDK so it can run agent-side in
627/// future crypto-isolated architectures.
628pub 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        // Aggregate stance
640        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        // Aggregate category scores
652        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        // Process claim assessments
662        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        // Process disagreement points
688        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    // Deduplicate verified claims
707    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
735/// Estimates token count from text. Designed as a pluggable trait so a real
736/// tokenizer (tiktoken, sentencepiece) can be swapped in later.
737pub trait TokenEstimator: Send + Sync {
738    fn estimate_tokens(&self, text: &str) -> u32;
739}
740
741/// Heuristic estimator: divides character count by `chars_per_token`.
742#[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        // Use chars().count() (Unicode scalar values) rather than len() (bytes)
761        // so CJK/emoji text isn't overestimated by multi-byte UTF-8 encoding.
762        (text.chars().count() as f64 / self.chars_per_token).ceil() as u32
763    }
764}
765
766/// Lightweight pricing metadata extracted from AgentConfig for cost computation.
767#[derive(Debug, Clone, Default, Serialize, Deserialize)]
768pub struct AgentPricingInfo {
769    /// USD per million input tokens (0.0 = free/unknown)
770    pub input_price_per_mtok: f64,
771    /// USD per million output tokens (0.0 = free/unknown)
772    pub output_price_per_mtok: f64,
773}
774
775impl AgentPricingInfo {
776    /// Compute cost for the given token counts.
777    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    /// Sum of signed QV contributions (`score_q_s`) across evaluators, unbounded over ℝ.
791    ///
792    /// `score_q_s = sign(f) × √(|f| × 100) / 10` per evaluator, where `f` is the
793    /// signed fraction from the per-evaluator QV pipeline.
794    /// Used for proposal ranking, winner selection, and display. See `docs/scoring-variables.md`.
795    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    /// `true` when this record was injected by the orchestrator because the
803    /// evaluator timed out or returned a partial batch.  Such evaluations
804    /// exist only to unblock the aggregate-score sum so the deliberation
805    /// proceeds; they MUST NOT contribute to convergence, variance, or
806    /// ranking metrics.
807    ///
808    /// Defaults to `false` on deserialisation so payloads produced before
809    /// this field was introduced are treated as real evaluations (matching
810    /// prior behaviour).
811    #[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    /// Optional tool changes to add/remove user tools mid-deliberation.
829    #[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/// Changes to user-defined tools, delivered via the hot-wire injection pipeline.
841#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
842pub struct ToolChanges {
843    /// New user tool definitions to add.
844    #[serde(default)]
845    pub add: Vec<UserToolDefinition>,
846    /// Tool names to remove (matched by name, without the `user_` prefix).
847    #[serde(default)]
848    pub remove: Vec<String>,
849}
850
851/// A user-defined tool that agents can call during deliberation.
852/// The schema follows the OpenAI function calling format.
853#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)]
854pub struct UserToolDefinition {
855    /// Tool name as provided by the user (e.g., "dm_user", "read_user_file").
856    /// Will be presented to the LLM with a "user_" prefix.
857    pub name: String,
858    /// Human-readable description of what this tool does.
859    pub description: String,
860    /// JSON Schema for tool parameters (OpenAI function calling format).
861    /// Follows OpenAI's `parameters` field: `{ "type": "object", "properties": {...}, "required": [...] }`
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    pub parameters: Option<serde_json::Value>,
864    /// Whether the LLM must strictly match the schema. Mirrors OpenAI's `strict` field.
865    #[serde(default, skip_serializing_if = "Option::is_none")]
866    pub strict: Option<bool>,
867}
868
869/// A pending, responded, or expired tool call from an agent to a user-defined tool.
870#[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    /// The tool name WITH the "user_" prefix (as the LLM called it).
876    pub tool_name: String,
877    pub arguments: serde_json::Value,
878    pub round: u32,
879    pub phase: DeliberationPhase,
880    pub status: ToolCallStatus,
881    /// Epoch milliseconds when the call was created.
882    pub created_at: u64,
883    /// Epoch milliseconds when the user responded (None if pending/expired).
884    #[serde(default, skip_serializing_if = "Option::is_none")]
885    pub responded_at: Option<u64>,
886    /// The user's response text (None if pending/expired).
887    #[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/// Optional trait for agents that support direct chat (bypassing NSED deliberation).
917///
918/// Implemented by [`ProposerEvaluatorAgent`]. Third-party agents can also
919/// implement this to enable the dashboard chat feature.
920#[async_trait]
921pub trait ChatCapable: Send + Sync {
922    /// Send a direct conversation to the agent's underlying LLM.
923    /// Messages use the agent's persona with an internal-voice wrapper.
924    async fn chat(
925        &self,
926        messages: Vec<async_openai::types::ChatCompletionRequestMessage>,
927    ) -> Result<String>;
928}
929
930/// Trait for user tool call handling. The reference implementation is
931/// [`UserToolHandler`]; this trait lets `AgentContext`
932/// hold a handler without leaking NATS internals into the public type.
933/// Validates a proposal submission inside the agent's react loop. A `Some(reason)`
934/// return rejects the submission — [`generate_structured_output`](crate::agents)
935/// feeds `reason` back to the model as a retry (reusing the parse-failure retry
936/// budget), exactly as a malformed submission is retried. The reference
937/// implementation wraps the `provider_response` middleware pipeline.
938#[async_trait]
939pub trait SubmissionValidator: Send + Sync + Debug {
940    /// Return `Some(reason)` to reject `content` (fed back to the model), `None` to accept.
941    async fn validate(&self, content: &str) -> Option<String>;
942}
943
944#[async_trait]
945pub trait UserToolHandlerTrait: Send + Sync + Debug {
946    /// Handle a user tool call: publish to KV, wait for response, return result string.
947    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// =============================================================================
957// Agent Discovery Protocol
958// =============================================================================
959
960/// Live status of an agent, reported via heartbeat.
961#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema, PartialEq, Default)]
962#[serde(rename_all = "lowercase")]
963pub enum AgentLiveStatus {
964    /// Agent is connected but not processing any task.
965    #[default]
966    Idle,
967    /// Agent is actively processing a task (propose or evaluate).
968    Busy,
969}
970
971/// Coarse operational health an agent self-reports so the orchestrator can act
972/// (exclude / deprioritize) and surface WHY.
973#[derive(
974    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema, Default,
975)]
976#[serde(rename_all = "snake_case")]
977pub enum AgentHealthState {
978    /// Serving normally.
979    #[default]
980    Healthy,
981    /// Serving but impaired — still assignable, but the orchestrator may
982    /// deprioritize (e.g. paused, payment pending, rate-limited).
983    Degraded,
984    /// Not serving — do NOT assign work (e.g. the remote model is unavailable).
985    Down,
986}
987
988/// An agent's self-reported health plus a short reason when it isn't `Healthy`.
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ToSchema, Default)]
990pub struct AgentHealth {
991    #[serde(default)]
992    pub state: AgentHealthState,
993    /// Short human-readable reason when `state != Healthy` (e.g. "remote model
994    /// unavailable", "paused"). `None` when `Healthy`.
995    #[serde(default, skip_serializing_if = "Option::is_none")]
996    pub reason: Option<String>,
997}
998
999impl AgentHealth {
1000    /// `true` when the agent should not be assigned new work.
1001    pub fn is_down(&self) -> bool {
1002        self.state == AgentHealthState::Down
1003    }
1004}
1005
1006/// Derive an agent's health from its current operational flags. `Down` (model
1007/// unavailable) takes precedence over `Degraded` (paused) — a down agent can't
1008/// serve at all. Keep this the single source of truth so `model_down` and
1009/// `health` stay consistent.
1010pub 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/// Heartbeat message published by agents to announce their presence on the bus.
1027///
1028/// Published to `{prefix}.agent.heartbeat.{agent_id}` every 10 seconds via
1029/// core NATS pub/sub (not JetStream — fire-and-forget).
1030#[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    /// `true` when the agent has determined its remote model is unreachable
1037    /// (e.g. a health probe or a task failed with 404 / model-unavailable).
1038    /// The scheduler excludes a down-model agent from job assignment even while
1039    /// its process keeps heartbeating. Defaults `false` (up) so an agent that
1040    /// never reports health — or an older agent that omits the field — stays
1041    /// schedulable, i.e. back-compatible.
1042    #[serde(default)]
1043    pub model_down: bool,
1044    /// Richer self-reported operational health: `Healthy`, `Degraded` (serving
1045    /// but impaired — paused, payment-pending, …), or `Down` (do not assign —
1046    /// e.g. remote model unavailable), each with a short reason. Lets the
1047    /// orchestrator surface WHY an agent is benched, not just that it is. A
1048    /// missing field (older agent) defaults to `Healthy`. `model_down` above is
1049    /// kept as the coarse boolean back-compat signal and equals
1050    /// `health.state == Down`.
1051    #[serde(default)]
1052    pub health: AgentHealth,
1053    /// Job ID if currently processing, else None.
1054    #[serde(default, skip_serializing_if = "Option::is_none")]
1055    pub current_job: Option<String>,
1056    /// Seconds since the agent process started.
1057    pub uptime_secs: u64,
1058    /// ISO 8601 timestamp.
1059    pub timestamp: String,
1060    /// USD per million input tokens (for orchestrator cost estimation).
1061    #[serde(default, skip_serializing_if = "Option::is_none")]
1062    pub input_price_per_mtok: Option<f64>,
1063    /// USD per million output tokens (for orchestrator cost estimation).
1064    #[serde(default, skip_serializing_if = "Option::is_none")]
1065    pub output_price_per_mtok: Option<f64>,
1066    /// Characters per token for heuristic estimation when provider omits usage stats.
1067    #[serde(default, skip_serializing_if = "Option::is_none")]
1068    pub chars_per_token: Option<f64>,
1069
1070    /// Maximum seconds per task (propose/evaluate) — self-reported from config.
1071    /// Used by orchestrator for feasibility validation and phase timeout floor.
1072    #[serde(default, skip_serializing_if = "Option::is_none")]
1073    pub response_sla_secs: Option<u64>,
1074
1075    // ── Agent config fields (self-reported for dashboard display) ──
1076    /// LLM temperature setting.
1077    #[serde(default, skip_serializing_if = "Option::is_none")]
1078    pub temperature: Option<f32>,
1079    /// Frequency penalty applied to generation.
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub frequency_penalty: Option<f32>,
1082    /// Presence penalty applied to generation.
1083    #[serde(default, skip_serializing_if = "Option::is_none")]
1084    pub presence_penalty: Option<f32>,
1085    /// Max tokens per generation.
1086    #[serde(default, skip_serializing_if = "Option::is_none")]
1087    pub max_tokens: Option<i32>,
1088    /// Context window size.
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub context_window: Option<i32>,
1091
1092    // ── Reliability stats (self-reported for dashboard display) ──
1093    /// Total tasks completed successfully since agent start.
1094    #[serde(default)]
1095    pub tasks_completed: u64,
1096    /// Total tasks that failed since agent start.
1097    #[serde(default)]
1098    pub tasks_failed: u64,
1099    /// Most recent error message (truncated), if any.
1100    #[serde(default, skip_serializing_if = "Option::is_none")]
1101    pub last_error: Option<String>,
1102
1103    // ── Agent metadata (for directory/ranking) ──
1104    /// Free-form capability tags (e.g., `["legal", "audit"]`).
1105    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1106    pub capability_tags: Vec<String>,
1107    /// Short description of the agent's specialization.
1108    #[serde(default, skip_serializing_if = "Option::is_none")]
1109    pub description: Option<String>,
1110    /// Signing schemes this agent supports (placeholder for #115).
1111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1112    pub signing_schemes: Vec<String>,
1113}
1114
1115/// Ping message published by the orchestrator so agents can verify it is alive.
1116///
1117/// Published to `{prefix}.orchestrator.ping` every 15 seconds via core NATS.
1118#[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
1125/// Normalize a signed endorsement weight to a signed fraction ∈ [-1, +1].
1126///
1127/// `total_abs_weight` must be `Σ|w_i|` across all proposals from the same
1128/// evaluator. Returns `(raw_weight / total_abs_weight).clamp(-1.0, 1.0)`.
1129/// When `total_abs_weight` is effectively zero (≤ `f32::EPSILON`), returns
1130/// `0.0` — the evaluator expressed no opinion.
1131pub fn normalize_score(raw_weight: f32, total_abs_weight: f32) -> f32 {
1132    // `endorsement_weight` is unvalidated LLM JSON; an overflowed value deserializes
1133    // to ±inf. inf/inf = NaN, and f32::clamp PROPAGATES NaN, so it would escape as the
1134    // evaluation score and poison the proposal's aggregated_score. Also inf/finite
1135    // clamps to ±1.0 — a garbage weight masquerading as a full endorsement. Treat any
1136    // non-finite input as no opinion.
1137    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
1147/// Signed QV-transform a normalized fraction ∈ [-1, +1] → `score_q_s` ∈ [-1, +1].
1148///
1149/// Formula: `score_q_s = sign(f) × √(|f| × 100) / 10`.
1150///
1151/// Preserves sign (endorsement vs opposition), applies QV diminishing returns
1152/// to the magnitude. Used for both ranking and convergence — single pipeline.
1153pub fn calculate_qv_from_fraction(fraction: f32) -> f32 {
1154    // Defense-in-depth: a non-finite fraction (e.g. a NaN leaking from an upstream
1155    // aggregate) would propagate through clamp/sqrt/signum as NaN. Collapse to 0.
1156    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
1167/// **Legacy unsigned** QV score from raw token weights (pre-signed-pipeline).
1168///
1169/// Formula: `score_q_u = sqrt(normalized_tokens) / 10`
1170///
1171/// The QV activation function dampens the impact of "extremist" voters who spend
1172/// 100% of their budget on one option, while the division by 10 normalizes the
1173/// result to the [0, 1] interval.
1174///
1175/// **Note**: The current scoring pipeline uses the signed variant
1176/// [`calculate_qv_from_fraction`] which produces `score_q_s ∈ ℝ`. This
1177/// unsigned version is retained for backward-compatible token estimation.
1178///
1179/// # Arguments
1180/// * `raw_weight` - The raw vote weight allocated by an evaluator
1181/// * `total_weight` - The sum of all weights from this evaluator (for normalization)
1182///
1183/// # Returns
1184/// A tuple of (`score_q_u`, normalized_tokens) where:
1185/// * `score_q_u` is the unsigned QV-transformed score in [0, 1]
1186/// * `normalized_tokens` is the budget-clamped input in [0, 100]
1187pub 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        // Down beats Degraded — a down agent can't serve at all.
1205        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        // An older agent omits `health`; it must deserialize as Healthy so the
1227        // orchestrator keeps scheduling it (back-compat).
1228        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    // =========================================================================
1243    // Serde Roundtrip Tests — ensure backward compatibility for all new types
1244    // =========================================================================
1245
1246    #[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        // Legacy format: only score + justification. All new fields must default.
1317        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        // Optional None fields should be omitted
1403        assert!(!json.contains("token_usage"));
1404        assert!(!json.contains("stance"));
1405        assert!(!json.contains("category_scores"));
1406        // claim_assessments defaults to [] but isn't skip_serializing_if
1407        // so it may appear as empty array — that's fine for backward compat
1408    }
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    // =========================================================================
1441    // generate_claim_id() Tests
1442    // =========================================================================
1443
1444    #[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    // =========================================================================
1480    // build_structured_feedback() Tests
1481    // =========================================================================
1482
1483    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); // Agree = 1.0
1537        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        // First claim keeps its explicit claim_id
1569        assert_eq!(sf.contested_claims[0].claim_id, "c1");
1570        assert_eq!(sf.contested_claims[0].what_you_claimed, "Thread safe");
1571        // Second claim gets auto-generated claim_id
1572        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        // StrongAgree(2) + Disagree(-1) + Neutral(0) = 1.0 / 3 ≈ 0.333
1608        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        // StrongDisagree(-2) + Agree(1) = -1.0 / 2 = -0.5
1628        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), // No stance
1637        ];
1638
1639        let sf = build_structured_feedback(&evals);
1640        // Only e1 has stance: Agree(1.0) / 1 = 1.0
1641        assert!((sf.mean_stance - 1.0).abs() < f32::EPSILON);
1642        assert_eq!(sf.evaluator_count, 2); // Both counted as evaluators
1643    }
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        // Deduplication: same claim verified by two evaluators → appears once
1720        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    // =========================================================================
1747    // Default Derive Tests
1748    // =========================================================================
1749
1750    #[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    // =========================================================================
1770    // Regression: DisagreementPoint field name aliases
1771    // Models hallucinate alternative field names for `proposal_claims` and
1772    // `evaluator_position`. Failures captured in failures/quant-ml_MACRO/
1773    // and failures/quant-ml_MOMENTUM/.
1774    // =========================================================================
1775
1776    /// Regression: Mistral uses "contested_claim" + "belief" instead of
1777    /// "proposal_claims" + "evaluator_position".
1778    #[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    /// Regression: GPT-OSS uses "claim" + "details" instead of
1795    /// "proposal_claims" + "evaluator_position".
1796    #[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    /// Regression: counter_position alias (close to evaluator_position but not exact)
1813    #[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    /// Canonical field names still work unchanged.
1825    #[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    /// Regression: full evaluation payload from MACRO failure dump line 116
1840    /// with "contested_claim" + "belief" in disagreements.
1841    #[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        // This is the same struct type used in the agent's evaluate() method
1872        #[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    /// Regression: Mistral uses "content" instead of "claim" in ClaimAssessment.
1914    /// From failures/quant-ml_MACRO/parse_error_r1.md line 289:
1915    ///   `missing field 'claim' at line 1 column 208`
1916    #[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    /// Regression: model uses "disagreement" field as reason alias on ClaimAssessment.
1931    /// From failures/quant-ml_MACRO/parse_error_r1.md line 355:
1932    ///   `{"claim":"...","verdict":"contested","disagreement":"I believe..."}`
1933    #[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    /// Regression: full MACRO evaluation payload with "content" alias on claims.
1948    /// From failures/quant-ml_MACRO/parse_error_r1.md lines 289-296.
1949    #[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    // =========================================================================
1994    // New alias regression tests — from failure dumps analysis
1995    // =========================================================================
1996
1997    /// DisagreementPoint: gpt-oss uses "explanation" for evaluator_position
1998    #[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    /// DisagreementPoint: gpt-oss uses "analysis" for evaluator_position
2017    #[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    /// DisagreementPoint: gpt-oss uses "counter" for evaluator_position and "proposal" for proposal_claims
2032    #[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    /// DisagreementPoint: gpt-oss uses "our_position" for evaluator_position
2049    #[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    /// DisagreementPoint: gpt-oss uses "your_view" for evaluator_position
2064    #[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    /// DisagreementPoint: gpt-oss uses "what_they_claimed" + "what_i_believe"
2080    #[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    /// ClaimAssessment: gpt-oss uses "description" instead of "claim"
2099    #[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    /// ClaimAssessment: gpt-oss uses "summary" instead of "claim"
2114    #[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    /// ClaimAssessment: "reasoning" alias for "reason"
2131    #[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    /// Full gpt-oss evaluation payload with mixed aliases (MOMENTUM r5 att3)
2143    #[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        // This payload uses "candidate_id" (aliased), "claim_text" (aliased),
2171        // "our_position" (newly aliased), and missing proposal_claims in disagreement
2172        // (uses only claim_id + our_position + confidence)
2173        #[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    // =========================================================================
2204    // AgentContext serde tests
2205    // =========================================================================
2206
2207    #[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, // serde(skip)
2237            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, // serde(skip)
2274            role: Some("security".to_string()),
2275            role_context: Some("Per-role context content".to_string()),
2276            telemetry: None,            // serde(skip)
2277            submission_validator: None, // serde(skip)
2278            event_store: None,          // serde(skip)
2279            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        // Skipped fields should be None after deserialization
2319        assert!(deserialized.store.is_none());
2320        assert!(deserialized.user_tool_handler.is_none());
2321        // Role fields roundtrip
2322        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        // Provide the required (non-#[serde(default)]) fields; all #[serde(default)]
2333        // fields (candidates, user_injections, user_tools, phase_budget_remaining_secs,
2334        // session_id, structured_feedback) should get their defaults.
2335        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        // These fields have #[serde(default)] so they should get defaults when omitted
2362        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    // =========================================================================
2373    // CandidateProposal serde tests
2374    // =========================================================================
2375
2376    #[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    // =========================================================================
2415    // Proposal serde tests
2416    // =========================================================================
2417
2418    #[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        // None fields with skip_serializing_if should be absent
2455        assert!(val.get("final_scratchpad").is_none());
2456        assert!(val.get("token_usage_stats").is_none());
2457    }
2458
2459    /// Old payloads (pre-published_at_ms) deserialize cleanly with the
2460    /// field defaulted to `0`. This is the backwards-compat contract
2461    /// the orchestrator depends on for `submission_received.agent_publish_ts`.
2462    #[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    /// Set + roundtrip. Field appears in JSON (no skip_if=0) so the
2470    /// orchestrator can distinguish "agent populated 0 explicitly"
2471    /// from "field absent" only via the JSON shape.
2472    #[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    /// Same backwards-compat contract for `Evaluation`.
2485    #[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    // =========================================================================
2505    // TokenUsage serde tests
2506    // =========================================================================
2507
2508    #[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    // =========================================================================
2525    // HeuristicTokenEstimator tests
2526    // =========================================================================
2527
2528    #[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        // "hello world" = 11 chars, 11/4.0 = 2.75, ceil = 3
2544        assert_eq!(estimator.estimate_tokens("hello world"), 3);
2545    }
2546
2547    #[test]
2548    fn heuristic_estimator_cjk_text() {
2549        let estimator = HeuristicTokenEstimator::default();
2550        // "你好世界" = 4 Unicode chars, 4/4.0 = 1.0, ceil = 1
2551        assert_eq!(estimator.estimate_tokens("你好世界"), 1);
2552    }
2553
2554    #[test]
2555    fn heuristic_estimator_emoji() {
2556        let estimator = HeuristicTokenEstimator::default();
2557        // Two emoji chars, 2/4.0 = 0.5, ceil = 1
2558        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        // "hello" = 5 chars, 5/1.5 = 3.333..., ceil = 4
2567        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    // =========================================================================
2587    // AgentPricingInfo::compute_cost() tests
2588    // =========================================================================
2589
2590    #[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        // (1000*10 + 500*30) / 1_000_000 = (10_000 + 15_000) / 1_000_000 = 0.025
2606        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        // (1_000_000 * 15 + 500_000 * 60) / 1_000_000 = 15 + 30 = 45.0
2626        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    // =========================================================================
2639    // calculate_qv_from_fraction() tests
2640    // =========================================================================
2641
2642    #[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        // √0.25 = 0.5
2650        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        // sign(-1) × √(1.0 × 100) / 10 = -1.0
2666        assert!((calculate_qv_from_fraction(-1.0) - (-1.0)).abs() < f32::EPSILON);
2667    }
2668
2669    #[test]
2670    fn qv_from_fraction_negative_quarter() {
2671        // sign(-0.25) × √(0.25 × 100) / 10 = -0.5
2672        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        // -2.0 clamped to -1.0 → sign(-1) × √(1.0 × 100) / 10 = -1.0
2678        assert!((calculate_qv_from_fraction(-2.0) - (-1.0)).abs() < f32::EPSILON);
2679    }
2680
2681    // =========================================================================
2682    // calculate_qv_score() tests
2683    // =========================================================================
2684
2685    #[test]
2686    fn qv_score_full_weight() {
2687        // raw=100, total=100 → total<=100 so normalized=clamp(100,0,100)=100
2688        // influence = sqrt(100)/10 = 10/10 = 1.0
2689        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        // raw=25, total=100 → total<=100 so normalized=clamp(25,0,100)=25
2697        // influence = sqrt(25)/10 = 5/10 = 0.5
2698        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        // raw=0, total=100 → normalized=0, influence=0
2706        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        // raw=50, total=50 → total<=100 so normalized=clamp(50,0,100)=50
2714        // influence = sqrt(50)/10
2715        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        // raw=200, total=200 → total>100 so normalized=(200/200)*100=100, clamped to 100
2724        // influence = sqrt(100)/10 = 1.0
2725        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        // raw=200, total=100 → total<=100 so normalized=clamp(200,0,100)=100
2733        // influence = sqrt(100)/10 = 1.0
2734        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        // raw=-50, total=100 → total<=100 so normalized=clamp(-50,0,100)=0
2742        // influence = sqrt(0)/10 = 0.0
2743        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    // =========================================================================
2749    // UserInjection serde roundtrip
2750    // =========================================================================
2751
2752    #[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    // =========================================================================
2782    // AgentHeartbeat serde roundtrip
2783    // =========================================================================
2784
2785    #[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        // New fields
2840        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        // Back-compat: a heartbeat from an older agent omits `model_down`.
2851        // It must deserialize as `false` (up) so the agent stays schedulable —
2852        // never silently benched by the new health filter.
2853        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        // Optional fields with skip_serializing_if = "Option::is_none" should be absent
2874        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    // =========================================================================
2888    // OrchestratorPing serde roundtrip
2889    // =========================================================================
2890
2891    #[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    // =========================================================================
2906    // PendingToolCall serde roundtrip
2907    // =========================================================================
2908
2909    #[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        // With responded fields
2939        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    // =========================================================================
2960    // ToolCallStatus serde tests
2961    // =========================================================================
2962
2963    #[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    // =========================================================================
2980    // AgentLiveStatus serde tests
2981    // =========================================================================
2982
2983    #[test]
2984    fn agent_live_status_serde() {
2985        // Idle variant
2986        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        // Busy variant
2992        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        // Default is Idle
2998        assert_eq!(AgentLiveStatus::default(), AgentLiveStatus::Idle);
2999    }
3000
3001    // =========================================================================
3002    // DeliberationPhase serde tests
3003    // =========================================================================
3004
3005    #[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    // =========================================================================
3025    // UserToolDefinition serde roundtrip
3026    // =========================================================================
3027
3028    #[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        // None fields with skip_serializing_if should be absent
3065        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    // ── Operator annotation / HITL serde tests ──────────────────────────
3075
3076    #[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        // rename_all = snake_case
3084        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    // ── OperatorAnnotation::validate() tests ────────────────────────────
3200
3201    #[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        // Backward compat: deserialization succeeds, validation is separate
3249        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        // Validation fails — but deserialization succeeded (backward compat)
3258        assert!(annotation.validate().is_err());
3259    }
3260
3261    // =========================================================================
3262    // normalize_score tests
3263    // =========================================================================
3264
3265    #[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        // Signed weights: -1.0 / 1.0 = -1.0 (opposition is valid)
3284        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        // -3.0 / 1.0 = -3.0 → clamped to -1.0
3290        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        // -50.0 / 100.0 = -0.5 (opposition with half budget)
3306        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        // Evaluator gives +60 to A, -40 to B → Σ|w| = 100
3312        // normalize(+60, 100) = 0.6, normalize(-40, 100) = -0.4
3313        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        // An overflowed LLM weight deserializes to ±inf. inf/inf would be NaN (which
3320        // clamp propagates) and inf/finite would clamp to a fake ±1.0 endorsement.
3321        // Both must collapse to 0 (no opinion), never NaN.
3322        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    // =========================================================================
3337    // AgentContext::telemetry_for
3338    // =========================================================================
3339
3340    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        // A resumed thread turn: the flattened history is huge and grows every
3353        // turn; `new_turn` is just the incremental message.
3354        let huge_history = "[user] t1\n[assistant] ...\n".repeat(2000); // ~grows with thread
3355        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        // Resume → delta carries only the new turn, NOT the flattened history.
3359        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        // Bounded: independent of how long the thread got.
3365        assert!(
3366            ctx.delta_task().len() < 40,
3367            "delta stays small as the thread grows"
3368        );
3369        // Fresh (no new_turn) → full history (a fresh session needs the context).
3370        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        // No conversation_id → falls back to session_id (thread-TUI / native path).
3377        let mut ctx = ctx_with_session(Some("room-turn-1"));
3378        assert_eq!(ctx.claude_session_key(), Some("room-turn-1"));
3379        // A thread key overrides the per-turn room so successive turns resume.
3380        ctx.conversation_id = Some("thread-abc".into());
3381        assert_eq!(ctx.claude_session_key(), Some("thread-abc"));
3382        // Two turns of one thread (different rooms, same conversation) → same key.
3383        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    /// Happy path: session_id present → context derives a
3389    /// TelemetryContext that round-trips the (agent, job, round, phase)
3390    /// tuple into the AgentEventCommon envelope.
3391    #[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        // trace_id is the 32-char (128-bit) hex from derive_trace_id.
3401        assert_eq!(common.trace_id.len(), 32);
3402        assert!(common.trace_id.chars().all(|c| c.is_ascii_hexdigit()));
3403    }
3404
3405    /// Same task hits telemetry_for twice → both envelopes share the
3406    /// same trace_id (deterministic derivation, not per-call uuid).
3407    #[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    /// Missing session → panic. The orchestrator establishes the
3416    /// invariant at dispatch; emitting telemetry from a session-less
3417    /// context is a programmer error and should fail loudly rather
3418    /// than synthesise a fake trace_id that breaks dashboard joins.
3419    #[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    /// Empty-string session_id is treated the same as None — same
3427    /// "no real session" condition, same panic.
3428    #[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}