1use 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct TemperatureMap {
19 #[serde(default)]
21 pub choice: IndexMap<String, f64>,
22
23 #[serde(default)]
25 pub score: IndexMap<String, f64>,
26
27 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
58pub struct GatingThresholds {
59 #[serde(default = "default_high_threshold")]
61 pub high_threshold: f64,
62
63 #[serde(default = "default_low_threshold")]
65 pub low_threshold: f64,
66
67 #[serde(default = "default_top_margin_threshold")]
69 pub top_margin_threshold: f64,
70
71 #[serde(default = "default_true")]
73 pub ood_enabled: bool,
74
75 #[serde(default = "default_energy_threshold")]
77 pub energy_threshold: f64,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub energy_temperature: Option<f64>,
85
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub loose_energy_threshold: Option<f64>,
89
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub ood_min_confidence: Option<f64>,
93
94 #[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
161pub struct CalibrationConfig {
162 pub version: String,
164
165 #[serde(default = "default_temperature")]
167 pub default_temperature: f64,
168
169 pub temperature_map: TemperatureMap,
171
172 #[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 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 pub fn with_gating_thresholds(mut self, gating_thresholds: GatingThresholds) -> Self {
205 self.gating_thresholds = gating_thresholds;
206 self
207 }
208
209 pub fn gating_config(&self) -> GatingConfig {
211 self.gating_thresholds.to_gating_config(true)
212 }
213
214 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 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 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 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
259fn 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 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 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}