sokuto_server/guardrails/
nli.rs1use std::collections::HashSet;
16use std::sync::LazyLock;
17
18use regex::Regex;
19use serde::{Deserialize, Serialize};
20
21static NUMERIC_ENTITY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
23 Regex::new(r"(?P<val>[0-90-9]+(?:[,\.,.][0-90-9]+)*)\s*(?P<unit>万|億|兆|円|ドル|%|%|個|本|件|人|倍)?")
24 .expect("数値エンティティ抽出正規表現のコンパイルに成功すること。")
25});
26
27static DATE_ENTITY_REGEX: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(r"(?P<year>[0-90-9]{4})\s*[年\-/]\s*(?P<month>[0-90-9]{1,2})\s*[月\-/]\s*(?P<day>[0-90-9]{1,2})\s*日?")
30 .expect("日付エンティティ抽出正規表現のコンパイルに成功すること。")
31});
32
33static SYNONYM_PAIRS: LazyLock<Vec<(&'static str, &'static str)>> = LazyLock::new(|| {
35 vec![
36 ("倍増", "100%増"),
37 ("倍増", "2倍"),
38 ("二倍", "2倍"),
39 ("減少", "低下"),
40 ("増加", "上昇"),
41 ("完了", "終了"),
42 ("完了", "済"),
43 ("開始", "スタート"),
44 ("売上", "売上高"),
45 ("利益", "純利益"),
46 ("不具合", "バグ"),
47 ("欠陥", "不良"),
48 ("承認", "許可"),
49 ("拒否", "却下"),
50 ]
51});
52
53#[derive(Debug, Clone, PartialEq)]
55pub struct HallucinationVerifierConfig {
56 pub enabled: bool,
58 pub base_threshold: f64,
61 pub synonym_margin_beta: f64,
63 pub enable_entity_mismatch_check: bool,
65}
66
67impl Default for HallucinationVerifierConfig {
68 fn default() -> Self {
69 Self {
70 enabled: true,
71 base_threshold: 0.60,
72 synonym_margin_beta: 0.20,
73 enable_entity_mismatch_check: true,
74 }
75 }
76}
77
78#[derive(Debug, Clone, PartialEq)]
80pub struct NormalizedEntity {
81 pub raw: String,
83 pub normalized_value: f64,
85 pub unit: Option<String>,
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct HallucinationVerificationResult {
92 pub entailment_prob: f64,
94 pub neutral_prob: f64,
96 pub contradiction_prob: f64,
98 pub hallucination_score: f64,
100 pub effective_threshold: f64,
102 pub synonym_coverage: f64,
104 pub entity_mismatch: bool,
106 pub is_hallucination: bool,
108 pub reason: Option<String>,
110}
111
112pub fn extract_normalized_entities(text: &str) -> Vec<NormalizedEntity> {
114 let mut entities = Vec::new();
115
116 for caps in DATE_ENTITY_REGEX.captures_iter(text) {
118 let (Some(y_m), Some(m_m), Some(d_m)) =
119 (caps.name("year"), caps.name("month"), caps.name("day"))
120 else {
121 continue;
122 };
123
124 let parse_digits = |s: &str| -> Option<u32> {
125 let clean = s
126 .chars()
127 .map(|c| match c {
128 '0'..='9' => (c as u32 - '0' as u32 + '0' as u32) as u8 as char,
129 other => other,
130 })
131 .collect::<String>();
132 clean.parse::<u32>().ok()
133 };
134
135 if let (Some(y), Some(m), Some(d)) = (
136 parse_digits(y_m.as_str()),
137 parse_digits(m_m.as_str()),
138 parse_digits(d_m.as_str()),
139 ) {
140 let date_val = (y * 10_000 + m * 100 + d) as f64;
141 entities.push(NormalizedEntity {
142 raw: caps
143 .get(0)
144 .map(|m| m.as_str().to_string())
145 .unwrap_or_default(),
146 normalized_value: date_val,
147 unit: Some("date".to_string()),
148 });
149 }
150 }
151
152 for caps in NUMERIC_ENTITY_REGEX.captures_iter(text) {
154 let Some(val_match) = caps.name("val") else {
155 continue;
156 };
157
158 let clean_val_str = val_match
160 .as_str()
161 .replace([',', ','], "")
162 .chars()
163 .map(|c| match c {
164 '0'..='9' => (c as u32 - '0' as u32 + '0' as u32) as u8 as char,
165 '.' => '.',
166 other => other,
167 })
168 .collect::<String>();
169
170 let Ok(mut val) = clean_val_str.parse::<f64>() else {
171 continue;
172 };
173
174 let unit = caps.name("unit").map(|m| m.as_str().to_string());
175
176 if let Some(ref u) = unit {
178 match u.as_str() {
179 "万" => val *= 10_000.0,
180 "億" => val *= 100_000_000.0,
181 "兆" => val *= 1_000_000_000_000.0,
182 _ => {}
183 }
184 }
185
186 entities.push(NormalizedEntity {
187 raw: caps
188 .get(0)
189 .map(|m| m.as_str().to_string())
190 .unwrap_or_default(),
191 normalized_value: val,
192 unit,
193 });
194 }
195
196 entities
197}
198
199pub fn check_entity_mismatch(state: &str, claim: &str) -> bool {
203 let state_entities = extract_normalized_entities(state);
204 let claim_entities = extract_normalized_entities(claim);
205
206 if claim_entities.is_empty() {
207 return false;
208 }
209
210 let state_values: Vec<f64> = state_entities.iter().map(|e| e.normalized_value).collect();
212
213 for claim_ent in &claim_entities {
214 let found = state_values
216 .iter()
217 .any(|&sv| (sv - claim_ent.normalized_value).abs() / (sv.abs().max(1.0)) < 1e-4);
218
219 if !found {
220 let is_covered_by_synonym = SYNONYM_PAIRS.iter().any(|(s1, s2)| {
222 (state.contains(s1)
223 && claim.contains(s2)
224 && (claim_ent.raw.contains(s2) || s2.contains(&claim_ent.raw)))
225 || (state.contains(s2)
226 && claim.contains(s1)
227 && (claim_ent.raw.contains(s1) || s1.contains(&claim_ent.raw)))
228 });
229
230 if !is_covered_by_synonym {
231 return true;
233 }
234 }
235 }
236
237 false
238}
239
240pub fn compute_synonym_coverage(state: &str, claim: &str) -> f64 {
242 let mut state_words = HashSet::new();
243 for (s1, s2) in SYNONYM_PAIRS.iter() {
244 if state.contains(s1) {
245 state_words.insert(*s1);
246 state_words.insert(*s2);
247 }
248 if state.contains(s2) {
249 state_words.insert(*s1);
250 state_words.insert(*s2);
251 }
252 }
253
254 if state_words.is_empty() {
255 return 0.0;
256 }
257
258 let mut matches = 0;
259 let mut total_claim_terms = 0;
260
261 for (s1, s2) in SYNONYM_PAIRS.iter() {
262 let has_s1 = claim.contains(s1);
263 let has_s2 = claim.contains(s2);
264 if has_s1 || has_s2 {
265 total_claim_terms += 1;
266 if (has_s1 && state_words.contains(s1)) || (has_s2 && state_words.contains(s2)) {
267 matches += 1;
268 }
269 }
270 }
271
272 if total_claim_terms == 0 {
273 0.0
274 } else {
275 (matches as f64) / (total_claim_terms as f64)
276 }
277}
278
279pub fn verify_claim_nli(
287 state: &str,
288 claim: &str,
289 nli_logits: [f64; 3],
290 config: &HallucinationVerifierConfig,
291) -> HallucinationVerificationResult {
292 let max_logit = nli_logits.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
294 let exp_e = (nli_logits[0] - max_logit).exp();
295 let exp_n = (nli_logits[1] - max_logit).exp();
296 let exp_c = (nli_logits[2] - max_logit).exp();
297 let sum_exp = exp_e + exp_n + exp_c;
298
299 let p_entailment = exp_e / sum_exp;
300 let p_neutral = exp_n / sum_exp;
301 let p_contradiction = exp_c / sum_exp;
302
303 let hallucination_score = 1.0 - p_entailment;
304
305 let entity_mismatch = if config.enable_entity_mismatch_check {
307 check_entity_mismatch(state, claim)
308 } else {
309 false
310 };
311
312 let synonym_cov = compute_synonym_coverage(state, claim);
314 let effective_threshold =
316 (config.base_threshold - config.synonym_margin_beta * synonym_cov).max(0.10);
317
318 let is_hallucination = entity_mismatch || (p_entailment < effective_threshold);
320
321 let reason = if entity_mismatch {
322 Some("数値・エンティティの不整合を検知しました (Entity Mismatch)。".to_string())
323 } else if is_hallucination {
324 Some(format!(
325 "含意確率 ({:.3}) が要求閾値 ({:.3}) を下回りました (ハルシネーションスコア: {:.3})。",
326 p_entailment, effective_threshold, hallucination_score
327 ))
328 } else {
329 None
330 };
331
332 HallucinationVerificationResult {
333 entailment_prob: p_entailment,
334 neutral_prob: p_neutral,
335 contradiction_prob: p_contradiction,
336 hallucination_score,
337 effective_threshold,
338 synonym_coverage: synonym_cov,
339 entity_mismatch,
340 is_hallucination,
341 reason,
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn test_extract_normalized_entities_scales() {
351 let text = "契約金額は 1,000万円で、売上は 5億円、前年比 150% です。";
352 let entities = extract_normalized_entities(text);
353 assert_eq!(entities.len(), 3);
354 assert_eq!(entities[0].normalized_value, 10_000_000.0);
355 assert_eq!(entities[1].normalized_value, 500_000_000.0);
356 assert_eq!(entities[2].normalized_value, 150.0);
357 }
358
359 #[test]
360 fn test_entity_mismatch_flag() {
361 let state = "契約金は 1,000万円です。";
362 let claim_ok = "金額は 10,000,000円と合意されました。";
363 assert!(!check_entity_mismatch(state, claim_ok));
364
365 let claim_bad = "契約金は 1,000億円です。";
366 assert!(check_entity_mismatch(state, claim_bad));
367 }
368
369 #[test]
370 fn test_synonym_coverage_and_soft_margin() {
371 let state = "今期の売上は前年比倍増しました。";
372 let claim = "売上高は 2倍になりました。";
373 let cov = compute_synonym_coverage(state, claim);
374 assert!(cov > 0.0);
375
376 let config = HallucinationVerifierConfig::default();
377 let res = verify_claim_nli(state, claim, [1.5, 0.5, -1.0], &config);
379 assert!(res.effective_threshold < config.base_threshold);
380 assert!(!res.is_hallucination);
381 }
382
383 #[test]
384 fn test_extract_normalized_decimal_and_dates() {
385 let text = "締結日は 2026年10月4日、投資額は 1.5億円 (全角: 1.5億円) です。";
386 let entities = extract_normalized_entities(text);
387
388 assert!(
390 entities
391 .iter()
392 .any(|e| (e.normalized_value - 20261004.0).abs() < 1e-4)
393 );
394 assert!(
395 entities
396 .iter()
397 .any(|e| (e.normalized_value - 150_000_000.0).abs() < 1e-4)
398 );
399
400 let state = "締結日は 2026年10月4日です。";
402 let claim_bad_date = "締結日は 2026年10月5日です。";
403 assert!(check_entity_mismatch(state, claim_bad_date));
404
405 let claim_good_date = "締結日は 2026-10-04 です。";
406 assert!(!check_entity_mismatch(state, claim_good_date));
407 }
408}