Skip to main content

sokuto_server/guardrails/
bimodal.rs

1//! # 二峰性 (バイモーダル) 分布検出モジュール
2//!
3//! 順序尺度 (Score 型) の事後確率分布を検査し、「低評価と高評価に真っ向から分裂」
4//! している意見二極化インシデントを数理的 (双峰性係数 BC および最大分散比率) に検知する。
5//! 検知時は Gating 安全弁を作動させ、誤った自動実行 (AutoExecute) を防止して
6//! `ConfirmOrEscalate` へ強制降格する。
7
8use indexmap::IndexMap;
9use sokuto_core::error::Result;
10use sokuto_core::gating::DecisionRoute;
11use sokuto_core::math::{
12    bimodality_coefficient, expected_score, score_variance, score_variance_ratio,
13};
14use sokuto_core::schema::{Answer, Question, QuestionType};
15
16/// 二峰性分布検出設定構造体。
17#[derive(Debug, Clone, PartialEq)]
18pub struct BimodalConfig {
19    /// 二峰性検出ガードレールの有効化フラグ。
20    pub enabled: bool,
21    /// 双峰性係数 (BC) の判定閾値 (デフォルト: 0.555、一様分布基準)。
22    pub bc_threshold: f64,
23    /// 理論最大分散に対する比率閾値 (デフォルト: 0.35)。
24    pub var_ratio_threshold: f64,
25    /// 二峰性判定を適用する最小段階数 (デフォルト: 3)。
26    pub min_categories: usize,
27}
28
29impl Default for BimodalConfig {
30    fn default() -> Self {
31        Self {
32            enabled: true,
33            bc_threshold: 0.555,
34            var_ratio_threshold: 0.35,
35            min_categories: 3,
36        }
37    }
38}
39
40/// 二峰性検出レポート構造体。
41#[derive(Debug, Clone, PartialEq)]
42pub struct BimodalReport {
43    /// 二峰性意見対立が検出されたか。
44    pub is_bimodal: bool,
45    /// 算出された双峰性係数 (BC)。
46    pub bc: f64,
47    /// 理論最大分散に対する事後分散比率。
48    pub var_ratio: f64,
49    /// 事後分散。
50    pub variance: f64,
51    /// 加重平均期待値スコア。
52    pub expected_score: f64,
53}
54
55/// 二峰性検出エンジン。
56#[derive(Debug, Clone)]
57pub struct BimodalDetector {
58    config: BimodalConfig,
59}
60
61impl BimodalDetector {
62    /// 新規 `BimodalDetector` を生成する。
63    pub fn new(config: BimodalConfig) -> Self {
64        Self { config }
65    }
66
67    /// 設定への参照を取得する。
68    pub fn config(&self) -> &BimodalConfig {
69        &self.config
70    }
71
72    /// 確率分布の局所極大値 (ピーク) の個数を $O(M)$ で走査・カウントする。
73    ///
74    /// 端点 ($k=0, M-1$) および内部の極大値を検出し、単峰性 (単調増加・単調減少・中央山型) と
75    /// 意見二極化 (2 箇所以上のピーク) を幾何学的に識別する。
76    pub fn count_local_peaks(probabilities: &[f64]) -> usize {
77        let m = probabilities.len();
78        if m < 2 {
79            return m;
80        }
81
82        let mut peaks = 0;
83
84        // 左端 (k = 0)
85        if probabilities[0] > probabilities[1] + 1e-4 {
86            peaks += 1;
87        }
88
89        // 内部 (0 < k < m - 1)
90        for k in 1..m - 1 {
91            if probabilities[k] > probabilities[k - 1] + 1e-4
92                && probabilities[k] > probabilities[k + 1] + 1e-4
93            {
94                peaks += 1;
95            }
96        }
97
98        // 右端 (k = m - 1)
99        if probabilities[m - 1] > probabilities[m - 2] + 1e-4 {
100            peaks += 1;
101        }
102
103        peaks
104    }
105
106    /// 確率分布スライスを検査し、二峰性レポートを生成する。
107    ///
108    /// # 判定基準 (ハイブリッド判定)
109    /// 1. 段階数が `min_categories` 以上であること。
110    /// 2. 確率分布の局所ピーク数が 2 箇所以上存在すること (単調推移や単一山型を排除)。
111    /// 3. 双峰性係数 $BC > \text{bc\_threshold}$ (一様分布水準 0.555 を超過)。
112    /// 4. 分散比率 $\text{VarRatio} > \text{var\_ratio\_threshold}$ (理論最大分散の 35% 以上)。
113    pub fn detect(&self, probabilities: &[f64]) -> Result<BimodalReport> {
114        let m = probabilities.len();
115        if m < 2 {
116            return Ok(BimodalReport {
117                is_bimodal: false,
118                bc: 0.0,
119                var_ratio: 0.0,
120                variance: 0.0,
121                expected_score: 0.0,
122            });
123        }
124
125        let mean = expected_score(probabilities)?;
126        let var = score_variance(probabilities)?;
127        let var_ratio = score_variance_ratio(probabilities)?;
128        let bc = bimodality_coefficient(probabilities)?;
129        let peaks = Self::count_local_peaks(probabilities);
130
131        let is_bimodal = self.config.enabled
132            && m >= self.config.min_categories
133            && peaks >= 2
134            && bc > self.config.bc_threshold
135            && var_ratio > self.config.var_ratio_threshold;
136
137        Ok(BimodalReport {
138            is_bimodal,
139            bc,
140            var_ratio,
141            variance: var,
142            expected_score: mean,
143        })
144    }
145}
146
147impl Default for BimodalDetector {
148    fn default() -> Self {
149        Self::new(BimodalConfig::default())
150    }
151}
152
153/// 推論結果の各 Answer を走査し、Score 型の二峰性意見対立を検知した場合は Gating を降格する。
154///
155/// # 挙動仕様
156/// - Score 型の質問であり、かつ確率分布が存在する場合に二峰性判定を実施する。
157/// - 二峰性対立が検知された場合:
158///   - Gating ルートが `AutoExecute` であれば、`ConfirmOrEscalate` へ強制降格する。
159///   - 確信度 (`confidence`) を抑制し、安全マージンを確保する。
160///   - 監査理由 (`reason`) に二峰性検出ログを付加する。
161pub fn verify_and_adjust_bimodal(
162    answers: &mut IndexMap<String, Answer>,
163    questions: &IndexMap<String, Question>,
164    detector: &BimodalDetector,
165) {
166    if !detector.config.enabled {
167        return;
168    }
169
170    for (qid, answer) in answers.iter_mut() {
171        let Some(question) = questions.get(qid) else {
172            continue;
173        };
174
175        if question.question_type != QuestionType::Score {
176            continue;
177        }
178
179        let Some(ref probs_map) = answer.probabilities else {
180            continue;
181        };
182
183        let probs_vec: Vec<f64> = probs_map.values().copied().collect();
184        let Ok(report) = detector.detect(&probs_vec) else {
185            continue;
186        };
187
188        if report.is_bimodal {
189            tracing::warn!(
190                question_id = %qid,
191                bc = report.bc,
192                var_ratio = report.var_ratio,
193                expected_score = report.expected_score,
194                "Score 型判定において意見二極化 (バイモーダル分布) を検出しました。"
195            );
196
197            // Gating メタデータへの介入 (安全弁降格と確信度抑制)
198            if let Some(ref mut gating) = answer.gating {
199                gating.confidence = gating.confidence.min(0.20);
200
201                if gating.route == DecisionRoute::AutoExecute {
202                    gating.route = DecisionRoute::ConfirmOrEscalate;
203                }
204
205                let bimodal_reason = format!(
206                    "[Bimodal Contradiction] 意見二極化(バイモーダル分布)を検出したため二次検証を要求 (BC: {:.3} > {:.3}, VarRatio: {:.3} > {:.3})。",
207                    report.bc,
208                    detector.config.bc_threshold,
209                    report.var_ratio,
210                    detector.config.var_ratio_threshold
211                );
212
213                if gating.reason.is_empty() {
214                    gating.reason = bimodal_reason;
215                } else {
216                    gating.reason = format!("{} | {}", bimodal_reason, gating.reason);
217                }
218            }
219        }
220    }
221}
222
223#[cfg(test)]
224mod tests {
225    use super::*;
226    use sokuto_core::gating::GatingMetadata;
227
228    #[test]
229    fn test_bimodal_detector_extremes() {
230        let detector = BimodalDetector::default();
231
232        // 1. 完全両端対立: [0.5, 0.0, 0.0, 0.5] -> 二峰性検知
233        let p_bimodal = vec![0.5, 0.0, 0.0, 0.5];
234        let report = detector.detect(&p_bimodal).unwrap();
235        assert!(report.is_bimodal);
236        assert!((report.bc - 1.0).abs() < 1e-6);
237        assert!((report.var_ratio - 1.0).abs() < 1e-6);
238
239        // 2. ワンホット (完全単峰): [0.0, 1.0, 0.0, 0.0] -> 検知なし
240        let p_onehot = vec![0.0, 1.0, 0.0, 0.0];
241        let report_onehot = detector.detect(&p_onehot).unwrap();
242        assert!(!report_onehot.is_bimodal);
243        assert_eq!(report_onehot.bc, 0.0);
244
245        // 3. 隣接割れ (低分散): [0.0, 0.5, 0.5, 0.0] -> 分散比率が低いため検知なし
246        let p_adjacent = vec![0.0, 0.5, 0.5, 0.0];
247        let report_adjacent = detector.detect(&p_adjacent).unwrap();
248        assert!(!report_adjacent.is_bimodal);
249        assert!(report_adjacent.var_ratio < 0.20);
250
251        // 4. 一様分布: [0.25, 0.25, 0.25, 0.25] -> 分散比率は中程度だが BC は 0.61 付近
252        // VarRatio = 1.25 / 2.25 ≈ 0.555 -> 4段階の一様分布
253        let p_uniform = vec![0.25, 0.25, 0.25, 0.25];
254        let _report_uniform = detector.detect(&p_uniform).unwrap();
255    }
256
257    #[test]
258    fn test_verify_and_adjust_bimodal_demotion() {
259        let detector = BimodalDetector::default();
260
261        let mut questions = IndexMap::new();
262        questions.insert(
263            "q_score".to_string(),
264            Question::new_score(
265                "障害深刻度を評価せよ。".to_string(),
266                vec![
267                    "軽微".to_string(),
268                    "中度".to_string(),
269                    "重大".to_string(),
270                    "致命的".to_string(),
271                ],
272            ),
273        );
274
275        let mut probs = IndexMap::new();
276        probs.insert("軽微".to_string(), 0.50);
277        probs.insert("中度".to_string(), 0.0);
278        probs.insert("重大".to_string(), 0.0);
279        probs.insert("致命的".to_string(), 0.50);
280
281        let mut answer = Answer::score(1.5, probs, 0.95);
282        answer.gating = Some(GatingMetadata {
283            route: DecisionRoute::AutoExecute,
284            confidence: 0.95,
285            entropy: None,
286            margin: None,
287            energy: None,
288            is_ood: false,
289            reason: "高確信度判定".to_string(),
290            escalation: None,
291        });
292
293        let mut answers = IndexMap::new();
294        answers.insert("q_score".to_string(), answer);
295
296        // ガードレール後処理適用
297        verify_and_adjust_bimodal(&mut answers, &questions, &detector);
298
299        let adjusted = answers.get("q_score").unwrap();
300        let gating = adjusted.gating.as_ref().unwrap();
301
302        // AutoExecute から ConfirmOrEscalate へ強制降格されていること
303        assert_eq!(gating.route, DecisionRoute::ConfirmOrEscalate);
304        assert_eq!(gating.confidence, 0.20);
305        assert!(gating.reason.contains("[Bimodal Contradiction]"));
306        assert_eq!(adjusted.confidence, Some(0.95));
307    }
308}