1use std::fmt;
12
13use indexmap::IndexMap;
14use serde::{Deserialize, Serialize};
15
16use crate::error::{CoreError, Result};
17use crate::schema::{Answer, Question, QuestionType};
18
19pub const DEFAULT_HIGH_CONFIDENCE_THRESHOLD: f64 = 0.70;
21
22pub const DEFAULT_LOW_CONFIDENCE_THRESHOLD: f64 = 0.35;
24
25pub const DEFAULT_TOP_MARGIN_THRESHOLD: f64 = 0.15;
27
28pub const DEFAULT_ENERGY_THRESHOLD: f64 = -1.0;
33
34const EPSILON: f64 = 1e-9;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
41#[cfg_attr(feature = "openapi", schema(rename_all = "snake_case"))]
42pub enum DecisionRoute {
43 AutoExecute,
45 ConfirmOrEscalate,
47 Fallback,
49}
50
51impl DecisionRoute {
52 pub fn as_str(&self) -> &'static str {
54 match self {
55 Self::AutoExecute => "auto_execute",
56 Self::ConfirmOrEscalate => "confirm_or_escalate",
57 Self::Fallback => "fallback",
58 }
59 }
60}
61
62impl fmt::Display for DecisionRoute {
63 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64 f.write_str(self.as_str())
65 }
66}
67
68#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71pub struct GatingConfig {
72 #[serde(default = "default_true")]
74 pub enabled: bool,
75
76 #[serde(default = "default_high_threshold")]
78 pub high_threshold: f64,
79
80 #[serde(default = "default_low_threshold")]
82 pub low_threshold: f64,
83
84 #[serde(default = "default_top_margin_threshold")]
89 pub top_margin_threshold: f64,
90
91 #[serde(default = "default_true")]
93 pub ood_enabled: bool,
94
95 #[serde(default = "default_energy_threshold")]
100 pub energy_threshold: f64,
101
102 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub energy_temperature: Option<f64>,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub loose_energy_threshold: Option<f64>,
112
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub ood_min_confidence: Option<f64>,
116
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub ood_min_margin: Option<f64>,
120}
121
122fn default_true() -> bool {
123 true
124}
125
126fn default_high_threshold() -> f64 {
127 DEFAULT_HIGH_CONFIDENCE_THRESHOLD
128}
129
130fn default_low_threshold() -> f64 {
131 DEFAULT_LOW_CONFIDENCE_THRESHOLD
132}
133
134fn default_top_margin_threshold() -> f64 {
135 DEFAULT_TOP_MARGIN_THRESHOLD
136}
137
138fn default_energy_threshold() -> f64 {
139 DEFAULT_ENERGY_THRESHOLD
140}
141
142impl Default for GatingConfig {
143 fn default() -> Self {
144 Self {
145 enabled: true,
146 high_threshold: DEFAULT_HIGH_CONFIDENCE_THRESHOLD,
147 low_threshold: DEFAULT_LOW_CONFIDENCE_THRESHOLD,
148 top_margin_threshold: DEFAULT_TOP_MARGIN_THRESHOLD,
149 ood_enabled: true,
150 energy_threshold: DEFAULT_ENERGY_THRESHOLD,
151 energy_temperature: None,
152 loose_energy_threshold: None,
153 ood_min_confidence: None,
154 ood_min_margin: None,
155 }
156 }
157}
158
159impl GatingConfig {
160 pub fn validate(&self) -> Result<()> {
162 if self.high_threshold < 0.0 || self.high_threshold > 1.0 {
163 return Err(CoreError::InvalidGatingConfig {
164 message: format!(
165 "high_threshold ({}) は 0.0〜1.0 の範囲である必要があります。",
166 self.high_threshold
167 ),
168 });
169 }
170 if self.low_threshold < 0.0 || self.low_threshold > 1.0 {
171 return Err(CoreError::InvalidGatingConfig {
172 message: format!(
173 "low_threshold ({}) は 0.0〜1.0 の範囲である必要があります。",
174 self.low_threshold
175 ),
176 });
177 }
178 if self.low_threshold > self.high_threshold {
179 return Err(CoreError::InvalidGatingConfig {
180 message: format!(
181 "low_threshold ({}) は high_threshold ({}) 以下である必要があります。",
182 self.low_threshold, self.high_threshold
183 ),
184 });
185 }
186 if self.top_margin_threshold < 0.0 || self.top_margin_threshold > 1.0 {
187 return Err(CoreError::InvalidGatingConfig {
188 message: format!(
189 "top_margin_threshold ({}) は 0.0〜1.0 の範囲である必要があります。",
190 self.top_margin_threshold
191 ),
192 });
193 }
194 if !self.energy_threshold.is_finite() {
195 return Err(CoreError::InvalidGatingConfig {
196 message: format!(
197 "energy_threshold ({}) は有限実数である必要があります。",
198 self.energy_threshold
199 ),
200 });
201 }
202 if let Some(t) = self.energy_temperature
203 && (!t.is_finite() || t <= 0.0)
204 {
205 return Err(CoreError::InvalidGatingConfig {
206 message: format!(
207 "energy_temperature ({}) は正の有限実数である必要があります。",
208 t
209 ),
210 });
211 }
212 if let Some(c) = self.ood_min_confidence
213 && !(0.0..=1.0).contains(&c)
214 {
215 return Err(CoreError::InvalidGatingConfig {
216 message: format!(
217 "ood_min_confidence ({}) は 0.0〜1.0 の範囲である必要があります。",
218 c
219 ),
220 });
221 }
222 if let Some(m) = self.ood_min_margin
223 && !(0.0..=1.0).contains(&m)
224 {
225 return Err(CoreError::InvalidGatingConfig {
226 message: format!(
227 "ood_min_margin ({}) は 0.0〜1.0 の範囲である必要があります。",
228 m
229 ),
230 });
231 }
232 Ok(())
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
239pub struct CandidateProbability {
240 pub candidate: String,
242 pub probability: f64,
244}
245
246#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
249pub struct EscalationContext {
250 pub top_candidates: Vec<CandidateProbability>,
252
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub margin: Option<f64>,
256
257 pub uncertainty_reason: String,
259
260 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub prompt_template: Option<String>,
263}
264
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
268pub struct GatingMetadata {
269 pub route: DecisionRoute,
271
272 pub confidence: f64,
274
275 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub entropy: Option<f64>,
283
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub margin: Option<f64>,
287
288 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub energy: Option<f64>,
291
292 #[serde(default)]
294 pub is_ood: bool,
295
296 pub reason: String,
298
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub escalation: Option<EscalationContext>,
302}
303
304impl GatingMetadata {
305 pub fn dispersion(&self) -> Option<f64> {
309 self.entropy
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
316pub struct SystemRoutingSummary {
317 pub aggregate_route: DecisionRoute,
319
320 pub auto_execute_count: usize,
322
323 pub confirm_count: usize,
325
326 pub fallback_count: usize,
328
329 pub escalation_needed: bool,
331}
332
333pub fn evaluate_answer_gating(
344 answer: &Answer,
345 question_id: Option<&str>,
346 question: Option<&Question>,
347 config: &GatingConfig,
348) -> GatingMetadata {
349 evaluate_answer_gating_with_energy(answer, question_id, question, config, None)
350}
351
352pub fn evaluate_answer_gating_with_energy(
369 answer: &Answer,
370 question_id: Option<&str>,
371 question: Option<&Question>,
372 config: &GatingConfig,
373 energy: Option<f64>,
374) -> GatingMetadata {
375 let conf = answer.effective_confidence().unwrap_or(0.0).clamp(0.0, 1.0);
376
377 let margin = if let Some(probs) = &answer.probabilities {
379 let k = probs.len();
380 if k <= 1 {
381 Some(1.0)
382 } else {
383 let mut max1 = -f64::INFINITY;
384 let mut max2 = -f64::INFINITY;
385 for &p in probs.values() {
386 if p > max1 {
387 max2 = max1;
388 max1 = p;
389 } else if p > max2 {
390 max2 = p;
391 }
392 }
393 if max1.is_finite() && max2.is_finite() {
394 Some((max1 - max2).abs().clamp(0.0, 1.0))
395 } else {
396 None
397 }
398 }
399 } else if let Some(p_true) = answer.noul {
400 let p_false = (1.0 - p_true).clamp(0.0, 1.0);
401 Some((p_true - p_false).abs().clamp(0.0, 1.0))
402 } else {
403 None
404 };
405
406 let entropy = match question.map(|q| q.question_type) {
408 Some(QuestionType::Choice) => {
409 if let Some(probs) = &answer.probabilities {
410 let p_vals: Vec<f64> = probs.values().copied().collect();
411 crate::math::normalized_entropy(&p_vals).ok()
412 } else {
413 None
414 }
415 }
416 Some(QuestionType::Score) => {
417 answer.confidence.map(|c| (1.0 - c).clamp(0.0, 1.0))
419 }
420 Some(QuestionType::Noul) => {
421 answer.noul.map(|p_true| {
423 let p_false = (1.0 - p_true).clamp(0.0, 1.0);
424 if p_true > 0.0 && p_false > 0.0 {
425 let h =
426 -(p_true * p_true.ln() + p_false * p_false.ln()) / std::f64::consts::LN_2;
427 h.clamp(0.0, 1.0)
428 } else {
429 0.0
430 }
431 })
432 }
433 _ => None,
434 };
435
436 let is_choice = question
438 .map(|q| q.question_type == QuestionType::Choice)
439 .unwrap_or(false)
440 || answer.choice.is_some();
441
442 let (is_ood, ood_reason) = if config.ood_enabled && is_choice {
443 if let Some(e) = energy {
444 let is_hard_energy_ood = e > config.energy_threshold;
445 let is_hybrid_ood = if let (Some(loose_th), Some(min_conf), Some(min_margin)) = (
446 config.loose_energy_threshold,
447 config.ood_min_confidence,
448 config.ood_min_margin,
449 ) {
450 let m = margin.unwrap_or(0.0);
451 e > loose_th && (conf < min_conf || m < min_margin)
452 } else {
453 false
454 };
455
456 if is_hard_energy_ood {
457 (
458 true,
459 Some(format!(
460 "正規化自由エネルギー({:.3} > {:.3})が閾値を超過したため、未定義カテゴリまたは該当なし(OOD)として安全弁フォールバックを適用しました。",
461 e, config.energy_threshold
462 )),
463 )
464 } else if is_hybrid_ood {
465 let m = margin.unwrap_or(0.0);
466 (
467 true,
468 Some(format!(
469 "正規化自由エネルギー({:.3} > {:.3})かつ不確実性(確信度={:.3} < {:.3} または マージン={:.3} < {:.3})の複合条件を満たしたため、該当なし(OOD)として安全弁フォールバックを適用しました。",
470 e,
471 config.loose_energy_threshold.unwrap(),
472 conf,
473 config.ood_min_confidence.unwrap(),
474 m,
475 config.ood_min_margin.unwrap()
476 )),
477 )
478 } else {
479 (false, None)
480 }
481 } else {
482 (false, None)
483 }
484 } else {
485 (false, None)
486 };
487
488 let (route, reason) = if is_ood {
490 (
491 DecisionRoute::Fallback,
492 ood_reason.unwrap_or_else(|| {
493 "OOD 安全弁回路が作動したため安全弁フォールバックを適用しました。".to_string()
494 }),
495 )
496 } else if conf >= config.high_threshold - EPSILON {
497 if let Some(m) = margin {
498 if m < config.top_margin_threshold - EPSILON {
499 (
500 DecisionRoute::ConfirmOrEscalate,
501 format!(
502 "実効確信度({:.3} >= {:.3})は高水準ですが、上位2候補の確率差({:.3} < {:.3})が僅差のため確認要求に降格しました。",
503 conf, config.high_threshold, m, config.top_margin_threshold
504 ),
505 )
506 } else {
507 (
508 DecisionRoute::AutoExecute,
509 format!(
510 "実効確信度({:.3} >= {:.3})および上位候補マージン({:.3} >= {:.3})を満たすため自動実行が承認されました。",
511 conf, config.high_threshold, m, config.top_margin_threshold
512 ),
513 )
514 }
515 } else {
516 (
517 DecisionRoute::AutoExecute,
518 format!(
519 "実効確信度({:.3} >= {:.3})が高水準であるため自動実行が承認されました。",
520 conf, config.high_threshold
521 ),
522 )
523 }
524 } else if conf >= config.low_threshold - EPSILON {
525 (
526 DecisionRoute::ConfirmOrEscalate,
527 format!(
528 "実効確信度({:.3})が確認要求境界 [{:.3}, {:.3}) 内のため二次検証が必要です。",
529 conf, config.low_threshold, config.high_threshold
530 ),
531 )
532 } else {
533 (
534 DecisionRoute::Fallback,
535 format!(
536 "実効確信度({:.3} < {:.3})が低水準のため安全弁フォールバックを適用しました。",
537 conf, config.low_threshold
538 ),
539 )
540 };
541
542 let escalation = if route != DecisionRoute::AutoExecute {
544 let mut candidates: Vec<CandidateProbability> = Vec::new();
545 if let Some(probs) = &answer.probabilities {
546 candidates = probs
547 .iter()
548 .map(|(k, &p)| CandidateProbability {
549 candidate: k.clone(),
550 probability: p,
551 })
552 .collect();
553 candidates.sort_by(|a, b| {
554 b.probability
555 .partial_cmp(&a.probability)
556 .unwrap_or(std::cmp::Ordering::Equal)
557 });
558 } else if let Some(p_true) = answer.noul {
559 let p_false = (1.0 - p_true).clamp(0.0, 1.0);
560 if p_true >= p_false {
561 candidates.push(CandidateProbability {
562 candidate: "true".to_string(),
563 probability: p_true,
564 });
565 candidates.push(CandidateProbability {
566 candidate: "false".to_string(),
567 probability: p_false,
568 });
569 } else {
570 candidates.push(CandidateProbability {
571 candidate: "false".to_string(),
572 probability: p_false,
573 });
574 candidates.push(CandidateProbability {
575 candidate: "true".to_string(),
576 probability: p_true,
577 });
578 }
579 }
580
581 let top_candidates = candidates.iter().take(3).cloned().collect();
582 let prompt_template = build_escalation_prompt(question_id, question, &candidates, margin);
583
584 Some(EscalationContext {
585 top_candidates,
586 margin,
587 uncertainty_reason: reason.clone(),
588 prompt_template: Some(prompt_template),
589 })
590 } else {
591 None
592 };
593
594 GatingMetadata {
595 route,
596 confidence: conf,
597 entropy,
598 margin,
599 energy,
600 is_ood,
601 reason,
602 escalation,
603 }
604}
605
606fn build_escalation_prompt(
608 question_id: Option<&str>,
609 question: Option<&Question>,
610 candidates: &[CandidateProbability],
611 margin: Option<f64>,
612) -> String {
613 let q_name = question_id.unwrap_or("target_question");
614 let instructions = question
615 .map(|q| q.instructions.as_str())
616 .unwrap_or("指示文なし");
617
618 if candidates.len() >= 2 {
619 let c1 = &candidates[0];
620 let c2 = &candidates[1];
621 let p1_str = format!("{:.1}", c1.probability * 100.0);
622 let p2_str = format!("{:.1}", c2.probability * 100.0);
623 let m_str = match margin {
624 Some(m) => format!("(確率差: {:.1}%)", m * 100.0),
625 None => String::new(),
626 };
627
628 let mut prompt = String::with_capacity(256);
629 prompt.push_str("質問「");
630 prompt.push_str(q_name);
631 prompt.push_str("」(");
632 prompt.push_str(instructions);
633 prompt.push_str(") について、System 1 の判定では候補「");
634 prompt.push_str(&c1.candidate);
635 prompt.push_str("」(確信度 ");
636 prompt.push_str(&p1_str);
637 prompt.push_str("%) と「");
638 prompt.push_str(&c2.candidate);
639 prompt.push_str("」(確信度 ");
640 prompt.push_str(&p2_str);
641 prompt.push_str("%) の間で迷いが生じています ");
642 prompt.push_str(&m_str);
643 prompt.push_str("。State コンテキストを精読し、思考連鎖 (Chain-of-Thought) に基づき最適な決定を行ってください。");
644 return prompt;
645 }
646
647 let mut prompt = String::with_capacity(128);
648 prompt.push_str("質問「");
649 prompt.push_str(q_name);
650 prompt.push_str("」(");
651 prompt.push_str(instructions);
652 prompt.push_str(") について、System 1 の判定信頼性が低水準となっています。State コンテキストを精読し、論理的根拠に基づいて決定を下してください。");
653 prompt
654}
655
656pub fn evaluate_response_routing(answers: &IndexMap<String, Answer>) -> SystemRoutingSummary {
663 let mut auto_execute_count = 0;
664 let mut confirm_count = 0;
665 let mut fallback_count = 0;
666
667 for answer in answers.values() {
668 match answer.route() {
669 Some(DecisionRoute::AutoExecute) => auto_execute_count += 1,
670 Some(DecisionRoute::ConfirmOrEscalate) => confirm_count += 1,
671 Some(DecisionRoute::Fallback) => fallback_count += 1,
672 None => {
673 let conf = answer.effective_confidence().unwrap_or(0.0);
675 if conf >= DEFAULT_HIGH_CONFIDENCE_THRESHOLD {
676 auto_execute_count += 1;
677 } else if conf >= DEFAULT_LOW_CONFIDENCE_THRESHOLD {
678 confirm_count += 1;
679 } else {
680 fallback_count += 1;
681 }
682 }
683 }
684 }
685
686 let aggregate_route = if fallback_count > 0 {
687 DecisionRoute::Fallback
688 } else if confirm_count > 0 {
689 DecisionRoute::ConfirmOrEscalate
690 } else {
691 DecisionRoute::AutoExecute
692 };
693
694 let escalation_needed = confirm_count > 0 || fallback_count > 0;
695
696 SystemRoutingSummary {
697 aggregate_route,
698 auto_execute_count,
699 confirm_count,
700 fallback_count,
701 escalation_needed,
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708
709 #[test]
710 fn test_gating_config_default_and_validation() {
711 let config = GatingConfig::default();
712 assert!(config.enabled);
713 assert!((config.high_threshold - 0.70).abs() < EPSILON);
714 assert!((config.low_threshold - 0.35).abs() < EPSILON);
715 assert!((config.top_margin_threshold - 0.15).abs() < EPSILON);
716 assert!(config.validate().is_ok());
717
718 let invalid_config = GatingConfig {
719 enabled: true,
720 high_threshold: 0.30,
721 low_threshold: 0.50,
722 top_margin_threshold: 0.15,
723 ood_enabled: true,
724 energy_threshold: -1.0,
725 ..GatingConfig::default()
726 };
727 assert!(invalid_config.validate().is_err());
728
729 let invalid_energy_config = GatingConfig {
730 energy_threshold: f64::NAN,
731 ..GatingConfig::default()
732 };
733 assert!(invalid_energy_config.validate().is_err());
734 }
735
736 #[test]
737 fn test_evaluate_answer_gating_auto_execute() {
738 let mut probs = IndexMap::new();
739 probs.insert("opt_a".to_string(), 0.90);
740 probs.insert("opt_b".to_string(), 0.10);
741 let answer = Answer::choice("opt_a", probs, 0.92);
742
743 let config = GatingConfig::default();
744 let meta = evaluate_answer_gating(&answer, Some("q1"), None, &config);
745
746 assert_eq!(meta.route, DecisionRoute::AutoExecute);
747 assert!((meta.confidence - 0.92).abs() < EPSILON);
748 assert!((meta.margin.unwrap() - 0.80).abs() < EPSILON);
749 assert!(meta.escalation.is_none());
750 }
751
752 #[test]
753 fn test_evaluate_answer_gating_margin_guard_demote_to_confirm() {
754 let mut probs = IndexMap::new();
756 probs.insert("opt_a".to_string(), 0.48);
757 probs.insert("opt_b".to_string(), 0.44);
758 probs.insert("opt_c".to_string(), 0.08);
759 let answer = Answer::choice("opt_a", probs, 0.75);
760
761 let config = GatingConfig::default();
762 let meta = evaluate_answer_gating(&answer, Some("q1"), None, &config);
763
764 assert_eq!(meta.route, DecisionRoute::ConfirmOrEscalate);
765 assert!(meta.reason.contains("上位2候補の確率差"));
766 assert!(meta.escalation.is_some());
767 let esc = meta.escalation.unwrap();
768 assert_eq!(esc.top_candidates.len(), 3);
769 assert_eq!(esc.top_candidates[0].candidate, "opt_a");
770 assert_eq!(esc.top_candidates[1].candidate, "opt_b");
771 }
772
773 #[test]
774 fn test_evaluate_answer_gating_medium_confirm() {
775 let mut probs = IndexMap::new();
776 probs.insert("opt_a".to_string(), 0.60);
777 probs.insert("opt_b".to_string(), 0.40);
778 let answer = Answer::choice("opt_a", probs, 0.50);
779
780 let config = GatingConfig::default();
781 let meta = evaluate_answer_gating(&answer, Some("q1"), None, &config);
782
783 assert_eq!(meta.route, DecisionRoute::ConfirmOrEscalate);
784 assert!(meta.escalation.is_some());
785 }
786
787 #[test]
788 fn test_evaluate_answer_gating_low_fallback() {
789 let mut probs = IndexMap::new();
790 probs.insert("opt_a".to_string(), 0.34);
791 probs.insert("opt_b".to_string(), 0.33);
792 probs.insert("opt_c".to_string(), 0.33);
793 let answer = Answer::choice("opt_a", probs, 0.10);
794
795 let config = GatingConfig::default();
796 let meta = evaluate_answer_gating(&answer, Some("q1"), None, &config);
797
798 assert_eq!(meta.route, DecisionRoute::Fallback);
799 assert!(meta.escalation.is_some());
800 }
801
802 #[test]
803 fn test_evaluate_answer_gating_noul_high_and_low() {
804 let config = GatingConfig::default();
805
806 let ans_high = Answer::noul(0.96);
808 let meta_high = evaluate_answer_gating(&ans_high, Some("noul_q"), None, &config);
809 assert_eq!(meta_high.route, DecisionRoute::AutoExecute);
810
811 let ans_low = Answer::noul(0.52);
813 let meta_low = evaluate_answer_gating(&ans_low, Some("noul_q"), None, &config);
814 assert_eq!(meta_low.route, DecisionRoute::Fallback);
815 assert!(meta_low.escalation.is_some());
816 }
817
818 #[test]
819 fn test_evaluate_response_routing_aggregation() {
820 let mut answers = IndexMap::new();
821
822 let ans1 = Answer::choice("a", IndexMap::new(), 0.95).with_gating(GatingMetadata {
823 route: DecisionRoute::AutoExecute,
824 confidence: 0.95,
825 entropy: None,
826 margin: None,
827 energy: None,
828 is_ood: false,
829 reason: String::new(),
830 escalation: None,
831 });
832
833 let ans2 = Answer::choice("b", IndexMap::new(), 0.50).with_gating(GatingMetadata {
834 route: DecisionRoute::ConfirmOrEscalate,
835 confidence: 0.50,
836 entropy: None,
837 margin: None,
838 energy: None,
839 is_ood: false,
840 reason: String::new(),
841 escalation: None,
842 });
843
844 answers.insert("q1".to_string(), ans1);
845 answers.insert("q2".to_string(), ans2);
846
847 let summary = evaluate_response_routing(&answers);
848 assert_eq!(summary.aggregate_route, DecisionRoute::ConfirmOrEscalate);
849 assert_eq!(summary.auto_execute_count, 1);
850 assert_eq!(summary.confirm_count, 1);
851 assert_eq!(summary.fallback_count, 0);
852 assert!(summary.escalation_needed);
853
854 let ans3 = Answer::choice("c", IndexMap::new(), 0.20).with_gating(GatingMetadata {
856 route: DecisionRoute::Fallback,
857 confidence: 0.20,
858 entropy: None,
859 margin: None,
860 energy: None,
861 is_ood: false,
862 reason: String::new(),
863 escalation: None,
864 });
865 answers.insert("q3".to_string(), ans3);
866
867 let summary_fallback = evaluate_response_routing(&answers);
868 assert_eq!(summary_fallback.aggregate_route, DecisionRoute::Fallback);
869 assert_eq!(summary_fallback.fallback_count, 1);
870 assert!(summary_fallback.escalation_needed);
871 }
872
873 #[test]
874 fn test_evaluate_answer_gating_ood_short_circuit() {
875 let mut probs = IndexMap::new();
877 probs.insert("tech_support".to_string(), 0.90);
878 probs.insert("billing".to_string(), 0.10);
879 let answer = Answer::choice("tech_support", probs, 0.90);
880
881 let config = GatingConfig::default();
882 let meta = evaluate_answer_gating_with_energy(
884 &answer,
885 Some("department"),
886 None,
887 &config,
888 Some(0.5),
889 );
890
891 assert_eq!(meta.route, DecisionRoute::Fallback);
893 assert!(meta.is_ood);
894 assert_eq!(meta.energy, Some(0.5));
895 assert!(meta.reason.contains("未定義カテゴリまたは該当なし(OOD)"));
896 assert!(meta.escalation.is_some());
897 }
898
899 #[test]
900 fn test_evaluate_answer_gating_ood_id_pass() {
901 let mut probs = IndexMap::new();
903 probs.insert("tech_support".to_string(), 0.90);
904 probs.insert("billing".to_string(), 0.10);
905 let answer = Answer::choice("tech_support", probs, 0.90);
906
907 let config = GatingConfig::default();
908 let meta = evaluate_answer_gating_with_energy(
909 &answer,
910 Some("department"),
911 None,
912 &config,
913 Some(-3.5),
914 );
915
916 assert_eq!(meta.route, DecisionRoute::AutoExecute);
917 assert!(!meta.is_ood);
918 assert_eq!(meta.energy, Some(-3.5));
919 assert!(meta.escalation.is_none());
920 }
921
922 #[test]
923 fn test_evaluate_answer_gating_ood_disabled() {
924 let mut probs = IndexMap::new();
926 probs.insert("tech_support".to_string(), 0.90);
927 probs.insert("billing".to_string(), 0.10);
928 let answer = Answer::choice("tech_support", probs, 0.90);
929
930 let config = GatingConfig {
931 ood_enabled: false,
932 ..Default::default()
933 };
934
935 let meta = evaluate_answer_gating_with_energy(
936 &answer,
937 Some("department"),
938 None,
939 &config,
940 Some(0.5),
941 );
942
943 assert_eq!(meta.route, DecisionRoute::AutoExecute);
944 assert!(!meta.is_ood);
945 }
946}