Skip to main content

ries_rs/
manifest.rs

1//! Run manifest for reproducibility
2//!
3//! Provides structured output of search configuration and results
4//! for academic reproducibility and verification.
5
6use serde::{Deserialize, Serialize};
7use std::time::{SystemTime, UNIX_EPOCH};
8
9/// Complete manifest of a search run
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct RunManifest {
12    /// Version of ries-rs
13    pub version: String,
14    /// Git commit hash (if available)
15    pub git_hash: Option<String>,
16    /// Timestamp of the run (ISO 8601)
17    pub timestamp: String,
18    /// Platform/OS info
19    pub platform: PlatformInfo,
20    /// Search configuration
21    pub config: SearchConfigInfo,
22    /// Top matches found
23    pub results: Vec<MatchInfo>,
24}
25
26/// Platform information
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PlatformInfo {
29    /// Operating system
30    pub os: String,
31    /// Architecture
32    pub arch: String,
33    /// Rust version used to compile
34    pub rust_version: String,
35}
36
37/// Search configuration summary
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SearchConfigInfo {
40    /// Target value searched for
41    pub target: f64,
42    /// Search level
43    pub level: f32,
44    /// Maximum LHS complexity
45    pub max_lhs_complexity: u32,
46    /// Maximum RHS complexity
47    pub max_rhs_complexity: u32,
48    /// Whether deterministic mode was enabled
49    pub deterministic: bool,
50    /// Whether parallel search was used
51    pub parallel: bool,
52    /// Maximum error tolerance
53    pub max_error: f64,
54    /// Maximum matches requested
55    pub max_matches: usize,
56    /// Ranking mode used
57    pub ranking_mode: String,
58    /// User constants (names and values)
59    pub user_constants: Vec<UserConstantInfo>,
60    /// Excluded symbols
61    pub excluded_symbols: Vec<String>,
62    /// Allowed symbols (if restricted)
63    pub allowed_symbols: Option<Vec<String>>,
64}
65
66/// User constant information
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct UserConstantInfo {
69    /// Name of the constant
70    pub name: String,
71    /// Value of the constant
72    pub value: f64,
73    /// Description
74    pub description: String,
75}
76
77/// Match result information
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct MatchInfo {
80    /// LHS expression (postfix)
81    pub lhs_postfix: String,
82    /// RHS expression (postfix)
83    pub rhs_postfix: String,
84    /// LHS expression (infix)
85    pub lhs_infix: String,
86    /// RHS expression (infix)
87    pub rhs_infix: String,
88    /// Error (absolute)
89    pub error: f64,
90    /// Whether this is an exact match
91    pub is_exact: bool,
92    /// Complexity score
93    pub complexity: u32,
94    /// X value that solves the equation
95    pub x_value: f64,
96    /// Stability score (0-1, higher is better)
97    ///
98    /// This is optional in the manifest type so callers can omit it when a
99    /// stability estimate is unavailable. The CLI manifest builder currently
100    /// populates it for all emitted results.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub stability: Option<f64>,
103}
104
105impl RunManifest {
106    /// Create a new manifest with current timestamp
107    pub fn new(config: SearchConfigInfo, results: Vec<MatchInfo>) -> Self {
108        let timestamp = SystemTime::now()
109            .duration_since(UNIX_EPOCH)
110            .map(|d| {
111                let secs = d.as_secs();
112                // Convert to ISO 8601-like format
113                chrono_like_timestamp(secs)
114            })
115            .unwrap_or_else(|_| "unknown".to_string());
116
117        Self {
118            version: env!("CARGO_PKG_VERSION").to_string(),
119            git_hash: get_git_hash(),
120            timestamp,
121            platform: PlatformInfo::current(),
122            config,
123            results,
124        }
125    }
126
127    /// Serialize to JSON
128    pub fn to_json(&self) -> Result<String, serde_json::Error> {
129        serde_json::to_string_pretty(self)
130    }
131
132    /// Serialize to JSON with compact format
133    pub fn to_json_compact(&self) -> Result<String, serde_json::Error> {
134        serde_json::to_string(self)
135    }
136}
137
138impl PlatformInfo {
139    /// Get current platform info
140    pub fn current() -> Self {
141        Self {
142            os: std::env::consts::OS.to_string(),
143            arch: std::env::consts::ARCH.to_string(),
144            rust_version: rustc_version().unwrap_or_else(|| "unknown".to_string()),
145        }
146    }
147}
148
149/// Get git commit hash from build time
150fn get_git_hash() -> Option<String> {
151    // Try to get from environment variable set during build
152    option_env!("GIT_HASH").map(|s| s.to_string()).or_else(|| {
153        // Fallback: try to read from .git at runtime (for development)
154        #[cfg(debug_assertions)]
155        {
156            std::process::Command::new("git")
157                .args(["rev-parse", "--short", "HEAD"])
158                .output()
159                .ok()
160                .and_then(|o| String::from_utf8(o.stdout).ok())
161                .map(|s| s.trim().to_string())
162        }
163        #[cfg(not(debug_assertions))]
164        {
165            None
166        }
167    })
168}
169
170/// Get rustc version
171fn rustc_version() -> Option<String> {
172    // In debug builds, try to get rustc version
173    #[cfg(debug_assertions)]
174    {
175        std::process::Command::new("rustc")
176            .arg("--version")
177            .output()
178            .ok()
179            .and_then(|o| String::from_utf8(o.stdout).ok())
180            .map(|s| s.trim().to_string())
181    }
182    #[cfg(not(debug_assertions))]
183    {
184        None
185    }
186}
187
188/// Create an ISO 8601-like timestamp from unix seconds
189fn chrono_like_timestamp(secs: u64) -> String {
190    // Simple implementation without chrono dependency
191    let days = secs / 86400;
192    let remaining = secs % 86400;
193    let hours = remaining / 3600;
194    let minutes = (remaining % 3600) / 60;
195    let seconds = remaining % 60;
196
197    // Unix epoch is 1970-01-01
198    // Calculate year, month, day from days since epoch
199    let (year, month, day) = days_to_ymd(days);
200
201    format!(
202        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
203        year, month, day, hours, minutes, seconds
204    )
205}
206
207/// Convert days since Unix epoch to (year, month, day)
208fn days_to_ymd(days: u64) -> (i32, u32, u32) {
209    // Start from 1970-01-01
210    let mut year = 1970_i32;
211    let mut remaining_days = days as i64;
212
213    loop {
214        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
215        if remaining_days < days_in_year {
216            break;
217        }
218        remaining_days -= days_in_year;
219        year += 1;
220    }
221
222    let days_in_months = if is_leap_year(year) {
223        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
224    } else {
225        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
226    };
227
228    let mut month = 1_u32;
229    for &days_in_month in &days_in_months {
230        if remaining_days < days_in_month as i64 {
231            break;
232        }
233        remaining_days -= days_in_month as i64;
234        month += 1;
235    }
236
237    let day = (remaining_days + 1) as u32; // Days are 1-indexed
238    (year, month, day)
239}
240
241fn is_leap_year(year: i32) -> bool {
242    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::MatchInfo;
248    use super::*;
249
250    #[test]
251    fn test_timestamp_format() {
252        // 2024-01-15 12:30:45 UTC = 1705318245 seconds since epoch
253        let ts = chrono_like_timestamp(1705318245);
254        assert!(ts.starts_with("2024-01-"));
255        assert!(ts.ends_with("Z"));
256    }
257
258    #[test]
259    fn test_leap_year() {
260        assert!(is_leap_year(2024));
261        assert!(!is_leap_year(2023));
262        assert!(!is_leap_year(1900));
263        assert!(is_leap_year(2000));
264    }
265
266    #[test]
267    fn test_timestamp_leap_year_feb29() {
268        // 2000-02-29 00:00:00 UTC = unix timestamp 951782400
269        let ts = chrono_like_timestamp(951782400);
270        assert!(ts.starts_with("2000-02-29"), "got: {}", ts);
271    }
272
273    #[test]
274    fn test_timestamp_year_boundary() {
275        // 1999-12-31 23:59:59 UTC = 946684799
276        let ts = chrono_like_timestamp(946684799);
277        assert!(ts.starts_with("1999-12-31"), "got: {}", ts);
278        // 2000-01-01 00:00:00 UTC = 946684800
279        let ts2 = chrono_like_timestamp(946684800);
280        assert!(ts2.starts_with("2000-01-01"), "got: {}", ts2);
281    }
282
283    #[test]
284    fn test_leap_year_century_rules() {
285        // 1900: divisible by 100 but not 400 — NOT a leap year
286        assert!(!is_leap_year(1900));
287        // 2100: same
288        assert!(!is_leap_year(2100));
289        // 2000: divisible by 400 — IS a leap year
290        assert!(is_leap_year(2000));
291        // 2400: same
292        assert!(is_leap_year(2400));
293    }
294
295    #[test]
296    fn test_match_info_omits_optional_stability_when_absent() {
297        let info = MatchInfo {
298            lhs_postfix: "x".to_string(),
299            rhs_postfix: "1".to_string(),
300            lhs_infix: "x".to_string(),
301            rhs_infix: "1".to_string(),
302            error: 0.0,
303            is_exact: true,
304            complexity: 2,
305            x_value: 1.0,
306            stability: None,
307        };
308
309        let value = serde_json::to_value(info).expect("manifest match should serialize");
310        assert!(
311            value.get("stability").is_none(),
312            "stability should be omitted when unavailable"
313        );
314    }
315}