1use indexmap::IndexMap;
8use sokuto_core::contract::CalibrationConfig;
9pub use sokuto_core::gating::DecisionRoute;
10use sokuto_core::gating::{GatingConfig, SystemRoutingSummary, evaluate_response_routing};
11use sokuto_core::schema::{Answer, Question};
12
13pub fn resolve_gating_config(
26 gating_config: Option<&GatingConfig>,
27 calib_config: &CalibrationConfig,
28) -> GatingConfig {
29 match gating_config {
30 Some(cfg) => cfg.clone(),
31 None => calib_config.gating_config(),
32 }
33}
34
35use crate::engine::escalation::build_rich_escalation_prompt;
36use sokuto_core::gating::evaluate_answer_gating_with_energy;
37use sokuto_core::math::normalized_free_energy;
38use sokuto_core::schema::QuestionType;
39
40pub fn calculate_choice_energy(
54 question: &Question,
55 logits: &[f64],
56 calib_config: &CalibrationConfig,
57) -> Option<f64> {
58 calculate_choice_energy_with_config(question, logits, calib_config, None)
59}
60
61pub fn calculate_choice_energy_with_config(
77 question: &Question,
78 logits: &[f64],
79 calib_config: &CalibrationConfig,
80 gating_config: Option<&GatingConfig>,
81) -> Option<f64> {
82 if question.question_type != QuestionType::Choice || logits.is_empty() {
83 return None;
84 }
85 let k = logits.len();
86 let temp = gating_config
87 .and_then(|cfg| cfg.energy_temperature)
88 .or(calib_config.gating_thresholds.energy_temperature)
89 .unwrap_or_else(|| calib_config.get_temperature(QuestionType::Choice, k));
90 normalized_free_energy(logits, temp).ok()
91}
92
93pub fn apply_gating_to_answer_with_energy(
109 answer: &mut Answer,
110 energy: Option<f64>,
111 state: Option<&str>,
112 question_id: Option<&str>,
113 question: Option<&Question>,
114 gating_config: Option<&GatingConfig>,
115 calib_config: &CalibrationConfig,
116) {
117 let config = resolve_gating_config(gating_config, calib_config);
118 if config.enabled {
119 let mut meta =
120 evaluate_answer_gating_with_energy(answer, question_id, question, &config, energy);
121 if meta.route != DecisionRoute::AutoExecute {
122 let rich_prompt = meta.escalation.as_ref().map(|esc| {
123 build_rich_escalation_prompt(
124 state,
125 question_id,
126 question,
127 &meta,
128 &esc.top_candidates,
129 )
130 });
131 if let (Some(esc), Some(prompt)) = (&mut meta.escalation, rich_prompt) {
132 esc.prompt_template = Some(prompt);
133 }
134 }
135 answer.gating = Some(meta);
136 }
137}
138
139pub fn apply_gating_to_answer_with_logits(
150 answer: &mut Answer,
151 logits: Option<&[f64]>,
152 state: Option<&str>,
153 question_id: Option<&str>,
154 question: Option<&Question>,
155 gating_config: Option<&GatingConfig>,
156 calib_config: &CalibrationConfig,
157) {
158 let energy = match (question, logits) {
159 (Some(q), Some(lg)) => {
160 calculate_choice_energy_with_config(q, lg, calib_config, gating_config)
161 }
162 _ => None,
163 };
164 apply_gating_to_answer_with_energy(
165 answer,
166 energy,
167 state,
168 question_id,
169 question,
170 gating_config,
171 calib_config,
172 );
173}
174
175pub fn apply_gating_to_answer(
189 answer: &mut Answer,
190 state: Option<&str>,
191 question_id: Option<&str>,
192 question: Option<&Question>,
193 gating_config: Option<&GatingConfig>,
194 calib_config: &CalibrationConfig,
195) {
196 apply_gating_to_answer_with_energy(
197 answer,
198 None,
199 state,
200 question_id,
201 question,
202 gating_config,
203 calib_config,
204 );
205}
206
207pub fn apply_gating_to_answers_with_logits(
222 answers: &mut IndexMap<String, Answer>,
223 logits_map: &IndexMap<String, Vec<f64>>,
224 state: Option<&str>,
225 questions: &IndexMap<String, Question>,
226 gating_config: Option<&GatingConfig>,
227 calib_config: &CalibrationConfig,
228) -> Option<SystemRoutingSummary> {
229 let config = resolve_gating_config(gating_config, calib_config);
230 if !config.enabled {
231 return None;
232 }
233
234 for (qid, answer) in answers.iter_mut() {
235 let q_def = questions.get(qid);
236 let q_logits = logits_map.get(qid).map(|v| v.as_slice());
237 apply_gating_to_answer_with_logits(
238 answer,
239 q_logits,
240 state,
241 Some(qid),
242 q_def,
243 gating_config,
244 calib_config,
245 );
246 }
247
248 Some(evaluate_response_routing(answers))
249}
250
251pub fn apply_gating_to_answers(
265 answers: &mut IndexMap<String, Answer>,
266 state: Option<&str>,
267 questions: &IndexMap<String, Question>,
268 gating_config: Option<&GatingConfig>,
269 calib_config: &CalibrationConfig,
270) -> Option<SystemRoutingSummary> {
271 let config = resolve_gating_config(gating_config, calib_config);
272 if !config.enabled {
273 return None;
274 }
275
276 for (qid, answer) in answers.iter_mut() {
277 let q_def = questions.get(qid);
278 apply_gating_to_answer(answer, state, Some(qid), q_def, gating_config, calib_config);
279 }
280
281 Some(evaluate_response_routing(answers))
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use sokuto_core::contract::calibration::GatingThresholds;
288
289 #[test]
290 fn test_resolve_gating_config_cascade() {
291 let calib = CalibrationConfig {
292 gating_thresholds: GatingThresholds {
293 high_threshold: 0.72,
294 low_threshold: 0.38,
295 top_margin_threshold: 0.18,
296 ..Default::default()
297 },
298 ..Default::default()
299 };
300
301 let resolved_default = resolve_gating_config(None, &calib);
303 assert!(resolved_default.enabled);
304 assert!((resolved_default.high_threshold - 0.72).abs() < 1e-9);
305
306 let explicit = GatingConfig {
308 enabled: false,
309 high_threshold: 0.90,
310 low_threshold: 0.40,
311 top_margin_threshold: 0.20,
312 ood_enabled: true,
313 energy_threshold: -1.0,
314 ..GatingConfig::default()
315 };
316 let resolved_explicit = resolve_gating_config(Some(&explicit), &calib);
317 assert!(!resolved_explicit.enabled);
318 assert!((resolved_explicit.high_threshold - 0.90).abs() < 1e-9);
319 }
320
321 #[test]
322 fn test_apply_gating_to_answer() {
323 let calib = CalibrationConfig::default();
324 let mut probs = IndexMap::new();
325 probs.insert("opt_a".to_string(), 0.95);
326 probs.insert("opt_b".to_string(), 0.05);
327
328 let mut ans = Answer::choice("opt_a", probs, 0.90);
329 assert!(ans.gating.is_none());
330
331 apply_gating_to_answer(&mut ans, Some("文脈"), Some("q1"), None, None, &calib);
332 assert!(ans.gating.is_some());
333 assert_eq!(
334 ans.gating.as_ref().unwrap().route,
335 DecisionRoute::AutoExecute
336 );
337 assert!(ans.gating.as_ref().unwrap().escalation.is_none());
339 }
340
341 #[test]
342 fn test_apply_gating_to_answer_disabled() {
343 let calib = CalibrationConfig::default();
344 let disabled_cfg = GatingConfig {
345 enabled: false,
346 ..GatingConfig::default()
347 };
348
349 let mut probs = IndexMap::new();
350 probs.insert("opt_a".to_string(), 0.95);
351 probs.insert("opt_b".to_string(), 0.05);
352
353 let mut ans = Answer::choice("opt_a", probs, 0.90);
354 apply_gating_to_answer(
355 &mut ans,
356 None,
357 Some("q1"),
358 None,
359 Some(&disabled_cfg),
360 &calib,
361 );
362 assert!(ans.gating.is_none());
363 }
364
365 #[test]
366 fn test_apply_gating_to_answer_escalation_rich_prompt() {
367 let calib = CalibrationConfig::default();
368 let mut criteria = IndexMap::new();
369 criteria.insert("opt_a".to_string(), "オプションAの詳細説明".to_string());
370 criteria.insert("opt_b".to_string(), "オプションBの詳細説明".to_string());
371 let q = Question::new_choice("指示文", criteria);
372
373 let mut probs = IndexMap::new();
374 probs.insert("opt_a".to_string(), 0.51);
375 probs.insert("opt_b".to_string(), 0.49);
376
377 let mut ans = Answer::choice("opt_a", probs, 0.51);
378 apply_gating_to_answer(
379 &mut ans,
380 Some("ユーザー問い合わせ文脈"),
381 Some("q_test"),
382 Some(&q),
383 None,
384 &calib,
385 );
386
387 assert!(ans.gating.is_some());
388 let meta = ans.gating.as_ref().unwrap();
389 assert_eq!(meta.route, DecisionRoute::ConfirmOrEscalate);
390 assert!(meta.escalation.is_some());
391
392 let prompt = meta
393 .escalation
394 .as_ref()
395 .unwrap()
396 .prompt_template
397 .as_ref()
398 .unwrap();
399
400 assert!(prompt.contains("<context>"));
401 assert!(prompt.contains("ユーザー問い合わせ文脈"));
402 assert!(prompt.contains("</context>"));
403 assert!(prompt.contains("オプションAの詳細説明"));
404 assert!(prompt.contains("System 1 の判定では候補"));
405 assert!(prompt.contains("思考連鎖 (Chain-of-Thought)"));
406 assert!(prompt.contains("```json"));
407 }
408
409 #[test]
410 fn test_calculate_choice_energy() {
411 let calib = CalibrationConfig::default();
412 let mut criteria = IndexMap::new();
413 criteria.insert("a".to_string(), "説明A".to_string());
414 criteria.insert("b".to_string(), "説明B".to_string());
415 let q = Question::new_choice("指示", criteria);
416
417 let id_logits = [5.0, -2.0];
419 let energy_id = calculate_choice_energy(&q, &id_logits, &calib).unwrap();
420 let ood_logits = [0.1, 0.1];
422 let energy_ood = calculate_choice_energy(&q, &ood_logits, &calib).unwrap();
423
424 assert!(energy_id < energy_ood);
426 }
427
428 #[test]
429 fn test_apply_gating_to_answer_with_energy_ood() {
430 let calib = CalibrationConfig::default();
431 let mut criteria = IndexMap::new();
432 criteria.insert("opt_a".to_string(), "説明A".to_string());
433 criteria.insert("opt_b".to_string(), "説明B".to_string());
434 let q = Question::new_choice("指示文", criteria);
435
436 let mut probs = IndexMap::new();
437 probs.insert("opt_a".to_string(), 0.95);
438 probs.insert("opt_b".to_string(), 0.05);
439
440 let mut ans = Answer::choice("opt_a", probs, 0.95);
442 apply_gating_to_answer_with_energy(
443 &mut ans,
444 Some(-0.2), Some("未定義カテゴリの文脈"),
446 Some("q_ood"),
447 Some(&q),
448 None,
449 &calib,
450 );
451
452 assert!(ans.gating.is_some());
453 let meta = ans.gating.as_ref().unwrap();
454 assert_eq!(meta.route, DecisionRoute::Fallback);
455 assert!(meta.is_ood);
456 assert_eq!(meta.energy, Some(-0.2));
457
458 let esc = meta.escalation.as_ref().unwrap();
460 let prompt = esc.prompt_template.as_ref().unwrap();
461 assert!(prompt.contains("None of the above / 該当なし"));
462 assert!(prompt.contains("未定義カテゴリ"));
463 }
464
465 #[test]
466 fn test_apply_gating_to_answer_with_logits() {
467 let calib = CalibrationConfig::default();
468 let mut criteria = IndexMap::new();
469 criteria.insert("opt_a".to_string(), "説明A".to_string());
470 criteria.insert("opt_b".to_string(), "説明B".to_string());
471 let q = Question::new_choice("指示文", criteria);
472
473 let mut probs = IndexMap::new();
474 probs.insert("opt_a".to_string(), 0.95);
475 probs.insert("opt_b".to_string(), 0.05);
476
477 let mut ans = Answer::choice("opt_a", probs, 0.95);
478 let id_logits = [5.0, -2.0];
480 apply_gating_to_answer_with_logits(
481 &mut ans,
482 Some(&id_logits),
483 Some("通常文脈"),
484 Some("q_id"),
485 Some(&q),
486 None,
487 &calib,
488 );
489
490 let meta = ans.gating.as_ref().unwrap();
491 assert_eq!(meta.route, DecisionRoute::AutoExecute);
492 assert!(!meta.is_ood);
493 assert!(meta.energy.is_some());
494 }
495}