Create Rust crate skeleton with Cargo.toml, main.rs, and config.rs. Includes package metadata, dependencies (anyhow, axum, image, ndarray, ort, serde, tokio, tower, tracing), and config module with LABELS constants, SERVICE_NAME, SERVICE_VERSION, DEFAULT_INPUT_SIZE, and resolve_model_path function. Config tests verify label order, relative path resolution, and absolute path preservation. Also includes approved spec and implementation plan documents. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
48 KiB
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/predicthandlers.Machine_Learning/convert_onnx.py— convert exported SavedModel tomodel/model.onnxusing 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:
[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:
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:
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:
mod config;
fn main() {
println!("zeavis-ml-service");
}
- Step 5: Run tests and verify they pass
Run:
cargo test --manifest-path apps/ml-service/Cargo.toml
Expected: all config tests pass.
- Step 6: Commit
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:
#[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:
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:
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<Self> {
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<Self> {
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<u16> {
match env::var(name) {
Ok(value) => value
.parse::<u16>()
.with_context(|| format!("{name} must be a valid u16")),
Err(_) => Ok(default),
}
}
fn parse_env_u32(name: &str, default: u32) -> Result<u32> {
match env::var(name) {
Ok(value) => value
.parse::<u32>()
.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:
cargo test --manifest-path apps/ml-service/Cargo.toml config
Expected: all config tests pass.
- Step 5: Commit
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:
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:
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:
mod config;
mod error;
mod routes;
fn main() {
println!("zeavis-ml-service");
}
Create apps/ml-service/src/routes.rs:
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<String, f32>,
}
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:
cargo test --manifest-path apps/ml-service/Cargo.toml error routes
Expected: error and route response tests pass.
- Step 5: Commit
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:
use crate::error::ServiceError;
use ndarray::Array4;
pub fn preprocess_image(_bytes: &[u8], _input_size: u32) -> Result<Array4<f32>, ServiceError> {
unimplemented!("preprocess image bytes")
}
#[cfg(test)]
mod tests {
use super::*;
use image::{DynamicImage, ImageFormat, RgbImage};
use std::io::Cursor;
fn png_bytes() -> Vec<u8> {
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:
mod config;
mod error;
mod image;
mod routes;
fn main() {
println!("zeavis-ml-service");
}
Run:
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:
use crate::error::ServiceError;
use image::imageops::FilterType;
use ndarray::Array4;
pub fn preprocess_image(bytes: &[u8], input_size: u32) -> Result<Array4<f32>, 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::<f32>::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:
cargo test --manifest-path apps/ml-service/Cargo.toml image
Expected: image preprocessing tests pass.
- Step 5: Commit
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:
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<String, f32>,
}
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<f32>) -> Result<Prediction, ServiceError> {
unimplemented!("run ONNX inference")
}
}
pub fn prediction_from_probabilities(probabilities: &[f32]) -> Result<Prediction, ServiceError> {
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:
mod config;
mod error;
mod image;
mod model;
mod routes;
fn main() {
println!("zeavis-ml-service");
}
Run:
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:
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<String, f32>,
}
pub struct ModelService {
model_path: PathBuf,
input_size: u32,
session: Option<Mutex<Session>>,
}
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<f32>) -> Result<Prediction, ServiceError> {
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::<f32>()
.map_err(|_| ServiceError::PredictionFailed("Prediction failed".to_string()))?;
let probabilities: Vec<f32> = probabilities.view().iter().copied().collect();
prediction_from_probabilities(&probabilities)
}
}
pub fn prediction_from_probabilities(probabilities: &[f32]) -> Result<Prediction, ServiceError> {
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:
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
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:
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<ModelService>,
}
#[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<String, f32>,
}
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<AppState>) -> Json<HealthResponse> {
Json(health_response(state.model.is_loaded()))
}
pub async fn metadata(State(state): State<AppState>) -> Json<MetadataResponse> {
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<AppState>,
mut multipart: Multipart,
) -> Result<Json<PredictionResponse>, 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:
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:
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:
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
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:
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:
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:
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:
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
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:
tf2onnx>=1.16.1
onnx>=1.16.0
onnxruntime>=1.17.0
- Step 2: Write conversion script
Create Machine_Learning/convert_onnx.py:
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:
python -m py_compile Machine_Learning/convert_onnx.py
Expected: command exits successfully.
- Step 4: Commit
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:
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:
python -m py_compile Machine_Learning/validate_onnx_parity.py
Expected: command exits successfully.
- Step 3: Commit
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:
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:
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:
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
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.withML 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.jsunder### Machine Learningwith:
- 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:
### ML Service
```bash
cd apps/ml-service
cargo run
Default path model adalah:
../../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:
### Langkah 2: Konversi ke ONNX (untuk Rust ML Service)
Setelah `model/saved_model/` tersedia, jalankan:
```bash
python convert_onnx.py
Output default:
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:
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
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
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
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:
cargo test --manifest-path apps/ml-service/Cargo.toml
Expected: all tests pass.
- Step 2: Run Rust release build
Run:
cargo build --manifest-path apps/ml-service/Cargo.toml --release
Expected: release build succeeds.
- Step 3: Compile-check ML scripts
Run:
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:
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:
cd apps/ml-service
ML_SERVICE_PORT=8001 cargo run
In another shell:
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:
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:
git add <changed-files>
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.