sokuto_server/guardrails/
bimodal.rs1use 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#[derive(Debug, Clone, PartialEq)]
18pub struct BimodalConfig {
19 pub enabled: bool,
21 pub bc_threshold: f64,
23 pub var_ratio_threshold: f64,
25 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#[derive(Debug, Clone, PartialEq)]
42pub struct BimodalReport {
43 pub is_bimodal: bool,
45 pub bc: f64,
47 pub var_ratio: f64,
49 pub variance: f64,
51 pub expected_score: f64,
53}
54
55#[derive(Debug, Clone)]
57pub struct BimodalDetector {
58 config: BimodalConfig,
59}
60
61impl BimodalDetector {
62 pub fn new(config: BimodalConfig) -> Self {
64 Self { config }
65 }
66
67 pub fn config(&self) -> &BimodalConfig {
69 &self.config
70 }
71
72 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 if probabilities[0] > probabilities[1] + 1e-4 {
86 peaks += 1;
87 }
88
89 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 if probabilities[m - 1] > probabilities[m - 2] + 1e-4 {
100 peaks += 1;
101 }
102
103 peaks
104 }
105
106 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
153pub 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 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 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 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 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 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 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 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}