Skip to main content

sokuto_core/contract/
calibration.rs

1//! # キャリブレーション設定スキーマモジュール
2//!
3//! Python 学習工程から出力される `calibration.json` のスキーマ定義および、
4//! 質問タイプ・候補数に応じた最適温度パラメータ $\tau^*$ の解決ロジックを提供する。
5
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8
9use crate::error::{CoreError, Result};
10use crate::gating::{
11    DEFAULT_ENERGY_THRESHOLD, DEFAULT_HIGH_CONFIDENCE_THRESHOLD, DEFAULT_LOW_CONFIDENCE_THRESHOLD,
12    DEFAULT_TOP_MARGIN_THRESHOLD, GatingConfig,
13};
14use crate::schema::QuestionType;
15
16/// 候補数バケットに基づく温度係数マップ。
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct TemperatureMap {
19    /// Choice 型向けの候補数バケット別温度テーブル(例: "2": 1.05, "3-5": 1.12, "6-10": 1.20, "11+": 1.35)。
20    #[serde(default)]
21    pub choice: IndexMap<String, f64>,
22
23    /// Score 型向けの評価段階数バケット別温度テーブル(例: "2-5": 1.00, "6-10": 1.08)。
24    #[serde(default)]
25    pub score: IndexMap<String, f64>,
26
27    /// Noul 型向けの二値判定温度係数(デフォルト: 1.0)。
28    #[serde(default = "default_noul_temperature")]
29    pub noul: f64,
30}
31
32fn default_noul_temperature() -> f64 {
33    1.0
34}
35
36impl Default for TemperatureMap {
37    fn default() -> Self {
38        let mut choice = IndexMap::new();
39        choice.insert("2".to_string(), 1.0);
40        choice.insert("3-5".to_string(), 1.0);
41        choice.insert("6-10".to_string(), 1.0);
42        choice.insert("11+".to_string(), 1.0);
43
44        let mut score = IndexMap::new();
45        score.insert("2-5".to_string(), 1.0);
46        score.insert("6-10".to_string(), 1.0);
47
48        Self {
49            choice,
50            score,
51            noul: 1.0,
52        }
53    }
54}
55
56/// ゲーティング判定用閾値設定構造体。
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct GatingThresholds {
59    /// 高確信度下限閾値 (自動実行境界)。
60    #[serde(default = "default_high_threshold")]
61    pub high_threshold: f64,
62
63    /// 中確信度下限閾値 (確認要求境界)。
64    #[serde(default = "default_low_threshold")]
65    pub low_threshold: f64,
66
67    /// 上位2候補確率マージン閾値。
68    #[serde(default = "default_top_margin_threshold")]
69    pub top_margin_threshold: f64,
70
71    /// OOD 安全弁を有効化するかどうか (デフォルト: true)。
72    #[serde(default = "default_true")]
73    pub ood_enabled: bool,
74
75    /// OOD 判定用の正規化自由エネルギー閾値 (デフォルト: -1.0)。
76    #[serde(default = "default_energy_threshold")]
77    pub energy_threshold: f64,
78
79    /// OOD 自由エネルギー算出専用の温度パラメータ。
80    ///
81    /// Softmax 確率較正温度とは独立に、低発火・高コントラストな自由エネルギーを算出するために使用する。
82    /// 未指定時は各バケットの Softmax 温度にフォールバックする。
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub energy_temperature: Option<f64>,
85
86    /// ハイブリッド OOD 判定用の緩和正規化自由エネルギー閾値 (任意)。
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub loose_energy_threshold: Option<f64>,
89
90    /// ハイブリッド OOD 判定用の最小確信度閾値 (任意)。
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub ood_min_confidence: Option<f64>,
93
94    /// ハイブリッド OOD 判定用の最小マージン閾値 (任意)。
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub ood_min_margin: Option<f64>,
97}
98
99fn default_true() -> bool {
100    true
101}
102
103fn default_high_threshold() -> f64 {
104    DEFAULT_HIGH_CONFIDENCE_THRESHOLD
105}
106
107fn default_low_threshold() -> f64 {
108    DEFAULT_LOW_CONFIDENCE_THRESHOLD
109}
110
111fn default_top_margin_threshold() -> f64 {
112    DEFAULT_TOP_MARGIN_THRESHOLD
113}
114
115fn default_energy_threshold() -> f64 {
116    DEFAULT_ENERGY_THRESHOLD
117}
118
119impl Default for GatingThresholds {
120    fn default() -> Self {
121        Self {
122            high_threshold: DEFAULT_HIGH_CONFIDENCE_THRESHOLD,
123            low_threshold: DEFAULT_LOW_CONFIDENCE_THRESHOLD,
124            top_margin_threshold: DEFAULT_TOP_MARGIN_THRESHOLD,
125            ood_enabled: true,
126            energy_threshold: DEFAULT_ENERGY_THRESHOLD,
127            energy_temperature: None,
128            loose_energy_threshold: None,
129            ood_min_confidence: None,
130            ood_min_margin: None,
131        }
132    }
133}
134
135impl GatingThresholds {
136    /// ゲーティング判定設定構造体へ変換する。
137    ///
138    /// # 引数
139    /// - `enabled`: ゲーティング処理を有効化するかどうかの真偽値。
140    ///
141    /// # 戻り値
142    /// - 閾値が反映された `GatingConfig`。
143    pub fn to_gating_config(&self, enabled: bool) -> GatingConfig {
144        GatingConfig {
145            enabled,
146            high_threshold: self.high_threshold,
147            low_threshold: self.low_threshold,
148            top_margin_threshold: self.top_margin_threshold,
149            ood_enabled: self.ood_enabled,
150            energy_threshold: self.energy_threshold,
151            energy_temperature: self.energy_temperature,
152            loose_energy_threshold: self.loose_energy_threshold,
153            ood_min_confidence: self.ood_min_confidence,
154            ood_min_margin: self.ood_min_margin,
155        }
156    }
157}
158
159/// 成果物引き渡し用 `calibration.json` のスキーマ構造体。
160#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct CalibrationConfig {
162    /// キャリブレーションスキーマのバージョン(例: "1.0")。
163    pub version: String,
164
165    /// マップに該当がない場合に使用するデフォルト温度係数。
166    #[serde(default = "default_temperature")]
167    pub default_temperature: f64,
168
169    /// 質問プリミティブ別の温度テーブルマップ。
170    pub temperature_map: TemperatureMap,
171
172    /// ゲーティング判定用閾値設定。
173    #[serde(default)]
174    pub gating_thresholds: GatingThresholds,
175}
176
177fn default_temperature() -> f64 {
178    1.0
179}
180
181impl Default for CalibrationConfig {
182    fn default() -> Self {
183        Self {
184            version: "1.0".to_string(),
185            default_temperature: 1.0,
186            temperature_map: TemperatureMap::default(),
187            gating_thresholds: GatingThresholds::default(),
188        }
189    }
190}
191
192impl CalibrationConfig {
193    /// 新規キャリブレーション設定を生成する。
194    pub fn new(version: impl Into<String>, temperature_map: TemperatureMap) -> Self {
195        Self {
196            version: version.into(),
197            default_temperature: 1.0,
198            temperature_map,
199            gating_thresholds: GatingThresholds::default(),
200        }
201    }
202
203    /// ゲーティング閾値を設定して自身を返却する。
204    pub fn with_gating_thresholds(mut self, gating_thresholds: GatingThresholds) -> Self {
205        self.gating_thresholds = gating_thresholds;
206        self
207    }
208
209    /// 内包されるゲーティング閾値から有効状態の `GatingConfig` を導出する。
210    pub fn gating_config(&self) -> GatingConfig {
211        self.gating_thresholds.to_gating_config(true)
212    }
213
214    /// JSON 文字列からデシリアライズする。
215    pub fn from_json_str(json_str: &str) -> Result<Self> {
216        serde_json::from_str(json_str).map_err(|e| CoreError::SerializationError {
217            message: format!("calibration.json の解析に失敗しました: {e}。"),
218        })
219    }
220
221    /// JSON 文字列へシリアライズする。
222    pub fn to_json_string(&self) -> Result<String> {
223        serde_json::to_string_pretty(self).map_err(|e| CoreError::SerializationError {
224            message: format!("calibration.json への変換に失敗しました: {e}。"),
225        })
226    }
227
228    /// 質問種別と候補数(または評価段階数)に基づいて最適温度係数 $\tau^*$ を解決する。
229    ///
230    /// バケットパターン例:
231    /// - `"2"`: 候補数がちょうど 2 の場合に一致。
232    /// - `"3-5"`: 候補数が 3 以上 5 以下の場合に一致。
233    /// - `"11+"`: 候補数が 11 以上の場合に一致。
234    ///
235    /// マッチする定義が存在しない場合は、`default_temperature` を返却する。
236    pub fn get_temperature(&self, question_type: QuestionType, candidate_count: usize) -> f64 {
237        match question_type {
238            QuestionType::Choice => {
239                self.resolve_bucket(&self.temperature_map.choice, candidate_count)
240            }
241            QuestionType::Score => {
242                self.resolve_bucket(&self.temperature_map.score, candidate_count)
243            }
244            QuestionType::Noul => self.temperature_map.noul,
245        }
246    }
247
248    /// バケットマップを走査し、候補数に一致する温度を探索する。
249    fn resolve_bucket(&self, table: &IndexMap<String, f64>, count: usize) -> f64 {
250        for (bucket_expr, &temperature) in table {
251            if matches_bucket(bucket_expr, count) {
252                return temperature;
253            }
254        }
255        self.default_temperature
256    }
257}
258
259/// バケット定義文字列(例: "2", "3-5", "11+")と候補数のマッチ判定を行う。
260fn matches_bucket(expr: &str, count: usize) -> bool {
261    let trimmed = expr.trim();
262    if let Ok(exact) = trimmed.parse::<usize>() {
263        return exact == count;
264    }
265
266    if let Some(lower_str) = trimmed.strip_suffix('+')
267        && let Ok(lower) = lower_str.trim().parse::<usize>()
268    {
269        return count >= lower;
270    }
271
272    if let Some((start_str, end_str)) = trimmed.split_once('-')
273        && let (Ok(start), Ok(end)) = (
274            start_str.trim().parse::<usize>(),
275            end_str.trim().parse::<usize>(),
276        )
277    {
278        return count >= start && count <= end;
279    }
280
281    false
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_bucket_matching() {
290        assert!(matches_bucket("2", 2));
291        assert!(!matches_bucket("2", 3));
292
293        assert!(matches_bucket("3-5", 3));
294        assert!(matches_bucket("3-5", 4));
295        assert!(matches_bucket("3-5", 5));
296        assert!(!matches_bucket("3-5", 2));
297        assert!(!matches_bucket("3-5", 6));
298
299        assert!(matches_bucket("11+", 11));
300        assert!(matches_bucket("11+", 50));
301        assert!(!matches_bucket("11+", 10));
302    }
303
304    #[test]
305    fn test_calibration_round_trip() {
306        let mut choice = IndexMap::new();
307        choice.insert("2".to_string(), 1.05);
308        choice.insert("3-5".to_string(), 1.15);
309        choice.insert("6-10".to_string(), 1.25);
310        choice.insert("11+".to_string(), 1.40);
311
312        let mut score = IndexMap::new();
313        score.insert("2-5".to_string(), 1.02);
314        score.insert("6-10".to_string(), 1.10);
315
316        let config = CalibrationConfig {
317            version: "1.0".to_string(),
318            default_temperature: 1.0,
319            temperature_map: TemperatureMap {
320                choice,
321                score,
322                noul: 0.95,
323            },
324            gating_thresholds: GatingThresholds::default(),
325        };
326
327        let json = config.to_json_string().unwrap();
328        let loaded = CalibrationConfig::from_json_str(&json).unwrap();
329        assert_eq!(config, loaded);
330
331        // 温度取得のテスト
332        assert_eq!(loaded.get_temperature(QuestionType::Choice, 2), 1.05);
333        assert_eq!(loaded.get_temperature(QuestionType::Choice, 4), 1.15);
334        assert_eq!(loaded.get_temperature(QuestionType::Choice, 8), 1.25);
335        assert_eq!(loaded.get_temperature(QuestionType::Choice, 20), 1.40);
336
337        assert_eq!(loaded.get_temperature(QuestionType::Score, 3), 1.02);
338        assert_eq!(loaded.get_temperature(QuestionType::Score, 7), 1.10);
339
340        assert_eq!(loaded.get_temperature(QuestionType::Noul, 1), 0.95);
341
342        // ゲーティング設定変換のテスト
343        let gating_cfg = loaded.gating_config();
344        assert!(gating_cfg.enabled);
345        assert!((gating_cfg.high_threshold - DEFAULT_HIGH_CONFIDENCE_THRESHOLD).abs() < 1e-9);
346        assert!((gating_cfg.low_threshold - DEFAULT_LOW_CONFIDENCE_THRESHOLD).abs() < 1e-9);
347        assert!((gating_cfg.top_margin_threshold - DEFAULT_TOP_MARGIN_THRESHOLD).abs() < 1e-9);
348    }
349}