Skip to main content

trustformers_debug/
mlflow_integration.rs

1//! MLflow Integration for Experiment Tracking
2//!
3//! Implements the real MLflow REST API 2.0 (create experiment/run, log
4//! metrics/params/tags, set terminated status) over `reqwest`, gated behind
5//! the `http-integrations` Cargo feature (see `Cargo.toml`) since it pulls a
6//! non-pure-Rust TLS stack into the build. `metrics`/`params`/`tags` are
7//! cached locally as they're logged (unchanged from before) and delivered
8//! to the tracking server in a real REST call from [`MLflowClient::flush`]
9//! and [`MLflowClient::end_run`] -- never fabricated, and never silently
10//! dropped.
11//!
12//! [`TrackingMode::LocalOnly`] is available for offline/test use, but must
13//! be selected explicitly via [`MLflowConfig::mode`]; the default
14//! ([`TrackingMode::Http`]) always attempts a real connection and surfaces
15//! a real error if the tracking server is unreachable, rather than quietly
16//! doing nothing.
17//!
18//! # Scope: artifacts are local-only
19//!
20//! [`MLflowClient::log_artifact`] (and [`MLflowClient::log_model`] /
21//! [`MLflowClient::log_plot`] / [`MLflowClient::log_report`], which are
22//! built on it) only ever writes into the local `artifact_dir` cache --
23//! never to the remote tracking server's artifact store, even in
24//! [`TrackingMode::Http`]. MLflow's artifact backend is
25//! deployment-specific (local disk, S3, GCS, Azure Blob, DBFS, ...) with no
26//! single REST endpoint to target, so uploading real artifact bytes is
27//! explicitly out of scope for this client.
28
29use 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/// How an [`MLflowClient`] talks to a tracking server.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
41pub enum TrackingMode {
42    /// Make real MLflow REST API calls against `tracking_uri`. The default:
43    /// matches the historical default `tracking_uri` of
44    /// `http://localhost:5000`, and fails with a real connection/transport
45    /// error (rather than silently) if no server is reachable there.
46    #[default]
47    Http,
48    /// Never contact a server; everything stays in the in-memory caches
49    /// until read back via [`MLflowClient::get_metrics`] /
50    /// [`MLflowClient::get_params`]. Must be selected explicitly -- this
51    /// client never falls back to it silently.
52    LocalOnly,
53}
54
55/// MLflow client for experiment tracking
56#[derive(Debug)]
57pub struct MLflowClient {
58    /// MLflow tracking URI
59    tracking_uri: String,
60    /// Current experiment ID
61    experiment_id: Option<String>,
62    /// Current run ID
63    run_id: Option<String>,
64    /// Name of the current run, set by [`MLflowClient::start_run`].
65    run_name: Option<String>,
66    /// Wall-clock start time (ms since epoch) of the current run.
67    run_start_time: Option<i64>,
68    /// Configuration
69    config: MLflowConfig,
70    /// Cached metrics, flushed to the tracking server by
71    /// [`MLflowClient::flush`] / [`MLflowClient::end_run`].
72    metrics_cache: Arc<RwLock<HashMap<String, Vec<MetricPoint>>>>,
73    /// Cached parameters, flushed the same way.
74    params_cache: Arc<RwLock<HashMap<String, String>>>,
75    /// Cached tags, flushed the same way.
76    tags_cache: Arc<RwLock<HashMap<String, String>>>,
77}
78
79/// Configuration for MLflow integration
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct MLflowConfig {
82    /// MLflow tracking server URI (default: http://localhost:5000)
83    pub tracking_uri: String,
84    /// Default experiment name
85    pub experiment_name: String,
86    /// Enable automatic metric logging
87    pub auto_log: bool,
88    /// Metric logging interval (steps)
89    pub log_interval: usize,
90    /// Maximum number of cached metrics before flush
91    pub max_cache_size: usize,
92    /// Enable artifact logging
93    pub log_artifacts: bool,
94    /// Artifact storage directory
95    pub artifact_dir: PathBuf,
96    /// Whether to actually contact a tracking server, or stay local-only.
97    /// See [`TrackingMode`].
98    #[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/// Real MLflow REST API 2.0 transport, compiled only when
118/// `http-integrations` is enabled (it pulls `reqwest` and a non-pure-Rust
119/// TLS stack). See the module docs.
120#[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    /// `GET`, treating a `404` response as `Ok(None)` (the resource
155    /// genuinely does not exist) rather than an error -- everything else
156    /// (network failure, `5xx`, malformed JSON) is still a real `Err`, so
157    /// callers can't confuse "doesn't exist" with "couldn't find out".
158    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/// Without `http-integrations`, no HTTP client exists in this build: fail
178/// honestly instead of pretending to contact a tracking server.
179#[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/// A single metric data point
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct MetricPoint {
214    /// Metric value
215    pub value: f64,
216    /// Step number
217    pub step: i64,
218    /// Timestamp (milliseconds since epoch)
219    pub timestamp: i64,
220}
221
222/// MLflow run information
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct RunInfo {
225    /// Run ID
226    pub run_id: String,
227    /// Experiment ID
228    pub experiment_id: String,
229    /// Run name
230    pub run_name: String,
231    /// Start time (milliseconds since epoch)
232    pub start_time: i64,
233    /// End time (milliseconds since epoch, None if active)
234    pub end_time: Option<i64>,
235    /// Run status
236    pub status: RunStatus,
237}
238
239/// Status of an MLflow run
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
241pub enum RunStatus {
242    /// Run is active
243    Running,
244    /// Run completed successfully
245    Finished,
246    /// Run failed
247    Failed,
248    /// Run was killed
249    Killed,
250}
251
252impl RunStatus {
253    /// The exact string MLflow's REST API expects for `runs/update`'s
254    /// `status` field.
255    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/// Artifact type for logging
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub enum ArtifactType {
268    /// Model weights/checkpoints
269    Model,
270    /// Visualization plots
271    Plot,
272    /// Text reports
273    Report,
274    /// Raw data
275    Data,
276    /// Configuration files
277    Config,
278}
279
280impl MLflowClient {
281    /// Create a new MLflow client
282    ///
283    /// # Arguments
284    /// * `config` - MLflow configuration
285    ///
286    /// # Example
287    /// ```rust
288    /// use trustformers_debug::{MLflowClient, MLflowConfig};
289    ///
290    /// let config = MLflowConfig::default();
291    /// let client = MLflowClient::new(config);
292    /// ```
293    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    /// Set the tracking URI
308    ///
309    /// # Arguments
310    /// * `uri` - MLflow tracking server URI
311    pub fn set_tracking_uri(&mut self, uri: impl Into<String>) {
312        self.tracking_uri = uri.into();
313    }
314
315    /// Start a new experiment.
316    ///
317    /// In [`TrackingMode::Http`] this looks the experiment up by name
318    /// (`GET experiments/get-by-name`) and creates it
319    /// (`POST experiments/create`) if it doesn't exist yet -- a real round
320    /// trip to `tracking_uri`, which fails with a real transport/HTTP error
321    /// if the server is unreachable or rejects the request. In
322    /// [`TrackingMode::LocalOnly`] no request is made and a synthetic
323    /// `local-*` id is generated instead, so callers can always tell the
324    /// two apart.
325    ///
326    /// # Arguments
327    /// * `name` - Experiment name
328    ///
329    /// # Returns
330    /// Experiment ID
331    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                // `get_optional` only ever returns `Ok(None)` for a genuine
347                // "no such experiment" (HTTP 404); any other failure --
348                // network error, auth failure, the `http-integrations`
349                // feature being disabled -- is a real `Err` that must
350                // propagate here rather than being silently reinterpreted
351                // as "doesn't exist yet, so create a new one".
352                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    /// Start a new run within the current experiment.
394    ///
395    /// Real `POST runs/create` in [`TrackingMode::Http`]; a synthetic
396    /// `local-*` id with no network access in [`TrackingMode::LocalOnly`].
397    ///
398    /// # Arguments
399    /// * `run_name` - Optional run name
400    ///
401    /// # Returns
402    /// Run ID
403    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        // Clear caches for new run
456        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    /// End the current run: flushes any cached metrics/params/tags, then
464    /// (in [`TrackingMode::Http`]) sets the run's terminal status via a real
465    /// `POST runs/update` call.
466    ///
467    /// # Arguments
468    /// * `status` - Final run status
469    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    /// Log a parameter. Cached locally; delivered to the tracking server by
499    /// [`MLflowClient::flush`] / [`MLflowClient::end_run`] (never on every
500    /// call -- a per-call network round trip for every logged value would
501    /// be prohibitively slow for training loops that log every step).
502    ///
503    /// # Arguments
504    /// * `key` - Parameter name
505    /// * `value` - Parameter value
506    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    /// Log multiple parameters at once
520    ///
521    /// # Arguments
522    /// * `params` - Map of parameter names to values
523    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    /// Log a tag. Cached locally, delivered the same way as parameters.
531    ///
532    /// # Arguments
533    /// * `key` - Tag name
534    /// * `value` - Tag value
535    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    /// Log multiple tags at once
549    ///
550    /// # Arguments
551    /// * `tags` - Map of tag names to values
552    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    /// Log a metric at a specific step. Cached locally; see
560    /// [`MLflowClient::log_param`] for why this does not hit the network
561    /// directly.
562    ///
563    /// # Arguments
564    /// * `key` - Metric name
565    /// * `value` - Metric value
566    /// * `step` - Step number
567    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            // Delivery is a real, fallible network call now, so it cannot
585            // happen implicitly inside a sync fn: surface the backlog
586            // instead of silently dropping or blocking on a runtime here.
587            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    /// Log multiple metrics at once
599    ///
600    /// # Arguments
601    /// * `metrics` - Map of metric names to values
602    /// * `step` - Step number
603    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    /// Log tensor statistics as metrics
611    ///
612    /// # Arguments
613    /// * `prefix` - Metric name prefix
614    /// * `tensor` - Tensor to analyze
615    /// * `step` - Step number
616    pub fn log_tensor_stats(&mut self, prefix: &str, tensor: &Tensor, step: i64) -> Result<()> {
617        // Log tensor element count and shape info
618        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    /// Log array statistics as metrics
636    ///
637    /// # Arguments
638    /// * `prefix` - Metric name prefix
639    /// * `array` - Array to analyze
640    /// * `step` - Step number
641    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    /// Flush cached metrics, parameters and tags to the MLflow tracking
656    /// server via a real `POST runs/log-batch` call (chunked to respect
657    /// MLflow's per-request limits of 1000 metrics / 100 params / 100 tags).
658    /// A no-op in [`TrackingMode::LocalOnly`] -- the caches themselves are
659    /// the record in that mode -- and a no-op when there is nothing cached
660    /// or no active run.
661    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    /// Log an artifact (file) to the *local* `artifact_dir` cache.
741    ///
742    /// Unlike [`Self::log_metric`]/[`Self::log_param`]/[`Self::log_tag`],
743    /// this method does **not** upload to the remote MLflow tracking
744    /// server's artifact store, even in [`TrackingMode::Http`] -- MLflow's
745    /// artifact storage is backend-dependent (local disk, S3, GCS, Azure
746    /// Blob, DBFS, ...) and there is no single REST endpoint this client
747    /// can target without knowing which backend the server is configured
748    /// with. Uploading real artifact bytes to a remote MLflow server is
749    /// intentionally out of scope for this client; only the experiment/run
750    /// lifecycle and metrics/params/tags are delivered over HTTP. Callers
751    /// that need artifacts on the actual tracking server must copy from
752    /// `artifact_dir` themselves via whatever channel the server's artifact
753    /// backend expects.
754    ///
755    /// # Arguments
756    /// * `local_path` - Path to local file
757    /// * `artifact_path` - Optional path within the local artifact cache
758    /// * `artifact_type` - Type of artifact
759    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        // Copy to artifact directory
775        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    /// Log a model artifact
813    ///
814    /// # Arguments
815    /// * `model_path` - Path to model file
816    /// * `model_name` - Optional model name
817    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    /// Log a plot/visualization
828    ///
829    /// # Arguments
830    /// * `plot_path` - Path to plot file
831    /// * `plot_name` - Optional plot name
832    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    /// Log a text report
843    ///
844    /// # Arguments
845    /// * `content` - Report content
846    /// * `filename` - Report filename
847    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    /// Get current run information. The `start_time` reflects when
863    /// [`MLflowClient::start_run`] was actually called (real wall-clock ms
864    /// since epoch), not a placeholder.
865    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    /// Get all logged parameters
880    pub fn get_params(&self) -> HashMap<String, String> {
881        self.params_cache.read().clone()
882    }
883
884    /// Get all logged metrics
885    pub fn get_metrics(&self) -> HashMap<String, Vec<MetricPoint>> {
886        self.metrics_cache.read().clone()
887    }
888
889    /// Get all logged tags
890    pub fn get_tags(&self) -> HashMap<String, String> {
891        self.tags_cache.read().clone()
892    }
893}
894
895/// Integration with TrustformeRS debug session
896pub struct MLflowDebugSession {
897    /// MLflow client
898    pub client: MLflowClient,
899    /// Current step
900    step: i64,
901}
902
903impl MLflowDebugSession {
904    /// Create a new MLflow debug session
905    pub fn new(config: MLflowConfig) -> Self {
906        Self {
907            client: MLflowClient::new(config),
908            step: 0,
909        }
910    }
911
912    /// Start debugging with MLflow tracking
913    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    /// Log debugging metrics for current step
921    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    /// End debugging session
928    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    /// A config that never touches the network -- used by tests that only
939    /// exercise local caching behavior, not transport.
940    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        // The mission requires LocalOnly to be an explicit opt-in, never a
955        // silent default.
956        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        // The old implementation hardcoded `start_time: 0`.
1110        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    // ------------------------------------------------------------------
1137    // Real HTTP delivery against a local mock MLflow server, and an
1138    // honest, structured failure when the feature is disabled.
1139    // ------------------------------------------------------------------
1140
1141    #[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            /// (path, body-or-query) for every request the mock server saw,
1155            /// in arrival order.
1156            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(&params).unwrap_or_default(),
1166            ));
1167            // Simulate "experiment does not exist yet" so the client
1168            // exercises the real create fallback too.
1169            (
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        /// A real local HTTP server (127.0.0.1, OS-assigned port) speaking
1211        /// just enough of the MLflow REST API 2.0 shape to exercise the
1212        /// client end to end.
1213        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            // Port 1 refuses connections. The old implementation fabricated
1289            // `exp_<uuid>` regardless of whether any server existed; the
1290            // fixed client must surface a real error instead.
1291            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            // Default mode (Http) with the feature compiled out must not
1310            // silently pretend to have created anything.
1311            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            // `start_experiment` wraps the transport error in an
1318            // operation-level `with_context` (e.g. "failed to look up
1319            // MLflow experiment ..."), so check the full `anyhow` cause
1320            // chain -- not just the top `Display` frame -- for the actual
1321            // "rebuild with http-integrations" message.
1322            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}