diff --git a/services/ml_training_service/src/service.rs b/services/ml_training_service/src/service.rs index 7d4a6eda1..ab33856cc 100644 --- a/services/ml_training_service/src/service.rs +++ b/services/ml_training_service/src/service.rs @@ -35,7 +35,7 @@ use proto::{ TrainingStatusUpdate as ProtoStatusUpdate, // Job completion & model promotion (on-demand training pipeline) JobCompletionReport, JobCompletionAck, - ListPendingPromotionsRequest, ListPendingPromotionsResponse, + ListPendingPromotionsRequest, ListPendingPromotionsResponse, PendingPromotion, ApprovePromotionRequest, ApprovePromotionResponse, RejectPromotionRequest, RejectPromotionResponse, }; @@ -1234,10 +1234,24 @@ impl MlTrainingService for MLTrainingServiceImpl { &self, _request: Request, ) -> Result, Status> { - // TODO(task-6): query promotion store - Ok(Response::new(ListPendingPromotionsResponse { - promotions: vec![], - })) + let pending = self.promotion_manager.list_pending().await; + info!(count = pending.len(), "listing pending promotions"); + + let promotions = pending + .into_iter() + .map(|m| PendingPromotion { + model_id: m.model_id, + model_type: m.model_type, + symbol: m.symbol, + s3_path: m.s3_path, + new_metrics: m.new_metrics.into_iter().collect(), + current_metrics: m.current_metrics.into_iter().collect(), + trained_at: m.trained_at.timestamp(), + job_id: m.job_id, + }) + .collect(); + + Ok(Response::new(ListPendingPromotionsResponse { promotions })) } async fn approve_promotion( @@ -1757,4 +1771,50 @@ mod tests { crate::promotion_manager::PromotionStatus::Registered ); } + + #[test] + fn test_list_pending_promotions_maps_to_proto() { + // Verify that PendingModel fields map correctly to proto PendingPromotion + use crate::promotion_manager::PendingModel; + use chrono::Utc; + + let now = Utc::now(); + let model = PendingModel { + model_id: "m-123".to_string(), + model_type: "DQN".to_string(), + symbol: "ES.FUT".to_string(), + s3_path: "s3://bucket/better.bin".to_string(), + new_metrics: HashMap::from([ + ("best_val_loss".to_string(), 0.03), + ("sharpe_ratio".to_string(), 2.5), + ]), + current_metrics: HashMap::from([ + ("best_val_loss".to_string(), 0.10), + ("sharpe_ratio".to_string(), 1.0), + ]), + trained_at: now, + job_id: "job-99".to_string(), + }; + + // Map to proto the same way the handler does + let proto = super::proto::PendingPromotion { + model_id: model.model_id.clone(), + model_type: model.model_type.clone(), + symbol: model.symbol.clone(), + s3_path: model.s3_path.clone(), + new_metrics: model.new_metrics.clone().into_iter().collect(), + current_metrics: model.current_metrics.clone().into_iter().collect(), + trained_at: model.trained_at.timestamp(), + job_id: model.job_id.clone(), + }; + + assert_eq!(proto.model_id, "m-123"); + assert_eq!(proto.model_type, "DQN"); + assert_eq!(proto.symbol, "ES.FUT"); + assert_eq!(proto.s3_path, "s3://bucket/better.bin"); + assert_eq!(proto.job_id, "job-99"); + assert_eq!(proto.trained_at, now.timestamp()); + assert_eq!(proto.new_metrics.len(), 2); + assert_eq!(proto.current_metrics.len(), 2); + } }