Skip to main content

sokuto_core/
decision.rs

1//! # 決定プリミティブ解決モジュール
2//!
3//! 推論エンジンが出力した生ロジット列とキャリブレーション設定から、
4//! Jev 互換の判定結果構造体 (`Answer`) を決定論的に導出する。
5
6use indexmap::IndexMap;
7
8use crate::contract::CalibrationConfig;
9use crate::error::{CoreError, Result};
10use crate::math::{
11    argmax, composite_confidence, expected_score, normalized_variance_confidence, noul_probability,
12    softmax_into,
13};
14use crate::schema::{Answer, Criteria, Question, QuestionType};
15
16/// 作業用バッファを活用し、Choice プリミティブの生ロジットから採択候補、確率分布、および複合確信度を算出する。
17///
18/// # 概要
19/// - 呼び出し側から作業用バッファスライス (`probs_buf`) を受け取ることで、推論バッチ処理における
20///   メモリヒープ確保を最小化する。
21/// - 候補キーの順序付きマップ (`Criteria::Map`) とロジット列を 1 対 1 で結合する。
22/// - 候補数 $K=1$ の特異点では、ショートサーキットにより確信度 1.0・確率 1.0 を即座に返却する。
23/// - 最大確率を与える候補の採択には決定論的タイブレーク規則が適用され、同率最大時は先頭インデックスが採択される。
24/// - 確信度には、候補数 $K$ に非依存な正規化エントロピーと Top-Margin を統合した
25///   複合確信度スコア $S_{\text{confidence}} = (1.0 - H_{\text{norm}}) \times M(p)$ を採用する。
26///
27/// # 引数
28/// - `logits`: 未正規化の生ロジットスライス(`f64`)。
29/// - `criteria`: 候補識別子と説明文のマップを保持する評価基準。
30/// - `temperature`: 適用する較正温度パラメータ $\tau$。
31/// - `probs_buf`: 確率分布書き込み用の作業バッファスライス(`logits` と同長が必要)。
32///
33/// # 戻り値
34/// - 成功時は Choice 判定が格納された `Answer`、入力不正時は `CoreError`。
35pub fn evaluate_choice_with_buf(
36    logits: &[f64],
37    criteria: &Criteria,
38    temperature: f64,
39    probs_buf: &mut [f64],
40) -> Result<Answer> {
41    let map = match criteria {
42        Criteria::Map(m) => m,
43        Criteria::List(_) => {
44            return Err(CoreError::InvalidCriteriaType {
45                question_type: "choice".to_string(),
46                expected: "Map (オブジェクト)",
47                actual: "List (配列)",
48            });
49        }
50        Criteria::None => {
51            return Err(CoreError::MissingCriteria {
52                question_type: "choice".to_string(),
53            });
54        }
55    };
56
57    let k = map.len();
58    if k == 0 || k > 255 {
59        return Err(CoreError::InvalidChoiceCount { count: k });
60    }
61
62    if logits.len() != k {
63        return Err(CoreError::MathError {
64            message: format!(
65                "ロジットの要素数({logits_len})が候補数({k})と一致しません。",
66                logits_len = logits.len()
67            ),
68        });
69    }
70
71    // 候補数 1 の特異点処理。
72    if k == 1 {
73        let (first_key, _) = map
74            .get_index(0)
75            .expect("k == 1 なので必ず先頭要素が存在する。");
76        let mut probabilities = IndexMap::with_capacity(1);
77        probabilities.insert(first_key.clone(), 1.0);
78        return Ok(Answer::choice(first_key.clone(), probabilities, 1.0));
79    }
80
81    // ゼロアロケーション版ソフトマックスの実行。
82    softmax_into(logits, probs_buf, temperature)?;
83
84    // 決定論的タイブレーク付き最大値インデックスの取得。
85    let best_idx = argmax(probs_buf)?;
86    let (selected_key, _) = map
87        .get_index(best_idx)
88        .expect("best_idx は 0..k の範囲内であることが保証されている。");
89
90    // 候補数非依存の複合確信度スコア S_confidence = (1 - H_norm) * M(p) の算出。
91    let confidence = composite_confidence(probs_buf)?;
92
93    // 候補識別子と確率値のマッピング構築。
94    let mut probabilities = IndexMap::with_capacity(k);
95    for (idx, (key, _)) in map.iter().enumerate() {
96        probabilities.insert(key.clone(), probs_buf[idx]);
97    }
98
99    Ok(Answer::choice(
100        selected_key.clone(),
101        probabilities,
102        confidence,
103    ))
104}
105
106/// Choice プリミティブの生ロジットから採択候補、確率分布、および正規化確信度を算出する。
107///
108/// # 概要
109/// - 内部で必要なバッファを確保した上で [`evaluate_choice_with_buf`] を呼び出す。
110pub fn evaluate_choice(logits: &[f64], criteria: &Criteria, temperature: f64) -> Result<Answer> {
111    let mut probs_buf = vec![0.0; logits.len()];
112    evaluate_choice_with_buf(logits, criteria, temperature, &mut probs_buf)
113}
114
115/// 作業用バッファを活用し、Score プリミティブの生ロジットから加重平均スコア実数値、確率分布、および分散確信度を算出する。
116///
117/// # 概要
118/// - 呼び出し側から作業用バッファスライス (`probs_buf`) を受け取ることでヒープ確保を最小化する。
119/// - 順序尺度評価配列 (`Criteria::List`) とロジット列を結合し、$M \in [2, 10]$ 段階の評価を行う。
120/// - 加重平均期待値 $\hat{s} = \sum_{k=0}^{M-1} k \cdot p_k$ を連続値スコアとして算出する。
121/// - 確信度には順序尺度のバイモーダル(両極端割れ)と低分散(隣接割れ)を適切に分離する
122///   正規化分散確信度 $C_{\text{var}} = 1.0 - \frac{4 \cdot \text{Var}\[S\]}{(M-1)^2}$ を採用する。
123/// - 確率マップのキーには、評価段階ラベルに重複がない場合はそのラベル名を用い、
124///   重複または空文字が存在する場合はインデックス文字列 (`"0"`, `"1"`, ...) を用いる。
125///   $M \le 10$ であるため、重複検査はヒープ確保を行わずにスタック上の二重走査で実施する。
126///
127/// # 引数
128/// - `logits`: 未正規化の生ロジットスライス(`f64`)。
129/// - `criteria`: 順序付けられた評価段階基準の配列。
130/// - `temperature`: 適用する較正温度パラメータ $\tau$。
131/// - `probs_buf`: 確率分布書き込み用の作業バッファスライス(`logits` と同長が必要)。
132///
133/// # 戻り値
134/// - 成功時は Score 判定が格納された `Answer`、入力不正時は `CoreError`。
135pub fn evaluate_score_with_buf(
136    logits: &[f64],
137    criteria: &Criteria,
138    temperature: f64,
139    probs_buf: &mut [f64],
140) -> Result<Answer> {
141    let list = match criteria {
142        Criteria::List(l) => l,
143        Criteria::Map(_) => {
144            return Err(CoreError::InvalidCriteriaType {
145                question_type: "score".to_string(),
146                expected: "List (配列)",
147                actual: "Map (オブジェクト)",
148            });
149        }
150        Criteria::None => {
151            return Err(CoreError::MissingCriteria {
152                question_type: "score".to_string(),
153            });
154        }
155    };
156
157    let m = list.len();
158    if !(2..=10).contains(&m) {
159        return Err(CoreError::InvalidScoreLevelCount { count: m });
160    }
161
162    if logits.len() != m {
163        return Err(CoreError::MathError {
164            message: format!(
165                "ロジットの要素数({logits_len})が段階数({m})と一致しません。",
166                logits_len = logits.len()
167            ),
168        });
169    }
170
171    // ゼロアロケーション版ソフトマックス確率分布の算出。
172    softmax_into(logits, probs_buf, temperature)?;
173
174    // 加重平均連続値スコアの算出。
175    let score_val = expected_score(probs_buf)?;
176
177    // 順序尺度分散確信度の算出。
178    let confidence = normalized_variance_confidence(probs_buf)?;
179
180    // 確率マップキーの一意性判定(M <= 10 のため HashSet を使わず二重走査でアロケーションゼロ判定)。
181    let is_unique_and_non_empty = list
182        .iter()
183        .enumerate()
184        .all(|(i, label)| !label.is_empty() && list[i + 1..].iter().all(|other| label != other));
185
186    let mut probabilities = IndexMap::with_capacity(m);
187    for (k, &prob) in probs_buf.iter().enumerate() {
188        let key = if is_unique_and_non_empty {
189            list[k].clone()
190        } else {
191            k.to_string()
192        };
193        probabilities.insert(key, prob);
194    }
195
196    Ok(Answer::score(score_val, probabilities, confidence))
197}
198
199/// Score プリミティブの生ロジットから加重平均連続値スコア、確率分布、および分散確信度を算出する。
200///
201/// # 概要
202/// - 内部で必要なバッファを確保した上で [`evaluate_score_with_buf`] を呼び出す。
203pub fn evaluate_score(logits: &[f64], criteria: &Criteria, temperature: f64) -> Result<Answer> {
204    let mut probs_buf = vec![0.0; logits.len()];
205    evaluate_score_with_buf(logits, criteria, temperature, &mut probs_buf)
206}
207
208/// Noul プリミティブの生ロジットから真偽確率判定結果を算出する。
209///
210/// # 概要
211/// - 入力ロジットの先頭 2 要素 $(z_{\text{true}}, z_{\text{false}})$ の差分から、
212///   数値安定シグモイド関数により言明が真である確率 $P(\text{true}) \in [0.0, 1.0]$ を直接算出する。
213/// - Jev 公式 API 契約に従い、Noul 型の判定結果では `choice`, `score`, `probabilities`, `confidence`
214///   をすべて `None` とし、`noul: Some(P(true))` のみを格納した `Answer` を返却する。
215///
216/// # 引数
217/// - `logits`: 生ロジットスライス(`f64`)。先頭 2 要素がそれぞれ真・偽に対応する。
218/// - `temperature`: 適用する較正温度パラメータ $\tau$。
219///
220/// # 戻り値
221/// - 成功時は Noul 判定が格納された `Answer`、要素数不足等の場合は `CoreError`。
222pub fn evaluate_noul(logits: &[f64], temperature: f64) -> Result<Answer> {
223    if logits.len() < 2 {
224        return Err(CoreError::MathError {
225            message: format!(
226                "Noul 判定には真・偽に対応する 2 要素以上のロジットが必要です(指定数: {})。",
227                logits.len()
228            ),
229        });
230    }
231
232    let prob_true = noul_probability(logits[0], logits[1], temperature)?;
233    Ok(Answer::noul(prob_true))
234}
235
236/// 作業用バッファを活用し、質問定義、生ロジット、および較正設定から型安全に判定結果 (`Answer`) を導出する。
237pub fn evaluate_question_with_buf(
238    question: &Question,
239    logits: &[f64],
240    config: &CalibrationConfig,
241    probs_buf: &mut [f64],
242) -> Result<Answer> {
243    // 質問定義のスキーマ検証を実施する。
244    question.validate("evaluate_question")?;
245
246    match question.question_type {
247        QuestionType::Choice => {
248            let criteria =
249                question
250                    .criteria
251                    .as_ref()
252                    .ok_or_else(|| CoreError::MissingCriteria {
253                        question_type: "choice".to_string(),
254                    })?;
255            let k = criteria.len();
256            let temperature = config.get_temperature(QuestionType::Choice, k);
257            evaluate_choice_with_buf(logits, criteria, temperature, probs_buf)
258        }
259        QuestionType::Score => {
260            let criteria =
261                question
262                    .criteria
263                    .as_ref()
264                    .ok_or_else(|| CoreError::MissingCriteria {
265                        question_type: "score".to_string(),
266                    })?;
267            let m = criteria.len();
268            let temperature = config.get_temperature(QuestionType::Score, m);
269            evaluate_score_with_buf(logits, criteria, temperature, probs_buf)
270        }
271        QuestionType::Noul => {
272            let temperature = config.get_temperature(QuestionType::Noul, 2);
273            evaluate_noul(logits, temperature)
274        }
275    }
276}
277
278/// 単一の質問定義、生ロジット、および較正設定から型安全に判定結果 (`Answer`) を導出する。
279///
280/// # 概要
281/// - 質問タイプ (`QuestionType`) および候補数・段階数に基づいて `CalibrationConfig` から
282///   最適な較正温度 $\tau^*$ を自動解決する。
283/// - 質問プリミティブに応じた評価関数へディスパッチする。
284///
285/// # 引数
286/// - `question`: 評価対象の質問定義。
287/// - `logits`: 推論エンジンから出力された生ロジットスライス。
288/// - `config`: 最適温度マップを保持するキャリブレーション設定。
289///
290/// # 戻り値
291/// - 成功時は算出された判定結果 `Answer`、異常時は `CoreError`。
292pub fn evaluate_question(
293    question: &Question,
294    logits: &[f64],
295    config: &CalibrationConfig,
296) -> Result<Answer> {
297    let mut probs_buf = vec![0.0; logits.len()];
298    evaluate_question_with_buf(question, logits, config, &mut probs_buf)
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn test_evaluate_choice_basic() {
307        let mut criteria_map = IndexMap::new();
308        criteria_map.insert("cat".to_string(), "猫".to_string());
309        criteria_map.insert("dog".to_string(), "犬".to_string());
310        criteria_map.insert("bird".to_string(), "鳥".to_string());
311        let criteria = Criteria::Map(criteria_map);
312
313        let logits = vec![1.0, 5.0, 2.0];
314        let answer = evaluate_choice(&logits, &criteria, 1.0).unwrap();
315
316        assert_eq!(answer.choice.as_deref(), Some("dog"));
317        assert!(answer.score.is_none());
318        assert!(answer.noul.is_none());
319
320        let probs = answer.probabilities.unwrap();
321        assert_eq!(probs.len(), 3);
322        assert!(probs["dog"] > probs["bird"]);
323        assert!(probs["bird"] > probs["cat"]);
324
325        let conf = answer.confidence.unwrap();
326        assert!(conf > 0.0 && conf <= 1.0);
327    }
328
329    #[test]
330    fn test_evaluate_choice_single_candidate() {
331        let mut criteria_map = IndexMap::new();
332        criteria_map.insert("only_one".to_string(), "唯一の候補".to_string());
333        let criteria = Criteria::Map(criteria_map);
334
335        let logits = vec![std::f64::consts::PI];
336        let answer = evaluate_choice(&logits, &criteria, 1.0).unwrap();
337
338        assert_eq!(answer.choice.as_deref(), Some("only_one"));
339        assert_eq!(answer.confidence, Some(1.0));
340        let probs = answer.probabilities.unwrap();
341        assert_eq!(probs["only_one"], 1.0);
342    }
343
344    #[test]
345    fn test_evaluate_choice_tied_logits() {
346        // 同率最大値の場合、先頭候補が確定的に選ばれるタイブレークの検証。
347        let mut criteria_map = IndexMap::new();
348        criteria_map.insert("opt_a".to_string(), "候補A".to_string());
349        criteria_map.insert("opt_b".to_string(), "候補B".to_string());
350        criteria_map.insert("opt_c".to_string(), "候補C".to_string());
351        let criteria = Criteria::Map(criteria_map);
352
353        let logits = vec![4.0, 4.0, 1.0];
354        let answer = evaluate_choice(&logits, &criteria, 1.0).unwrap();
355
356        assert_eq!(answer.choice.as_deref(), Some("opt_a"));
357    }
358
359    #[test]
360    fn test_evaluate_choice_errors() {
361        let mut criteria_map = IndexMap::new();
362        criteria_map.insert("a".to_string(), "A".to_string());
363        let criteria = Criteria::Map(criteria_map);
364
365        // ロジット数と候補数の不一致
366        let logits = vec![1.0, 2.0];
367        assert!(evaluate_choice(&logits, &criteria, 1.0).is_err());
368
369        // 不正な criteria 種別 (List や None)
370        assert!(evaluate_choice(&logits, &Criteria::List(vec!["A".to_string()]), 1.0).is_err());
371        assert!(evaluate_choice(&logits, &Criteria::None, 1.0).is_err());
372    }
373
374    #[test]
375    fn test_evaluate_score_basic() {
376        let criteria = Criteria::List(vec!["低".to_string(), "中".to_string(), "高".to_string()]);
377
378        // レベル 2 (高) に高いロジット
379        let logits = vec![0.0, 1.0, 4.0];
380        let answer = evaluate_score(&logits, &criteria, 1.0).unwrap();
381
382        assert!(answer.choice.is_none());
383        assert!(answer.noul.is_none());
384
385        let score = answer.score.unwrap();
386        assert!(score > 1.5 && score <= 2.0);
387
388        let probs = answer.probabilities.unwrap();
389        assert_eq!(probs.len(), 3);
390        assert!(probs["高"] > probs["中"]);
391
392        let conf = answer.confidence.unwrap();
393        assert!(conf > 0.0 && conf <= 1.0);
394    }
395
396    #[test]
397    fn test_evaluate_score_duplicate_labels() {
398        // ラベルに重複がある場合はインデックス文字列 ("0", "1", "2") がキーとなる検証。
399        let criteria = Criteria::List(vec![
400            "同じ".to_string(),
401            "同じ".to_string(),
402            "同じ".to_string(),
403        ]);
404
405        let logits = vec![1.0, 2.0, 3.0];
406        let answer = evaluate_score(&logits, &criteria, 1.0).unwrap();
407        let probs = answer.probabilities.unwrap();
408
409        assert_eq!(probs.len(), 3);
410        assert!(probs.contains_key("0"));
411        assert!(probs.contains_key("1"));
412        assert!(probs.contains_key("2"));
413    }
414
415    #[test]
416    fn test_evaluate_score_errors() {
417        // 段階数 2 未満
418        let criteria_small = Criteria::List(vec!["単一".to_string()]);
419        assert!(evaluate_score(&[1.0], &criteria_small, 1.0).is_err());
420
421        // 段階数 10 超過
422        let criteria_large = Criteria::List((0..11).map(|i| i.to_string()).collect());
423        let logits_11 = vec![0.0; 11];
424        assert!(evaluate_score(&logits_11, &criteria_large, 1.0).is_err());
425
426        // 長さ不一致
427        let criteria_3 = Criteria::List(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
428        assert!(evaluate_score(&[1.0, 2.0], &criteria_3, 1.0).is_err());
429    }
430
431    #[test]
432    fn test_evaluate_noul_basic() {
433        let logits = vec![3.0, 0.0];
434        let answer = evaluate_noul(&logits, 1.0).unwrap();
435
436        assert!(answer.choice.is_none());
437        assert!(answer.score.is_none());
438        assert!(answer.probabilities.is_none());
439        assert!(answer.confidence.is_none());
440
441        let prob = answer.noul.unwrap();
442        assert!(prob > 0.90 && prob <= 1.0);
443
444        // 異常系: ロジット数が 2 未満
445        assert!(evaluate_noul(&[1.0], 1.0).is_err());
446    }
447
448    #[test]
449    fn test_evaluate_question_dispatch() {
450        let config = CalibrationConfig::default();
451
452        // 1. Choice のディスパッチ検証
453        let mut c_map = IndexMap::new();
454        c_map.insert("yes".to_string(), "はい".to_string());
455        c_map.insert("no".to_string(), "いいえ".to_string());
456        let q_choice = Question::new_choice("選択肢を選べ。", c_map);
457        let ans_choice = evaluate_question(&q_choice, &[2.0, 0.5], &config).unwrap();
458        assert_eq!(ans_choice.choice.as_deref(), Some("yes"));
459
460        // 2. Score のディスパッチ検証
461        let q_score = Question::new_score(
462            "評価せよ。",
463            vec!["1".to_string(), "2".to_string(), "3".to_string()],
464        );
465        let ans_score = evaluate_question(&q_score, &[0.0, 2.0, 4.0], &config).unwrap();
466        assert!(ans_score.score.unwrap() > 1.0);
467
468        // 3. Noul のディスパッチ検証
469        let q_noul = Question::new_noul("真偽を判定せよ。");
470        let ans_noul = evaluate_question(&q_noul, &[2.5, 0.0], &config).unwrap();
471        assert!(ans_noul.noul.unwrap() > 0.85);
472    }
473}