1use super::types::{CheckItem, Evidence, EvidenceType, Severity};
12use std::path::Path;
13use std::time::Instant;
14
15pub 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
31pub 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
65pub 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
94pub 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
126pub 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
161pub 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
196pub 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
228pub 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
256pub 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
283pub 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
312pub 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 #[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 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}