Skip to main content

apr_qa_certify/
lib.rs

1//! Model certification tools and README synchronization.
2//!
3//! This crate provides utilities for:
4//! - Parsing model certification CSV data
5//! - Generating markdown tables for README
6//! - Synchronizing certification status with documentation
7
8#![forbid(unsafe_code)]
9
10use chrono::{DateTime, Utc};
11use std::fmt;
12use thiserror::Error;
13
14/// Errors that can occur during certification operations.
15#[derive(Error, Debug)]
16pub enum CertifyError {
17    /// CSV parsing error.
18    #[error("CSV parse error at line {line}: {message}")]
19    CsvParse {
20        /// Line number where error occurred.
21        line: usize,
22        /// Error message.
23        message: String,
24    },
25
26    /// README marker not found.
27    #[error("README marker not found: {0}")]
28    MarkerNotFound(String),
29
30    /// IO error.
31    #[error("IO error: {0}")]
32    Io(#[from] std::io::Error),
33}
34
35/// Result type for certification operations.
36pub type Result<T> = std::result::Result<T, CertifyError>;
37
38/// Certification status for a model.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum CertificationStatus {
41    /// Model passed all tests with MQS >= 850.
42    Certified,
43    /// Model passed with MQS >= 700 but < 850.
44    Provisional,
45    /// Model failed tests or MQS < 700.
46    Blocked,
47    /// Model not yet tested.
48    #[default]
49    Pending,
50}
51
52impl CertificationStatus {
53    /// Parse status from string representation.
54    #[must_use]
55    pub fn parse(s: &str) -> Self {
56        match s.to_uppercase().as_str() {
57            "CERTIFIED" => Self::Certified,
58            "PROVISIONAL" => Self::Provisional,
59            "BLOCKED" => Self::Blocked,
60            _ => Self::Pending,
61        }
62    }
63
64    /// Get badge markdown for this status.
65    #[must_use]
66    pub const fn badge(&self) -> &'static str {
67        match self {
68            Self::Certified => "![certified](https://img.shields.io/badge/CERTIFIED-brightgreen)",
69            Self::Provisional => "![provisional](https://img.shields.io/badge/PROVISIONAL-yellow)",
70            Self::Blocked => "![blocked](https://img.shields.io/badge/BLOCKED-red)",
71            Self::Pending => "![pending](https://img.shields.io/badge/PENDING-lightgray)",
72        }
73    }
74}
75
76impl fmt::Display for CertificationStatus {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        match self {
79            Self::Certified => write!(f, "CERTIFIED"),
80            Self::Provisional => write!(f, "PROVISIONAL"),
81            Self::Blocked => write!(f, "BLOCKED"),
82            Self::Pending => write!(f, "PENDING"),
83        }
84    }
85}
86
87/// Size category for models.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
89pub enum SizeCategory {
90    /// < 1B parameters.
91    Tiny,
92    /// 1B - 5B parameters.
93    #[default]
94    Small,
95    /// 5B - 10B parameters.
96    Medium,
97    /// 10B - 30B parameters.
98    Large,
99    /// > 30B parameters.
100    XLarge,
101}
102
103impl SizeCategory {
104    /// Parse size category from string representation.
105    #[must_use]
106    pub fn parse(s: &str) -> Self {
107        match s.to_lowercase().as_str() {
108            "tiny" => Self::Tiny,
109            "medium" => Self::Medium,
110            "large" => Self::Large,
111            "xlarge" => Self::XLarge,
112            // "small" and unknown values default to Small
113            _ => Self::Small,
114        }
115    }
116}
117
118/// Model certification record.
119///
120/// Contains certification data for a single model including gateway status
121/// and throughput measurements per format.
122/// The four gateway bools (g1-g4) are required for the certification protocol.
123#[allow(clippy::struct_excessive_bools)]
124#[derive(Debug, Clone)]
125pub struct ModelCertification {
126    /// `HuggingFace` model ID.
127    pub model_id: String,
128    /// Model family (e.g., qwen-coder, llama).
129    pub family: String,
130    /// Parameter count (e.g., "1.5B").
131    pub parameters: String,
132    /// Size category.
133    pub size_category: SizeCategory,
134    /// Certification status.
135    pub status: CertificationStatus,
136    /// Model Qualification Score (0-1000).
137    pub mqs_score: u32,
138    /// Letter grade.
139    pub grade: String,
140    /// Highest passing tier.
141    pub certified_tier: String,
142    /// Last certification timestamp.
143    pub last_certified: Option<DateTime<Utc>>,
144    /// Gateway 1 (load) status.
145    pub g1: bool,
146    /// Gateway 2 (inference) status.
147    pub g2: bool,
148    /// Gateway 3 (stability) status.
149    pub g3: bool,
150    /// Gateway 4 (quality) status.
151    pub g4: bool,
152    /// Throughput in tokens/sec for GGUF format (CPU).
153    pub tps_gguf_cpu: Option<f64>,
154    /// Throughput in tokens/sec for GGUF format (GPU).
155    pub tps_gguf_gpu: Option<f64>,
156    /// Throughput in tokens/sec for APR format (CPU).
157    pub tps_apr_cpu: Option<f64>,
158    /// Throughput in tokens/sec for APR format (GPU).
159    pub tps_apr_gpu: Option<f64>,
160    /// Throughput in tokens/sec for `SafeTensors` format (CPU).
161    pub tps_st_cpu: Option<f64>,
162    /// Throughput in tokens/sec for `SafeTensors` format (GPU).
163    pub tps_st_gpu: Option<f64>,
164    /// Provenance verified (PMAT-PROV-001).
165    pub provenance_verified: bool,
166}
167
168impl ModelCertification {
169    /// Get the short model name (without org prefix).
170    #[must_use]
171    pub fn short_name(&self) -> &str {
172        self.model_id
173            .split('/')
174            .next_back()
175            .unwrap_or(&self.model_id)
176    }
177
178    /// Get `HuggingFace` URL for this model.
179    #[must_use]
180    pub fn hf_url(&self) -> String {
181        format!("https://huggingface.co/{}", self.model_id)
182    }
183
184    /// Get markdown link for this model.
185    #[must_use]
186    pub fn markdown_link(&self) -> String {
187        format!("[{}]({})", self.short_name(), self.hf_url())
188    }
189
190    /// Get gateway symbol for display.
191    #[must_use]
192    pub const fn gateway_symbol(passed: bool, status: CertificationStatus) -> &'static str {
193        if matches!(status, CertificationStatus::Pending) {
194            "-"
195        } else if passed {
196            "\u{2713}" // checkmark
197        } else {
198            "\u{2717}" // x mark
199        }
200    }
201
202    /// Parse numeric parameter count for sorting.
203    #[must_use]
204    pub fn param_count(&self) -> f64 {
205        self.parameters
206            .trim_end_matches('B')
207            .parse::<f64>()
208            .unwrap_or(0.0)
209    }
210}
211
212/// Parse CSV content into model certifications.
213///
214/// # Errors
215///
216/// Returns `CertifyError::CsvParse` if the CSV is malformed.
217#[allow(clippy::similar_names)]
218pub fn parse_csv(content: &str) -> Result<Vec<ModelCertification>> {
219    let mut models = Vec::new();
220    let mut lines = content.lines().enumerate();
221
222    // Skip header
223    let Some((_, header)) = lines.next() else {
224        return Ok(models);
225    };
226
227    // Validate header (minimum 13 fields for backwards compatibility, 16 with tps)
228    let header_fields: Vec<&str> = header.split(',').collect();
229    if header_fields.len() < 13 {
230        return Err(CertifyError::CsvParse {
231            line: 1,
232            message: format!("expected at least 13 fields, got {}", header_fields.len()),
233        });
234    }
235
236    for (line_num, line) in lines {
237        if line.trim().is_empty() {
238            continue;
239        }
240
241        let fields: Vec<&str> = line.split(',').collect();
242        if fields.len() < 13 {
243            return Err(CertifyError::CsvParse {
244                line: line_num + 1,
245                message: format!("expected at least 13 fields, got {}", fields.len()),
246            });
247        }
248
249        let last_certified = DateTime::parse_from_rfc3339(fields[8])
250            .ok()
251            .map(|dt| dt.with_timezone(&Utc));
252
253        // Parse optional tps fields (backwards compatible) - 6 columns for format × backend
254        let tps_gguf_cpu = fields.get(13).and_then(|s| s.parse().ok());
255        let tps_gguf_gpu = fields.get(14).and_then(|s| s.parse().ok());
256        let tps_apr_cpu = fields.get(15).and_then(|s| s.parse().ok());
257        let tps_apr_gpu = fields.get(16).and_then(|s| s.parse().ok());
258        let tps_st_cpu = fields.get(17).and_then(|s| s.parse().ok());
259        let tps_st_gpu = fields.get(18).and_then(|s| s.parse().ok());
260        let provenance_verified = fields.get(19).is_some_and(|s| s.to_lowercase() == "true");
261
262        models.push(ModelCertification {
263            model_id: fields[0].to_string(),
264            family: fields[1].to_string(),
265            parameters: fields[2].to_string(),
266            size_category: SizeCategory::parse(fields[3]),
267            status: CertificationStatus::parse(fields[4]),
268            mqs_score: fields[5].parse().unwrap_or(0),
269            grade: fields[6].to_string(),
270            certified_tier: fields[7].to_string(),
271            last_certified,
272            g1: fields[9].to_lowercase() == "true",
273            g2: fields[10].to_lowercase() == "true",
274            g3: fields[11].to_lowercase() == "true",
275            g4: fields[12].to_lowercase() == "true",
276            tps_gguf_cpu,
277            tps_gguf_gpu,
278            tps_apr_cpu,
279            tps_apr_gpu,
280            tps_st_cpu,
281            tps_st_gpu,
282            provenance_verified,
283        });
284    }
285
286    Ok(models)
287}
288
289/// Generate certification summary statistics.
290#[must_use]
291pub fn generate_summary(models: &[ModelCertification], timestamp: &str) -> String {
292    let total = models.len();
293    let certified = models
294        .iter()
295        .filter(|m| matches!(m.status, CertificationStatus::Certified))
296        .count();
297    let provisional = models
298        .iter()
299        .filter(|m| matches!(m.status, CertificationStatus::Provisional))
300        .count();
301    let blocked = models
302        .iter()
303        .filter(|m| matches!(m.status, CertificationStatus::Blocked))
304        .count();
305    let pending = models
306        .iter()
307        .filter(|m| matches!(m.status, CertificationStatus::Pending))
308        .count();
309
310    format!(
311        r"**Certification Summary** (updated: {timestamp})
312
313| Status | Count |
314|--------|-------|
315| Certified | {certified}/{total} |
316| Provisional | {provisional}/{total} |
317| Blocked | {blocked}/{total} |
318| Pending | {pending}/{total} |
319
320**Priority Family:** Qwen Coder (see [Certified Testing Spec](docs/specifications/certified-testing.md))"
321    )
322}
323
324/// Generate markdown table from model certifications.
325#[must_use]
326pub fn generate_table(models: &[ModelCertification]) -> String {
327    let mut lines = Vec::new();
328
329    // Header with tok/s columns (format × backend = 6 columns) + provenance
330    lines.push(
331        "| Model | Family | Size | Status | MQS | Grade | G1-4 | Prov | GGUF CPU | GGUF GPU | APR CPU | APR GPU | ST CPU | ST GPU |"
332            .to_string(),
333    );
334    lines.push(
335        "|-------|--------|------|--------|-----|-------|------|------|----------|----------|---------|---------|--------|--------|"
336            .to_string(),
337    );
338
339    // Sort by family, then by parameter count
340    let mut sorted: Vec<_> = models.iter().collect();
341    sorted.sort_by(|a, b| {
342        a.family.cmp(&b.family).then_with(|| {
343            a.param_count()
344                .partial_cmp(&b.param_count())
345                .unwrap_or(std::cmp::Ordering::Equal)
346        })
347    });
348
349    for m in sorted {
350        // Combine gateways into single column (all must pass)
351        let gateways = if matches!(m.status, CertificationStatus::Pending) {
352            "-".to_string()
353        } else if m.g1 && m.g2 && m.g3 && m.g4 {
354            "\u{2713}".to_string() // checkmark
355        } else {
356            "\u{2717}".to_string() // x mark
357        };
358
359        // Provenance status
360        let prov = if matches!(m.status, CertificationStatus::Pending) {
361            "-"
362        } else if m.provenance_verified {
363            "\u{2713}" // checkmark
364        } else {
365            "\u{2717}" // x mark
366        };
367
368        // Format tok/s values (6 columns)
369        let fmt = |v: Option<f64>| v.map_or_else(|| "-".to_string(), |x| format!("{x:.1}"));
370
371        lines.push(format!(
372            "| {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |",
373            m.markdown_link(),
374            m.family,
375            m.parameters,
376            m.status.badge(),
377            m.mqs_score,
378            m.grade,
379            gateways,
380            prov,
381            fmt(m.tps_gguf_cpu),
382            fmt(m.tps_gguf_gpu),
383            fmt(m.tps_apr_cpu),
384            fmt(m.tps_apr_gpu),
385            fmt(m.tps_st_cpu),
386            fmt(m.tps_st_gpu),
387        ));
388    }
389
390    lines.join("\n")
391}
392
393/// Markers for README table replacement.
394pub const START_MARKER: &str = "<!-- CERTIFICATION_TABLE_START -->";
395/// End marker for README table.
396pub const END_MARKER: &str = "<!-- CERTIFICATION_TABLE_END -->";
397
398/// Write certification records to CSV format.
399///
400/// Generates a CSV string with headers that can be written to models.csv.
401#[must_use]
402pub fn write_csv(models: &[ModelCertification]) -> String {
403    let mut lines = Vec::new();
404
405    // Header with 6 tps columns (format × backend) + provenance
406    lines.push(
407        "model_id,family,parameters,size_category,status,mqs_score,grade,certified_tier,last_certified,g1,g2,g3,g4,tps_gguf_cpu,tps_gguf_gpu,tps_apr_cpu,tps_apr_gpu,tps_st_cpu,tps_st_gpu,provenance_verified"
408            .to_string(),
409    );
410
411    for m in models {
412        let size_cat = match m.size_category {
413            SizeCategory::Tiny => "tiny",
414            SizeCategory::Small => "small",
415            SizeCategory::Medium => "medium",
416            SizeCategory::Large => "large",
417            SizeCategory::XLarge => "xlarge",
418        };
419        let last_cert = m
420            .last_certified
421            .map_or_else(|| "2026-01-31T00:00:00Z".to_string(), |dt| dt.to_rfc3339());
422
423        // Format tps values (empty string for None)
424        let fmt = |v: Option<f64>| v.map_or(String::new(), |x| format!("{x:.1}"));
425
426        lines.push(format!(
427            "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}",
428            m.model_id,
429            m.family,
430            m.parameters,
431            size_cat,
432            m.status,
433            m.mqs_score,
434            m.grade,
435            m.certified_tier,
436            last_cert,
437            m.g1,
438            m.g2,
439            m.g3,
440            m.g4,
441            fmt(m.tps_gguf_cpu),
442            fmt(m.tps_gguf_gpu),
443            fmt(m.tps_apr_cpu),
444            fmt(m.tps_apr_gpu),
445            fmt(m.tps_st_cpu),
446            fmt(m.tps_st_gpu),
447            m.provenance_verified,
448        ));
449    }
450
451    lines.join("\n") + "\n"
452}
453
454/// Certification tier for tier-aware scoring.
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
456pub enum CertificationTier {
457    /// MVP tier: Tests 18 combinations (3 formats × 2 backends × 3 modalities).
458    /// Pass = B grade (800 score), PROVISIONAL status.
459    #[default]
460    Mvp,
461    /// Full tier: Complete 170-point verification matrix.
462    /// Pass = A+ grade (950+ score), CERTIFIED status.
463    Full,
464}
465
466/// MVP tier pass threshold (90% pass rate).
467pub const MVP_PASS_THRESHOLD: f64 = 0.90;
468
469/// MVP tier pass score (B grade).
470pub const MVP_PASS_SCORE: u32 = 800;
471
472/// Full tier pass threshold (95% on verification matrix).
473pub const FULL_PASS_THRESHOLD: f64 = 0.95;
474
475/// Full tier pass score (A+ grade).
476pub const FULL_PASS_SCORE: u32 = 950;
477
478/// Calculate certification status from MQS score.
479#[must_use]
480pub const fn status_from_score(mqs_score: u32, has_p0_failure: bool) -> CertificationStatus {
481    if has_p0_failure {
482        CertificationStatus::Blocked
483    } else if mqs_score >= 850 {
484        CertificationStatus::Certified
485    } else if mqs_score >= 700 {
486        CertificationStatus::Provisional
487    } else {
488        CertificationStatus::Blocked
489    }
490}
491
492/// Calculate certification status for a specific tier.
493///
494/// # Arguments
495/// * `tier` - The certification tier (MVP or Full)
496/// * `pass_rate` - The pass rate from test execution (0.0 to 1.0)
497/// * `has_p0_failure` - Whether any P0 (critical) test failed
498#[must_use]
499pub fn status_from_tier(
500    tier: CertificationTier,
501    pass_rate: f64,
502    has_p0_failure: bool,
503) -> CertificationStatus {
504    if has_p0_failure {
505        return CertificationStatus::Blocked;
506    }
507
508    match tier {
509        CertificationTier::Mvp => {
510            if pass_rate >= MVP_PASS_THRESHOLD {
511                CertificationStatus::Provisional
512            } else {
513                CertificationStatus::Blocked
514            }
515        }
516        CertificationTier::Full => {
517            if pass_rate >= FULL_PASS_THRESHOLD {
518                CertificationStatus::Certified
519            } else if pass_rate >= MVP_PASS_THRESHOLD {
520                CertificationStatus::Provisional
521            } else {
522                CertificationStatus::Blocked
523            }
524        }
525    }
526}
527
528/// Convert pass rate to a scaled score, clamping to valid range.
529#[inline]
530fn scale_to_f_grade(pass_rate: f64) -> u32 {
531    // Clamp pass_rate to [0.0, 1.0] and scale to [0, 699]
532    let clamped = pass_rate.clamp(0.0, 1.0);
533    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
534    let score = (clamped * 699.0) as u32;
535    score.min(699)
536}
537
538/// Calculate MQS score for a specific tier.
539///
540/// # Arguments
541/// * `tier` - The certification tier (MVP or Full)
542/// * `pass_rate` - The pass rate from test execution (0.0 to 1.0)
543/// * `has_p0_failure` - Whether any P0 (critical) test failed
544#[must_use]
545pub fn score_from_tier(tier: CertificationTier, pass_rate: f64, has_p0_failure: bool) -> u32 {
546    if has_p0_failure {
547        // Scale score based on pass rate, max 699 (F grade)
548        return scale_to_f_grade(pass_rate);
549    }
550
551    match tier {
552        CertificationTier::Mvp => {
553            if pass_rate >= MVP_PASS_THRESHOLD {
554                // MVP pass: B grade (800)
555                MVP_PASS_SCORE
556            } else {
557                // Scale score based on pass rate, max 699 (F grade)
558                scale_to_f_grade(pass_rate)
559            }
560        }
561        CertificationTier::Full => {
562            if pass_rate >= FULL_PASS_THRESHOLD {
563                // Full pass: A+ grade (950+)
564                let bonus = ((pass_rate - FULL_PASS_THRESHOLD) * 1000.0).clamp(0.0, 50.0);
565                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
566                let bonus_u32 = bonus as u32;
567                FULL_PASS_SCORE + bonus_u32
568            } else if pass_rate >= MVP_PASS_THRESHOLD {
569                // Between MVP and Full threshold: B to B+ range (800-899)
570                let ratio =
571                    (pass_rate - MVP_PASS_THRESHOLD) / (FULL_PASS_THRESHOLD - MVP_PASS_THRESHOLD);
572                let bonus = (ratio * 99.0).clamp(0.0, 99.0);
573                #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
574                let bonus_u32 = bonus as u32;
575                800 + bonus_u32
576            } else {
577                // Below MVP threshold: F grade
578                scale_to_f_grade(pass_rate)
579            }
580        }
581    }
582}
583
584/// Calculate letter grade from MQS score.
585#[must_use]
586pub const fn grade_from_score(mqs_score: u32) -> &'static str {
587    match mqs_score {
588        950..=1000 => "A+",
589        900..=949 => "A",
590        850..=899 => "B+",
591        800..=849 => "B",
592        700..=799 => "C",
593        _ => "F",
594    }
595}
596
597/// Calculate grade for a specific tier.
598///
599/// # Arguments
600/// * `tier` - The certification tier (MVP or Full)
601/// * `pass_rate` - The pass rate from test execution (0.0 to 1.0)
602/// * `has_p0_failure` - Whether any P0 (critical) test failed
603#[must_use]
604pub fn grade_from_tier(
605    tier: CertificationTier,
606    pass_rate: f64,
607    has_p0_failure: bool,
608) -> &'static str {
609    let score = score_from_tier(tier, pass_rate, has_p0_failure);
610    grade_from_score(score)
611}
612
613/// Update README content with new certification table.
614///
615/// # Errors
616///
617/// Returns `CertifyError::MarkerNotFound` if the markers are not found.
618pub fn update_readme(readme: &str, table_content: &str) -> Result<String> {
619    let start_idx = readme
620        .find(START_MARKER)
621        .ok_or_else(|| CertifyError::MarkerNotFound(START_MARKER.to_string()))?;
622    let end_idx = readme
623        .find(END_MARKER)
624        .ok_or_else(|| CertifyError::MarkerNotFound(END_MARKER.to_string()))?;
625
626    let before = &readme[..start_idx + START_MARKER.len()];
627    let after = &readme[end_idx..];
628
629    Ok(format!("{before}\n{table_content}\n{after}"))
630}
631
632#[cfg(test)]
633#[allow(clippy::expect_used)]
634mod tests {
635    use super::*;
636
637    const SAMPLE_CSV: &str = r"model_id,family,parameters,size_category,status,mqs_score,grade,certified_tier,last_certified,g1,g2,g3,g4,tps_gguf_cpu,tps_gguf_gpu,tps_apr_cpu,tps_apr_gpu,tps_st_cpu,tps_st_gpu,provenance_verified
638Qwen/Qwen2.5-Coder-0.5B-Instruct,qwen-coder,0.5B,tiny,PENDING,0,-,none,2026-01-31T00:00:00Z,false,false,false,false,,,,,,false,false
639Qwen/Qwen2.5-Coder-1.5B-Instruct,qwen-coder,1.5B,small,CERTIFIED,920,A,deep,2026-01-31T12:00:00Z,true,true,true,true,25.5,85.2,22.3,78.1,18.1,62.5,true
640meta-llama/Llama-3.2-1B-Instruct,llama,1B,small,BLOCKED,450,F,smoke,2026-01-31T00:00:00Z,true,false,false,false,12.0,45.0,,,,,false";
641
642    #[test]
643    fn test_parse_csv_valid() {
644        let models = parse_csv(SAMPLE_CSV).expect("should parse");
645        assert_eq!(models.len(), 3);
646
647        assert_eq!(models[0].model_id, "Qwen/Qwen2.5-Coder-0.5B-Instruct");
648        assert_eq!(models[0].family, "qwen-coder");
649        assert_eq!(models[0].parameters, "0.5B");
650        assert!(matches!(models[0].status, CertificationStatus::Pending));
651
652        assert_eq!(models[1].mqs_score, 920);
653        assert!(matches!(models[1].status, CertificationStatus::Certified));
654        assert!(models[1].g1);
655        assert!(models[1].g2);
656
657        assert!(matches!(models[2].status, CertificationStatus::Blocked));
658        assert!(models[2].g1);
659        assert!(!models[2].g2);
660    }
661
662    #[test]
663    fn test_parse_csv_empty() {
664        let models = parse_csv("").expect("should parse empty");
665        assert!(models.is_empty());
666    }
667
668    #[test]
669    fn test_parse_csv_header_only() {
670        let csv = "model_id,family,parameters,size_category,status,mqs_score,grade,certified_tier,last_certified,g1,g2,g3,g4";
671        let models = parse_csv(csv).expect("should parse header only");
672        assert!(models.is_empty());
673    }
674
675    #[test]
676    fn test_parse_csv_invalid_fields() {
677        let csv = "a,b,c\n1,2,3";
678        let result = parse_csv(csv);
679        assert!(result.is_err());
680    }
681
682    #[test]
683    fn test_certification_status_parse() {
684        assert!(matches!(
685            CertificationStatus::parse("CERTIFIED"),
686            CertificationStatus::Certified
687        ));
688        assert!(matches!(
689            CertificationStatus::parse("certified"),
690            CertificationStatus::Certified
691        ));
692        assert!(matches!(
693            CertificationStatus::parse("PROVISIONAL"),
694            CertificationStatus::Provisional
695        ));
696        assert!(matches!(
697            CertificationStatus::parse("BLOCKED"),
698            CertificationStatus::Blocked
699        ));
700        assert!(matches!(
701            CertificationStatus::parse("PENDING"),
702            CertificationStatus::Pending
703        ));
704        assert!(matches!(
705            CertificationStatus::parse("unknown"),
706            CertificationStatus::Pending
707        ));
708    }
709
710    #[test]
711    fn test_certification_status_badge() {
712        assert!(
713            CertificationStatus::Certified
714                .badge()
715                .contains("brightgreen")
716        );
717        assert!(CertificationStatus::Provisional.badge().contains("yellow"));
718        assert!(CertificationStatus::Blocked.badge().contains("red"));
719        assert!(CertificationStatus::Pending.badge().contains("lightgray"));
720    }
721
722    #[test]
723    fn test_model_short_name() {
724        let model = ModelCertification {
725            model_id: "Qwen/Qwen2.5-Coder-1.5B-Instruct".to_string(),
726            family: "qwen-coder".to_string(),
727            parameters: "1.5B".to_string(),
728            size_category: SizeCategory::Small,
729            status: CertificationStatus::Pending,
730            mqs_score: 0,
731            grade: "-".to_string(),
732            certified_tier: "none".to_string(),
733            last_certified: None,
734            g1: false,
735            g2: false,
736            g3: false,
737            g4: false,
738            tps_gguf_cpu: None,
739            tps_gguf_gpu: None,
740            tps_apr_cpu: None,
741            tps_apr_gpu: None,
742            tps_st_cpu: None,
743            tps_st_gpu: None,
744            provenance_verified: false,
745        };
746        assert_eq!(model.short_name(), "Qwen2.5-Coder-1.5B-Instruct");
747    }
748
749    #[test]
750    fn test_model_hf_url() {
751        let model = ModelCertification {
752            model_id: "Qwen/Qwen2.5-Coder-1.5B-Instruct".to_string(),
753            family: String::new(),
754            parameters: String::new(),
755            size_category: SizeCategory::Small,
756            status: CertificationStatus::Pending,
757            mqs_score: 0,
758            grade: String::new(),
759            certified_tier: String::new(),
760            last_certified: None,
761            g1: false,
762            g2: false,
763            g3: false,
764            g4: false,
765            tps_gguf_cpu: None,
766            tps_gguf_gpu: None,
767            tps_apr_cpu: None,
768            tps_apr_gpu: None,
769            tps_st_cpu: None,
770            tps_st_gpu: None,
771            provenance_verified: false,
772        };
773        assert_eq!(
774            model.hf_url(),
775            "https://huggingface.co/Qwen/Qwen2.5-Coder-1.5B-Instruct"
776        );
777    }
778
779    #[test]
780    fn test_model_param_count() {
781        let mut model = ModelCertification {
782            model_id: String::new(),
783            family: String::new(),
784            parameters: "1.5B".to_string(),
785            size_category: SizeCategory::Small,
786            status: CertificationStatus::Pending,
787            mqs_score: 0,
788            grade: String::new(),
789            certified_tier: String::new(),
790            last_certified: None,
791            g1: false,
792            g2: false,
793            g3: false,
794            g4: false,
795            tps_gguf_cpu: None,
796            tps_gguf_gpu: None,
797            tps_apr_cpu: None,
798            tps_apr_gpu: None,
799            tps_st_cpu: None,
800            tps_st_gpu: None,
801            provenance_verified: false,
802        };
803        assert!((model.param_count() - 1.5).abs() < f64::EPSILON);
804
805        model.parameters = "32B".to_string();
806        assert!((model.param_count() - 32.0).abs() < f64::EPSILON);
807
808        model.parameters = "invalid".to_string();
809        assert!((model.param_count() - 0.0).abs() < f64::EPSILON);
810    }
811
812    #[test]
813    fn test_gateway_symbol() {
814        assert_eq!(
815            ModelCertification::gateway_symbol(true, CertificationStatus::Certified),
816            "\u{2713}"
817        );
818        assert_eq!(
819            ModelCertification::gateway_symbol(false, CertificationStatus::Certified),
820            "\u{2717}"
821        );
822        assert_eq!(
823            ModelCertification::gateway_symbol(true, CertificationStatus::Pending),
824            "-"
825        );
826        assert_eq!(
827            ModelCertification::gateway_symbol(false, CertificationStatus::Pending),
828            "-"
829        );
830    }
831
832    #[test]
833    fn test_generate_summary() {
834        let models = parse_csv(SAMPLE_CSV).expect("should parse");
835        let summary = generate_summary(&models, "2026-01-31 12:00 UTC");
836
837        assert!(summary.contains("Certified | 1/3"));
838        assert!(summary.contains("Blocked | 1/3"));
839        assert!(summary.contains("Pending | 1/3"));
840        assert!(summary.contains("2026-01-31 12:00 UTC"));
841    }
842
843    #[test]
844    fn test_generate_table() {
845        let models = parse_csv(SAMPLE_CSV).expect("should parse");
846        let table = generate_table(&models);
847
848        assert!(table.contains("| Model | Family |"));
849        assert!(table.contains("Qwen2.5-Coder-0.5B-Instruct"));
850        assert!(table.contains("qwen-coder"));
851        assert!(table.contains("CERTIFIED-brightgreen"));
852        assert!(table.contains("BLOCKED-red"));
853    }
854
855    #[test]
856    fn test_generate_table_sorting() {
857        let models = parse_csv(SAMPLE_CSV).expect("should parse");
858        let table = generate_table(&models);
859        let lines: Vec<&str> = table.lines().collect();
860
861        // Should be sorted by family, then by param count
862        // llama (1B) should come before qwen-coder (0.5B, 1.5B)
863        let llama_idx = lines
864            .iter()
865            .position(|l| l.contains("Llama"))
866            .expect("llama found");
867        let qwen_05_idx = lines
868            .iter()
869            .position(|l| l.contains("0.5B"))
870            .expect("qwen 0.5 found");
871
872        assert!(
873            llama_idx < qwen_05_idx,
874            "llama should come before qwen-coder"
875        );
876    }
877
878    #[test]
879    fn test_update_readme_success() {
880        let readme = r"# Title
881
882Some content
883
884<!-- CERTIFICATION_TABLE_START -->
885old table
886<!-- CERTIFICATION_TABLE_END -->
887
888More content";
889
890        let new_table = "new table content";
891        let result = update_readme(readme, new_table).expect("should update");
892
893        assert!(result.contains("new table content"));
894        assert!(!result.contains("old table"));
895        assert!(result.contains("# Title"));
896        assert!(result.contains("More content"));
897    }
898
899    #[test]
900    fn test_update_readme_missing_start_marker() {
901        let readme = "no markers here <!-- CERTIFICATION_TABLE_END -->";
902        let result = update_readme(readme, "table");
903        assert!(matches!(result, Err(CertifyError::MarkerNotFound(_))));
904    }
905
906    #[test]
907    fn test_update_readme_missing_end_marker() {
908        let readme = "<!-- CERTIFICATION_TABLE_START --> no end marker";
909        let result = update_readme(readme, "table");
910        assert!(matches!(result, Err(CertifyError::MarkerNotFound(_))));
911    }
912
913    #[test]
914    fn test_size_category_parse() {
915        assert!(matches!(SizeCategory::parse("tiny"), SizeCategory::Tiny));
916        assert!(matches!(SizeCategory::parse("SMALL"), SizeCategory::Small));
917        assert!(matches!(
918            SizeCategory::parse("Medium"),
919            SizeCategory::Medium
920        ));
921        assert!(matches!(SizeCategory::parse("large"), SizeCategory::Large));
922        assert!(matches!(
923            SizeCategory::parse("xlarge"),
924            SizeCategory::XLarge
925        ));
926        assert!(matches!(
927            SizeCategory::parse("unknown"),
928            SizeCategory::Small
929        ));
930    }
931
932    #[test]
933    fn test_certification_status_display() {
934        assert_eq!(format!("{}", CertificationStatus::Certified), "CERTIFIED");
935        assert_eq!(
936            format!("{}", CertificationStatus::Provisional),
937            "PROVISIONAL"
938        );
939        assert_eq!(format!("{}", CertificationStatus::Blocked), "BLOCKED");
940        assert_eq!(format!("{}", CertificationStatus::Pending), "PENDING");
941    }
942
943    #[test]
944    fn test_short_name_no_slash() {
945        let model = ModelCertification {
946            model_id: "model-without-org".to_string(),
947            family: String::new(),
948            parameters: String::new(),
949            size_category: SizeCategory::Small,
950            status: CertificationStatus::Pending,
951            mqs_score: 0,
952            grade: String::new(),
953            certified_tier: String::new(),
954            last_certified: None,
955            g1: false,
956            g2: false,
957            g3: false,
958            g4: false,
959            tps_gguf_cpu: None,
960            tps_gguf_gpu: None,
961            tps_apr_cpu: None,
962            tps_apr_gpu: None,
963            tps_st_cpu: None,
964            tps_st_gpu: None,
965            provenance_verified: false,
966        };
967        assert_eq!(model.short_name(), "model-without-org");
968    }
969
970    #[test]
971    fn test_markdown_link() {
972        let model = ModelCertification {
973            model_id: "Org/Model".to_string(),
974            family: String::new(),
975            parameters: String::new(),
976            size_category: SizeCategory::Small,
977            status: CertificationStatus::Pending,
978            mqs_score: 0,
979            grade: String::new(),
980            certified_tier: String::new(),
981            last_certified: None,
982            g1: false,
983            g2: false,
984            g3: false,
985            g4: false,
986            tps_gguf_cpu: None,
987            tps_gguf_gpu: None,
988            tps_apr_cpu: None,
989            tps_apr_gpu: None,
990            tps_st_cpu: None,
991            tps_st_gpu: None,
992            provenance_verified: false,
993        };
994        assert_eq!(
995            model.markdown_link(),
996            "[Model](https://huggingface.co/Org/Model)"
997        );
998    }
999
1000    #[test]
1001    fn test_write_csv_roundtrip() {
1002        let models = parse_csv(SAMPLE_CSV).expect("should parse");
1003        let csv_output = write_csv(&models);
1004
1005        // Parse it back
1006        let reparsed = parse_csv(&csv_output).expect("should reparse");
1007        assert_eq!(reparsed.len(), models.len());
1008
1009        // Check first model
1010        assert_eq!(reparsed[0].model_id, models[0].model_id);
1011        assert_eq!(reparsed[0].family, models[0].family);
1012        assert_eq!(reparsed[0].mqs_score, models[0].mqs_score);
1013    }
1014
1015    #[test]
1016    fn test_write_csv_has_header() {
1017        let models = parse_csv(SAMPLE_CSV).expect("should parse");
1018        let csv_output = write_csv(&models);
1019        assert!(csv_output.starts_with("model_id,family,"));
1020    }
1021
1022    #[test]
1023    fn test_status_from_score_certified() {
1024        assert!(matches!(
1025            status_from_score(900_u32, false),
1026            CertificationStatus::Certified
1027        ));
1028        assert!(matches!(
1029            status_from_score(850_u32, false),
1030            CertificationStatus::Certified
1031        ));
1032    }
1033
1034    #[test]
1035    fn test_status_from_score_provisional() {
1036        assert!(matches!(
1037            status_from_score(800_u32, false),
1038            CertificationStatus::Provisional
1039        ));
1040        assert!(matches!(
1041            status_from_score(700_u32, false),
1042            CertificationStatus::Provisional
1043        ));
1044    }
1045
1046    #[test]
1047    fn test_status_from_score_blocked() {
1048        assert!(matches!(
1049            status_from_score(699_u32, false),
1050            CertificationStatus::Blocked
1051        ));
1052        assert!(matches!(
1053            status_from_score(0_u32, false),
1054            CertificationStatus::Blocked
1055        ));
1056    }
1057
1058    #[test]
1059    fn test_status_from_score_p0_failure() {
1060        // P0 failure always results in BLOCKED regardless of score
1061        assert!(matches!(
1062            status_from_score(950_u32, true),
1063            CertificationStatus::Blocked
1064        ));
1065        assert!(matches!(
1066            status_from_score(900_u32, true),
1067            CertificationStatus::Blocked
1068        ));
1069    }
1070
1071    #[test]
1072    fn test_grade_from_score() {
1073        assert_eq!(grade_from_score(1000_u32), "A+");
1074        assert_eq!(grade_from_score(950_u32), "A+");
1075        assert_eq!(grade_from_score(920_u32), "A");
1076        assert_eq!(grade_from_score(900_u32), "A");
1077        assert_eq!(grade_from_score(880_u32), "B+");
1078        assert_eq!(grade_from_score(850_u32), "B+");
1079        assert_eq!(grade_from_score(820_u32), "B");
1080        assert_eq!(grade_from_score(800_u32), "B");
1081        assert_eq!(grade_from_score(750_u32), "C");
1082        assert_eq!(grade_from_score(700_u32), "C");
1083        assert_eq!(grade_from_score(699_u32), "F");
1084        assert_eq!(grade_from_score(0_u32), "F");
1085    }
1086
1087    #[test]
1088    fn test_mvp_tier_pass() {
1089        // MVP tier with 90%+ pass rate should get B grade (800 score)
1090        let status = status_from_tier(CertificationTier::Mvp, 0.95, false);
1091        assert!(matches!(status, CertificationStatus::Provisional));
1092
1093        let score = score_from_tier(CertificationTier::Mvp, 0.95, false);
1094        assert_eq!(score, 800);
1095
1096        let grade = grade_from_tier(CertificationTier::Mvp, 0.95, false);
1097        assert_eq!(grade, "B");
1098    }
1099
1100    #[test]
1101    fn test_mvp_tier_exactly_90_percent() {
1102        // MVP tier at exactly 90% should pass
1103        let status = status_from_tier(CertificationTier::Mvp, 0.90, false);
1104        assert!(matches!(status, CertificationStatus::Provisional));
1105
1106        let score = score_from_tier(CertificationTier::Mvp, 0.90, false);
1107        assert_eq!(score, 800);
1108    }
1109
1110    #[test]
1111    fn test_mvp_tier_fail() {
1112        // MVP tier below 90% should fail
1113        let status = status_from_tier(CertificationTier::Mvp, 0.85, false);
1114        assert!(matches!(status, CertificationStatus::Blocked));
1115
1116        let score = score_from_tier(CertificationTier::Mvp, 0.85, false);
1117        assert!(score < 700); // F grade
1118    }
1119
1120    #[test]
1121    fn test_mvp_tier_p0_failure() {
1122        // MVP tier with P0 failure should always block
1123        let status = status_from_tier(CertificationTier::Mvp, 0.99, true);
1124        assert!(matches!(status, CertificationStatus::Blocked));
1125
1126        let score = score_from_tier(CertificationTier::Mvp, 0.99, true);
1127        assert!(score < 700); // F grade even with high pass rate
1128    }
1129
1130    #[test]
1131    fn test_full_tier_pass() {
1132        // Full tier with 95%+ should get A+ (950+ score)
1133        let status = status_from_tier(CertificationTier::Full, 0.98, false);
1134        assert!(matches!(status, CertificationStatus::Certified));
1135
1136        let score = score_from_tier(CertificationTier::Full, 0.98, false);
1137        assert!(score >= 950);
1138
1139        let grade = grade_from_tier(CertificationTier::Full, 0.98, false);
1140        assert_eq!(grade, "A+");
1141    }
1142
1143    #[test]
1144    fn test_full_tier_provisional() {
1145        // Full tier between 90% and 95% should get PROVISIONAL
1146        let status = status_from_tier(CertificationTier::Full, 0.92, false);
1147        assert!(matches!(status, CertificationStatus::Provisional));
1148
1149        let score = score_from_tier(CertificationTier::Full, 0.92, false);
1150        assert!((800..900).contains(&score)); // B to B+ range
1151    }
1152
1153    #[test]
1154    fn test_full_tier_fail() {
1155        // Full tier below 90% should fail
1156        let status = status_from_tier(CertificationTier::Full, 0.85, false);
1157        assert!(matches!(status, CertificationStatus::Blocked));
1158
1159        let score = score_from_tier(CertificationTier::Full, 0.85, false);
1160        assert!(score < 700);
1161    }
1162
1163    #[test]
1164    fn test_certification_tier_default() {
1165        let tier = CertificationTier::default();
1166        assert!(matches!(tier, CertificationTier::Mvp));
1167    }
1168
1169    #[test]
1170    fn test_parse_csv_with_empty_lines() {
1171        let csv = r"model_id,family,parameters,size_category,status,mqs_score,grade,certified_tier,last_certified,g1,g2,g3,g4
1172
1173Qwen/Qwen2.5-Coder-0.5B-Instruct,qwen-coder,0.5B,tiny,PENDING,0,-,none,2026-01-31T00:00:00Z,false,false,false,false
1174
1175";
1176        let models = parse_csv(csv).expect("should parse with empty lines");
1177        assert_eq!(models.len(), 1);
1178        assert_eq!(models[0].model_id, "Qwen/Qwen2.5-Coder-0.5B-Instruct");
1179    }
1180
1181    #[test]
1182    fn test_parse_csv_insufficient_fields_in_line() {
1183        let csv = r"model_id,family,parameters,size_category,status,mqs_score,grade,certified_tier,last_certified,g1,g2,g3,g4
1184only,a,few,fields";
1185        let result = parse_csv(csv);
1186        assert!(result.is_err());
1187        let err = result.expect_err("Should be an error");
1188        assert!(
1189            matches!(err, CertifyError::CsvParse { line: 2, .. }),
1190            "Error should indicate line 2"
1191        );
1192    }
1193
1194    #[test]
1195    fn test_write_csv_all_size_categories() {
1196        // Test that write_csv correctly handles all size categories
1197        let models = vec![
1198            ModelCertification {
1199                model_id: "tiny-model".to_string(),
1200                family: "test".to_string(),
1201                parameters: "0.5B".to_string(),
1202                size_category: SizeCategory::Tiny,
1203                status: CertificationStatus::Pending,
1204                mqs_score: 0,
1205                grade: "-".to_string(),
1206                certified_tier: "none".to_string(),
1207                last_certified: None,
1208                g1: false,
1209                g2: false,
1210                g3: false,
1211                g4: false,
1212                tps_gguf_cpu: None,
1213                tps_gguf_gpu: None,
1214                tps_apr_cpu: None,
1215                tps_apr_gpu: None,
1216                tps_st_cpu: None,
1217                tps_st_gpu: None,
1218                provenance_verified: false,
1219            },
1220            ModelCertification {
1221                model_id: "medium-model".to_string(),
1222                family: "test".to_string(),
1223                parameters: "7B".to_string(),
1224                size_category: SizeCategory::Medium,
1225                status: CertificationStatus::Pending,
1226                mqs_score: 0,
1227                grade: "-".to_string(),
1228                certified_tier: "none".to_string(),
1229                last_certified: None,
1230                g1: false,
1231                g2: false,
1232                g3: false,
1233                g4: false,
1234                tps_gguf_cpu: None,
1235                tps_gguf_gpu: None,
1236                tps_apr_cpu: None,
1237                tps_apr_gpu: None,
1238                tps_st_cpu: None,
1239                tps_st_gpu: None,
1240                provenance_verified: false,
1241            },
1242            ModelCertification {
1243                model_id: "large-model".to_string(),
1244                family: "test".to_string(),
1245                parameters: "34B".to_string(),
1246                size_category: SizeCategory::Large,
1247                status: CertificationStatus::Pending,
1248                mqs_score: 0,
1249                grade: "-".to_string(),
1250                certified_tier: "none".to_string(),
1251                last_certified: None,
1252                g1: false,
1253                g2: false,
1254                g3: false,
1255                g4: false,
1256                tps_gguf_cpu: None,
1257                tps_gguf_gpu: None,
1258                tps_apr_cpu: None,
1259                tps_apr_gpu: None,
1260                tps_st_cpu: None,
1261                tps_st_gpu: None,
1262                provenance_verified: false,
1263            },
1264            ModelCertification {
1265                model_id: "xlarge-model".to_string(),
1266                family: "test".to_string(),
1267                parameters: "70B".to_string(),
1268                size_category: SizeCategory::XLarge,
1269                status: CertificationStatus::Pending,
1270                mqs_score: 0,
1271                grade: "-".to_string(),
1272                certified_tier: "none".to_string(),
1273                last_certified: None,
1274                g1: false,
1275                g2: false,
1276                g3: false,
1277                g4: false,
1278                tps_gguf_cpu: None,
1279                tps_gguf_gpu: None,
1280                tps_apr_cpu: None,
1281                tps_apr_gpu: None,
1282                tps_st_cpu: None,
1283                tps_st_gpu: None,
1284                provenance_verified: false,
1285            },
1286        ];
1287
1288        let csv_output = write_csv(&models);
1289        assert!(csv_output.contains(",tiny,"));
1290        assert!(csv_output.contains(",medium,"));
1291        assert!(csv_output.contains(",large,"));
1292        assert!(csv_output.contains(",xlarge,"));
1293    }
1294}