use crate::config::{CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_LOW, DEFAULT_TEMPERATURE, LABELS}; use crate::error::ServiceError; use ndarray::Array4; use ort::{session::Session, value::TensorRef}; use serde::Serialize; use std::collections::BTreeMap; use std::path::Path; use std::sync::Mutex; /// Prediction result with temperature-calibrated probabilities and status. #[derive(Debug, Clone, Serialize)] pub struct Prediction { pub status: String, // "confident", "uncertain", "rejected" pub label: String, pub confidence: f32, pub probabilities: BTreeMap, } /// Service for running ONNX model inference with temperature-scaled calibration. /// /// The ONNX model outputs raw logits. Temperature scaling + softmax is applied /// in predict() to produce calibrated probabilities and a decision status: /// - confident: max_prob >= conf_threshold_high /// - uncertain: conf_threshold_low <= max_prob < conf_threshold_high /// - rejected: max_prob < conf_threshold_low pub struct ModelService { model_path: std::path::PathBuf, input_size: u32, temperature: f32, conf_threshold_high: f32, conf_threshold_low: f32, session: Option>, } impl ModelService { pub fn new(model_path: &Path, input_size: u32) -> Self { Self::with_calibration(model_path, input_size, DEFAULT_TEMPERATURE, CONFIDENCE_THRESHOLD_HIGH, CONFIDENCE_THRESHOLD_LOW) } /// Creates a new ModelService with temperature scaling and confidence thresholds. pub fn with_calibration(model_path: &Path, input_size: u32, temperature: f32, conf_high: f32, conf_low: f32) -> Self { let session = Session::builder() .ok() .and_then(|mut builder| builder.commit_from_file(model_path).ok()) .map(Mutex::new); Self { model_path: model_path.to_path_buf(), input_size, temperature, conf_threshold_high: conf_high, conf_threshold_low: conf_low, session, } } pub fn is_loaded(&self) -> bool { self.session.is_some() } pub fn model_path(&self) -> &Path { &self.model_path } pub fn input_size(&self) -> u32 { self.input_size } pub fn temperature(&self) -> f32 { self.temperature } /// Runs inference and returns temperature-calibrated Prediction. /// /// The ONNX model outputs raw logits (no softmax). Temperature scaling /// is applied: probs = softmax(logits / T). pub fn predict(&self, input: Array4) -> Result { let session = self.session.as_ref() .ok_or_else(|| ServiceError::ModelUnavailable("Model is not loaded".to_string()))?; let mut session_guard = session.lock() .map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?; let input = TensorRef::from_array_view(&input) .map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?; let outputs = session_guard.run(ort::inputs![input]) .map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?; let output_tensor = outputs[0].try_extract_tensor::() .map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?; let logits: Vec = output_tensor.1.iter().copied().collect(); Self::calibrate_prediction(&logits, self.temperature, self.conf_threshold_high, self.conf_threshold_low) } /// Applies temperature scaling + softmax to raw logits, determines status. fn calibrate_prediction(logits: &[f32], temperature: f32, conf_high: f32, conf_low: f32) -> Result { if logits.len() != LABELS.len() { return Err(ServiceError::PredictionFailed("Prediction failed".to_string())); } // Reject non-finite values if logits.iter().any(|p| !p.is_finite()) { return Err(ServiceError::PredictionFailed("Prediction failed".to_string())); } // Temperature scaling: divide by T let T = if temperature > 0.0 { temperature } else { 1.0 }; let scaled: Vec = logits.iter().map(|l| l / T).collect(); // Numerically stable softmax: shift by max to avoid overflow let max_logit = scaled.iter().cloned().fold(f32::NEG_INFINITY, f32::max); let exp_vals: Vec = scaled.iter().map(|l| (l - max_logit).exp()).collect(); let sum: f32 = exp_vals.iter().sum(); let probs: Vec = exp_vals.iter().map(|e| e / sum).collect(); // Find top probability let top_idx = probs.iter().enumerate() .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) .map(|(idx, _)| idx) .unwrap_or(0); let confidence = probs[top_idx]; let top_label = LABELS[top_idx].to_string(); // Determine status let status = if confidence >= conf_high { "confident" } else if confidence >= conf_low { "uncertain" } else { "rejected" }; let mut probabilities = BTreeMap::new(); for (i, &prob) in probs.iter().enumerate() { probabilities.insert(LABELS[i].to_string(), prob); } Ok(Prediction { status: status.to_string(), label: top_label, confidence, probabilities, }) } } #[cfg(test)] mod tests { use super::*; const T: f32 = 1.0; const HIGH: f32 = 0.70; const LOW: f32 = 0.45; #[test] fn calibrate_probs_selects_top_label() { // [1, 2, 3, 0.5] → softmax ≈ [0.086, 0.235, 0.638, 0.040] // 0.638 < 0.70 → "uncertain". Use larger gap for "confident". let logits = [0.0, 0.0, 10.0, 0.0]; // softmax ≈ [0, 0, ~1, 0] let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW); assert!(result.is_ok()); let p = result.unwrap(); // Index 2 = Hawar Daun (highest logit) assert_eq!(p.label, "Hawar Daun"); assert!(p.confidence >= 0.999); assert_eq!(p.status, "confident"); } #[test] fn calibrate_probs_uncertain_when_borderline() { let logits = [0.0, 0.4, 0.0, 0.0]; // softmax with low max let result = ModelService::calibrate_prediction(&logits, 2.0, HIGH, LOW); assert!(result.is_ok()); let p = result.unwrap(); // T=2.0 flattens further — likely uncertain or rejected assert!(p.status == "uncertain" || p.status == "rejected"); } #[test] fn calibrate_probs_rejects_low_confidence() { let logits = [0.01, 0.01, 0.01, 0.02]; let result = ModelService::calibrate_prediction(&logits, 10.0, HIGH, LOW); assert!(result.is_ok()); let p = result.unwrap(); assert_eq!(p.status, "rejected"); } #[test] fn calibrate_probs_all_probabilities_present() { let logits = [1.0, 2.0, 3.0, 4.0]; let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW); assert!(result.is_ok()); let p = result.unwrap(); assert_eq!(p.probabilities.len(), 4); assert!(p.probabilities.contains_key("Bercak Daun")); assert!(p.probabilities.contains_key("Daun Sehat")); assert!(p.probabilities.contains_key("Hawar Daun")); assert!(p.probabilities.contains_key("Karat Daun")); } #[test] fn calibrate_probs_rejects_wrong_length() { let logits = [0.25, 0.25, 0.25]; let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW); assert!(result.is_err()); } #[test] fn calibrate_probs_rejects_nan() { let logits = [0.1, f32::NAN, 0.6, 0.1]; let result = ModelService::calibrate_prediction(&logits, T, HIGH, LOW); assert!(result.is_err()); } #[test] fn temperature_one_gives_same_ranking() { let logits = [0.0, 1.0, 2.0, 3.0]; let r1 = ModelService::calibrate_prediction(&logits, 1.0, 0.0, 0.0).unwrap(); let r2 = ModelService::calibrate_prediction(&logits, 2.0, 0.0, 0.0).unwrap(); assert_eq!(r1.label, r2.label); assert!(r1.confidence > r2.confidence); // T=2 flattens } #[test] fn missing_model_file_creates_unloaded_service() { let service = ModelService::new(Path::new("/nonexistent/model.onnx"), 224); assert!(!service.is_loaded()); } #[test] fn unloaded_service_returns_model_unavailable() { let service = ModelService::new(Path::new("/nonexistent/model.onnx"), 224); let result = service.predict(ndarray::Array4::zeros((1, 224, 224, 3))); assert!(result.is_err()); match result.unwrap_err() { ServiceError::ModelUnavailable(msg) => assert_eq!(msg, "Model is not loaded"), _ => panic!("expected ModelUnavailable error"), } } }