Skip to main content

batuta/falsification/
model_cards.rs

1//! Section 8: Model Cards & Auditability (MA-01 to MA-10)
2//!
3//! Governance artifacts for ML models and datasets.
4//!
5//! # TPS Principles
6//!
7//! - **Governance documentation**: Model Cards, Datasheets
8//! - **Genchi Genbutsu**: Verify claims match behavior
9//! - **Kaizen**: Learning from incidents
10
11use super::types::{CheckItem, Evidence, EvidenceType, Severity};
12use std::path::Path;
13use std::time::Instant;
14
15/// Evaluate all Model Cards & Auditability checks.
16pub fn evaluate_all(project_path: &Path) -> Vec<CheckItem> {
17    vec![
18        check_model_card_completeness(project_path),
19        check_datasheet_completeness(project_path),
20        check_model_card_accuracy(project_path),
21        check_decision_logging(project_path),
22        check_provenance_chain(project_path),
23        check_version_tracking(project_path),
24        check_rollback_capability(project_path),
25        check_ab_test_logging(project_path),
26        check_bias_audit(project_path),
27        check_incident_response(project_path),
28    ]
29}
30
31/// MA-01: Model Card Completeness
32pub fn check_model_card_completeness(project_path: &Path) -> CheckItem {
33    let start = Instant::now();
34    let mut item =
35        CheckItem::new("MA-01", "Model Card Completeness", "Every model has complete Model Card")
36            .with_severity(Severity::Major)
37            .with_tps("Governance documentation");
38
39    let model_card_paths = [
40        project_path.join("MODEL_CARD.md"),
41        project_path.join("docs/model_card.md"),
42        project_path.join("docs/MODEL_CARD.md"),
43    ];
44
45    let has_model_card = model_card_paths.iter().any(|p| p.exists());
46    let has_model_cards_dir = project_path.join("docs/model_cards/").exists();
47
48    item = item.with_evidence(Evidence {
49        evidence_type: EvidenceType::StaticAnalysis,
50        description: format!("Model card: exists={}, dir={}", has_model_card, has_model_cards_dir),
51        data: None,
52        files: Vec::new(),
53    });
54
55    let has_models = check_for_pattern(project_path, &["model", "Model", "train", "predict"]);
56    if !has_models || has_model_card || has_model_cards_dir {
57        item = item.pass();
58    } else {
59        item = item.partial("Models without Model Card documentation");
60    }
61
62    item.finish_timed(start)
63}
64
65/// MA-02: Datasheet Completeness
66pub fn check_datasheet_completeness(project_path: &Path) -> CheckItem {
67    let start = Instant::now();
68    let mut item = CheckItem::new("MA-02", "Datasheet Completeness", "Every dataset has Datasheet")
69        .with_severity(Severity::Major)
70        .with_tps("Data governance");
71
72    let has_datasheet = project_path.join("docs/datasheets/").exists()
73        || project_path.join("DATASHEET.md").exists();
74
75    let has_data_docs = check_for_pattern(project_path, &["datasheet", "Datasheet", "data_card"]);
76
77    item = item.with_evidence(Evidence {
78        evidence_type: EvidenceType::StaticAnalysis,
79        description: format!("Datasheet: exists={}, docs={}", has_datasheet, has_data_docs),
80        data: None,
81        files: Vec::new(),
82    });
83
84    let uses_data = check_for_pattern(project_path, &["Dataset", "DataLoader", "load_data"]);
85    if !uses_data || has_datasheet || has_data_docs {
86        item = item.pass();
87    } else {
88        item = item.partial("Datasets without Datasheet documentation");
89    }
90
91    item.finish_timed(start)
92}
93
94/// MA-03: Model Card Accuracy
95pub fn check_model_card_accuracy(project_path: &Path) -> CheckItem {
96    let start = Instant::now();
97    let mut item =
98        CheckItem::new("MA-03", "Model Card Accuracy", "Model Card reflects current behavior")
99            .with_severity(Severity::Major)
100            .with_tps("Genchi Genbutsu — verify claims");
101
102    let has_drift_detection =
103        check_for_pattern(project_path, &["drift", "model_drift", "behavior_test"]);
104    let has_validation =
105        check_for_pattern(project_path, &["validate_card", "card_accuracy", "claim_verification"]);
106
107    item = item.with_evidence(Evidence {
108        evidence_type: EvidenceType::StaticAnalysis,
109        description: format!(
110            "Card accuracy: drift={}, validation={}",
111            has_drift_detection, has_validation
112        ),
113        data: None,
114        files: Vec::new(),
115    });
116
117    if has_drift_detection || has_validation {
118        item = item.pass();
119    } else {
120        item = item.partial("No model card accuracy verification");
121    }
122
123    item.finish_timed(start)
124}
125
126/// MA-04: Decision Logging Completeness
127pub fn check_decision_logging(project_path: &Path) -> CheckItem {
128    let start = Instant::now();
129    let mut item = CheckItem::new(
130        "MA-04",
131        "Decision Logging Completeness",
132        "Model decisions logged with context",
133    )
134    .with_severity(Severity::Major)
135    .with_tps("Auditability requirement");
136
137    let has_logging =
138        check_for_pattern(project_path, &["decision_log", "prediction_log", "audit_log"]);
139    let has_context =
140        check_for_pattern(project_path, &["input_hash", "timestamp", "model_version"]);
141
142    item = item.with_evidence(Evidence {
143        evidence_type: EvidenceType::StaticAnalysis,
144        description: format!("Decision logging: logging={}, context={}", has_logging, has_context),
145        data: None,
146        files: Vec::new(),
147    });
148
149    let does_inference = check_for_pattern(project_path, &["inference", "predict", "forward"]);
150    if !does_inference || (has_logging && has_context) {
151        item = item.pass();
152    } else if has_logging {
153        item = item.partial("Decision logging (verify context)");
154    } else {
155        item = item.partial("Inference without decision logging");
156    }
157
158    item.finish_timed(start)
159}
160
161/// MA-05: Provenance Chain Completeness
162pub fn check_provenance_chain(project_path: &Path) -> CheckItem {
163    let start = Instant::now();
164    let mut item = CheckItem::new(
165        "MA-05",
166        "Provenance Chain Completeness",
167        "Full provenance from data to prediction",
168    )
169    .with_severity(Severity::Major)
170    .with_tps("Audit trail integrity");
171
172    let has_provenance = check_for_pattern(project_path, &["provenance", "lineage", "trace"]);
173    let has_data_lineage = check_for_pattern(project_path, &["data_lineage", "training_lineage"]);
174
175    item = item.with_evidence(Evidence {
176        evidence_type: EvidenceType::StaticAnalysis,
177        description: format!(
178            "Provenance: tracking={}, lineage={}",
179            has_provenance, has_data_lineage
180        ),
181        data: None,
182        files: Vec::new(),
183    });
184
185    if has_provenance && has_data_lineage {
186        item = item.pass();
187    } else if has_provenance {
188        item = item.partial("Provenance tracking (verify completeness)");
189    } else {
190        item = item.partial("No provenance chain tracking");
191    }
192
193    item.finish_timed(start)
194}
195
196/// MA-06: Version Tracking
197pub fn check_version_tracking(project_path: &Path) -> CheckItem {
198    let start = Instant::now();
199    let mut item =
200        CheckItem::new("MA-06", "Version Tracking", "All model versions uniquely identified")
201            .with_severity(Severity::Major)
202            .with_tps("Configuration management");
203
204    let has_versioning =
205        check_for_pattern(project_path, &["version", "Version", "model_id", "hash"]);
206    let has_registry = check_for_pattern(project_path, &["registry", "Registry", "model_store"]);
207
208    item = item.with_evidence(Evidence {
209        evidence_type: EvidenceType::StaticAnalysis,
210        description: format!(
211            "Version tracking: versioning={}, registry={}",
212            has_versioning, has_registry
213        ),
214        data: None,
215        files: Vec::new(),
216    });
217
218    let has_models = check_for_pattern(project_path, &["save_model", "load_model", "Model"]);
219    if !has_models || has_versioning {
220        item = item.pass();
221    } else {
222        item = item.partial("Models without version tracking");
223    }
224
225    item.finish_timed(start)
226}
227
228/// MA-07: Rollback Capability
229pub fn check_rollback_capability(project_path: &Path) -> CheckItem {
230    let start = Instant::now();
231    let mut item =
232        CheckItem::new("MA-07", "Rollback Capability", "Any model version can be restored")
233            .with_severity(Severity::Major)
234            .with_tps("Recovery capability");
235
236    let has_rollback = check_for_pattern(project_path, &["rollback", "restore", "revert"]);
237    let has_retention = check_for_pattern(project_path, &["retention", "archive", "backup"]);
238
239    item = item.with_evidence(Evidence {
240        evidence_type: EvidenceType::StaticAnalysis,
241        description: format!("Rollback: capability={}, retention={}", has_rollback, has_retention),
242        data: None,
243        files: Vec::new(),
244    });
245
246    let has_deployment = check_for_pattern(project_path, &["deploy", "serve", "production"]);
247    if !has_deployment || has_rollback || has_retention {
248        item = item.pass();
249    } else {
250        item = item.partial("Deployment without rollback capability");
251    }
252
253    item.finish_timed(start)
254}
255
256/// MA-08: A/B Test Logging
257pub fn check_ab_test_logging(project_path: &Path) -> CheckItem {
258    let start = Instant::now();
259    let mut item =
260        CheckItem::new("MA-08", "A/B Test Logging", "A/B tests fully logged for analysis")
261            .with_severity(Severity::Minor)
262            .with_tps("Scientific experimentation");
263
264    let has_ab_testing = check_for_pattern(project_path, &["ab_test", "experiment", "variant"]);
265    let has_ab_logging = check_for_pattern(project_path, &["experiment_log", "assignment_log"]);
266
267    item = item.with_evidence(Evidence {
268        evidence_type: EvidenceType::StaticAnalysis,
269        description: format!("A/B testing: impl={}, logging={}", has_ab_testing, has_ab_logging),
270        data: None,
271        files: Vec::new(),
272    });
273
274    if !has_ab_testing || has_ab_logging {
275        item = item.pass();
276    } else {
277        item = item.partial("A/B testing without logging");
278    }
279
280    item.finish_timed(start)
281}
282
283/// MA-09: Bias Audit Trail
284pub fn check_bias_audit(project_path: &Path) -> CheckItem {
285    let start = Instant::now();
286    let mut item =
287        CheckItem::new("MA-09", "Bias Audit Trail", "Bias assessments documented per model")
288            .with_severity(Severity::Major)
289            .with_tps("Ethical governance");
290
291    let has_bias_testing =
292        check_for_pattern(project_path, &["bias", "fairness", "demographic_parity"]);
293    let has_audit = check_for_pattern(project_path, &["bias_audit", "fairness_report"]);
294
295    item = item.with_evidence(Evidence {
296        evidence_type: EvidenceType::StaticAnalysis,
297        description: format!("Bias audit: testing={}, audit={}", has_bias_testing, has_audit),
298        data: None,
299        files: Vec::new(),
300    });
301
302    let is_ml = check_for_pattern(project_path, &["classifier", "predict", "model"]);
303    if !is_ml || has_bias_testing || has_audit {
304        item = item.pass();
305    } else {
306        item = item.partial("ML without bias audit documentation");
307    }
308
309    item.finish_timed(start)
310}
311
312/// MA-10: Incident Response Logging
313pub fn check_incident_response(project_path: &Path) -> CheckItem {
314    let start = Instant::now();
315    let mut item =
316        CheckItem::new("MA-10", "Incident Response Logging", "Model incidents fully documented")
317            .with_severity(Severity::Major)
318            .with_tps("Kaizen — learning from failures");
319
320    let has_incident_log =
321        check_for_pattern(project_path, &["incident", "postmortem", "root_cause"]);
322    let has_incident_docs =
323        project_path.join("docs/incidents/").exists() || project_path.join("INCIDENTS.md").exists();
324
325    item = item.with_evidence(Evidence {
326        evidence_type: EvidenceType::StaticAnalysis,
327        description: format!(
328            "Incident logging: code={}, docs={}",
329            has_incident_log, has_incident_docs
330        ),
331        data: None,
332        files: Vec::new(),
333    });
334
335    if has_incident_log || has_incident_docs {
336        item = item.pass();
337    } else {
338        item = item.partial("No incident response documentation");
339    }
340
341    item.finish_timed(start)
342}
343
344fn check_for_pattern(project_path: &Path, patterns: &[&str]) -> bool {
345    super::helpers::source_contains_pattern(project_path, patterns)
346        || super::helpers::files_contain_pattern_ci(project_path, &["**/*.md"], patterns)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use std::path::PathBuf;
353
354    #[test]
355    fn test_evaluate_all_returns_10_items() {
356        let path = PathBuf::from(".");
357        let items = evaluate_all(&path);
358        assert_eq!(items.len(), 10);
359    }
360
361    #[test]
362    fn test_all_items_have_tps_principle() {
363        let path = PathBuf::from(".");
364        for item in evaluate_all(&path) {
365            assert!(!item.tps_principle.is_empty(), "Item {} missing TPS", item.id);
366        }
367    }
368
369    #[test]
370    fn test_all_items_have_evidence() {
371        let path = PathBuf::from(".");
372        for item in evaluate_all(&path) {
373            assert!(!item.evidence.is_empty(), "Item {} missing evidence", item.id);
374        }
375    }
376
377    // ========================================================================
378    // Additional Coverage Tests for Model Cards
379    // ========================================================================
380
381    #[test]
382    fn test_ma01_model_card_completeness_id() {
383        let result = check_model_card_completeness(Path::new("."));
384        assert_eq!(result.id, "MA-01");
385        assert_eq!(result.severity, Severity::Major);
386    }
387
388    #[test]
389    fn test_ma02_datasheet_completeness_id() {
390        let result = check_datasheet_completeness(Path::new("."));
391        assert_eq!(result.id, "MA-02");
392        assert_eq!(result.severity, Severity::Major);
393    }
394
395    #[test]
396    fn test_ma03_model_card_accuracy_id() {
397        let result = check_model_card_accuracy(Path::new("."));
398        assert_eq!(result.id, "MA-03");
399        assert_eq!(result.severity, Severity::Major);
400    }
401
402    #[test]
403    fn test_ma04_decision_logging_id() {
404        let result = check_decision_logging(Path::new("."));
405        assert_eq!(result.id, "MA-04");
406        assert_eq!(result.severity, Severity::Major);
407    }
408
409    #[test]
410    fn test_ma05_provenance_chain_id() {
411        let result = check_provenance_chain(Path::new("."));
412        assert_eq!(result.id, "MA-05");
413        assert_eq!(result.severity, Severity::Major);
414    }
415
416    #[test]
417    fn test_ma06_version_tracking_id() {
418        let result = check_version_tracking(Path::new("."));
419        assert_eq!(result.id, "MA-06");
420        assert_eq!(result.severity, Severity::Major);
421    }
422
423    #[test]
424    fn test_ma07_rollback_capability_id() {
425        let result = check_rollback_capability(Path::new("."));
426        assert_eq!(result.id, "MA-07");
427        assert_eq!(result.severity, Severity::Major);
428    }
429
430    #[test]
431    fn test_ma08_ab_test_logging_id() {
432        let result = check_ab_test_logging(Path::new("."));
433        assert_eq!(result.id, "MA-08");
434        assert_eq!(result.severity, Severity::Minor);
435    }
436
437    #[test]
438    fn test_ma09_bias_audit_id() {
439        let result = check_bias_audit(Path::new("."));
440        assert_eq!(result.id, "MA-09");
441        assert_eq!(result.severity, Severity::Major);
442    }
443
444    #[test]
445    fn test_ma10_incident_response_id() {
446        let result = check_incident_response(Path::new("."));
447        assert_eq!(result.id, "MA-10");
448        assert_eq!(result.severity, Severity::Major);
449    }
450
451    #[test]
452    fn test_model_card_with_temp_dir() {
453        let temp_dir = std::env::temp_dir().join("test_model_cards");
454        let _ = std::fs::remove_dir_all(&temp_dir);
455        std::fs::create_dir_all(&temp_dir).expect("mkdir failed");
456
457        // Create MODEL_CARD.md
458        std::fs::write(temp_dir.join("MODEL_CARD.md"), "# Model Card").expect("fs write failed");
459
460        let result = check_model_card_completeness(&temp_dir);
461        assert_eq!(result.id, "MA-01");
462
463        let _ = std::fs::remove_dir_all(&temp_dir);
464    }
465
466    #[test]
467    fn test_datasheet_with_dir() {
468        let temp_dir = std::env::temp_dir().join("test_datasheets");
469        let _ = std::fs::remove_dir_all(&temp_dir);
470        std::fs::create_dir_all(temp_dir.join("docs/datasheets")).expect("mkdir failed");
471
472        let result = check_datasheet_completeness(&temp_dir);
473        assert_eq!(result.id, "MA-02");
474
475        let _ = std::fs::remove_dir_all(&temp_dir);
476    }
477
478    #[test]
479    fn test_nonexistent_path() {
480        let path = Path::new("/nonexistent/path/for/model_cards");
481        let items = evaluate_all(path);
482        assert_eq!(items.len(), 10);
483    }
484}