Skip to main content

sokuto_server/guardrails/
nli.rs

1//! # 語彙正規化・エンティティ投影型 Post-Execution ハルシネーション検証モジュール
2//!
3//! System 2 (上位 LLM) が生成した主張文 (Claim) と前提文 (State) の整合性を検証する。
4//!
5//! ## 二段構えの検証パイプライン
6//! 1. **エンティティ・数値整合性検査 (`Entity Mismatch Flag`)**:
7//!    `arithmetic.rs` と連動し、数値・金額・パーセンテージ・日付表現を正規化抽出。
8//!    State と Claim の間で数値や単位が食い違っている場合、NLI の埋め込み空間の曖昧さを
9//!    待たずに即座に `Entity Mismatch Flag` を立てて遮断する。
10//! 2. **同義語カバレッジ連動の Soft Margin Calibration (動的マージン補正)**:
11//!    正当な言い換えによる過剰遮断 (偽陽性) を防止するため、同義語カバレッジに応じて
12//!    要求含意確率の閾値を動的に緩和する:
13//!    $$\tau_{\text{effective}} = \tau_0 - \beta \cdot \text{SynonymCoverage}(\text{State}, \text{Claim})$$
14
15use std::collections::HashSet;
16use std::sync::LazyLock;
17
18use regex::Regex;
19use serde::{Deserialize, Serialize};
20
21/// 数値・通貨・パーセント抽出用正規表現 (全角数字・ピリオド・カンマ対応)。
22static 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
27/// 年月日日付エンティティ抽出用正規表現 (2026年10月4日, 2026-10-04, 2026/10/04 等)。
28static 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
33/// 代表的な日本語同義語・言い換えペアリスト。
34static 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/// ハルシネーション検証設定構造体。
54#[derive(Debug, Clone, PartialEq)]
55pub struct HallucinationVerifierConfig {
56    /// ハルシネーション検証を有効にするか。
57    pub enabled: bool,
58    /// 基準要求含意確率閾値 $\tau_0$ (デフォルト: 0.60)。
59    /// 含意確率 $P(\text{Entailment}) < \tau_{\text{effective}}$ でハルシネーションと判定。
60    pub base_threshold: f64,
61    /// 同義語カバレッジによる緩和係数 $\beta$ (デフォルト: 0.20)。
62    pub synonym_margin_beta: f64,
63    /// 数値・エンティティ不一致時の即時遮断フラグを有効にするか。
64    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/// 抽出された正規化数値エンティティ。
79#[derive(Debug, Clone, PartialEq)]
80pub struct NormalizedEntity {
81    /// 表層文字列。
82    pub raw: String,
83    /// 正規化数値。
84    pub normalized_value: f64,
85    /// 単位 (存在する場合)。
86    pub unit: Option<String>,
87}
88
89/// 出力ハルシネーション検証結果。
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
91pub struct HallucinationVerificationResult {
92    /// 前提文に対する含意確率 $P(\text{Entailment} \mid S, c)$。
93    pub entailment_prob: f64,
94    /// 中立確率 $P(\text{Neutral} \mid S, c)$。
95    pub neutral_prob: f64,
96    /// 矛盾確率 $P(\text{Contradiction} \mid S, c)$。
97    pub contradiction_prob: f64,
98    /// ハルシネーションスコア $H(c \mid S) = 1.0 - P(\text{Entailment})$。
99    pub hallucination_score: f64,
100    /// 動的補正後の実効閾値 $\tau_{\text{effective}}$。
101    pub effective_threshold: f64,
102    /// 同義語カバレッジ比率。
103    pub synonym_coverage: f64,
104    /// 数値・固有表現不一致即死フラグが発火したか。
105    pub entity_mismatch: bool,
106    /// ハルシネーション (不整合) と判定されたか。
107    pub is_hallucination: bool,
108    /// 遮断または警告理由。
109    pub reason: Option<String>,
110}
111
112/// テキストから数値および日付エンティティを正規化抽出する。
113pub fn extract_normalized_entities(text: &str) -> Vec<NormalizedEntity> {
114    let mut entities = Vec::new();
115
116    // 1. 年月日日付エンティティの抽出 (YYYYMMDD 整数値として正規化)
117    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    // 2. 数値・通貨・パーセントエンティティの抽出
153    for caps in NUMERIC_ENTITY_REGEX.captures_iter(text) {
154        let Some(val_match) = caps.name("val") else {
155            continue;
156        };
157
158        // 全角数字、全角カンマ、全角ピリオドを半角へ正規化
159        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        // 単位に応じた桁数正規化 (万 -> 1e4, 億 -> 1e8, 兆 -> 1e12)
177        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
199/// State と Claim の数値エンティティを照合し、不整合 (未記載の数値や食い違い) があるか判定する。
200///
201/// Claim 側に現れる数値が State 側に存在しない場合、または値が異なる場合に不整合と判定する。
202pub 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    // State 側に存在する正規化値の集合
211    let state_values: Vec<f64> = state_entities.iter().map(|e| e.normalized_value).collect();
212
213    for claim_ent in &claim_entities {
214        // 浮動小数点誤差を許容 (相対誤差 1e-4 以内)
215        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            // 同義語表現 (例: state に「倍増」があり、claim_ent が「2倍」「100%増」の場合) を救済
221            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                // Claim に State にない数値が含まれている
232                return true;
233            }
234        }
235    }
236
237    false
238}
239
240/// State と Claim 間の同義語カバレッジ比率を算出する。
241pub 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
279/// NLI ロジット (含意, 中立, 矛盾) と前提・主張テキストからハルシネーション検証を実行する。
280///
281/// # 引数
282/// - `state`: 前提文 (Context / State)。
283/// - `claim`: 生成主張文 (Generated Claim)。
284/// - `nli_logits`: [含意, 中立, 矛盾] の 3 クラス未正規化ロジット。
285/// - `config`: 検証設定。
286pub fn verify_claim_nli(
287    state: &str,
288    claim: &str,
289    nli_logits: [f64; 3],
290    config: &HallucinationVerifierConfig,
291) -> HallucinationVerificationResult {
292    // Softmax 計算
293    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    // 1. エンティティ照合
306    let entity_mismatch = if config.enable_entity_mismatch_check {
307        check_entity_mismatch(state, claim)
308    } else {
309        false
310    };
311
312    // 2. 同義語カバレッジと動的マージン補正
313    let synonym_cov = compute_synonym_coverage(state, claim);
314    // 同義語が多いほど要求含意確率閾値 tau を緩和 (引き下げ) する
315    let effective_threshold =
316        (config.base_threshold - config.synonym_margin_beta * synonym_cov).max(0.10);
317
318    // 含意確率が要求閾値を下回るか、エンティティ不一致の場合にハルシネーションと判定
319    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        // 微妙なロジットでも同義語緩和で通過することを検証
378        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        // 日付 1 件 + 数値 2 件 = 3 件
389        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        // 日付不整合の検知 (2026年10月4日 vs 2026年10月5日)
401        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}