diff --git a/apps/ml-service/Cargo.toml b/apps/ml-service/Cargo.toml new file mode 100644 index 0000000..d3fe32c --- /dev/null +++ b/apps/ml-service/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "zeavis-ml-service" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +axum = { version = "0.7", features = ["multipart"] } +image = "0.25" +ndarray = "0.15" +ort = "2.0.0-rc.10" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net"] } +tower = "0.5" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +temp-env = "0.3" diff --git a/apps/ml-service/src/config.rs b/apps/ml-service/src/config.rs new file mode 100644 index 0000000..488dc05 --- /dev/null +++ b/apps/ml-service/src/config.rs @@ -0,0 +1,47 @@ +use std::path::{Path, PathBuf}; + +pub const LABELS: [&str; 4] = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]; +pub const SERVICE_NAME: &str = "zeavis-ml-service"; +pub const SERVICE_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const DEFAULT_INPUT_SIZE: u32 = 224; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Config { + pub host: String, + pub port: u16, + pub model_path: PathBuf, + pub input_size: u32, +} + +pub fn resolve_model_path(base_dir: &Path, model_path: &str) -> PathBuf { + let path = PathBuf::from(model_path); + if path.is_absolute() { + path + } else { + base_dir.join(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_match_training_class_order_with_display_names() { + assert_eq!(LABELS, ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]); + } + + #[test] + fn relative_model_path_resolves_from_service_directory() { + let base = Path::new("/repo/apps/ml-service"); + let resolved = resolve_model_path(base, "../../Machine_Learning/model/model.onnx"); + assert_eq!(resolved, PathBuf::from("/repo/apps/ml-service/../../Machine_Learning/model/model.onnx")); + } + + #[test] + fn absolute_model_path_is_preserved() { + let base = Path::new("/repo/apps/ml-service"); + let resolved = resolve_model_path(base, "/models/model.onnx"); + assert_eq!(resolved, PathBuf::from("/models/model.onnx")); + } +} diff --git a/apps/ml-service/src/main.rs b/apps/ml-service/src/main.rs new file mode 100644 index 0000000..2d17e8e --- /dev/null +++ b/apps/ml-service/src/main.rs @@ -0,0 +1,5 @@ +mod config; + +fn main() { + println!("zeavis-ml-service"); +} diff --git a/docs/superpowers/plans/2026-05-23-rust-onnx-ml-service.md b/docs/superpowers/plans/2026-05-23-rust-onnx-ml-service.md new file mode 100644 index 0000000..426f127 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-rust-onnx-ml-service.md @@ -0,0 +1,1781 @@ +# Rust ONNX ML Service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Python FastAPI ML service with a Rust Axum service that serves ONNX Runtime inference while preserving the current API contract and deployment shape. + +**Architecture:** `apps/ml-service` becomes a Rust binary crate with focused modules for config, routes, errors, image preprocessing, and ONNX inference. The ML pipeline keeps TensorFlow/Keras for training/export and adds ONNX conversion plus manual parity validation. Docker continues to publish an `ml` service listening on port `8000`. + +**Tech Stack:** Rust, Axum, Tokio, Serde, image, ndarray, ort, Python TensorFlow/tf2onnx/onnxruntime for export validation, Docker. + +--- + +## File structure + +### Create + +- `apps/ml-service/Cargo.toml` — Rust crate metadata and dependencies. +- `apps/ml-service/src/main.rs` — application startup, shared state, router binding. +- `apps/ml-service/src/config.rs` — environment parsing, constants, labels, model path resolution. +- `apps/ml-service/src/error.rs` — service error enum and Axum response mapping. +- `apps/ml-service/src/image.rs` — image decode, RGB conversion, resizing, NHWC float32 tensor creation. +- `apps/ml-service/src/model.rs` — ONNX Runtime session wrapper and prediction result mapping. +- `apps/ml-service/src/routes.rs` — `/health`, `/metadata`, and `/predict` handlers. +- `Machine_Learning/convert_onnx.py` — convert exported SavedModel to `model/model.onnx` using tf2onnx. +- `Machine_Learning/validate_onnx_parity.py` — manual parity check between Keras and ONNX for sample images. + +### Modify + +- `apps/ml-service/Dockerfile` — replace Python runtime with Rust multi-stage build and ONNX model copy. +- `apps/ml-service/moon.yml` — replace uvicorn/py_compile tasks with cargo tasks. +- `apps/ml-service/.env.example` — update default model path and port for Rust service. +- `Machine_Learning/requirements.txt` — add ONNX conversion/parity dependencies. +- `Machine_Learning/README.md` — document ONNX conversion and parity validation. +- `README.md` — update service description, prerequisites, endpoints, artifacts, and troubleshooting. + +### Remove + +- `apps/ml-service/main.py` — superseded by Rust Axum entrypoint. +- `apps/ml-service/model.py` — superseded by Rust ONNX model module. +- `apps/ml-service/schemas.py` — superseded by Rust response structs. +- `apps/ml-service/test_model.py` — superseded by Rust tests. +- `apps/ml-service/requirements.txt` — no longer used by serving runtime. + +--- + +## Task 1: Create Rust crate skeleton and config + +**Files:** +- Create: `apps/ml-service/Cargo.toml` +- Create: `apps/ml-service/src/main.rs` +- Create: `apps/ml-service/src/config.rs` + +- [ ] **Step 1: Write crate manifest** + +Create `apps/ml-service/Cargo.toml`: + +```toml +[package] +name = "zeavis-ml-service" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0" +axum = { version = "0.7", features = ["multipart"] } +image = "0.25" +ndarray = "0.15" +ort = "2.0.0-rc.10" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1.0", features = ["macros", "rt-multi-thread", "net"] } +tower = "0.5" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +temp-env = "0.3" +``` + +- [ ] **Step 2: Write config tests first** + +Create `apps/ml-service/src/config.rs` with only constants and tests initially: + +```rust +use std::path::{Path, PathBuf}; + +pub const LABELS: [&str; 4] = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]; +pub const SERVICE_NAME: &str = "zeavis-ml-service"; +pub const SERVICE_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const DEFAULT_INPUT_SIZE: u32 = 224; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Config { + pub host: String, + pub port: u16, + pub model_path: PathBuf, + pub input_size: u32, +} + +pub fn resolve_model_path(base_dir: &Path, model_path: &str) -> PathBuf { + let path = PathBuf::from(model_path); + if path.is_absolute() { + path + } else { + base_dir.join(path) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn labels_match_training_class_order_with_display_names() { + assert_eq!(LABELS, ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]); + } + + #[test] + fn relative_model_path_resolves_from_service_directory() { + let base = Path::new("/repo/apps/ml-service"); + let resolved = resolve_model_path(base, "../../Machine_Learning/model/model.onnx"); + assert_eq!(resolved, PathBuf::from("/repo/apps/ml-service/../../Machine_Learning/model/model.onnx")); + } + + #[test] + fn absolute_model_path_is_preserved() { + let base = Path::new("/repo/apps/ml-service"); + let resolved = resolve_model_path(base, "/models/model.onnx"); + assert_eq!(resolved, PathBuf::from("/models/model.onnx")); + } +} +``` + +- [ ] **Step 3: Run tests and verify expected compile failure** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml +``` + +Expected: compilation fails because there is no `src/main.rs` target yet or because the crate has no complete binary entrypoint. + +- [ ] **Step 4: Add minimal main file** + +Create `apps/ml-service/src/main.rs`: + +```rust +mod config; + +fn main() { + println!("zeavis-ml-service"); +} +``` + +- [ ] **Step 5: Run tests and verify they pass** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml +``` + +Expected: all config tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add apps/ml-service/Cargo.toml apps/ml-service/src/main.rs apps/ml-service/src/config.rs +git commit -m "feat: scaffold Rust ML service crate" +``` + +--- + +## Task 2: Implement environment config loading + +**Files:** +- Modify: `apps/ml-service/src/config.rs` + +- [ ] **Step 1: Add failing tests for environment defaults and overrides** + +Append these tests inside the existing `#[cfg(test)] mod tests` in `apps/ml-service/src/config.rs`: + +```rust + #[test] + fn config_uses_default_values_when_env_is_absent() { + temp_env::with_vars_unset( + ["ML_SERVICE_HOST", "ML_SERVICE_PORT", "MODEL_PATH", "MODEL_INPUT_SIZE"], + || { + let config = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap(); + + assert_eq!(config.host, "0.0.0.0"); + assert_eq!(config.port, 8000); + assert_eq!(config.input_size, 224); + assert_eq!( + config.model_path, + PathBuf::from("/repo/apps/ml-service/../../Machine_Learning/model/model.onnx") + ); + }, + ); + } + + #[test] + fn config_reads_environment_overrides() { + temp_env::with_vars( + [ + ("ML_SERVICE_HOST", Some("127.0.0.1")), + ("ML_SERVICE_PORT", Some("9000")), + ("MODEL_PATH", Some("/tmp/model.onnx")), + ("MODEL_INPUT_SIZE", Some("128")), + ], + || { + let config = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap(); + + assert_eq!(config.host, "127.0.0.1"); + assert_eq!(config.port, 9000); + assert_eq!(config.input_size, 128); + assert_eq!(config.model_path, PathBuf::from("/tmp/model.onnx")); + }, + ); + } + + #[test] + fn invalid_port_returns_error() { + temp_env::with_vars( + [("ML_SERVICE_PORT", Some("not-a-port"))], + || { + let error = Config::from_env_with_base_dir(Path::new("/repo/apps/ml-service")).unwrap_err(); + assert!(error.to_string().contains("ML_SERVICE_PORT")); + }, + ); + } +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml config +``` + +Expected: FAIL with `no function or associated item named 'from_env_with_base_dir'`. + +- [ ] **Step 3: Implement config loader** + +Replace the top-level implementation in `apps/ml-service/src/config.rs` with: + +```rust +use anyhow::{Context, Result}; +use std::env; +use std::path::{Path, PathBuf}; + +pub const LABELS: [&str; 4] = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"]; +pub const SERVICE_NAME: &str = "zeavis-ml-service"; +pub const SERVICE_VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const DEFAULT_INPUT_SIZE: u32 = 224; +pub const DEFAULT_MODEL_PATH: &str = "../../Machine_Learning/model/model.onnx"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Config { + pub host: String, + pub port: u16, + pub model_path: PathBuf, + pub input_size: u32, +} + +impl Config { + pub fn from_env() -> Result { + let base_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + Self::from_env_with_base_dir(&base_dir) + } + + pub fn from_env_with_base_dir(base_dir: &Path) -> Result { + let host = env::var("ML_SERVICE_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); + let port = parse_env_u16("ML_SERVICE_PORT", 8000)?; + let input_size = parse_env_u32("MODEL_INPUT_SIZE", DEFAULT_INPUT_SIZE)?; + let model_path = env::var("MODEL_PATH").unwrap_or_else(|_| DEFAULT_MODEL_PATH.to_string()); + + Ok(Self { + host, + port, + model_path: resolve_model_path(base_dir, &model_path), + input_size, + }) + } +} + +pub fn resolve_model_path(base_dir: &Path, model_path: &str) -> PathBuf { + let path = PathBuf::from(model_path); + if path.is_absolute() { + path + } else { + base_dir.join(path) + } +} + +fn parse_env_u16(name: &str, default: u16) -> Result { + match env::var(name) { + Ok(value) => value + .parse::() + .with_context(|| format!("{name} must be a valid u16")), + Err(_) => Ok(default), + } +} + +fn parse_env_u32(name: &str, default: u32) -> Result { + match env::var(name) { + Ok(value) => value + .parse::() + .with_context(|| format!("{name} must be a valid u32")), + Err(_) => Ok(default), + } +} +``` + +Keep the existing tests after this implementation. + +- [ ] **Step 4: Run tests and verify they pass** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml config +``` + +Expected: all config tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/ml-service/src/config.rs apps/ml-service/Cargo.toml +git commit -m "feat: load ML service config from environment" +``` + +--- + +## Task 3: Implement HTTP error mapping and response schemas + +**Files:** +- Create: `apps/ml-service/src/error.rs` +- Create: `apps/ml-service/src/routes.rs` +- Modify: `apps/ml-service/src/main.rs` + +- [ ] **Step 1: Write error mapping tests** + +Create `apps/ml-service/src/error.rs`: + +```rust +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +#[derive(Debug, Clone)] +pub enum ServiceError { + BadRequest(String), + ModelUnavailable(String), + PredictionFailed(String), +} + +#[derive(Serialize)] +struct ErrorResponse { + detail: String, +} + +impl IntoResponse for ServiceError { + fn into_response(self) -> Response { + let (status, detail) = match self { + ServiceError::BadRequest(detail) => (StatusCode::BAD_REQUEST, detail), + ServiceError::ModelUnavailable(detail) => (StatusCode::SERVICE_UNAVAILABLE, detail), + ServiceError::PredictionFailed(detail) => (StatusCode::INTERNAL_SERVER_ERROR, detail), + }; + + (status, Json(ErrorResponse { detail })).into_response() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::to_bytes; + + #[tokio::test] + async fn bad_request_maps_to_400() { + let response = ServiceError::BadRequest("Uploaded file must be an image".to_string()).into_response(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let value: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(value["detail"], "Uploaded file must be an image"); + } + + #[tokio::test] + async fn model_unavailable_maps_to_503() { + let response = ServiceError::ModelUnavailable("Model is not loaded".to_string()).into_response(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[tokio::test] + async fn prediction_failed_maps_to_500() { + let response = ServiceError::PredictionFailed("Prediction failed".to_string()).into_response(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} +``` + +- [ ] **Step 2: Run error tests and verify module is not wired** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml error +``` + +Expected: FAIL because `error.rs` is not declared in `main.rs` yet. + +- [ ] **Step 3: Wire module and create route response structs** + +Replace `apps/ml-service/src/main.rs` with: + +```rust +mod config; +mod error; +mod routes; + +fn main() { + println!("zeavis-ml-service"); +} +``` + +Create `apps/ml-service/src/routes.rs`: + +```rust +use crate::config::{LABELS, SERVICE_NAME, SERVICE_VERSION}; +use serde::Serialize; +use std::collections::BTreeMap; + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: &'static str, + pub model_loaded: bool, +} + +#[derive(Serialize)] +pub struct MetadataResponse { + pub service_name: &'static str, + pub service_version: &'static str, + pub model_path: String, + pub model_loaded: bool, + pub input_size: u32, + pub labels: Vec<&'static str>, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct PredictionResponse { + pub label: String, + pub confidence: f32, + pub probabilities: BTreeMap, +} + +pub fn health_response(model_loaded: bool) -> HealthResponse { + HealthResponse { + status: "ok", + model_loaded, + } +} + +pub fn metadata_response(model_path: String, model_loaded: bool, input_size: u32) -> MetadataResponse { + MetadataResponse { + service_name: SERVICE_NAME, + service_version: SERVICE_VERSION, + model_path, + model_loaded, + input_size, + labels: LABELS.to_vec(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn health_response_matches_existing_contract() { + let response = health_response(false); + let value = serde_json::to_value(response).unwrap(); + + assert_eq!(value["status"], "ok"); + assert_eq!(value["model_loaded"], false); + } + + #[test] + fn metadata_response_matches_existing_contract() { + let response = metadata_response("/models/model.onnx".to_string(), true, 224); + let value = serde_json::to_value(response).unwrap(); + + assert_eq!(value["service_name"], "zeavis-ml-service"); + assert_eq!(value["service_version"], env!("CARGO_PKG_VERSION")); + assert_eq!(value["model_path"], "/models/model.onnx"); + assert_eq!(value["model_loaded"], true); + assert_eq!(value["input_size"], 224); + assert_eq!(value["labels"], serde_json::json!(["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"])); + } +} +``` + +- [ ] **Step 4: Run tests and verify they pass** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml error routes +``` + +Expected: error and route response tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/ml-service/src/main.rs apps/ml-service/src/error.rs apps/ml-service/src/routes.rs +git commit -m "feat: define ML service API responses" +``` + +--- + +## Task 4: Implement image preprocessing + +**Files:** +- Create: `apps/ml-service/src/image.rs` +- Modify: `apps/ml-service/src/main.rs` + +- [ ] **Step 1: Write preprocessing tests** + +Create `apps/ml-service/src/image.rs`: + +```rust +use crate::error::ServiceError; +use ndarray::Array4; + +pub fn preprocess_image(_bytes: &[u8], _input_size: u32) -> Result, ServiceError> { + unimplemented!("preprocess image bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + use image::{DynamicImage, ImageFormat, RgbImage}; + use std::io::Cursor; + + fn png_bytes() -> Vec { + let mut image = RgbImage::new(2, 1); + image.put_pixel(0, 0, image::Rgb([10, 20, 30])); + image.put_pixel(1, 0, image::Rgb([40, 50, 60])); + + let mut bytes = Vec::new(); + DynamicImage::ImageRgb8(image) + .write_to(&mut Cursor::new(&mut bytes), ImageFormat::Png) + .unwrap(); + bytes + } + + #[test] + fn preprocess_returns_nhwc_float32_batch() { + let tensor = preprocess_image(&png_bytes(), 2).unwrap(); + + assert_eq!(tensor.shape(), &[1, 2, 2, 3]); + assert_eq!(tensor[[0, 0, 0, 0]], 10.0); + assert_eq!(tensor[[0, 0, 0, 1]], 20.0); + assert_eq!(tensor[[0, 0, 0, 2]], 30.0); + } + + #[test] + fn invalid_image_returns_bad_request() { + let error = preprocess_image(b"not an image", 224).unwrap_err(); + + match error { + ServiceError::BadRequest(detail) => assert_eq!(detail, "Uploaded file is not a valid image"), + other => panic!("expected bad request, got {other:?}"), + } + } +} +``` + +- [ ] **Step 2: Wire module and run tests to verify failure** + +Add `mod image;` to `apps/ml-service/src/main.rs`: + +```rust +mod config; +mod error; +mod image; +mod routes; + +fn main() { + println!("zeavis-ml-service"); +} +``` + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml image +``` + +Expected: FAIL because `preprocess_image` is unimplemented. + +- [ ] **Step 3: Implement preprocessing** + +Replace `apps/ml-service/src/image.rs` implementation section above the tests with: + +```rust +use crate::error::ServiceError; +use image::imageops::FilterType; +use ndarray::Array4; + +pub fn preprocess_image(bytes: &[u8], input_size: u32) -> Result, ServiceError> { + let image = image::load_from_memory(bytes) + .map_err(|_| ServiceError::BadRequest("Uploaded file is not a valid image".to_string()))? + .to_rgb8(); + + let resized = image::imageops::resize(&image, input_size, input_size, FilterType::Triangle); + let size = input_size as usize; + let mut tensor = Array4::::zeros((1, size, size, 3)); + + for (x, y, pixel) in resized.enumerate_pixels() { + let x = x as usize; + let y = y as usize; + tensor[[0, y, x, 0]] = pixel[0] as f32; + tensor[[0, y, x, 1]] = pixel[1] as f32; + tensor[[0, y, x, 2]] = pixel[2] as f32; + } + + Ok(tensor) +} +``` + +Keep the existing tests below this implementation. + +- [ ] **Step 4: Run image tests and verify they pass** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml image +``` + +Expected: image preprocessing tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/ml-service/src/main.rs apps/ml-service/src/image.rs +git commit -m "feat: preprocess uploaded images in Rust" +``` + +--- + +## Task 5: Implement ONNX model wrapper + +**Files:** +- Create: `apps/ml-service/src/model.rs` +- Modify: `apps/ml-service/src/main.rs` + +- [ ] **Step 1: Write prediction mapping tests** + +Create `apps/ml-service/src/model.rs`: + +```rust +use crate::config::LABELS; +use crate::error::ServiceError; +use ndarray::Array4; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, PartialEq)] +pub struct Prediction { + pub label: String, + pub confidence: f32, + pub probabilities: BTreeMap, +} + +pub struct ModelService { + model_path: PathBuf, + input_size: u32, + loaded: bool, +} + +impl ModelService { + pub fn new(_model_path: &Path, _input_size: u32) -> Self { + unimplemented!("create model service") + } + + pub fn is_loaded(&self) -> bool { + self.loaded + } + + pub fn model_path(&self) -> &Path { + &self.model_path + } + + pub fn input_size(&self) -> u32 { + self.input_size + } + + pub fn predict(&self, _input: Array4) -> Result { + unimplemented!("run ONNX inference") + } +} + +pub fn prediction_from_probabilities(probabilities: &[f32]) -> Result { + if probabilities.len() != LABELS.len() { + return Err(ServiceError::PredictionFailed("Prediction failed".to_string())); + } + + let mut top_index = 0usize; + let mut top_value = probabilities[0]; + let mut mapped = BTreeMap::new(); + + for (index, label) in LABELS.iter().enumerate() { + let value = probabilities[index]; + if value > top_value { + top_index = index; + top_value = value; + } + mapped.insert((*label).to_string(), value); + } + + Ok(Prediction { + label: LABELS[top_index].to_string(), + confidence: top_value, + probabilities: mapped, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prediction_mapping_selects_top_label_and_all_probabilities() { + let prediction = prediction_from_probabilities(&[0.1, 0.2, 0.6, 0.1]).unwrap(); + + assert_eq!(prediction.label, "Karat Daun"); + assert_eq!(prediction.confidence, 0.6); + assert_eq!(prediction.probabilities["Bercak Daun"], 0.1); + assert_eq!(prediction.probabilities["Daun Sehat"], 0.2); + assert_eq!(prediction.probabilities["Karat Daun"], 0.6); + assert_eq!(prediction.probabilities["Hawar Daun"], 0.1); + } + + #[test] + fn prediction_mapping_rejects_wrong_output_length() { + let error = prediction_from_probabilities(&[0.1, 0.2]).unwrap_err(); + + match error { + ServiceError::PredictionFailed(detail) => assert_eq!(detail, "Prediction failed"), + other => panic!("expected prediction failure, got {other:?}"), + } + } +} +``` + +- [ ] **Step 2: Wire module and run tests to verify current failures** + +Add `mod model;` to `apps/ml-service/src/main.rs`: + +```rust +mod config; +mod error; +mod image; +mod model; +mod routes; + +fn main() { + println!("zeavis-ml-service"); +} +``` + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml model +``` + +Expected: mapping tests pass, but `ModelService::new` and `predict` are still unimplemented for runtime behavior. + +- [ ] **Step 3: Implement ONNX session storage** + +Replace `apps/ml-service/src/model.rs` with: + +```rust +use crate::config::LABELS; +use crate::error::ServiceError; +use ndarray::Array4; +use ort::session::Session; +use ort::value::TensorRef; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +#[derive(Debug, PartialEq)] +pub struct Prediction { + pub label: String, + pub confidence: f32, + pub probabilities: BTreeMap, +} + +pub struct ModelService { + model_path: PathBuf, + input_size: u32, + session: Option>, +} + +impl ModelService { + pub fn new(model_path: &Path, input_size: u32) -> Self { + let session = Session::builder() + .and_then(|builder| builder.commit_from_file(model_path)) + .map(Mutex::new) + .ok(); + + Self { + model_path: model_path.to_path_buf(), + input_size, + 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 predict(&self, input: Array4) -> Result { + let session = self + .session + .as_ref() + .ok_or_else(|| ServiceError::ModelUnavailable("Model is not loaded".to_string()))?; + + let input = TensorRef::from_array_view(input.view()) + .map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?; + let mut session = session + .lock() + .map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?; + let outputs = session + .run(ort::inputs![input]) + .map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?; + let output = outputs + .values() + .next() + .ok_or_else(|| ServiceError::PredictionFailed("Prediction failed".to_string()))?; + let probabilities = output + .try_extract_tensor::() + .map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?; + let probabilities: Vec = probabilities.view().iter().copied().collect(); + + prediction_from_probabilities(&probabilities) + } +} + +pub fn prediction_from_probabilities(probabilities: &[f32]) -> Result { + if probabilities.len() != LABELS.len() { + return Err(ServiceError::PredictionFailed("Prediction failed".to_string())); + } + + let mut top_index = 0usize; + let mut top_value = probabilities[0]; + let mut mapped = BTreeMap::new(); + + for (index, label) in LABELS.iter().enumerate() { + let value = probabilities[index]; + if value > top_value { + top_index = index; + top_value = value; + } + mapped.insert((*label).to_string(), value); + } + + Ok(Prediction { + label: LABELS[top_index].to_string(), + confidence: top_value, + probabilities: mapped, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prediction_mapping_selects_top_label_and_all_probabilities() { + let prediction = prediction_from_probabilities(&[0.1, 0.2, 0.6, 0.1]).unwrap(); + + assert_eq!(prediction.label, "Karat Daun"); + assert_eq!(prediction.confidence, 0.6); + assert_eq!(prediction.probabilities["Bercak Daun"], 0.1); + assert_eq!(prediction.probabilities["Daun Sehat"], 0.2); + assert_eq!(prediction.probabilities["Karat Daun"], 0.6); + assert_eq!(prediction.probabilities["Hawar Daun"], 0.1); + } + + #[test] + fn prediction_mapping_rejects_wrong_output_length() { + let error = prediction_from_probabilities(&[0.1, 0.2]).unwrap_err(); + + match error { + ServiceError::PredictionFailed(detail) => assert_eq!(detail, "Prediction failed"), + other => panic!("expected prediction failure, got {other:?}"), + } + } + + #[test] + fn missing_model_file_creates_unloaded_service() { + let service = ModelService::new(Path::new("/missing/model.onnx"), 224); + + assert!(!service.is_loaded()); + assert_eq!(service.model_path(), Path::new("/missing/model.onnx")); + assert_eq!(service.input_size(), 224); + } +} +``` + +- [ ] **Step 4: Run model tests and fix any ort API mismatch** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml model +``` + +Expected: all model tests pass. If the `ort` API differs from the snippets above, inspect compiler errors and update only `ModelService::new` and `ModelService::predict` to the equivalent current `ort` calls while preserving the public methods and tests. + +- [ ] **Step 5: Commit** + +```bash +git add apps/ml-service/src/main.rs apps/ml-service/src/model.rs apps/ml-service/Cargo.toml +git commit -m "feat: add ONNX model inference wrapper" +``` + +--- + +## Task 6: Implement Axum routes and app startup + +**Files:** +- Modify: `apps/ml-service/src/routes.rs` +- Modify: `apps/ml-service/src/main.rs` + +- [ ] **Step 1: Add app state and route handler tests** + +Replace `apps/ml-service/src/routes.rs` with: + +```rust +use crate::config::{LABELS, SERVICE_NAME, SERVICE_VERSION}; +use crate::error::ServiceError; +use crate::image::preprocess_image; +use crate::model::{ModelService, Prediction}; +use axum::extract::{Multipart, State}; +use axum::{routing::get, routing::post, Json, Router}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub model: Arc, +} + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: &'static str, + pub model_loaded: bool, +} + +#[derive(Serialize)] +pub struct MetadataResponse { + pub service_name: &'static str, + pub service_version: &'static str, + pub model_path: String, + pub model_loaded: bool, + pub input_size: u32, + pub labels: Vec<&'static str>, +} + +#[derive(Debug, Serialize, PartialEq)] +pub struct PredictionResponse { + pub label: String, + pub confidence: f32, + pub probabilities: BTreeMap, +} + +pub fn router(state: AppState) -> Router { + Router::new() + .route("/health", get(health)) + .route("/metadata", get(metadata)) + .route("/predict", post(predict)) + .with_state(state) +} + +pub async fn health(State(state): State) -> Json { + Json(health_response(state.model.is_loaded())) +} + +pub async fn metadata(State(state): State) -> Json { + Json(metadata_response( + state.model.model_path().display().to_string(), + state.model.is_loaded(), + state.model.input_size(), + )) +} + +pub async fn predict( + State(state): State, + mut multipart: Multipart, +) -> Result, ServiceError> { + let mut image_bytes = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|_| ServiceError::BadRequest("Uploaded file must be an image".to_string()))? + { + if field.name() == Some("file") { + if let Some(content_type) = field.content_type() { + if !content_type.starts_with("image/") { + return Err(ServiceError::BadRequest("Uploaded file must be an image".to_string())); + } + } + + image_bytes = Some( + field + .bytes() + .await + .map_err(|_| ServiceError::BadRequest("Uploaded file must be an image".to_string()))?, + ); + break; + } + } + + let image_bytes = image_bytes + .ok_or_else(|| ServiceError::BadRequest("Uploaded file must be an image".to_string()))?; + let input = preprocess_image(&image_bytes, state.model.input_size())?; + let prediction = state.model.predict(input)?; + + Ok(Json(prediction_response(prediction))) +} + +pub fn health_response(model_loaded: bool) -> HealthResponse { + HealthResponse { + status: "ok", + model_loaded, + } +} + +pub fn metadata_response(model_path: String, model_loaded: bool, input_size: u32) -> MetadataResponse { + MetadataResponse { + service_name: SERVICE_NAME, + service_version: SERVICE_VERSION, + model_path, + model_loaded, + input_size, + labels: LABELS.to_vec(), + } +} + +pub fn prediction_response(prediction: Prediction) -> PredictionResponse { + PredictionResponse { + label: prediction.label, + confidence: prediction.confidence, + probabilities: prediction.probabilities, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::prediction_from_probabilities; + + #[test] + fn health_response_matches_existing_contract() { + let response = health_response(false); + let value = serde_json::to_value(response).unwrap(); + + assert_eq!(value["status"], "ok"); + assert_eq!(value["model_loaded"], false); + } + + #[test] + fn metadata_response_matches_existing_contract() { + let response = metadata_response("/models/model.onnx".to_string(), true, 224); + let value = serde_json::to_value(response).unwrap(); + + assert_eq!(value["service_name"], "zeavis-ml-service"); + assert_eq!(value["service_version"], env!("CARGO_PKG_VERSION")); + assert_eq!(value["model_path"], "/models/model.onnx"); + assert_eq!(value["model_loaded"], true); + assert_eq!(value["input_size"], 224); + assert_eq!(value["labels"], serde_json::json!(["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"])); + } + + #[test] + fn prediction_response_matches_existing_contract() { + let prediction = prediction_from_probabilities(&[0.1, 0.2, 0.6, 0.1]).unwrap(); + let response = prediction_response(prediction); + let value = serde_json::to_value(response).unwrap(); + + assert_eq!(value["label"], "Karat Daun"); + assert_eq!(value["confidence"], 0.6); + assert_eq!(value["probabilities"]["Karat Daun"], 0.6); + } +} +``` + +- [ ] **Step 2: Run route tests** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml routes +``` + +Expected: route response tests pass. + +- [ ] **Step 3: Implement async main startup** + +Replace `apps/ml-service/src/main.rs` with: + +```rust +mod config; +mod error; +mod image; +mod model; +mod routes; + +use anyhow::Context; +use config::Config; +use model::ModelService; +use routes::{router, AppState}; +use std::sync::Arc; +use tokio::net::TcpListener; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + + let config = Config::from_env()?; + let model = Arc::new(ModelService::new(&config.model_path, config.input_size)); + let address = format!("{}:{}", config.host, config.port); + let listener = TcpListener::bind(&address) + .await + .with_context(|| format!("failed to bind {address}"))?; + + tracing::info!(address, model_loaded = model.is_loaded(), "starting ML service"); + + axum::serve(listener, router(AppState { model })).await?; + + Ok(()) +} +``` + +- [ ] **Step 4: Run full Rust tests and build** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml +cargo build --manifest-path apps/ml-service/Cargo.toml --release +``` + +Expected: tests pass and release binary builds. + +- [ ] **Step 5: Commit** + +```bash +git add apps/ml-service/src/routes.rs apps/ml-service/src/main.rs +git commit -m "feat: serve ML inference endpoints with Axum" +``` + +--- + +## Task 7: Replace ML service runtime files and tasks + +**Files:** +- Modify: `apps/ml-service/moon.yml` +- Modify: `apps/ml-service/.env.example` +- Remove: `apps/ml-service/main.py` +- Remove: `apps/ml-service/model.py` +- Remove: `apps/ml-service/schemas.py` +- Remove: `apps/ml-service/test_model.py` +- Remove: `apps/ml-service/requirements.txt` + +- [ ] **Step 1: Update Moon tasks** + +Replace `apps/ml-service/moon.yml` with: + +```yaml +tasks: + dev: + command: cargo run + typecheck: + command: cargo check + inputs: + - Cargo.toml + - src/**/*.rs + test: + command: cargo test + inputs: + - Cargo.toml + - src/**/*.rs + build: + command: cargo build --release + inputs: + - Cargo.toml + - src/**/*.rs +``` + +- [ ] **Step 2: Update local env example** + +Replace `apps/ml-service/.env.example` with: + +```env +MODEL_PATH=../../Machine_Learning/model/model.onnx +MODEL_INPUT_SIZE=224 +ML_SERVICE_HOST=0.0.0.0 +ML_SERVICE_PORT=8001 +``` + +- [ ] **Step 3: Remove Python serving files** + +Run: + +```bash +rm apps/ml-service/main.py apps/ml-service/model.py apps/ml-service/schemas.py apps/ml-service/test_model.py apps/ml-service/requirements.txt +``` + +Expected: Python service files are removed. Do not remove `.venv` or `__pycache__` in this task unless they are tracked by git. + +- [ ] **Step 4: Run Rust service checks** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml +cargo check --manifest-path apps/ml-service/Cargo.toml +``` + +Expected: tests and check pass. + +- [ ] **Step 5: Commit** + +```bash +git add apps/ml-service/moon.yml apps/ml-service/.env.example apps/ml-service/Cargo.toml apps/ml-service/src +git rm apps/ml-service/main.py apps/ml-service/model.py apps/ml-service/schemas.py apps/ml-service/test_model.py apps/ml-service/requirements.txt +git commit -m "refactor: replace Python ML service runtime with Rust" +``` + +--- + +## Task 8: Add ONNX conversion script + +**Files:** +- Create: `Machine_Learning/convert_onnx.py` +- Modify: `Machine_Learning/requirements.txt` + +- [ ] **Step 1: Add conversion dependencies** + +Append these lines to `Machine_Learning/requirements.txt` if they are not present: + +```txt +tf2onnx>=1.16.1 +onnx>=1.16.0 +onnxruntime>=1.17.0 +``` + +- [ ] **Step 2: Write conversion script** + +Create `Machine_Learning/convert_onnx.py`: + +```python +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys + + +DEFAULT_SAVED_MODEL_PATH = Path("model/saved_model") +DEFAULT_OUTPUT_PATH = Path("model/model.onnx") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Convert ZeaVis SavedModel export to ONNX.") + parser.add_argument("--saved-model", type=Path, default=DEFAULT_SAVED_MODEL_PATH) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT_PATH) + parser.add_argument("--opset", type=int, default=13) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + + if not args.saved_model.exists(): + raise FileNotFoundError(f"SavedModel directory not found: {args.saved_model}") + + args.output.parent.mkdir(parents=True, exist_ok=True) + + command = [ + sys.executable, + "-m", + "tf2onnx.convert", + "--saved-model", + str(args.saved_model), + "--output", + str(args.output), + "--opset", + str(args.opset), + ] + subprocess.run(command, check=True) + print(f"ONNX model exported to {args.output}") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 3: Compile-check script** + +Run: + +```bash +python -m py_compile Machine_Learning/convert_onnx.py +``` + +Expected: command exits successfully. + +- [ ] **Step 4: Commit** + +```bash +git add Machine_Learning/requirements.txt Machine_Learning/convert_onnx.py +git commit -m "feat: add ONNX conversion script" +``` + +--- + +## Task 9: Add manual Keras vs ONNX parity validation + +**Files:** +- Create: `Machine_Learning/validate_onnx_parity.py` + +- [ ] **Step 1: Write parity validation script** + +Create `Machine_Learning/validate_onnx_parity.py`: + +```python +from __future__ import annotations + +import argparse +from pathlib import Path + +import numpy as np +import onnxruntime as ort +from PIL import Image, UnidentifiedImageError +import tensorflow as tf + + +LABELS = ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"] +DEFAULT_KERAS_MODEL_PATH = Path("best_model/best_model.keras") +DEFAULT_ONNX_MODEL_PATH = Path("model/model.onnx") + + +class ParityError(RuntimeError): + pass + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate Keras and ONNX predictions match for sample images.") + parser.add_argument("images", nargs="+", type=Path) + parser.add_argument("--keras-model", type=Path, default=DEFAULT_KERAS_MODEL_PATH) + parser.add_argument("--onnx-model", type=Path, default=DEFAULT_ONNX_MODEL_PATH) + parser.add_argument("--input-size", type=int, default=224) + parser.add_argument("--atol", type=float, default=1e-4) + return parser.parse_args() + + +def preprocess_image(image_path: Path, input_size: int) -> np.ndarray: + try: + image = Image.open(image_path).convert("RGB") + except (UnidentifiedImageError, OSError) as exc: + raise ParityError(f"Invalid image: {image_path}") from exc + + image = image.resize((input_size, input_size)) + image_array = np.asarray(image, dtype=np.float32) + return np.expand_dims(image_array, axis=0) + + +def predict_keras(model: tf.keras.Model, batch: np.ndarray) -> np.ndarray: + return np.asarray(model.predict(batch, verbose=0)[0], dtype=np.float32) + + +def predict_onnx(session: ort.InferenceSession, batch: np.ndarray) -> np.ndarray: + input_name = session.get_inputs()[0].name + predictions = session.run(None, {input_name: batch})[0][0] + return np.asarray(predictions, dtype=np.float32) + + +def validate_image(image_path: Path, keras_model: tf.keras.Model, onnx_session: ort.InferenceSession, input_size: int, atol: float) -> None: + batch = preprocess_image(image_path, input_size) + keras_probs = predict_keras(keras_model, batch) + onnx_probs = predict_onnx(onnx_session, batch) + + keras_top = int(np.argmax(keras_probs)) + onnx_top = int(np.argmax(onnx_probs)) + + if keras_top != onnx_top: + raise ParityError( + f"Top-1 mismatch for {image_path}: Keras={LABELS[keras_top]} ONNX={LABELS[onnx_top]}" + ) + + if not np.allclose(keras_probs, onnx_probs, atol=atol): + raise ParityError( + f"Probability mismatch for {image_path}: Keras={keras_probs.tolist()} ONNX={onnx_probs.tolist()}" + ) + + print(f"PASS {image_path}: {LABELS[keras_top]}") + + +def main() -> None: + args = parse_args() + + if not args.keras_model.exists(): + raise FileNotFoundError(f"Keras model not found: {args.keras_model}") + if not args.onnx_model.exists(): + raise FileNotFoundError(f"ONNX model not found: {args.onnx_model}") + + keras_model = tf.keras.models.load_model(args.keras_model, compile=False) + onnx_session = ort.InferenceSession(str(args.onnx_model), providers=["CPUExecutionProvider"]) + + for image_path in args.images: + validate_image(image_path, keras_model, onnx_session, args.input_size, args.atol) + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 2: Compile-check script** + +Run: + +```bash +python -m py_compile Machine_Learning/validate_onnx_parity.py +``` + +Expected: command exits successfully. + +- [ ] **Step 3: Commit** + +```bash +git add Machine_Learning/validate_onnx_parity.py +git commit -m "test: add ONNX parity validation script" +``` + +--- + +## Task 10: Update Docker image for Rust ML service + +**Files:** +- Modify: `apps/ml-service/Dockerfile` + +- [ ] **Step 1: Replace Dockerfile with Rust multi-stage image** + +Replace `apps/ml-service/Dockerfile` with: + +```dockerfile +FROM rust:1.82-bookworm AS builder + +WORKDIR /app +COPY apps/ml-service/Cargo.toml ./Cargo.toml +COPY apps/ml-service/src ./src +RUN cargo build --release + +FROM debian:bookworm-slim AS runner + +WORKDIR /app +ENV MODEL_PATH=/app/model/model.onnx +ENV MODEL_INPUT_SIZE=224 +ENV ML_SERVICE_HOST=0.0.0.0 +ENV ML_SERVICE_PORT=8000 +ENV RUST_LOG=info + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /app/target/release/zeavis-ml-service /usr/local/bin/zeavis-ml-service +COPY Machine_Learning/model/model.onnx /app/model/model.onnx + +EXPOSE 8000 +CMD ["zeavis-ml-service"] +``` + +- [ ] **Step 2: Build Rust binary before Docker build** + +Run: + +```bash +cargo build --manifest-path apps/ml-service/Cargo.toml --release +``` + +Expected: release binary builds locally. + +- [ ] **Step 3: Document model artifact requirement for Docker build in commit context** + +Run: + +```bash +git diff -- apps/ml-service/Dockerfile +``` + +Expected: Dockerfile copies `Machine_Learning/model/model.onnx`; Docker build will require this generated artifact just like the previous image required `best_model.keras`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/ml-service/Dockerfile +git commit -m "build: containerize Rust ML service" +``` + +--- + +## Task 11: Update README documentation + +**Files:** +- Modify: `README.md` +- Modify: `Machine_Learning/README.md` +- Create or Modify: `apps/ml-service/README.md` + +- [ ] **Step 1: Update root README ML service sections** + +In `README.md`, make these exact content changes: + +- Replace `ML service berbasis FastAPI untuk inferensi penyakit daun jagung dari gambar.` with `ML service berbasis Rust, Axum, dan ONNX Runtime untuk inferensi penyakit daun jagung dari gambar.` +- Replace tech stack bullets `Python`, `TensorFlow/Keras`, `EfficientNetV2B0`, `FastAPI`, `Uvicorn`, `TFLite`, `TensorFlow.js` under `### Machine Learning` with: + +```markdown +- Python untuk preprocessing, training, dan ekspor model +- TensorFlow/Keras +- EfficientNetV2B0 +- Rust +- Axum +- ONNX Runtime +- TFLite +- TensorFlow.js +``` + +- Replace the ML service local run block with: + +```markdown +### ML Service + +```bash +cd apps/ml-service +cargo run +``` + +Default path model adalah: + +```text +../../Machine_Learning/model/model.onnx +``` + +Jika model berada di lokasi lain, gunakan environment variable `MODEL_PATH`. +``` + +- Add `Machine_Learning/model/model.onnx` to artifact tables and generated artifact lists as the ONNX model used by the Rust service. +- Replace troubleshooting that points to `best_model.keras` for serving with `Machine_Learning/model/model.onnx` and show: + +```bash +MODEL_PATH=/path/to/model.onnx cargo run +``` + +- [ ] **Step 2: Update Machine Learning README export section** + +In `Machine_Learning/README.md`, after the SavedModel/TFLite export instructions, add this section: + +```markdown +### Langkah 2: Konversi ke ONNX (untuk Rust ML Service) + +Setelah `model/saved_model/` tersedia, jalankan: + +```bash +python convert_onnx.py +``` + +Output default: + +```text +model/model.onnx +``` + +Model ONNX ini digunakan oleh service Rust di `apps/ml-service`. + +Untuk memvalidasi hasil ONNX terhadap model Keras, jalankan parity check manual dengan satu atau lebih gambar contoh: + +```bash +python validate_onnx_parity.py /path/to/corn-leaf.jpg +``` + +Validasi ini mengecek label top-1 dan kedekatan probabilitas antara Keras dan ONNX. +``` + +Also add `model/model.onnx` to the final output table with usage `Inferensi server-side via Rust ONNX Runtime`. + +- [ ] **Step 3: Create ML service README** + +Create `apps/ml-service/README.md`: + +```markdown +# ZeaVis ML Service + +Rust service for corn leaf disease inference using Axum and ONNX Runtime. + +## Requirements + +- Rust stable toolchain +- ONNX model at `../../Machine_Learning/model/model.onnx` + +## Run locally + +```bash +cargo run +``` + +The service listens on `0.0.0.0:8001` when `ML_SERVICE_PORT=8001` is set in local env files. In production Docker it listens on port `8000`. + +## Environment variables + +| Variable | Default | Description | +|---|---|---| +| `MODEL_PATH` | `../../Machine_Learning/model/model.onnx` | ONNX model path | +| `MODEL_INPUT_SIZE` | `224` | Input image size | +| `ML_SERVICE_HOST` | `0.0.0.0` | Bind host | +| `ML_SERVICE_PORT` | `8000` | Bind port | + +## Endpoints + +```bash +curl http://localhost:8001/health +curl http://localhost:8001/metadata +curl -X POST http://localhost:8001/predict -F "file=@/path/to/corn-leaf.jpg" +``` + +## Verification + +```bash +cargo test +cargo build --release +``` +``` + +- [ ] **Step 4: Review documentation for stale FastAPI/Uvicorn runtime references** + +Run: + +```bash +grep -R "FastAPI\|Uvicorn\|uvicorn\|best_model.keras" -n README.md apps/ml-service Machine_Learning/README.md +``` + +Expected: FastAPI/Uvicorn should not appear as the current serving runtime. `best_model.keras` may still appear only in training/export documentation. + +- [ ] **Step 5: Commit** + +```bash +git add README.md Machine_Learning/README.md apps/ml-service/README.md +git commit -m "docs: document Rust ONNX ML service" +``` + +--- + +## Task 12: Final verification + +**Files:** +- No planned edits unless verification finds a defect. + +- [ ] **Step 1: Run Rust tests** + +Run: + +```bash +cargo test --manifest-path apps/ml-service/Cargo.toml +``` + +Expected: all tests pass. + +- [ ] **Step 2: Run Rust release build** + +Run: + +```bash +cargo build --manifest-path apps/ml-service/Cargo.toml --release +``` + +Expected: release build succeeds. + +- [ ] **Step 3: Compile-check ML scripts** + +Run: + +```bash +python -m py_compile Machine_Learning/convert_onnx.py Machine_Learning/validate_onnx_parity.py +``` + +Expected: command exits successfully. + +- [ ] **Step 4: Run root typecheck if available** + +Run: + +```bash +bun run typecheck +``` + +Expected: Moon typecheck tasks pass. If this fails because the root workspace assumes Python files that were removed, update the relevant Moon task to point at Rust cargo commands and rerun. + +- [ ] **Step 5: Optional endpoint verification with real ONNX model** + +Only run this if `Machine_Learning/model/model.onnx` exists: + +```bash +cd apps/ml-service +ML_SERVICE_PORT=8001 cargo run +``` + +In another shell: + +```bash +curl http://localhost:8001/health +curl http://localhost:8001/metadata +curl -X POST http://localhost:8001/predict -F "file=@/path/to/corn-leaf.jpg" +``` + +Expected: `/health` and `/metadata` return JSON matching the existing contract. `/predict` returns `label`, `confidence`, and `probabilities` when a valid image is provided. + +- [ ] **Step 6: Inspect git status** + +Run: + +```bash +git status --short +``` + +Expected: no unintended untracked files. Large generated artifacts such as `model.onnx` should not be committed unless repository policy explicitly allows it. + +- [ ] **Step 7: Commit verification fixes if any** + +If verification required fixes, commit them: + +```bash +git add +git commit -m "fix: align Rust ML service verification" +``` + +If no fixes were needed, do not create an empty commit. + +--- + +## Self-review notes + +- Spec coverage: Rust Axum replacement, API compatibility, ONNX runtime, preprocessing, ONNX conversion, parity validation, Docker, docs, and verification are all mapped to tasks. +- Placeholder scan: no `TBD`, `TODO`, `FIXME`, or intentionally vague implementation steps remain. +- Type consistency: config, route response, model prediction, and error names are consistent across tasks. diff --git a/docs/superpowers/specs/2026-05-23-rust-onnx-ml-service-design.md b/docs/superpowers/specs/2026-05-23-rust-onnx-ml-service-design.md new file mode 100644 index 0000000..007781c --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-rust-onnx-ml-service-design.md @@ -0,0 +1,163 @@ +# Rust ONNX ML Service Migration Design + +## Goal + +Replace the current Python/FastAPI ML serving runtime with a Rust service that uses Axum and ONNX Runtime while preserving the existing HTTP contract, deployment shape, and model labels. The migration also adds an ONNX conversion path so the Keras/SavedModel training output can produce the model artifact used by the Rust service. + +## Current context + +The existing service lives in `apps/ml-service` and exposes three endpoints: + +- `GET /health` +- `GET /metadata` +- `POST /predict` + +It loads a Keras model from `MODEL_PATH`, defaults to `../../Machine_Learning/best_model/best_model.keras`, preprocesses uploaded images as RGB resized to `224x224`, sends a float32 NHWC batch to the model, and returns the top label, confidence, and all label probabilities. + +Deployment already expects an `ml` service listening on port `8000`, with API integration configured through `ML_SERVICE_URL`. + +## Decisions + +- Replace the Python serving code fully rather than running Python and Rust side by side. +- Use Rust with Axum for the HTTP server. +- Use the `ort` crate for ONNX Runtime inference. +- Keep the current API contract for `/health`, `/metadata`, and `/predict`. +- Keep the current preprocessing behavior: RGB, resize to `224x224`, float32 tensor, NHWC shape `[1, 224, 224, 3]`, no additional normalization. +- Keep deployment compatibility: service name `ml`, internal port `8000`. +- Add ONNX conversion to the ML export workflow. +- Add parity validation as a manual verification command because model artifacts and sample images are not guaranteed to exist in fresh clones or CI. + +## Architecture + +`apps/ml-service` becomes a Rust binary crate. The service is split into small modules: + +- `main.rs`: startup, configuration loading, Axum router, TCP listener. +- `config.rs`: environment variables, defaults, labels, service metadata. +- `routes.rs`: HTTP handlers and response types. +- `model.rs`: ONNX session loading and prediction. +- `image.rs`: upload image decoding and preprocessing. +- `error.rs`: typed errors mapped to HTTP responses. + +The default model path changes to `../../Machine_Learning/model/model.onnx`. `MODEL_PATH` can still override the path. `MODEL_INPUT_SIZE` defaults to `224`. + +## API contract + +### `GET /health` + +Returns: + +```json +{ + "status": "ok", + "model_loaded": true +} +``` + +The endpoint still responds even if the model failed to load, with `model_loaded: false`. + +### `GET /metadata` + +Returns: + +```json +{ + "service_name": "zeavis-ml-service", + "service_version": "0.1.0", + "model_path": ".../Machine_Learning/model/model.onnx", + "model_loaded": true, + "input_size": 224, + "labels": ["Bercak Daun", "Daun Sehat", "Karat Daun", "Hawar Daun"] +} +``` + +### `POST /predict` + +Accepts multipart form data with field `file`. Returns: + +```json +{ + "label": "Karat Daun", + "confidence": 0.98, + "probabilities": { + "Bercak Daun": 0.01, + "Daun Sehat": 0.0, + "Karat Daun": 0.98, + "Hawar Daun": 0.01 + } +} +``` + +## Data flow + +1. Client uploads an image to `/predict` as multipart field `file`. +2. The route validates that a file is present and that the content type is an image when provided. +3. `image.rs` decodes the image, converts it to RGB, resizes it to `MODEL_INPUT_SIZE x MODEL_INPUT_SIZE`, casts pixels to `f32`, and creates an NHWC tensor with batch dimension. +4. `model.rs` runs the tensor through ONNX Runtime. +5. The output vector is mapped to the fixed Indonesian labels. +6. The service selects the highest-probability label and returns all probabilities. + +## ONNX export pipeline + +The ML pipeline keeps the current Keras and SavedModel exports, then adds an ONNX output: + +1. Training produces `Machine_Learning/best_model/best_model.keras`. +2. `Machine_Learning/save_model.py` continues exporting SavedModel and TFLite. +3. A new conversion command or script produces `Machine_Learning/model/model.onnx` from the exported SavedModel or Keras model. +4. Documentation explains required Python dependencies and the exact command to regenerate `model.onnx`. + +The ONNX artifact is generated/local like the existing model exports and may not exist in a fresh clone. + +## Parity validation + +Parity validation compares Keras and ONNX predictions for the same sample images. It is a manual verification command, not a required CI test. + +The validation should check: + +- Top-1 label matches. +- Probability vectors are numerically close within an explicit tolerance. +- The preprocessing used for comparison matches the Rust service: RGB resize, float32 NHWC, no extra normalization. + +If parity fails, the migration should stop until conversion input shape, preprocessing, or output mapping is corrected. + +## Error handling + +The Rust service maps errors to the same behavior as the current Python service: + +- Missing file or non-image upload: `400`. +- Invalid image bytes or decode failure: `400`. +- Model not loaded: `503`. +- Unexpected inference failure: `500`. + +Startup should try to load the model and keep the service alive if loading fails so `/health` and `/metadata` can report `model_loaded: false`. + +## Testing and verification + +Required local verification: + +- `cargo test` from `apps/ml-service`. +- `cargo build --release` from `apps/ml-service`. +- Manual endpoint checks with `curl` for `/health`, `/metadata`, and `/predict` when `model.onnx` is available. +- Manual parity validation when both Keras and ONNX artifacts plus sample images are available. + +The repository has no existing global test suite, so the Rust service checks become the primary verification for this migration. + +## Documentation and deployment updates + +Update documentation so runtime serving no longer describes FastAPI/TensorFlow as the production ML service. Keep Python/TensorFlow documentation for training and export. + +Update: + +- Root `README.md`. +- `apps/ml-service/README.md`. +- `Machine_Learning/README.md` where export artifacts and ONNX conversion are described. +- Dockerfile or deployment files that build the `ml` image. + +Deployment remains compatible with the existing `ml` service and port `8000` expectation. + +## Out of scope + +- Changing the model architecture or class labels. +- Retraining the model. +- Changing API/backend integration contracts. +- Adding GPU acceleration. +- Making parity validation mandatory in CI.