Skip to main content

apr_format/
model_card.rs

1//! Model Card for .apr format (spec ยง11)
2//!
3//! Provides ML model documentation following best practices from:
4//! - Mitchell et al. (2019) "Model Cards for Model Reporting"
5//! - Hugging Face Model Card specification
6//!
7//! # Toyota Way Principles
8//!
9//! - **Standardized Work**: Every model must be self-describing
10//! - **Jidoka**: Build quality in through complete documentation
11//! - **Genchi Genbutsu**: Go and see - provenance enables debugging
12
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15
16/// Model card metadata embedded in .apr files.
17///
18/// Designed for dual compatibility:
19/// - APR sovereign format (full control)
20/// - Hugging Face ecosystem (interoperability)
21///
22/// # Example
23///
24/// ```rust
25/// use apr_format::model_card::{ModelCard, TrainingDataInfo};
26///
27/// let card = ModelCard::new("my-model", "1.0.0")
28///     .with_author("user@host")
29///     .with_description("A test model");
30///
31/// assert_eq!(card.version, "1.0.0");
32/// ```
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
34pub struct ModelCard {
35    // === Identity ===
36    /// Unique model identifier (e.g., "aprender-shell-markov-3gram-20251127")
37    pub model_id: String,
38
39    /// Human-readable model name
40    pub name: String,
41
42    /// Semantic version (e.g., "1.2.3")
43    pub version: String,
44
45    // === Provenance ===
46    /// Model author or organization
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub author: Option<String>,
49
50    /// Creation timestamp (ISO 8601)
51    pub created_at: String,
52
53    /// Training framework version (e.g., "aprender 0.10.0")
54    pub framework_version: String,
55
56    /// Rust toolchain used (e.g., "1.82.0")
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub rust_version: Option<String>,
59
60    // === Description ===
61    /// Short description (one line)
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub description: Option<String>,
64
65    /// License (SPDX identifier)
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub license: Option<String>,
68
69    // === Training Details ===
70    /// Training dataset description
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub training_data: Option<TrainingDataInfo>,
73
74    /// Hyperparameters used
75    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
76    pub hyperparameters: HashMap<String, serde_json::Value>,
77
78    /// Training/evaluation metrics
79    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
80    pub metrics: HashMap<String, serde_json::Value>,
81
82    // === Technical Details ===
83    /// Model architecture type (e.g., "`MarkovModel`", "`LinearRegression`")
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub architecture: Option<String>,
86
87    /// Number of parameters (for complexity estimation)
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub param_count: Option<u64>,
90
91    /// Target hardware platforms
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub target_hardware: Vec<String>,
94
95    /// Custom metadata (extensible)
96    #[serde(default, skip_serializing_if = "HashMap::is_empty", flatten)]
97    pub extra: HashMap<String, serde_json::Value>,
98}
99
100impl ModelCard {
101    /// Create a new model card with required fields.
102    #[must_use]
103    pub fn new(model_id: impl Into<String>, version: impl Into<String>) -> Self {
104        let model_id = model_id.into();
105        let name = model_id.clone();
106
107        Self {
108            model_id,
109            name,
110            version: version.into(),
111            author: None,
112            created_at: Self::now_iso8601(),
113            framework_version: format!("aprender {}", env!("CARGO_PKG_VERSION")),
114            rust_version: option_env!("RUSTC_VERSION").map(String::from),
115            description: None,
116            license: None,
117            training_data: None,
118            hyperparameters: HashMap::new(),
119            metrics: HashMap::new(),
120            architecture: None,
121            param_count: None,
122            target_hardware: vec!["cpu".to_string()],
123            extra: HashMap::new(),
124        }
125    }
126
127    /// Set the model name (human-readable).
128    #[must_use]
129    pub fn with_name(mut self, name: impl Into<String>) -> Self {
130        self.name = name.into();
131        self
132    }
133
134    /// Set the author.
135    #[must_use]
136    pub fn with_author(mut self, author: impl Into<String>) -> Self {
137        self.author = Some(author.into());
138        self
139    }
140
141    /// Set the description.
142    #[must_use]
143    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
144        self.description = Some(desc.into());
145        self
146    }
147
148    /// Set the license (SPDX identifier).
149    #[must_use]
150    pub fn with_license(mut self, license: impl Into<String>) -> Self {
151        self.license = Some(license.into());
152        self
153    }
154
155    /// Set the architecture type.
156    #[must_use]
157    pub fn with_architecture(mut self, arch: impl Into<String>) -> Self {
158        self.architecture = Some(arch.into());
159        self
160    }
161
162    /// Set the parameter count.
163    #[must_use]
164    pub fn with_param_count(mut self, count: u64) -> Self {
165        self.param_count = Some(count);
166        self
167    }
168
169    /// Set training data info.
170    #[must_use]
171    pub fn with_training_data(mut self, data: TrainingDataInfo) -> Self {
172        self.training_data = Some(data);
173        self
174    }
175
176    /// Add a hyperparameter.
177    #[must_use]
178    pub fn with_hyperparameter(
179        mut self,
180        key: impl Into<String>,
181        value: impl Into<serde_json::Value>,
182    ) -> Self {
183        self.hyperparameters.insert(key.into(), value.into());
184        self
185    }
186
187    /// Add a metric.
188    #[must_use]
189    pub fn with_metric(
190        mut self,
191        key: impl Into<String>,
192        value: impl Into<serde_json::Value>,
193    ) -> Self {
194        self.metrics.insert(key.into(), value.into());
195        self
196    }
197
198    /// Get current time as ISO 8601 string.
199    fn now_iso8601() -> String {
200        // Simple implementation without external deps
201        // Format: 2025-11-27T12:30:00Z
202        let now = std::time::SystemTime::now();
203        let duration = now
204            .duration_since(std::time::UNIX_EPOCH)
205            .unwrap_or_default();
206        let secs = duration.as_secs();
207
208        // Convert to date/time components
209        let days = secs / 86400;
210        let time_secs = secs % 86400;
211        let hours = time_secs / 3600;
212        let minutes = (time_secs % 3600) / 60;
213        let seconds = time_secs % 60;
214
215        // Calculate year/month/day from days since epoch
216        let (year, month, day) = days_to_ymd(days);
217
218        format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
219    }
220
221    /// Export to Hugging Face model card format (YAML front matter + Markdown).
222    #[must_use]
223    pub fn to_huggingface(&self) -> String {
224        use std::fmt::Write;
225
226        let mut output = String::from("---\n");
227
228        // License
229        if let Some(license) = &self.license {
230            let _ = writeln!(output, "license: {}", license.to_lowercase());
231        }
232
233        // Pipeline tag
234        output.push_str("pipeline_tag: text-generation\n");
235
236        // Tags
237        output.push_str("tags:\n");
238        if let Some(arch) = &self.architecture {
239            let _ = writeln!(output, "  - {}", arch.to_lowercase());
240        }
241        output.push_str("  - aprender\n");
242        output.push_str("  - rust\n");
243
244        // Model index โ€” only emit when metrics exist. HF Hub validates
245        // model-index entries and rejects with HTTP 400
246        // `"model-index[0].results" is required` if `results:` is absent.
247        // Empty model-index is invalid and adds no signal, so skip
248        // entirely when there are no metrics. (PMAT-690 defect 5,
249        // 2026-05-17 โ€” first observed publishing paiml/albor-370m-v1.)
250        if !self.metrics.is_empty() {
251            output.push_str("model-index:\n");
252            let _ = writeln!(output, "  - name: {}", self.model_id);
253            output.push_str("    results:\n");
254            output.push_str("      - task:\n");
255            output.push_str("          type: text-generation\n");
256            output.push_str("        metrics:\n");
257            for (key, value) in &self.metrics {
258                let _ = writeln!(output, "          - name: {key}");
259                output.push_str("            type: custom\n");
260                let _ = writeln!(output, "            value: {value}");
261            }
262        }
263
264        output.push_str("---\n\n");
265
266        // Title
267        let _ = writeln!(output, "# {}\n", self.name);
268
269        // Description
270        if let Some(desc) = &self.description {
271            let _ = writeln!(output, "{desc}\n");
272        }
273
274        // Training data
275        if let Some(data) = &self.training_data {
276            output.push_str("## Training Data\n\n");
277            let _ = writeln!(output, "- **Source:** {}", data.name);
278            if let Some(samples) = data.samples {
279                let _ = writeln!(output, "- **Samples:** {samples}");
280            }
281            if let Some(hash) = &data.hash {
282                let _ = writeln!(output, "- **Hash:** `{hash}`");
283            }
284            output.push('\n');
285        }
286
287        // Framework
288        output.push_str("## Framework\n\n");
289        let _ = writeln!(output, "- **Version:** {}", self.framework_version);
290        if let Some(rust) = &self.rust_version {
291            let _ = writeln!(output, "- **Rust:** {rust}");
292        }
293
294        output
295    }
296
297    /// Serialize to JSON.
298    pub fn to_json(&self) -> Result<String, serde_json::Error> {
299        serde_json::to_string_pretty(self)
300    }
301
302    /// Deserialize from JSON.
303    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
304        serde_json::from_str(json)
305    }
306}
307
308impl Default for ModelCard {
309    fn default() -> Self {
310        Self::new("unnamed", "0.0.0")
311    }
312}
313
314/// Training data information for model card.
315#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
316pub struct TrainingDataInfo {
317    /// Dataset name or source path
318    pub name: String,
319
320    /// Number of samples/commands
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub samples: Option<u64>,
323
324    /// Content hash for reproducibility (SHA-256)
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub hash: Option<String>,
327}
328
329impl TrainingDataInfo {
330    /// Create new training data info.
331    #[must_use]
332    pub fn new(name: impl Into<String>) -> Self {
333        Self {
334            name: name.into(),
335            samples: None,
336            hash: None,
337        }
338    }
339
340    /// Set sample count.
341    #[must_use]
342    pub fn with_samples(mut self, count: u64) -> Self {
343        self.samples = Some(count);
344        self
345    }
346
347    /// Set content hash.
348    #[must_use]
349    pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
350        self.hash = Some(hash.into());
351        self
352    }
353}
354
355/// Convert days since Unix epoch to (year, month, day).
356fn days_to_ymd(days: u64) -> (u32, u32, u32) {
357    // Simplified calculation (not accounting for leap seconds)
358    let mut remaining = days as i64;
359    let mut year = 1970i32;
360
361    loop {
362        let days_in_year = if is_leap_year(year) { 366 } else { 365 };
363        if remaining < days_in_year {
364            break;
365        }
366        remaining -= days_in_year;
367        year += 1;
368    }
369
370    let leap = is_leap_year(year);
371    let months = if leap {
372        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
373    } else {
374        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
375    };
376
377    let mut month = 1u32;
378    for days_in_month in months {
379        if remaining < days_in_month {
380            break;
381        }
382        remaining -= days_in_month;
383        month += 1;
384    }
385
386    let day = remaining as u32 + 1;
387
388    (year as u32, month, day)
389}
390
391/// Check if year is a leap year.
392fn is_leap_year(year: i32) -> bool {
393    (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
394}
395
396include!("generating.rs");