1use anyhow::{Context, Result};
30use parking_lot::RwLock;
31use scirs2_core::ndarray::Array1;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36use std::time::{SystemTime, UNIX_EPOCH};
37use trustformers_core::tensor::Tensor;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
41pub enum TrackingMode {
42 #[default]
47 Http,
48 LocalOnly,
53}
54
55#[derive(Debug)]
57pub struct MLflowClient {
58 tracking_uri: String,
60 experiment_id: Option<String>,
62 run_id: Option<String>,
64 run_name: Option<String>,
66 run_start_time: Option<i64>,
68 config: MLflowConfig,
70 metrics_cache: Arc<RwLock<HashMap<String, Vec<MetricPoint>>>>,
73 params_cache: Arc<RwLock<HashMap<String, String>>>,
75 tags_cache: Arc<RwLock<HashMap<String, String>>>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct MLflowConfig {
82 pub tracking_uri: String,
84 pub experiment_name: String,
86 pub auto_log: bool,
88 pub log_interval: usize,
90 pub max_cache_size: usize,
92 pub log_artifacts: bool,
94 pub artifact_dir: PathBuf,
96 #[serde(default)]
99 pub mode: TrackingMode,
100}
101
102impl Default for MLflowConfig {
103 fn default() -> Self {
104 Self {
105 tracking_uri: "http://localhost:5000".to_string(),
106 experiment_name: "trustformers-debug".to_string(),
107 auto_log: true,
108 log_interval: 10,
109 max_cache_size: 1000,
110 log_artifacts: true,
111 artifact_dir: PathBuf::from("./mlflow_artifacts"),
112 mode: TrackingMode::Http,
113 }
114 }
115}
116
117#[cfg(feature = "http-integrations")]
121mod rest {
122 use super::Result;
123 use anyhow::Context;
124
125 fn endpoint(base: &str, path: &str) -> String {
126 format!("{}/api/2.0/mlflow/{path}", base.trim_end_matches('/'))
127 }
128
129 async fn parse_response(url: &str, response: reqwest::Response) -> Result<serde_json::Value> {
130 let status = response.status();
131 let text = response.text().await.unwrap_or_default();
132 if !status.is_success() {
133 anyhow::bail!("MLflow request to {url} returned {status}: {text}");
134 }
135 serde_json::from_str(&text)
136 .with_context(|| format!("MLflow response from {url} was not valid JSON: {text}"))
137 }
138
139 pub(super) async fn post(
140 base: &str,
141 path: &str,
142 body: serde_json::Value,
143 ) -> Result<serde_json::Value> {
144 let url = endpoint(base, path);
145 let response = reqwest::Client::new()
146 .post(&url)
147 .json(&body)
148 .send()
149 .await
150 .with_context(|| format!("MLflow request to {url} failed"))?;
151 parse_response(&url, response).await
152 }
153
154 pub(super) async fn get_optional(
159 base: &str,
160 path: &str,
161 query: &[(&str, &str)],
162 ) -> Result<Option<serde_json::Value>> {
163 let url = endpoint(base, path);
164 let response = reqwest::Client::new()
165 .get(&url)
166 .query(query)
167 .send()
168 .await
169 .with_context(|| format!("MLflow request to {url} failed"))?;
170 if response.status() == reqwest::StatusCode::NOT_FOUND {
171 return Ok(None);
172 }
173 parse_response(&url, response).await.map(Some)
174 }
175}
176
177#[cfg(not(feature = "http-integrations"))]
180mod rest {
181 use super::Result;
182
183 const DISABLED_MESSAGE: &str = "MLflow HTTP tracking is not enabled: rebuild \
184 trustformers-debug with `--features http-integrations`, or select \
185 `TrackingMode::LocalOnly`";
186
187 pub(super) async fn post(
188 _base: &str,
189 _path: &str,
190 _body: serde_json::Value,
191 ) -> Result<serde_json::Value> {
192 anyhow::bail!(DISABLED_MESSAGE)
193 }
194
195 pub(super) async fn get_optional(
196 _base: &str,
197 _path: &str,
198 _query: &[(&str, &str)],
199 ) -> Result<Option<serde_json::Value>> {
200 anyhow::bail!(DISABLED_MESSAGE)
201 }
202}
203
204fn now_ms() -> i64 {
205 SystemTime::now()
206 .duration_since(UNIX_EPOCH)
207 .map(|d| d.as_millis() as i64)
208 .unwrap_or(0)
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct MetricPoint {
214 pub value: f64,
216 pub step: i64,
218 pub timestamp: i64,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct RunInfo {
225 pub run_id: String,
227 pub experiment_id: String,
229 pub run_name: String,
231 pub start_time: i64,
233 pub end_time: Option<i64>,
235 pub status: RunStatus,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241pub enum RunStatus {
242 Running,
244 Finished,
246 Failed,
248 Killed,
250}
251
252impl RunStatus {
253 fn as_mlflow_str(self) -> &'static str {
256 match self {
257 RunStatus::Running => "RUNNING",
258 RunStatus::Finished => "FINISHED",
259 RunStatus::Failed => "FAILED",
260 RunStatus::Killed => "KILLED",
261 }
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub enum ArtifactType {
268 Model,
270 Plot,
272 Report,
274 Data,
276 Config,
278}
279
280impl MLflowClient {
281 pub fn new(config: MLflowConfig) -> Self {
294 Self {
295 tracking_uri: config.tracking_uri.clone(),
296 experiment_id: None,
297 run_id: None,
298 run_name: None,
299 run_start_time: None,
300 config,
301 metrics_cache: Arc::new(RwLock::new(HashMap::new())),
302 params_cache: Arc::new(RwLock::new(HashMap::new())),
303 tags_cache: Arc::new(RwLock::new(HashMap::new())),
304 }
305 }
306
307 pub fn set_tracking_uri(&mut self, uri: impl Into<String>) {
312 self.tracking_uri = uri.into();
313 }
314
315 pub async fn start_experiment(&mut self, name: impl Into<String>) -> Result<String> {
332 let experiment_name = name.into();
333
334 let experiment_id = match self.config.mode {
335 TrackingMode::LocalOnly => {
336 let id = format!("local-exp-{}", uuid::Uuid::new_v4());
337 tracing::info!(
338 experiment_id = %id,
339 experiment_name = %experiment_name,
340 "Started MLflow experiment (LocalOnly: not sent to a tracking server)"
341 );
342 id
343 },
344 TrackingMode::Http => {
345 let query = [("experiment_name", experiment_name.as_str())];
346 let existing =
353 rest::get_optional(&self.tracking_uri, "experiments/get-by-name", &query)
354 .await
355 .with_context(|| {
356 format!("failed to look up MLflow experiment {experiment_name:?}")
357 })?;
358 let id = match existing {
359 Some(response) => response
360 .get("experiment")
361 .and_then(|e| e.get("experiment_id"))
362 .and_then(|v| v.as_str())
363 .map(str::to_string)
364 .context("MLflow experiments/get-by-name response missing experiment_id")?,
365 None => {
366 let body = serde_json::json!({ "name": experiment_name });
367 let response = rest::post(&self.tracking_uri, "experiments/create", body)
368 .await
369 .with_context(|| {
370 format!("failed to create MLflow experiment {experiment_name:?}")
371 })?;
372 response
373 .get("experiment_id")
374 .and_then(|v| v.as_str())
375 .map(str::to_string)
376 .context("MLflow experiments/create response missing experiment_id")?
377 },
378 };
379 tracing::info!(
380 experiment_id = %id,
381 experiment_name = %experiment_name,
382 tracking_uri = %self.tracking_uri,
383 "Started MLflow experiment"
384 );
385 id
386 },
387 };
388
389 self.experiment_id = Some(experiment_id.clone());
390 Ok(experiment_id)
391 }
392
393 pub async fn start_run(&mut self, run_name: Option<&str>) -> Result<String> {
404 let experiment_id = self
405 .experiment_id
406 .clone()
407 .context("No active experiment. Call start_experiment() first")?;
408
409 let run_name = run_name.unwrap_or("debug_run").to_string();
410 let start_time = now_ms();
411
412 let run_id = match self.config.mode {
413 TrackingMode::LocalOnly => {
414 let id = format!("local-run-{}", uuid::Uuid::new_v4());
415 tracing::info!(
416 run_id = %id,
417 run_name = %run_name,
418 experiment_id = %experiment_id,
419 "Started MLflow run (LocalOnly: not sent to a tracking server)"
420 );
421 id
422 },
423 TrackingMode::Http => {
424 let body = serde_json::json!({
425 "experiment_id": experiment_id,
426 "run_name": run_name,
427 "start_time": start_time,
428 "tags": [{"key": "mlflow.runName", "value": run_name}],
429 });
430 let response =
431 rest::post(&self.tracking_uri, "runs/create", body).await.with_context(
432 || format!("failed to create MLflow run in experiment {experiment_id}"),
433 )?;
434 let id = response
435 .get("run")
436 .and_then(|r| r.get("info"))
437 .and_then(|i| i.get("run_id"))
438 .and_then(|v| v.as_str())
439 .map(str::to_string)
440 .context("MLflow runs/create response missing run.info.run_id")?;
441 tracing::info!(
442 run_id = %id,
443 run_name = %run_name,
444 experiment_id = %experiment_id,
445 "Started MLflow run"
446 );
447 id
448 },
449 };
450
451 self.run_id = Some(run_id.clone());
452 self.run_name = Some(run_name);
453 self.run_start_time = Some(start_time);
454
455 self.metrics_cache.write().clear();
457 self.params_cache.write().clear();
458 self.tags_cache.write().clear();
459
460 Ok(run_id)
461 }
462
463 pub async fn end_run(&mut self, status: RunStatus) -> Result<()> {
470 let run_id = self.run_id.clone().context("No active run")?;
471
472 self.flush().await?;
473
474 if self.config.mode == TrackingMode::Http {
475 let body = serde_json::json!({
476 "run_id": run_id,
477 "status": status.as_mlflow_str(),
478 "end_time": now_ms(),
479 });
480 rest::post(&self.tracking_uri, "runs/update", body).await.with_context(|| {
481 format!("failed to set MLflow run {run_id} status to {status:?}")
482 })?;
483 }
484
485 tracing::info!(
486 run_id = %run_id,
487 status = ?status,
488 "Ended MLflow run"
489 );
490
491 self.run_id = None;
492 self.run_name = None;
493 self.run_start_time = None;
494
495 Ok(())
496 }
497
498 pub fn log_param(&mut self, key: impl Into<String>, value: impl ToString) -> Result<()> {
507 let key = key.into();
508 let value = value.to_string();
509
510 self.run_id.as_ref().context("No active run. Call start_run() first")?;
511
512 self.params_cache.write().insert(key.clone(), value.clone());
513
514 tracing::debug!(key = %key, value = %value, "Logged parameter");
515
516 Ok(())
517 }
518
519 pub fn log_params(&mut self, params: HashMap<String, String>) -> Result<()> {
524 for (key, value) in params {
525 self.log_param(key, value)?;
526 }
527 Ok(())
528 }
529
530 pub fn log_tag(&mut self, key: impl Into<String>, value: impl ToString) -> Result<()> {
536 let key = key.into();
537 let value = value.to_string();
538
539 self.run_id.as_ref().context("No active run. Call start_run() first")?;
540
541 self.tags_cache.write().insert(key.clone(), value.clone());
542
543 tracing::debug!(key = %key, value = %value, "Logged tag");
544
545 Ok(())
546 }
547
548 pub fn log_tags(&mut self, tags: HashMap<String, String>) -> Result<()> {
553 for (key, value) in tags {
554 self.log_tag(key, value)?;
555 }
556 Ok(())
557 }
558
559 pub fn log_metric(&mut self, key: impl Into<String>, value: f64, step: i64) -> Result<()> {
568 let key = key.into();
569
570 self.run_id.as_ref().context("No active run. Call start_run() first")?;
571
572 let metric = MetricPoint {
573 value,
574 step,
575 timestamp: now_ms(),
576 };
577
578 self.metrics_cache.write().entry(key.clone()).or_default().push(metric);
579
580 tracing::debug!(key = %key, value = %value, step = %step, "Logged metric");
581
582 let cached_count = self.metrics_cache.read().values().map(|v| v.len()).sum::<usize>();
583 if cached_count >= self.config.max_cache_size {
584 tracing::warn!(
588 cached_metric_count = cached_count,
589 max_cache_size = self.config.max_cache_size,
590 "MLflow metric cache exceeds max_cache_size; call `MLflowClient::flush().await` \
591 to deliver cached metrics to the tracking server"
592 );
593 }
594
595 Ok(())
596 }
597
598 pub fn log_metrics(&mut self, metrics: HashMap<String, f64>, step: i64) -> Result<()> {
604 for (key, value) in metrics {
605 self.log_metric(key, value, step)?;
606 }
607 Ok(())
608 }
609
610 pub fn log_tensor_stats(&mut self, prefix: &str, tensor: &Tensor, step: i64) -> Result<()> {
617 self.log_metric(
619 format!("{}/element_count", prefix),
620 tensor.len() as f64,
621 step,
622 )?;
623 self.log_metric(
624 format!("{}/memory_bytes", prefix),
625 tensor.memory_usage() as f64,
626 step,
627 )?;
628
629 let shape = tensor.shape();
630 self.log_metric(format!("{}/ndim", prefix), shape.len() as f64, step)?;
631
632 Ok(())
633 }
634
635 pub fn log_array_stats(&mut self, prefix: &str, array: &Array1<f64>, step: i64) -> Result<()> {
642 let mean = array.mean().unwrap_or(0.0);
643 let std = array.std(0.0);
644 let min = array.iter().copied().fold(f64::INFINITY, f64::min);
645 let max = array.iter().copied().fold(f64::NEG_INFINITY, f64::max);
646
647 self.log_metric(format!("{}/mean", prefix), mean, step)?;
648 self.log_metric(format!("{}/std", prefix), std, step)?;
649 self.log_metric(format!("{}/min", prefix), min, step)?;
650 self.log_metric(format!("{}/max", prefix), max, step)?;
651
652 Ok(())
653 }
654
655 pub async fn flush(&self) -> Result<()> {
662 if self.config.mode == TrackingMode::LocalOnly {
663 return Ok(());
664 }
665
666 let Some(run_id) = self.run_id.clone() else {
667 return Ok(());
668 };
669
670 let metrics: Vec<serde_json::Value> = self
671 .metrics_cache
672 .read()
673 .iter()
674 .flat_map(|(key, points)| {
675 points.iter().map(move |p| {
676 serde_json::json!({
677 "key": key,
678 "value": p.value,
679 "timestamp": p.timestamp,
680 "step": p.step,
681 })
682 })
683 })
684 .collect();
685 let params: Vec<serde_json::Value> = self
686 .params_cache
687 .read()
688 .iter()
689 .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
690 .collect();
691 let tags: Vec<serde_json::Value> = self
692 .tags_cache
693 .read()
694 .iter()
695 .map(|(k, v)| serde_json::json!({"key": k, "value": v}))
696 .collect();
697
698 if metrics.is_empty() && params.is_empty() && tags.is_empty() {
699 return Ok(());
700 }
701
702 const METRIC_CHUNK: usize = 1000;
703 const PARAM_CHUNK: usize = 100;
704 const TAG_CHUNK: usize = 100;
705
706 let metric_chunks: Vec<Vec<serde_json::Value>> =
707 metrics.chunks(METRIC_CHUNK).map(|c| c.to_vec()).collect();
708 let param_chunks: Vec<Vec<serde_json::Value>> =
709 params.chunks(PARAM_CHUNK).map(|c| c.to_vec()).collect();
710 let tag_chunks: Vec<Vec<serde_json::Value>> =
711 tags.chunks(TAG_CHUNK).map(|c| c.to_vec()).collect();
712 let rounds = metric_chunks.len().max(param_chunks.len()).max(tag_chunks.len()).max(1);
713
714 for i in 0..rounds {
715 let body = serde_json::json!({
716 "run_id": run_id,
717 "metrics": metric_chunks.get(i).cloned().unwrap_or_default(),
718 "params": param_chunks.get(i).cloned().unwrap_or_default(),
719 "tags": tag_chunks.get(i).cloned().unwrap_or_default(),
720 });
721 rest::post(&self.tracking_uri, "runs/log-batch", body).await.with_context(|| {
722 format!(
723 "failed to flush batch {}/{rounds} to MLflow run {run_id}",
724 i + 1
725 )
726 })?;
727 }
728
729 tracing::debug!(
730 run_id = %run_id,
731 metric_count = metrics.len(),
732 param_count = params.len(),
733 tag_count = tags.len(),
734 "Flushed metrics/params/tags to MLflow"
735 );
736
737 Ok(())
738 }
739
740 pub fn log_artifact(
760 &self,
761 local_path: impl AsRef<Path>,
762 artifact_path: Option<&str>,
763 artifact_type: ArtifactType,
764 ) -> Result<()> {
765 let _run_id = self.run_id.as_ref().context("No active run")?;
766
767 let local_path = local_path.as_ref();
768
769 if !self.config.log_artifacts {
770 tracing::debug!("Artifact logging disabled");
771 return Ok(());
772 }
773
774 let artifact_dir = &self.config.artifact_dir;
776 std::fs::create_dir_all(artifact_dir)?;
777
778 let dest_path = if let Some(rel_path) = artifact_path {
779 artifact_dir.join(rel_path)
780 } else {
781 artifact_dir.join(local_path.file_name().context("local_path must have a filename")?)
782 };
783
784 if let Some(parent) = dest_path.parent() {
785 std::fs::create_dir_all(parent)?;
786 }
787
788 std::fs::copy(local_path, &dest_path).context("Failed to copy artifact")?;
789
790 if self.config.mode == TrackingMode::Http {
791 tracing::warn!(
792 local_path = ?local_path,
793 artifact_path = ?dest_path,
794 artifact_type = ?artifact_type,
795 "Artifact cached locally only -- NOT uploaded to the remote MLflow tracking \
796 server at {}; MLflow artifact storage backends vary and are not implemented \
797 by this client",
798 self.tracking_uri
799 );
800 } else {
801 tracing::info!(
802 local_path = ?local_path,
803 artifact_path = ?dest_path,
804 artifact_type = ?artifact_type,
805 "Logged artifact to local cache"
806 );
807 }
808
809 Ok(())
810 }
811
812 pub fn log_model(&self, model_path: impl AsRef<Path>, model_name: Option<&str>) -> Result<()> {
818 let artifact_path = if let Some(name) = model_name {
819 format!("models/{}", name)
820 } else {
821 "models/model".to_string()
822 };
823
824 self.log_artifact(model_path, Some(&artifact_path), ArtifactType::Model)
825 }
826
827 pub fn log_plot(&self, plot_path: impl AsRef<Path>, plot_name: Option<&str>) -> Result<()> {
833 let artifact_path = if let Some(name) = plot_name {
834 format!("plots/{}", name)
835 } else {
836 "plots/plot".to_string()
837 };
838
839 self.log_artifact(plot_path, Some(&artifact_path), ArtifactType::Plot)
840 }
841
842 pub fn log_report(&self, content: &str, filename: &str) -> Result<()> {
848 let temp_path = std::env::temp_dir().join(filename);
849 std::fs::write(&temp_path, content)?;
850
851 self.log_artifact(
852 &temp_path,
853 Some(&format!("reports/{}", filename)),
854 ArtifactType::Report,
855 )?;
856
857 std::fs::remove_file(&temp_path)?;
858
859 Ok(())
860 }
861
862 pub fn get_run_info(&self) -> Option<RunInfo> {
866 let run_id = self.run_id.as_ref()?;
867 let experiment_id = self.experiment_id.as_ref()?;
868
869 Some(RunInfo {
870 run_id: run_id.clone(),
871 experiment_id: experiment_id.clone(),
872 run_name: self.run_name.clone().unwrap_or_else(|| "debug_run".to_string()),
873 start_time: self.run_start_time.unwrap_or(0),
874 end_time: None,
875 status: RunStatus::Running,
876 })
877 }
878
879 pub fn get_params(&self) -> HashMap<String, String> {
881 self.params_cache.read().clone()
882 }
883
884 pub fn get_metrics(&self) -> HashMap<String, Vec<MetricPoint>> {
886 self.metrics_cache.read().clone()
887 }
888
889 pub fn get_tags(&self) -> HashMap<String, String> {
891 self.tags_cache.read().clone()
892 }
893}
894
895pub struct MLflowDebugSession {
897 pub client: MLflowClient,
899 step: i64,
901}
902
903impl MLflowDebugSession {
904 pub fn new(config: MLflowConfig) -> Self {
906 Self {
907 client: MLflowClient::new(config),
908 step: 0,
909 }
910 }
911
912 pub async fn start(&mut self, experiment_name: &str, run_name: Option<&str>) -> Result<()> {
914 self.client.start_experiment(experiment_name).await?;
915 self.client.start_run(run_name).await?;
916 self.step = 0;
917 Ok(())
918 }
919
920 pub fn log_debug_metrics(&mut self, metrics: HashMap<String, f64>) -> Result<()> {
922 self.client.log_metrics(metrics, self.step)?;
923 self.step += 1;
924 Ok(())
925 }
926
927 pub async fn end(&mut self, status: RunStatus) -> Result<()> {
929 self.client.end_run(status).await
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936 use scirs2_core::ndarray::Array1;
937
938 fn local_only_config() -> MLflowConfig {
941 let mut config = MLflowConfig::default();
942 config.mode = TrackingMode::LocalOnly;
943 config
944 }
945
946 #[test]
947 fn test_mlflow_client_creation() {
948 let config = MLflowConfig::default();
949 let _client = MLflowClient::new(config);
950 }
951
952 #[test]
953 fn test_default_mode_is_http_not_silent_local_only() {
954 assert_eq!(MLflowConfig::default().mode, TrackingMode::Http);
957 }
958
959 #[tokio::test]
960 async fn test_local_only_start_experiment_and_run_never_touch_network() {
961 let mut client = MLflowClient::new(local_only_config());
962
963 let exp_id = client
964 .start_experiment("test_experiment")
965 .await
966 .expect("LocalOnly must not require a server");
967 assert!(exp_id.starts_with("local-exp-"), "got {exp_id:?}");
968
969 let run_id = client
970 .start_run(Some("test_run"))
971 .await
972 .expect("LocalOnly must not require a server");
973 assert!(run_id.starts_with("local-run-"), "got {run_id:?}");
974 }
975
976 #[tokio::test]
977 async fn test_log_params() -> Result<()> {
978 let mut client = MLflowClient::new(local_only_config());
979
980 client.start_experiment("test").await?;
981 client.start_run(None).await?;
982
983 client.log_param("learning_rate", "0.001")?;
984 client.log_param("batch_size", "32")?;
985
986 let params = client.get_params();
987 assert_eq!(params.get("learning_rate"), Some(&"0.001".to_string()));
988 assert_eq!(params.get("batch_size"), Some(&"32".to_string()));
989
990 Ok(())
991 }
992
993 #[tokio::test]
994 async fn test_log_tags() -> Result<()> {
995 let mut client = MLflowClient::new(local_only_config());
996
997 client.start_experiment("test").await?;
998 client.start_run(None).await?;
999
1000 client.log_tag("owner", "trustformers-debug")?;
1001 let tags = client.get_tags();
1002 assert_eq!(tags.get("owner"), Some(&"trustformers-debug".to_string()));
1003
1004 Ok(())
1005 }
1006
1007 #[tokio::test]
1008 async fn test_log_metrics() -> Result<()> {
1009 let mut client = MLflowClient::new(local_only_config());
1010
1011 client.start_experiment("test").await?;
1012 client.start_run(None).await?;
1013
1014 client.log_metric("loss", 0.5, 0)?;
1015 client.log_metric("loss", 0.4, 1)?;
1016 client.log_metric("accuracy", 0.8, 0)?;
1017
1018 let metrics = client.get_metrics();
1019 assert_eq!(
1020 metrics.get("loss").expect("expected value not found").len(),
1021 2
1022 );
1023 assert_eq!(
1024 metrics.get("accuracy").expect("expected value not found").len(),
1025 1
1026 );
1027
1028 Ok(())
1029 }
1030
1031 #[tokio::test]
1032 async fn test_log_array_stats() -> Result<()> {
1033 let mut client = MLflowClient::new(local_only_config());
1034
1035 client.start_experiment("test").await?;
1036 client.start_run(None).await?;
1037
1038 let array = Array1::from_vec(vec![1.0, 2.0, 3.0, 4.0, 5.0]);
1039 client.log_array_stats("weights", &array, 0)?;
1040
1041 let metrics = client.get_metrics();
1042 assert!(metrics.contains_key("weights/mean"));
1043 assert!(metrics.contains_key("weights/std"));
1044 assert!(metrics.contains_key("weights/min"));
1045 assert!(metrics.contains_key("weights/max"));
1046
1047 Ok(())
1048 }
1049
1050 #[tokio::test]
1051 async fn test_log_artifact_really_copies_the_file_to_local_cache() -> Result<()> {
1052 let artifact_dir =
1053 std::env::temp_dir().join(format!("mlflow_test_artifacts_{}", uuid::Uuid::new_v4()));
1054 let mut config = local_only_config();
1055 config.artifact_dir = artifact_dir.clone();
1056 let mut client = MLflowClient::new(config);
1057
1058 client.start_experiment("test").await?;
1059 client.start_run(None).await?;
1060
1061 let source_path =
1062 std::env::temp_dir().join(format!("mlflow_test_source_{}.txt", uuid::Uuid::new_v4()));
1063 std::fs::write(&source_path, b"real artifact bytes")?;
1064
1065 client.log_artifact(
1066 &source_path,
1067 Some("checkpoints/model.txt"),
1068 ArtifactType::Model,
1069 )?;
1070
1071 let dest_path = artifact_dir.join("checkpoints/model.txt");
1072 assert!(
1073 dest_path.exists(),
1074 "artifact must really be copied, not just logged"
1075 );
1076 assert_eq!(std::fs::read(&dest_path)?, b"real artifact bytes");
1077
1078 std::fs::remove_file(&source_path).ok();
1079 std::fs::remove_dir_all(&artifact_dir).ok();
1080
1081 Ok(())
1082 }
1083
1084 #[tokio::test]
1085 async fn test_end_run() -> Result<()> {
1086 let mut client = MLflowClient::new(local_only_config());
1087
1088 client.start_experiment("test").await?;
1089 client.start_run(None).await?;
1090 client.log_metric("loss", 0.5, 0)?;
1091 client.end_run(RunStatus::Finished).await?;
1092
1093 assert!(client.run_id.is_none());
1094
1095 Ok(())
1096 }
1097
1098 #[tokio::test]
1099 async fn test_get_run_info_reports_real_start_time_not_zero_placeholder() -> Result<()> {
1100 let mut client = MLflowClient::new(local_only_config());
1101 client.start_experiment("test").await?;
1102
1103 let before = now_ms();
1104 client.start_run(Some("named-run")).await?;
1105 let after = now_ms();
1106
1107 let info = client.get_run_info().expect("run should be active");
1108 assert_eq!(info.run_name, "named-run");
1109 assert!(
1111 info.start_time >= before && info.start_time <= after,
1112 "start_time {} should fall within [{before}, {after}]",
1113 info.start_time
1114 );
1115
1116 Ok(())
1117 }
1118
1119 #[tokio::test]
1120 async fn test_mlflow_debug_session() -> Result<()> {
1121 let mut session = MLflowDebugSession::new(local_only_config());
1122
1123 session.start("test_debug", Some("debug_run_1")).await?;
1124
1125 let mut metrics = HashMap::new();
1126 metrics.insert("gradient_norm".to_string(), 0.1);
1127 metrics.insert("activation_mean".to_string(), 0.5);
1128
1129 session.log_debug_metrics(metrics)?;
1130
1131 session.end(RunStatus::Finished).await?;
1132
1133 Ok(())
1134 }
1135
1136 #[cfg(feature = "http-integrations")]
1142 mod http_delivery {
1143 use super::*;
1144 use axum::extract::{Query, State};
1145 use axum::http::StatusCode;
1146 use axum::routing::{get, post};
1147 use axum::{Json, Router};
1148 use std::sync::Arc;
1149 use tokio::net::TcpListener;
1150 use tokio::sync::Mutex as AsyncMutex;
1151
1152 #[derive(Default, Clone)]
1153 struct MockState {
1154 requests: Vec<(&'static str, serde_json::Value)>,
1157 }
1158
1159 async fn get_by_name(
1160 State(state): State<Arc<AsyncMutex<MockState>>>,
1161 Query(params): Query<HashMap<String, String>>,
1162 ) -> (StatusCode, Json<serde_json::Value>) {
1163 state.lock().await.requests.push((
1164 "experiments/get-by-name",
1165 serde_json::to_value(¶ms).unwrap_or_default(),
1166 ));
1167 (
1170 StatusCode::NOT_FOUND,
1171 Json(serde_json::json!({"error_code": "RESOURCE_DOES_NOT_EXIST"})),
1172 )
1173 }
1174
1175 async fn create_experiment(
1176 State(state): State<Arc<AsyncMutex<MockState>>>,
1177 Json(body): Json<serde_json::Value>,
1178 ) -> Json<serde_json::Value> {
1179 state.lock().await.requests.push(("experiments/create", body));
1180 Json(serde_json::json!({"experiment_id": "1"}))
1181 }
1182
1183 async fn create_run(
1184 State(state): State<Arc<AsyncMutex<MockState>>>,
1185 Json(body): Json<serde_json::Value>,
1186 ) -> Json<serde_json::Value> {
1187 let experiment_id = body["experiment_id"].clone();
1188 state.lock().await.requests.push(("runs/create", body));
1189 Json(serde_json::json!({
1190 "run": {"info": {"run_id": "run-1", "experiment_id": experiment_id, "status": "RUNNING"}}
1191 }))
1192 }
1193
1194 async fn log_batch(
1195 State(state): State<Arc<AsyncMutex<MockState>>>,
1196 Json(body): Json<serde_json::Value>,
1197 ) -> Json<serde_json::Value> {
1198 state.lock().await.requests.push(("runs/log-batch", body));
1199 Json(serde_json::json!({}))
1200 }
1201
1202 async fn update_run(
1203 State(state): State<Arc<AsyncMutex<MockState>>>,
1204 Json(body): Json<serde_json::Value>,
1205 ) -> Json<serde_json::Value> {
1206 state.lock().await.requests.push(("runs/update", body));
1207 Json(serde_json::json!({}))
1208 }
1209
1210 async fn start_mock_server() -> (String, Arc<AsyncMutex<MockState>>) {
1214 let state = Arc::new(AsyncMutex::new(MockState::default()));
1215 let app = Router::new()
1216 .route("/api/2.0/mlflow/experiments/get-by-name", get(get_by_name))
1217 .route(
1218 "/api/2.0/mlflow/experiments/create",
1219 post(create_experiment),
1220 )
1221 .route("/api/2.0/mlflow/runs/create", post(create_run))
1222 .route("/api/2.0/mlflow/runs/log-batch", post(log_batch))
1223 .route("/api/2.0/mlflow/runs/update", post(update_run))
1224 .with_state(state.clone());
1225 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock MLflow server");
1226 let addr = listener.local_addr().expect("mock server local addr");
1227 tokio::spawn(async move {
1228 let _ = axum::serve(listener, app).await;
1229 });
1230 (format!("http://{addr}"), state)
1231 }
1232
1233 #[tokio::test]
1234 async fn test_full_http_run_lifecycle_hits_real_mock_server() {
1235 let (base_url, state) = start_mock_server().await;
1236 let mut config = MLflowConfig::default();
1237 config.tracking_uri = base_url;
1238 let mut client = MLflowClient::new(config);
1239
1240 let exp_id =
1241 client.start_experiment("exp").await.expect("mock server should accept create");
1242 assert_eq!(exp_id, "1");
1243
1244 let run_id = client
1245 .start_run(Some("my-run"))
1246 .await
1247 .expect("mock server should accept create");
1248 assert_eq!(run_id, "run-1");
1249
1250 client.log_metric("loss", 0.5, 0).expect("cache metric");
1251 client.log_param("lr", "0.01").expect("cache param");
1252 client.log_tag("owner", "ci").expect("cache tag");
1253
1254 client
1255 .end_run(RunStatus::Finished)
1256 .await
1257 .expect("end_run should flush and update status via the mock server");
1258
1259 let requests = state.lock().await.requests.clone();
1260 let paths: Vec<&str> = requests.iter().map(|(p, _)| *p).collect();
1261 assert!(paths.contains(&"experiments/get-by-name"));
1262 assert!(paths.contains(&"experiments/create"));
1263 assert!(paths.contains(&"runs/create"));
1264 assert!(paths.contains(&"runs/log-batch"));
1265 assert!(paths.contains(&"runs/update"));
1266
1267 let log_batch_body = requests
1268 .iter()
1269 .find(|(p, _)| *p == "runs/log-batch")
1270 .map(|(_, b)| b.clone())
1271 .expect("log-batch request captured");
1272 assert_eq!(log_batch_body["run_id"], "run-1");
1273 assert_eq!(log_batch_body["metrics"][0]["key"], "loss");
1274 assert_eq!(log_batch_body["metrics"][0]["value"], 0.5);
1275 assert_eq!(log_batch_body["params"][0]["key"], "lr");
1276 assert_eq!(log_batch_body["tags"][0]["key"], "owner");
1277
1278 let update_body = requests
1279 .iter()
1280 .find(|(p, _)| *p == "runs/update")
1281 .map(|(_, b)| b.clone())
1282 .expect("update request captured");
1283 assert_eq!(update_body["status"], "FINISHED");
1284 }
1285
1286 #[tokio::test]
1287 async fn test_http_mode_returns_real_transport_error_when_server_unreachable() {
1288 let mut config = MLflowConfig::default();
1292 config.tracking_uri = "http://127.0.0.1:1".to_string();
1293 let mut client = MLflowClient::new(config);
1294
1295 let err = client
1296 .start_experiment("exp")
1297 .await
1298 .expect_err("unreachable server must not fabricate an experiment id");
1299 assert!(!err.to_string().is_empty());
1300 }
1301 }
1302
1303 #[cfg(not(feature = "http-integrations"))]
1304 mod http_disabled {
1305 use super::*;
1306
1307 #[tokio::test]
1308 async fn test_http_mode_fails_honestly_without_feature() {
1309 let mut client = MLflowClient::new(MLflowConfig::default());
1312
1313 let err = client
1314 .start_experiment("exp")
1315 .await
1316 .expect_err("must not silently pretend to have created an experiment");
1317 let full_chain = err.chain().map(|c| c.to_string()).collect::<Vec<_>>().join(" | ");
1323 assert!(
1324 full_chain.contains("http-integrations"),
1325 "expected the disabled-feature message somewhere in the error chain, got: {full_chain}"
1326 );
1327 }
1328 }
1329}