Skip to main content

forge_engine/
baseline.rs

1//! Baseline provenance and workspace policy for experiment comparability.
2
3use serde::{Deserialize, Serialize};
4
5/// How a baseline was obtained.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum BaselineSourceKind {
9    /// Clean checkout of a specific commit.
10    GitCommit,
11    /// Working tree snapshot (may be dirty).
12    WorkingTree,
13    /// Cached result from a previous run.
14    CachedRun,
15    /// Explicitly provided by the caller.
16    Explicit,
17}
18
19/// Full provenance record for a baseline execution.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct BaselineDescriptor {
22    pub source_kind: BaselineSourceKind,
23    /// Git commit SHA, if available.
24    pub commit_sha: Option<String>,
25    /// Whether the working tree was dirty when the baseline was taken.
26    pub dirty: bool,
27    /// Number of untracked files present.
28    pub untracked_count: u32,
29    /// blake3 hash of Cargo.lock, if present.
30    pub lockfile_hash: Option<String>,
31    /// `rustc --version` output.
32    pub rustc_version: String,
33    /// `cargo --version` output.
34    pub cargo_version: String,
35    /// Target triple (e.g. x86_64-unknown-linux-gnu).
36    pub target_triple: String,
37    /// blake3 hash of sorted, filtered environment variables.
38    pub env_fingerprint: String,
39    /// Submodule state: list of (path, commit_sha) pairs.
40    pub submodule_state: Vec<(String, String)>,
41}
42
43impl BaselineDescriptor {
44    /// Compute a deterministic fingerprint for comparability checks.
45    pub fn fingerprint(&self) -> String {
46        let content = serde_json::to_string(self).unwrap_or_default();
47        blake3::hash(content.as_bytes()).to_hex().to_string()
48    }
49}
50
51/// Policy for workspace isolation during experiments.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct WorkspacePolicy {
54    /// Maximum bytes per workspace.
55    #[serde(default = "default_max_workspace_bytes")]
56    pub max_workspace_bytes: u64,
57    /// Maximum number of retained workspaces.
58    #[serde(default = "default_max_retained")]
59    pub max_retained_workspaces: u32,
60    /// Whether to retain workspaces from failed runs.
61    #[serde(default)]
62    pub retain_failed_workspaces: bool,
63    /// TTL in seconds for workspace cleanup.
64    #[serde(default = "default_cleanup_ttl")]
65    pub workspace_cleanup_ttl_secs: u64,
66}
67
68fn default_max_workspace_bytes() -> u64 {
69    2_000_000_000 // 2 GB
70}
71fn default_max_retained() -> u32 {
72    8
73}
74fn default_cleanup_ttl() -> u64 {
75    3600 // 1 hour
76}
77
78impl Default for WorkspacePolicy {
79    fn default() -> Self {
80        Self {
81            max_workspace_bytes: default_max_workspace_bytes(),
82            max_retained_workspaces: default_max_retained(),
83            retain_failed_workspaces: false,
84            workspace_cleanup_ttl_secs: default_cleanup_ttl(),
85        }
86    }
87}
88
89/// Policy for experiment comparability.
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ComparabilityPolicy {
92    /// Whether baseline fingerprints must match between trials.
93    #[serde(default = "default_true")]
94    pub require_fingerprint_match: bool,
95    /// Whether timing measurements are admissible as evidence.
96    #[serde(default)]
97    pub timing_admissible: bool,
98    /// Minimum number of trials for statistical claims.
99    #[serde(default = "default_min_trials")]
100    pub min_trials: u32,
101}
102
103fn default_true() -> bool {
104    true
105}
106fn default_min_trials() -> u32 {
107    3
108}
109
110impl Default for ComparabilityPolicy {
111    fn default() -> Self {
112        Self {
113            require_fingerprint_match: true,
114            timing_admissible: false,
115            min_trials: default_min_trials(),
116        }
117    }
118}
119
120/// Capture baseline provenance from the current environment.
121///
122/// This is a best-effort snapshot: git info may be unavailable.
123pub async fn capture_baseline_provenance(
124    workspace_dir: &std::path::Path,
125) -> crate::error::ForgeResult<BaselineDescriptor> {
126    use tokio::process::Command;
127
128    let commit_sha = Command::new("git")
129        .args(["rev-parse", "HEAD"])
130        .current_dir(workspace_dir)
131        .output()
132        .await
133        .ok()
134        .filter(|o| o.status.success())
135        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
136
137    let dirty = Command::new("git")
138        .args(["diff", "--quiet", "HEAD"])
139        .current_dir(workspace_dir)
140        .output()
141        .await
142        .map(|o| !o.status.success())
143        .unwrap_or(true);
144
145    let untracked_count = Command::new("git")
146        .args(["ls-files", "--others", "--exclude-standard"])
147        .current_dir(workspace_dir)
148        .output()
149        .await
150        .ok()
151        .map(|o| String::from_utf8_lossy(&o.stdout).lines().count() as u32)
152        .unwrap_or(0);
153
154    let lockfile_hash = {
155        let lockfile = workspace_dir.join("Cargo.lock");
156        if lockfile.exists() {
157            let content = tokio::fs::read(&lockfile).await.unwrap_or_default();
158            Some(blake3::hash(&content).to_hex().to_string())
159        } else {
160            None
161        }
162    };
163
164    let rustc_version = Command::new("rustc")
165        .arg("--version")
166        .output()
167        .await
168        .ok()
169        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
170        .unwrap_or_else(|| "unknown".to_string());
171
172    let cargo_version = Command::new("cargo")
173        .arg("--version")
174        .output()
175        .await
176        .ok()
177        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
178        .unwrap_or_else(|| "unknown".to_string());
179
180    let target_triple = Command::new("rustc")
181        .args(["-vV"])
182        .output()
183        .await
184        .ok()
185        .and_then(|o| {
186            let text = String::from_utf8_lossy(&o.stdout);
187            text.lines()
188                .find(|l| l.starts_with("host:"))
189                .map(|l| l.trim_start_matches("host:").trim().to_string())
190        })
191        .unwrap_or_else(|| "unknown".to_string());
192
193    // Compute env fingerprint from sorted, filtered env vars
194    let env_fingerprint = {
195        let mut relevant: Vec<String> = std::env::vars()
196            .filter(|(k, _)| crate::exec::is_env_allowed(k))
197            .map(|(k, v)| format!("{k}={v}"))
198            .collect();
199        relevant.sort();
200        blake3::hash(relevant.join("\n").as_bytes())
201            .to_hex()
202            .to_string()
203    };
204
205    let submodule_state = Command::new("git")
206        .args(["submodule", "status"])
207        .current_dir(workspace_dir)
208        .output()
209        .await
210        .ok()
211        .map(|o| {
212            String::from_utf8_lossy(&o.stdout)
213                .lines()
214                .filter_map(|line| {
215                    let parts: Vec<&str> = line.split_whitespace().collect();
216                    if parts.len() >= 2 {
217                        Some((
218                            parts[1].to_string(),
219                            parts[0].trim_start_matches(['+', '-', 'U']).to_string(),
220                        ))
221                    } else {
222                        None
223                    }
224                })
225                .collect()
226        })
227        .unwrap_or_default();
228
229    let source_kind = if commit_sha.is_some() && !dirty {
230        BaselineSourceKind::GitCommit
231    } else {
232        BaselineSourceKind::WorkingTree
233    };
234
235    Ok(BaselineDescriptor {
236        source_kind,
237        commit_sha,
238        dirty,
239        untracked_count,
240        lockfile_hash,
241        rustc_version,
242        cargo_version,
243        target_triple,
244        env_fingerprint,
245        submodule_state,
246    })
247}