1use std::collections::BTreeMap;
18
19use exo_core::types::{Hash256, Timestamp};
20
21use crate::{
22 commitment::{commit_response, verify_response_commitment},
23 error::{ConsensusError, Result},
24 panel::{ModelRole, Panel},
25 record::DeliberationResult,
26 report::{MinorityReport, is_minority_report},
27 round::{DeliberationRound, DevilAdvocateReview, ModelDeliberationResponse, ModelPosition},
28 scoring::{
29 PanelConfidenceInputs, calculate_convergence, calculate_panel_confidence,
30 canonical_claim_set, consensus_claims_at_threshold,
31 },
32};
33
34#[derive(Debug, Clone)]
35pub struct DeterministicResponseProvider {
36 positions: BTreeMap<String, ModelDeliberationResponse>,
37 devil_advocate_reviews: BTreeMap<String, DevilAdvocateReview>,
38}
39
40impl DeterministicResponseProvider {
41 pub fn new(
42 positions: BTreeMap<String, ModelDeliberationResponse>,
43 devil_advocate_reviews: BTreeMap<String, DevilAdvocateReview>,
44 ) -> Self {
45 Self {
46 positions,
47 devil_advocate_reviews,
48 }
49 }
50
51 pub fn with_positions(positions: BTreeMap<String, ModelDeliberationResponse>) -> Self {
52 Self::new(positions, BTreeMap::new())
53 }
54
55 fn position_for(&self, model_id: &str) -> Result<ModelDeliberationResponse> {
56 self.positions.get(model_id).cloned().ok_or_else(|| {
57 ConsensusError::ProviderError(format!(
58 "missing structured deterministic response for model {model_id}"
59 ))
60 })
61 }
62
63 fn devil_advocate_review_for(&self, model_id: &str) -> Result<DevilAdvocateReview> {
64 self.devil_advocate_reviews
65 .get(model_id)
66 .cloned()
67 .ok_or_else(|| {
68 ConsensusError::ProviderError(format!(
69 "missing structured devil's advocate review for model {model_id}"
70 ))
71 })
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub struct RoundExecutionTiming {
77 pub submitted_at: Timestamp,
78 pub revealed_at: Timestamp,
79}
80
81impl RoundExecutionTiming {
82 fn validate(&self) -> Result<()> {
83 if self.submitted_at == Timestamp::ZERO {
84 return Err(ConsensusError::StateError(
85 "round submitted_at must be caller-supplied non-zero HLC".into(),
86 ));
87 }
88 if self.revealed_at < self.submitted_at {
89 return Err(ConsensusError::StateError(
90 "round revealed_at must not precede submitted_at".into(),
91 ));
92 }
93 Ok(())
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct FinalizationTiming {
99 pub completed_at: Timestamp,
100}
101
102impl FinalizationTiming {
103 fn validate(&self) -> Result<()> {
104 if self.completed_at == Timestamp::ZERO {
105 return Err(ConsensusError::StateError(
106 "finalization completed_at must be caller-supplied non-zero HLC".into(),
107 ));
108 }
109 Ok(())
110 }
111}
112
113pub struct DeliberationSession {
114 pub session_id: String,
115 pub panel: Panel,
116 pub question: String,
117 pub current_round: u32,
118 pub rounds: Vec<DeliberationRound>,
119 pub response_provider: DeterministicResponseProvider,
120}
121
122impl DeliberationSession {
123 pub fn new(
124 session_id: String,
125 panel: Panel,
126 question: String,
127 response_provider: DeterministicResponseProvider,
128 ) -> Self {
129 Self {
130 session_id,
131 panel,
132 question,
133 current_round: 1,
134 rounds: Vec::new(),
135 response_provider,
136 }
137 }
138
139 pub fn execute_round(&mut self, timing: RoundExecutionTiming) -> Result<DeliberationRound> {
140 if self.current_round > self.panel.max_rounds {
141 return Err(ConsensusError::RoundLimitExceeded);
142 }
143 timing.validate()?;
144 let next_round = self
145 .current_round
146 .checked_add(1)
147 .ok_or(ConsensusError::RoundLimitExceeded)?;
148
149 let mut positions = BTreeMap::new();
150
151 let mut commitments = BTreeMap::new();
153 for model in &self.panel.models {
154 if model.role == ModelRole::Panelist {
155 let response = self.response_provider.position_for(&model.model_id)?;
156 let response = validate_model_response(&model.model_id, response)?;
157 let position_hash = commit_response(&response)?;
158 commitments.insert(model.model_id.clone(), (response, position_hash));
159 }
160 }
161
162 for (model_id, (response, position_hash)) in commitments {
164 if !verify_response_commitment(&response, &position_hash)? {
165 return Err(ConsensusError::CommitmentMismatch { model_id });
166 }
167
168 let pos = ModelPosition {
169 model_id: model_id.clone(),
170 round: self.current_round,
171 position_hash,
172 position_text: response.position_text,
173 key_claims: response.key_claims,
174 confidence_bps: response.confidence_bps,
175 submitted_at: timing.submitted_at,
176 revealed_at: Some(timing.revealed_at),
177 };
178
179 positions.insert(model_id, pos);
180 }
181
182 let claim_sets = position_claim_sets(&positions);
184 let convergence_score_bps = calculate_convergence(&claim_sets);
185 let consensus_claims =
186 consensus_claims_at_threshold(&claim_sets, self.panel.convergence_threshold_bps);
187
188 let synthesis_text = consensus_summary(&consensus_claims);
190
191 let mut devil_advocate_review = None;
193 if convergence_score_bps >= self.panel.convergence_threshold_bps
194 || self.current_round == self.panel.max_rounds
195 {
196 if let Some(da_id) = &self.panel.devil_advocate_model {
197 let review = self.response_provider.devil_advocate_review_for(da_id)?;
198 devil_advocate_review = Some(validate_devil_advocate_review(da_id, review)?);
199 }
200 }
201
202 let mut round = DeliberationRound {
203 round_number: self.current_round,
204 question: self.question.clone(),
205 positions,
206 synthesis: Some(synthesis_text),
207 convergence_score_bps,
208 devil_advocate_review,
209 round_hash: Hash256::ZERO,
210 };
211
212 round.round_hash = round.compute_hash()?;
213
214 self.rounds.push(round.clone());
215 self.current_round = next_round;
216
217 Ok(round)
218 }
219
220 pub fn is_converged(&self) -> bool {
221 if let Some(last) = self.rounds.last() {
222 return last.convergence_score_bps >= self.panel.convergence_threshold_bps;
223 }
224 false
225 }
226
227 pub fn finalize(self, timing: FinalizationTiming) -> Result<DeliberationResult> {
230 timing.validate()?;
231
232 let DeliberationSession {
233 session_id,
234 panel,
235 question,
236 rounds,
237 ..
238 } = self;
239
240 if rounds.is_empty() {
241 return Err(ConsensusError::StateError(
242 "Cannot finalize without any rounds".into(),
243 ));
244 }
245
246 let Some(last_round) = rounds.last() else {
247 return Err(ConsensusError::StateError(
248 "Rounds exist but last() failed".into(),
249 ));
250 };
251 let final_consensus = last_round
252 .synthesis
253 .as_deref()
254 .map(str::trim)
255 .filter(|synthesis| !synthesis.is_empty())
256 .ok_or_else(|| {
257 ConsensusError::StateError(
258 "Cannot finalize round with missing synthesis evidence".into(),
259 )
260 })?
261 .to_string();
262 let mut minority_reports = Vec::new();
263
264 let claim_sets = position_claim_sets(&last_round.positions);
265 let consensus_claims =
266 consensus_claims_at_threshold(&claim_sets, panel.convergence_threshold_bps);
267 let no_consensus_claims = consensus_claims.is_empty();
268
269 for pos in last_round.positions.values() {
270 if no_consensus_claims
271 || is_minority_report(pos, &consensus_claims, panel.convergence_threshold_bps)
272 {
273 let reasons = if no_consensus_claims {
274 vec!["No structured consensus claims met threshold.".to_string()]
275 } else {
276 let missing_claims = missing_consensus_claims(pos, &consensus_claims);
277 vec![format!(
278 "Missing structured consensus claims: {}",
279 missing_claims.join(", ")
280 )]
281 };
282 minority_reports.push(MinorityReport {
283 model_id: pos.model_id.clone(),
284 round: pos.round,
285 dissenting_position: pos.position_text.clone(),
286 reasons,
287 divergence_score_bps: 10_000u64
288 .saturating_sub(last_round.convergence_score_bps),
289 });
290 }
291 }
292
293 let mut da_summary = None;
294 let mut serious_objection = false;
295 if let Some(review) = &last_round.devil_advocate_review {
296 da_summary = Some(review.review_text.clone());
297 serious_objection = review.serious_objection;
298 }
299
300 let panelists_count = usize_to_u32(
301 "panelists_count",
302 panel
303 .models
304 .iter()
305 .filter(|m| m.role == ModelRole::Panelist)
306 .count(),
307 )?;
308 let minority_reports_count =
309 usize_to_u32("minority_reports_count", minority_reports.len())?;
310 let rounds_to_convergence = usize_to_u32("rounds_to_convergence", rounds.len())?;
311 let converged = last_round.convergence_score_bps >= panel.convergence_threshold_bps;
312 let models_agreeing = if no_consensus_claims {
313 0
314 } else {
315 panelists_count.saturating_sub(minority_reports_count)
316 };
317
318 let inputs = PanelConfidenceInputs {
319 models_agreeing,
320 total_models: panelists_count,
321 converged,
322 rounds_to_convergence,
323 max_rounds: panel.max_rounds,
324 devil_found_serious_objection: serious_objection,
325 minority_reports_count,
326 };
327
328 let pci = calculate_panel_confidence(&inputs);
329
330 let mut result = DeliberationResult {
331 session_id,
332 question,
333 rounds,
334 final_consensus,
335 minority_reports,
336 panel_confidence_index_bps: pci,
337 rounds_to_convergence,
338 devil_advocate_summary: da_summary,
339 deliberation_hash: Hash256::ZERO,
340 completed_at: timing.completed_at,
341 };
342
343 result.deliberation_hash = result.compute_hash()?;
344
345 Ok(result)
346 }
347}
348
349fn validate_model_response(
350 model_id: &str,
351 response: ModelDeliberationResponse,
352) -> Result<ModelDeliberationResponse> {
353 let position_text = response.position_text.trim().to_string();
354 if position_text.is_empty() {
355 return Err(ConsensusError::ProviderError(format!(
356 "structured deterministic response for model {model_id} has empty position_text"
357 )));
358 }
359 if response.confidence_bps > 10000 {
360 return Err(ConsensusError::ProviderError(format!(
361 "structured deterministic response for model {model_id} has confidence_bps above 10000"
362 )));
363 }
364 let key_claims = canonical_claim_set(&response.key_claims);
365 if key_claims.is_empty() {
366 return Err(ConsensusError::ProviderError(format!(
367 "structured deterministic response for model {model_id} must include explicit key_claims"
368 )));
369 }
370
371 Ok(ModelDeliberationResponse {
372 position_text,
373 key_claims,
374 confidence_bps: response.confidence_bps,
375 })
376}
377
378fn validate_devil_advocate_review(
379 model_id: &str,
380 review: DevilAdvocateReview,
381) -> Result<DevilAdvocateReview> {
382 let review_text = review.review_text.trim().to_string();
383 if review_text.is_empty() {
384 return Err(ConsensusError::ProviderError(format!(
385 "structured devil's advocate review for model {model_id} has empty review_text"
386 )));
387 }
388
389 let reasons = canonical_claim_set(&review.reasons);
390 if review.serious_objection && reasons.is_empty() {
391 return Err(ConsensusError::ProviderError(format!(
392 "structured devil's advocate review for model {model_id} marks serious_objection without reasons"
393 )));
394 }
395
396 Ok(DevilAdvocateReview {
397 review_text,
398 serious_objection: review.serious_objection,
399 reasons,
400 })
401}
402
403fn position_claim_sets(positions: &BTreeMap<String, ModelPosition>) -> Vec<Vec<String>> {
404 positions
405 .values()
406 .map(|position| position.key_claims.clone())
407 .collect()
408}
409
410fn consensus_summary(consensus_claims: &[String]) -> String {
411 if consensus_claims.is_empty() {
412 "No structured consensus claims met threshold.".to_string()
413 } else {
414 format!(
415 "Structured consensus claims: {}.",
416 consensus_claims.join("; ")
417 )
418 }
419}
420
421fn missing_consensus_claims(position: &ModelPosition, consensus_claims: &[String]) -> Vec<String> {
422 let position_claims = canonical_claim_set(&position.key_claims);
423 consensus_claims
424 .iter()
425 .filter(|claim| !position_claims.contains(claim))
426 .cloned()
427 .collect()
428}
429
430fn usize_to_u32(field: &'static str, value: usize) -> Result<u32> {
431 u32::try_from(value).map_err(|_| {
432 ConsensusError::StateError(format!(
433 "finalization count {field} value {value} exceeds u32::MAX"
434 ))
435 })
436}
437
438#[cfg(test)]
439#[allow(clippy::unwrap_used, clippy::expect_used)]
440mod tests {
441 use decision_forum::decision_object::DecisionClass;
442
443 use super::*;
444
445 fn response(text: &str, claims: &[&str]) -> ModelDeliberationResponse {
446 ModelDeliberationResponse {
447 position_text: text.to_string(),
448 key_claims: claims.iter().map(|claim| (*claim).to_string()).collect(),
449 confidence_bps: 8000,
450 }
451 }
452
453 fn routine_responses(
454 response_text: &str,
455 claims: &[&str],
456 ) -> BTreeMap<String, ModelDeliberationResponse> {
457 BTreeMap::from([
458 (
459 "claude-3-haiku".to_string(),
460 response(response_text, claims),
461 ),
462 ("gpt-4o-mini".to_string(), response(response_text, claims)),
463 (
464 "gemini-1.5-flash".to_string(),
465 response(response_text, claims),
466 ),
467 ])
468 }
469
470 fn neutral_review() -> DevilAdvocateReview {
471 DevilAdvocateReview {
472 review_text: "No threshold objection found.".to_string(),
473 serious_objection: false,
474 reasons: Vec::new(),
475 }
476 }
477
478 fn timing(round: u64) -> RoundExecutionTiming {
479 RoundExecutionTiming {
480 submitted_at: Timestamp::new(round * 10, 0),
481 revealed_at: Timestamp::new(round * 10, 1),
482 }
483 }
484
485 fn finalization_timing() -> FinalizationTiming {
486 FinalizationTiming {
487 completed_at: Timestamp::new(1000, 0),
488 }
489 }
490
491 #[test]
493 fn execute_round_returns_round_limit_exceeded_when_current_round_exceeds_max() {
494 let panel = Panel::default_panel(DecisionClass::Routine); let provider =
496 DeterministicResponseProvider::with_positions(routine_responses("A, B", &["a", "b"]));
497 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
498 let first = session.execute_round(timing(1)).expect("first round ok");
500 assert_eq!(first.round_number, 1);
501 assert_eq!(session.current_round, 2);
502 let err = session
503 .execute_round(timing(2))
504 .expect_err("must exceed limit");
505 assert!(matches!(err, ConsensusError::RoundLimitExceeded));
506 assert_eq!(session.rounds.len(), 1);
508 assert_eq!(session.current_round, 2);
509 }
510
511 #[test]
512 fn execute_round_rejects_u32_max_round_counter_without_overflowing() {
513 let mut panel = Panel::default_panel(DecisionClass::Routine);
514 panel.max_rounds = u32::MAX;
515 let provider =
516 DeterministicResponseProvider::with_positions(routine_responses("A, B", &["a", "b"]));
517 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
518 session.current_round = u32::MAX;
519
520 let err = session
521 .execute_round(timing(1))
522 .expect_err("u32::MAX round counter must fail closed");
523
524 assert!(matches!(err, ConsensusError::RoundLimitExceeded));
525 assert!(session.rounds.is_empty());
526 assert_eq!(session.current_round, u32::MAX);
527 }
528
529 #[test]
531 fn is_converged_false_when_last_round_below_threshold() {
532 let panel = Panel::default_panel(DecisionClass::Operational);
534 let mut responses = BTreeMap::new();
535 responses.insert(
536 "claude-3-5-sonnet".into(),
537 response("alpha position", &["alpha"]),
538 );
539 responses.insert("gpt-4o".into(), response("beta position", &["beta"]));
540 responses.insert(
541 "gemini-1.5-pro".into(),
542 response("gamma position", &["gamma"]),
543 );
544 let provider = DeterministicResponseProvider::with_positions(responses);
545 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
546 let round = session.execute_round(timing(1)).unwrap();
547 assert_eq!(round.convergence_score_bps, 0);
549 assert!(!session.is_converged());
550 }
551
552 #[test]
553 fn finalize_no_consensus_does_not_award_full_panel_confidence() {
554 let panel = Panel::default_panel(DecisionClass::Routine);
555 let responses = BTreeMap::from([
556 (
557 "claude-3-haiku".to_string(),
558 response("alpha position", &["alpha"]),
559 ),
560 (
561 "gpt-4o-mini".to_string(),
562 response("beta position", &["beta"]),
563 ),
564 (
565 "gemini-1.5-flash".to_string(),
566 response("gamma position", &["gamma"]),
567 ),
568 ]);
569 let provider = DeterministicResponseProvider::with_positions(responses);
570 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
571
572 let round = session.execute_round(timing(1)).unwrap();
573 assert_eq!(round.convergence_score_bps, 0);
574 let result = session.finalize(finalization_timing()).unwrap();
575
576 assert_eq!(result.minority_reports.len(), 3);
577 assert!(
578 result.panel_confidence_index_bps <= 2_000,
579 "no-consensus deliberation must not receive high confidence, got {}",
580 result.panel_confidence_index_bps
581 );
582 }
583
584 #[test]
586 fn is_converged_false_when_no_rounds() {
587 let panel = Panel::default_panel(DecisionClass::Routine);
588 let provider = DeterministicResponseProvider::with_positions(BTreeMap::new());
589 let session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
590 assert!(!session.is_converged());
591 }
592
593 #[test]
595 fn finalize_errors_with_state_error_when_rounds_empty() {
596 let panel = Panel::default_panel(DecisionClass::Routine);
597 let provider = DeterministicResponseProvider::with_positions(BTreeMap::new());
598 let session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
599 let err = session
600 .finalize(finalization_timing())
601 .expect_err("must fail when empty");
602 match err {
603 ConsensusError::StateError(msg) => {
604 assert!(
605 msg.contains("Cannot finalize without any rounds"),
606 "unexpected state error message: {msg}"
607 );
608 }
609 other => panic!("expected StateError, got {other:?}"),
610 }
611 }
612
613 #[test]
614 fn finalize_rejects_round_without_synthesis_evidence() {
615 let panel = Panel::default_panel(DecisionClass::Routine);
616 let provider = DeterministicResponseProvider::with_positions(routine_responses(
617 "shared position",
618 &["shared claim"],
619 ));
620 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
621 session
622 .execute_round(timing(1))
623 .expect("round executes with synthesis");
624 session.rounds[0].synthesis = None;
625
626 let err = session
627 .finalize(finalization_timing())
628 .expect_err("missing synthesis evidence must fail closed");
629
630 match err {
631 ConsensusError::StateError(message) => {
632 assert!(message.contains("missing synthesis"));
633 }
634 other => panic!("expected StateError, got {other:?}"),
635 }
636 }
637
638 #[test]
639 fn finalize_rejects_blank_synthesis_evidence() {
640 let panel = Panel::default_panel(DecisionClass::Routine);
641 let provider = DeterministicResponseProvider::with_positions(routine_responses(
642 "shared position",
643 &["shared claim"],
644 ));
645 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
646 session
647 .execute_round(timing(1))
648 .expect("round executes with synthesis");
649 session.rounds[0].synthesis = Some(" ".to_string());
650
651 let err = session
652 .finalize(finalization_timing())
653 .expect_err("blank synthesis evidence must fail closed");
654
655 match err {
656 ConsensusError::StateError(message) => {
657 assert!(message.contains("missing synthesis"));
658 }
659 other => panic!("expected StateError, got {other:?}"),
660 }
661 }
662
663 #[test]
664 fn execute_round_rejects_text_only_response_without_structured_claims() {
665 let panel = Panel::default_panel(DecisionClass::Routine);
666 let provider = DeterministicResponseProvider::with_positions(routine_responses(
667 "raw text has commas, but no structured claims",
668 &[],
669 ));
670 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
671
672 let err = session
673 .execute_round(timing(1))
674 .expect_err("text-only model response must fail closed");
675
676 match err {
677 ConsensusError::ProviderError(message) => {
678 assert!(message.contains("explicit key_claims"));
679 }
680 other => panic!("expected ProviderError, got {other:?}"),
681 }
682 }
683
684 #[test]
685 fn execute_round_rejects_out_of_range_model_confidence() {
686 let panel = Panel::default_panel(DecisionClass::Routine);
687 let mut responses = routine_responses("claim text", &["claim"]);
688 let mut invalid = response("claim text", &["claim"]);
689 invalid.confidence_bps = 10001;
690 responses.insert("gpt-4o-mini".to_string(), invalid);
691 let provider = DeterministicResponseProvider::with_positions(responses);
692 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
693
694 let err = session
695 .execute_round(timing(1))
696 .expect_err("confidence above 10000 bps must fail closed");
697
698 match err {
699 ConsensusError::ProviderError(message) => {
700 assert!(message.contains("confidence_bps above 10000"));
701 }
702 other => panic!("expected ProviderError, got {other:?}"),
703 }
704 }
705
706 #[test]
708 fn devil_advocate_runs_on_final_round_even_without_convergence() {
709 let panel = Panel::default_panel(DecisionClass::Operational);
711 let mut responses = BTreeMap::new();
712 responses.insert(
714 "claude-3-5-sonnet".into(),
715 response("alpha position", &["alpha"]),
716 );
717 responses.insert("gpt-4o".into(), response("beta position", &["beta"]));
718 responses.insert(
719 "gemini-1.5-pro".into(),
720 response("gamma position", &["gamma"]),
721 );
722 let provider = DeterministicResponseProvider::new(
723 responses,
724 BTreeMap::from([("gpt-4o".to_string(), neutral_review())]),
725 );
726 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
727
728 let r1 = session.execute_round(timing(1)).unwrap();
730 assert!(r1.convergence_score_bps < 7500);
731 assert!(
732 r1.devil_advocate_review.is_none(),
733 "DA should not run when neither converged nor on the final round"
734 );
735
736 let r2 = session.execute_round(timing(2)).unwrap();
738 assert_eq!(r2.round_number, 2);
739 assert!(r2.convergence_score_bps < 7500);
740 assert!(
741 r2.devil_advocate_review.is_some(),
742 "DA must trigger on the final round even without convergence"
743 );
744
745 let result = session.finalize(finalization_timing()).unwrap();
747 assert!(result.devil_advocate_summary.is_some());
748 }
749
750 #[test]
751 fn devil_advocate_keyword_text_is_not_binding_without_serious_flag() {
752 let panel = Panel::default_panel(DecisionClass::Operational);
753 let positions = BTreeMap::from([
754 (
755 "claude-3-5-sonnet".to_string(),
756 response("shared position", &["shared claim"]),
757 ),
758 (
759 "gpt-4o".to_string(),
760 response("shared position", &["shared claim"]),
761 ),
762 (
763 "gemini-1.5-pro".to_string(),
764 response("shared position", &["shared claim"]),
765 ),
766 ]);
767 let reviews = BTreeMap::from([(
768 "gpt-4o".to_string(),
769 DevilAdvocateReview {
770 review_text: "The prose says serious and fatal but the structured flag is false."
771 .to_string(),
772 serious_objection: false,
773 reasons: Vec::new(),
774 },
775 )]);
776 let provider = DeterministicResponseProvider::new(positions, reviews);
777 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
778
779 session.execute_round(timing(1)).unwrap();
780 let result = session.finalize(finalization_timing()).unwrap();
781
782 assert_eq!(result.panel_confidence_index_bps, 10000);
783 assert_eq!(
784 result.devil_advocate_summary.as_deref(),
785 Some("The prose says serious and fatal but the structured flag is false.")
786 );
787 }
788
789 #[test]
790 fn finalize_saturates_minority_divergence_when_convergence_exceeds_bps_ceiling() {
791 let panel = Panel::default_panel(DecisionClass::Routine);
792 let responses = BTreeMap::from([
793 (
794 "claude-3-haiku".to_string(),
795 response("shared position", &["shared claim"]),
796 ),
797 (
798 "gpt-4o-mini".to_string(),
799 response("also shared", &["shared claim"]),
800 ),
801 (
802 "gemini-1.5-flash".to_string(),
803 response("minority position", &["minority claim"]),
804 ),
805 ]);
806 let provider = DeterministicResponseProvider::with_positions(responses);
807 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
808
809 session.execute_round(timing(1)).unwrap();
810 session.rounds[0].convergence_score_bps = 10_001;
811
812 let result = session.finalize(finalization_timing()).unwrap();
813
814 assert_eq!(result.minority_reports.len(), 1);
815 assert_eq!(result.minority_reports[0].divergence_score_bps, 0);
816 }
817
818 #[test]
819 fn production_finalization_does_not_default_failed_u32_conversions_to_zero() {
820 let source = include_str!("session.rs");
821 let production = source
822 .split("\n#[cfg(test)]")
823 .next()
824 .expect("production section");
825
826 assert!(
827 !production.contains(".unwrap_or(0)"),
828 "failed finalization count conversions must fail closed instead of defaulting to zero"
829 );
830 assert!(
831 production.contains("usize_to_u32(\"rounds_to_convergence\""),
832 "round count conversion must use the typed finalization count helper"
833 );
834 assert!(
835 production.contains("usize_to_u32(\"minority_reports_count\""),
836 "minority report count conversion must use the typed finalization count helper"
837 );
838 }
839
840 #[test]
841 fn production_finalization_moves_rounds_without_cloning_full_history() {
842 let source = include_str!("session.rs");
843 let production = source
844 .split("\n#[cfg(test)]")
845 .next()
846 .expect("production section");
847 let finalize = production
848 .split("pub fn finalize(")
849 .nth(1)
850 .and_then(|section| section.split("\nfn validate_model_response").next())
851 .expect("finalize implementation");
852
853 assert!(
854 !finalize.contains("self.rounds.clone()"),
855 "DeliberationSession::finalize must not clone the full round history"
856 );
857 assert!(
858 production.contains("pub fn finalize(self,"),
859 "DeliberationSession::finalize must consume the session so rounds can move into the result"
860 );
861 }
862
863 #[test]
864 fn devil_advocate_serious_objection_requires_reasons_and_penalizes_panel_confidence() {
865 let panel = Panel::default_panel(DecisionClass::Operational);
866 let positions = BTreeMap::from([
867 (
868 "claude-3-5-sonnet".to_string(),
869 response("shared position", &["shared claim"]),
870 ),
871 (
872 "gpt-4o".to_string(),
873 response("shared position", &["shared claim"]),
874 ),
875 (
876 "gemini-1.5-pro".to_string(),
877 response("shared position", &["shared claim"]),
878 ),
879 ]);
880 let reviews = BTreeMap::from([(
881 "gpt-4o".to_string(),
882 DevilAdvocateReview {
883 review_text: "Structured objection accepted.".to_string(),
884 serious_objection: true,
885 reasons: vec!["missing safety bound".to_string()],
886 },
887 )]);
888 let provider = DeterministicResponseProvider::new(positions, reviews);
889 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
890
891 session.execute_round(timing(1)).unwrap();
892 let result = session.finalize(finalization_timing()).unwrap();
893
894 assert_eq!(result.panel_confidence_index_bps, 8000);
895
896 let invalid_positions = BTreeMap::from([
897 (
898 "claude-3-5-sonnet".to_string(),
899 response("shared position", &["shared claim"]),
900 ),
901 (
902 "gpt-4o".to_string(),
903 response("shared position", &["shared claim"]),
904 ),
905 (
906 "gemini-1.5-pro".to_string(),
907 response("shared position", &["shared claim"]),
908 ),
909 ]);
910 let invalid_reviews = BTreeMap::from([(
911 "gpt-4o".to_string(),
912 DevilAdvocateReview {
913 review_text: "Structured objection lacks reasons.".to_string(),
914 serious_objection: true,
915 reasons: Vec::new(),
916 },
917 )]);
918 let provider = DeterministicResponseProvider::new(invalid_positions, invalid_reviews);
919 let mut session = DeliberationSession::new(
920 "s2".into(),
921 Panel::default_panel(DecisionClass::Operational),
922 "Q?".into(),
923 provider,
924 );
925 let err = session
926 .execute_round(timing(1))
927 .expect_err("serious objection without reasons must fail closed");
928 match err {
929 ConsensusError::ProviderError(message) => {
930 assert!(message.contains("marks serious_objection without reasons"));
931 }
932 other => panic!("expected ProviderError, got {other:?}"),
933 }
934 }
935
936 #[test]
938 fn devil_advocate_skipped_when_panel_has_no_da_model_even_on_converged_final_round() {
939 let panel = Panel::default_panel(DecisionClass::Routine);
941 assert!(panel.devil_advocate_model.is_none());
942 let provider = DeterministicResponseProvider::with_positions(routine_responses(
943 "same claim",
944 &["same claim"],
945 ));
946 let mut session = DeliberationSession::new("s".into(), panel, "Q?".into(), provider);
947 let round = session.execute_round(timing(1)).unwrap();
948 assert_eq!(round.convergence_score_bps, 10000);
951 assert!(round.devil_advocate_review.is_none());
952 }
953}