Skip to main content

sokuto_core/
gating.rs

1//! # 確信度ゲーティングおよび3系統ルーティングモジュール
2//!
3//! 事後較正(RLCD + 最適温度スケーリング $\tau^*$)を経た実効確信度に基づき、
4//! 決定論的に 3 系統のアクションへ分岐する安全制御ロジックを提供する。
5//!
6//! ## 3系統ルーティングの判定基準
7//! - **`AutoExecute` ($\ge 0.85$ & Margin $\ge 0.15$)**: 自動実行(高信頼・人手や大型LLMの介在なし)。
8//! - **`ConfirmOrEscalate` ($0.50 \dots 0.85$ または Margin $< 0.15$)**: 確認/二次検証要求(System 2 LLMへの委託または人手確認)。
9//! - **`Fallback` ($< 0.50$)**: 安全弁フォールバック(情報不足・一様迷い時のデフォルト動作・棄却)。
10
11use std::fmt;
12
13use indexmap::IndexMap;
14use serde::{Deserialize, Serialize};
15
16use crate::error::{CoreError, Result};
17use crate::schema::{Answer, Question, QuestionType};
18
19/// デフォルトの高確信度閾値 (自動実行境界)。
20pub const DEFAULT_HIGH_CONFIDENCE_THRESHOLD: f64 = 0.70;
21
22/// デフォルトの中確信度下限閾値 (確認・二次検証要求境界)。
23pub const DEFAULT_LOW_CONFIDENCE_THRESHOLD: f64 = 0.35;
24
25/// デフォルトの上位 2 候補確率マージン閾値。
26pub const DEFAULT_TOP_MARGIN_THRESHOLD: f64 = 0.15;
27
28/// デフォルトの OOD 自由エネルギー棄却閾値 (正規化自由エネルギー基準)。
29///
30/// Choice 型の正規化自由エネルギー $E_{\text{norm}}(x; C)$ がこの値を超過した場合、
31/// 未定義カテゴリまたは該当なし入力 (OOD) と判定して `Fallback` へ短絡ルーティングする。
32pub const DEFAULT_ENERGY_THRESHOLD: f64 = -1.0;
33
34/// 浮動小数点比較時の微小許容誤差。
35const EPSILON: f64 = 1e-9;
36
37/// ゲーティング判定に基づく 3 系統のアクション種別。
38#[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    /// 自動実行: モデルの確信度が極めて高く、人手介在なしに後続処理を実行する。
44    AutoExecute,
45    /// 確認/二次検証要求: 候補間で迷いが生じており、System 2 (大型LLM) やユーザー確認を求める。
46    ConfirmOrEscalate,
47    /// 安全弁フォールバック: 信頼性が著しく低く、安全なデフォルト値の採用や人手転送を行う。
48    Fallback,
49}
50
51impl DecisionRoute {
52    /// ルーティング文字列スライスを取得する。
53    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/// ゲーティング判定の設定パラメータ。
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
71pub struct GatingConfig {
72    /// ゲーティング処理を有効化するかどうか。
73    #[serde(default = "default_true")]
74    pub enabled: bool,
75
76    /// 自動実行 (AutoExecute) と判定するための確信度下限値 (デフォルト: 0.70)。
77    #[serde(default = "default_high_threshold")]
78    pub high_threshold: f64,
79
80    /// 確認/エスカレーション (ConfirmOrEscalate) と判定するための確信度下限値 (デフォルト: 0.35)。
81    #[serde(default = "default_low_threshold")]
82    pub low_threshold: f64,
83
84    /// 上位 2 候補の最小確率マージン閾値 (デフォルト: 0.15)。
85    ///
86    /// 確信度が `high_threshold` 以上であっても、1位と2位の確率差がこの値を下回る場合は
87    /// 選択肢間で拮抗が生じていると判断し、強制的に `ConfirmOrEscalate` へ降格する。
88    #[serde(default = "default_top_margin_threshold")]
89    pub top_margin_threshold: f64,
90
91    /// Energy-based OOD 安全弁を有効化するかどうか (デフォルト: true)。
92    #[serde(default = "default_true")]
93    pub ood_enabled: bool,
94
95    /// OOD 判定用の正規化自由エネルギー閾値 (デフォルト: -1.0)。
96    ///
97    /// Choice 型において正規化自由エネルギー $E_{\text{norm}}$ がこの値を超過した場合、
98    /// 未定義カテゴリ・該当なしとして強制的に `Fallback` へ降格する。
99    #[serde(default = "default_energy_threshold")]
100    pub energy_threshold: f64,
101
102    /// OOD 自由エネルギー算出専用の温度パラメータ。
103    ///
104    /// Softmax 確率較正温度とは独立に、低発火・高コントラストな自由エネルギーを算出するために使用する。
105    /// 未指定時はモデル成果物のキャリブレーション設定にフォールバックする。
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub energy_temperature: Option<f64>,
108
109    /// ハイブリッド OOD 判定用の緩和正規化自由エネルギー閾値 (任意)。
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub loose_energy_threshold: Option<f64>,
112
113    /// ハイブリッド OOD 判定用の最小確信度閾値 (任意)。
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub ood_min_confidence: Option<f64>,
116
117    /// ハイブリッド OOD 判定用の最小マージン閾値 (任意)。
118    #[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    /// 閾値設定値の数学的妥当性を検証する。
161    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/// 候補とその予測確率値のペア。
237#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
239pub struct CandidateProbability {
240    /// 候補識別子ラベル。
241    pub candidate: String,
242    /// 較正済み確率値 (0.0 <= p <= 1.0)。
243    pub probability: f64,
244}
245
246/// 大型自己回帰 LLM (System 2) へのエスカレーション用コンテキスト。
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
248#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
249pub struct EscalationContext {
250    /// 確率上位の候補一覧 (降順、最大3候補)。
251    pub top_candidates: Vec<CandidateProbability>,
252
253    /// 1位候補と2位候補の確率差。
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub margin: Option<f64>,
256
257    /// 不確実性および迷いが生じた理由の説明文。
258    pub uncertainty_reason: String,
259
260    /// 大型 LLM への入力プロンプト案。
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub prompt_template: Option<String>,
263}
264
265/// 単一質問に対する確信度ゲーティング判定結果および監査メタデータ。
266#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
267#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
268pub struct GatingMetadata {
269    /// 判定されたルーティングアクション種別。
270    pub route: DecisionRoute,
271
272    /// 判定に用いられた較正済み実効確信度スコア (0.0 <= C <= 1.0)。
273    pub confidence: f64,
274
275    /// 確率分布の不確実性・散らばり指標値。
276    ///
277    /// # 備考
278    /// 質問タイプに応じて以下の正規化不確実性指標を格納する:
279    /// - **Choice 型**: 逆算された正規化エントロピー指標 ($1 - C_{\text{entropy}}$)。
280    /// - **Score 型**: 逆算された正規化分散指標 ($1 - C_{\text{var}}$)。
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub entropy: Option<f64>,
283
284    /// 上位 2 候補の確率マージン (Top-1 - Top-2)。
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub margin: Option<f64>,
287
288    /// 算出された正規化自由エネルギー値。
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub energy: Option<f64>,
291
292    /// 未定義カテゴリ・該当なし (OOD) として検知されたかどうかの真偽値。
293    #[serde(default)]
294    pub is_ood: bool,
295
296    /// 判定根拠の説明文 (監査証跡用)。
297    pub reason: String,
298
299    /// 中確信度または低確信度時の大型 LLM 連携コンテキスト。
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub escalation: Option<EscalationContext>,
302}
303
304impl GatingMetadata {
305    /// 確率分布の散らばり度合い(不確実性指標)を取得する。
306    ///
307    /// Choice 型のエントロピーおよび Score 型の分散指標を同一の不確実性スコアとして参照する。
308    pub fn dispersion(&self) -> Option<f64> {
309        self.entropy
310    }
311}
312
313/// リクエスト全体の集約ルーティングサマリー。
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
315#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
316pub struct SystemRoutingSummary {
317    /// リクエスト全体の集約ルーティング (最悪値ルール)。
318    pub aggregate_route: DecisionRoute,
319
320    /// 自動実行 (AutoExecute) と判定された質問数。
321    pub auto_execute_count: usize,
322
323    /// 確認・二次検証要求 (ConfirmOrEscalate) と判定された質問数。
324    pub confirm_count: usize,
325
326    /// 安全弁フォールバック (Fallback) と判定された質問数。
327    pub fallback_count: usize,
328
329    /// System 2 へのエスカレーションまたは人手介入が必要であるかどうか。
330    pub escalation_needed: bool,
331}
332
333/// 単一の `Answer` に対して確信度ゲーティング評価を実施し、メタデータを生成する。
334///
335/// # 引数
336/// - `answer`: 事後較正済みの判定結果。
337/// - `question_id`: 質問識別子(プロンプト構築用、任意)。
338/// - `question`: 質問定義(指示文やタイプ取得用、任意)。
339/// - `config`: ゲーティング設定。
340///
341/// # 戻り値
342/// - 導出された `GatingMetadata`。
343pub 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
352/// 単一の `Answer` に対し、自由エネルギー情報を含めて確信度ゲーティング評価を実施し、メタデータを生成する。
353///
354/// # 概要
355/// - 通常の確信度・マージン判定に先立ち、Energy-based OOD 安全弁 (Circuit Breaker) を評価する。
356/// - Choice 型において正規化自由エネルギー $E_{\text{norm}}$ が `config.energy_threshold` を超過した場合、
357///   高確信度判定をショートサーキットし、強制的に `DecisionRoute::Fallback` へ降格する。
358///
359/// # 引数
360/// - `answer`: 事後較正済みの判定結果。
361/// - `question_id`: 質問識別子(プロンプト構築用、任意)。
362/// - `question`: 質問定義(指示文やタイプ取得用、任意)。
363/// - `config`: ゲーティング設定。
364/// - `energy`: 算出された正規化自由エネルギー値 (任意)。
365///
366/// # 戻り値
367/// - 導出された `GatingMetadata`。
368pub 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    // ゼロアロケーション走査による上位 2 候補の確率マージン (Top-Margin) 算出。
378    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    // 分布エントロピー・散らばり指標の直接算出。
407    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            // Score 型の場合、confidence は正規化分散確信度 C_var であるため、散らばり指標として正規化分散 (1 - C_var) を記録する。
418            answer.confidence.map(|c| (1.0 - c).clamp(0.0, 1.0))
419        }
420        Some(QuestionType::Noul) => {
421            // Noul 型の場合、二値エントロピー指標 (底 2 のシャノンエントロピー) を記録する。
422            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    // OOD 判定 (Pre-filter / Circuit Breaker): Choice 型かつ ood_enabled
437    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    // 閾値判定とマージンガード (OOD 検知時は最優先で Fallback へ短絡降格)。
489    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    // エスカレーションコンテキストの遅延生成 (ConfirmOrEscalate または Fallback 時のみアロケーションを実行)。
543    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
606/// System 2 (大型自己回帰LLM) に向けた委託用プロンプト案を生成する。
607fn 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
656/// リクエストに含まれる全回答のゲーティング情報から集約サマリーを算出する。
657///
658/// 最悪値ルール(Conservative Policy)を適用する:
659/// - 1 つでも `Fallback` が存在する場合 $\rightarrow$ 全体も `Fallback`
660/// - そうでなく 1 つでも `ConfirmOrEscalate` が存在する場合 $\rightarrow$ 全体も `ConfirmOrEscalate`
661/// - 全ての質問が `AutoExecute` である場合 $\rightarrow$ 全体も `AutoExecute`
662pub 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                // ゲーティング未適用の場合は実効確信度から簡易分類
674                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        // 確信度は 0.75 と高水準だが、Top-1 と Top-2 が僅差 (0.48 vs 0.44 -> margin 0.04 < 0.15) のケース
755        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        // High: P=0.96 -> C=|2*0.96 - 1| = 0.92 >= 0.70
807        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        // Low: P=0.52 -> C=|2*0.52 - 1| = 0.04 < 0.35
812        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        // Fallback が 1 つでも加わると全体が Fallback
855        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        // 見かけ上は高確信度 (conf=0.90, margin=0.80) だが、自由エネルギーが異常に高い (energy=0.5 > -1.0)
876        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        // OOD 入力: energy = 0.5 (閾値 -1.0 を超過)
883        let meta = evaluate_answer_gating_with_energy(
884            &answer,
885            Some("department"),
886            None,
887            &config,
888            Some(0.5),
889        );
890
891        // 高確信度をショートサーキットして強制的に Fallback へ降格
892        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        // 既知の適合入力 (ID): energy = -3.5 <= -1.0 で正常
902        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        // ood_enabled が false の場合、エネルギーが高くても OOD 安全弁が発動しない
925        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}