Files
zeavis-edu/apps/ml-service/src/model.rs
T
MythEclipseandClaude c116a2c4a5 fix(ml-service): test logic + prediction_response status forward
- calibrate_probs_selects_top_label: use logits that produce confidence >= 0.70
- prediction_response: forward status from Prediction struct instead of hardcode

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-12 17:08:27 +00:00

248 lines
8.9 KiB
Rust

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<String, f32>,
}
/// 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<Mutex<Session>>,
}
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<f32>) -> Result<Prediction, ServiceError> {
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::<f32>()
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
let logits: Vec<f32> = 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<Prediction, ServiceError> {
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<f32> = 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<f32> = scaled.iter().map(|l| (l - max_logit).exp()).collect();
let sum: f32 = exp_vals.iter().sum();
let probs: Vec<f32> = 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"),
}
}
}