Skip to main content

apr_cli/commands/
qa.rs

1//! QA Command Implementation - Falsifiable Quality Assurance Checklist
2//!
3//! Implements a scientific QA process for model releases. Every claim must be
4//! falsifiable - if a test can't fail, it doesn't provide information.
5//!
6//! # Gates
7//!
8//! 1. **Golden Output Test** (Correctness Gate)
9//!    - Run model with known prompts, verify expected patterns in output
10//!    - Falsifiable: Output must match expected pattern or test fails
11//!
12//! 2. **Throughput Falsification** (Performance Gate)
13//!    - Run benchmark with statistical rigor (CV < 5%)
14//!    - Assert minimum tok/s threshold
15//!    - Falsifiable: If tok/s < threshold, test fails
16//!
17//! 3. **Ollama Parity Test** (Parity Gate)
18//!    - Compare against Ollama baseline (if available)
19//!    - Assert speedup factor >= target
20//!    - Falsifiable: If speedup < target, test fails
21//!
22//! 4. **GPU vs CPU Speedup Test** (F-PERF-042)
23//!    - Measure throughput on both GPU and CPU
24//!    - Assert GPU >= 2x CPU (default threshold)
25//!    - Falsifiable: If GPU speedup < threshold, test fails
26//!    - Toyota Way: Genchi Genbutsu - measure real performance
27//!
28//! 5. **Cross-Format Parity Test** (F-QUAL-032)
29//!    - Compare argmax between GGUF and SafeTensors for same model
30//!    - Invariant: argmax(forward_gguf) == argmax(forward_safetensors)
31//!    - Falsifiable: If argmax differs, cross-format parity is BROKEN
32//!    - Cornerstone of architecture's logical validity
33//!
34//! 6. **PTX Parity Test** (GH-219, F-PTX-001)
35//!    - Validate batched GPU kernels maintain structural parity with single-vector references
36//!    - Checks: batch dispatch mechanism, u64 shared memory addressing, dispatch strategy
37//!    - Falsifiable: If any of 6 kernel pairs fails structural validation, test fails
38//!    - Toyota Way: Poka-Yoke - error-proof PTX generation at compile time
39//!
40//! # Usage
41//!
42//! ```bash
43//! apr qa model.gguf                           # Run all gates
44//! apr qa model.gguf --assert-tps 100          # Custom throughput threshold
45//! apr qa model.gguf --assert-speedup 2.0      # Custom Ollama speedup
46//! apr qa model.gguf --assert-gpu-speedup 3.0  # Custom GPU vs CPU speedup
47//! apr qa model.gguf --skip-ollama             # Skip Ollama comparison
48//! apr qa model.gguf --skip-gpu-speedup        # Skip GPU vs CPU test
49//! apr qa model.gguf --skip-format-parity      # Skip cross-format test
50//! apr qa model.gguf --safetensors-path m.st   # Compare with SafeTensors model
51//! apr qa model.gguf --json                    # JSON output for CI
52//! ```
53//!
54//! # Exit Codes
55//!
56//! - 0: All gates passed
57//! - 5: One or more gates failed (ValidationFailed)
58//!
59//! Toyota Way: Jidoka - Stop and fix quality issues immediately.
60//! Scientific Method: Claims must be falsifiable to have meaning.
61
62use crate::error::{CliError, Result};
63use crate::output;
64use colored::Colorize;
65use serde::{Deserialize, Serialize};
66use std::path::Path;
67use std::time::{Duration, Instant};
68
69#[cfg(not(feature = "visualization"))]
70use brick_tracer_shim::BrickTracer as TracerImpl;
71#[cfg(feature = "visualization")]
72use renacer::brick_tracer::BrickTracer as TracerImpl;
73
74/// No-op BrickTracer shim when the `visualization` (renacer) feature is disabled.
75/// Provides the same API surface so callers compile without cfg gates on every call site.
76#[cfg(not(feature = "visualization"))]
77mod brick_tracer_shim {
78    /// Stub syscall breakdown — all zeros.
79    pub struct SyscallBreakdown {
80        pub compute_us: u64,
81        pub mmap_us: u64,
82        pub futex_us: u64,
83        pub ioctl_us: u64,
84    }
85    impl SyscallBreakdown {
86        pub fn syscall_overhead_percent(&self) -> f64 {
87            0.0
88        }
89        pub fn dominant_syscall(&self) -> &'static str {
90            "none"
91        }
92    }
93
94    /// Stub trace metadata.
95    pub struct TraceMetadata {
96        pub budget_us: u64,
97        pub actual_us: u64,
98        pub efficiency: f64,
99    }
100
101    /// Result of a traced operation — contains the closure result + timing.
102    pub struct TracedResult<T> {
103        pub result: T,
104        pub duration_us: u64,
105        pub syscall_breakdown: SyscallBreakdown,
106        pub metadata: Option<TraceMetadata>,
107    }
108
109    /// No-op tracer that just times the closure with `Instant`.
110    pub struct BrickTracer;
111    impl BrickTracer {
112        pub fn new_local() -> Self {
113            Self
114        }
115        pub fn trace<T>(
116            &self,
117            _name: &str,
118            _budget_us: u64,
119            f: impl FnOnce() -> T,
120        ) -> TracedResult<T> {
121            let start = std::time::Instant::now();
122            let result = f();
123            let duration_us = start.elapsed().as_micros() as u64;
124            TracedResult {
125                result,
126                duration_us,
127                syscall_breakdown: SyscallBreakdown {
128                    compute_us: duration_us,
129                    mmap_us: 0,
130                    futex_us: 0,
131                    ioctl_us: 0,
132                },
133                metadata: None,
134            }
135        }
136    }
137}
138
139/// QA configuration
140#[derive(Debug, Clone)]
141pub struct QaConfig {
142    /// Throughput floor asserted by the user via `--assert-tps`, in tok/s.
143    ///
144    /// `None` means the user asserted nothing, and the throughput gate picks a
145    /// format-aware default instead (see `speedup.rs`). This has to stay an
146    /// `Option`: collapsing it to a plain `f64` with a default is what let the
147    /// gate confuse "the user demanded 100 tok/s" with "nobody said anything",
148    /// and then quietly substitute its own much lower number for both.
149    pub min_tps: Option<f64>,
150    /// Minimum speedup vs Ollama (default: 2.0x)
151    pub min_speedup: f64,
152    /// Minimum GPU vs CPU speedup (default: 2.0x) - F-PERF-042
153    pub min_gpu_speedup: f64,
154    /// Skip golden output test
155    pub skip_golden: bool,
156    /// Skip throughput test
157    pub skip_throughput: bool,
158    /// Skip Ollama parity test
159    pub skip_ollama: bool,
160    /// Skip GPU vs CPU speedup test (F-PERF-042)
161    pub skip_gpu_speedup: bool,
162    /// Skip tensor contract validation (PMAT-235)
163    pub skip_contract: bool,
164    /// Skip cross-format parity test (F-QUAL-032)
165    pub skip_format_parity: bool,
166    /// Skip PTX parity validation (GH-219, F-PTX-001)
167    pub skip_ptx_parity: bool,
168    /// SafeTensors model path for cross-format parity (F-QUAL-032)
169    pub safetensors_path: Option<std::path::PathBuf>,
170    /// Number of benchmark iterations
171    pub iterations: usize,
172    /// Number of warmup iterations
173    pub warmup: usize,
174    /// Max tokens for generation
175    pub max_tokens: usize,
176    /// Output as JSON
177    pub json: bool,
178    /// Verbose output
179    pub verbose: bool,
180    /// Minimum number of gates that must execute (not be skipped)
181    pub min_executed: Option<usize>,
182    /// Path to previous QA report for regression comparison
183    pub previous_report: Option<std::path::PathBuf>,
184    /// Maximum allowed performance regression (0.10 = 10%)
185    pub regression_threshold: f64,
186    /// Skip GPU state isolation test
187    pub skip_gpu_state: bool,
188    /// Skip metadata plausibility validation (Bug 210, GH-222)
189    pub skip_metadata: bool,
190    /// Skip GPU capability match gate (GH-280)
191    pub skip_capability: bool,
192    /// Assert classifier head presence and shape (F-CLASS-004)
193    pub assert_classifier_head: bool,
194}
195
196impl Default for QaConfig {
197    fn default() -> Self {
198        Self {
199            min_tps: None,        // no assertion; the gate picks a format-aware default
200            min_speedup: 0.2, // Ollama uses llama.cpp optimized kernels; 0.2x is realistic floor
201            min_gpu_speedup: 2.0, // GPU must be 2x faster than CPU (F-PERF-042)
202            skip_golden: false,
203            skip_throughput: false,
204            skip_ollama: false,
205            skip_gpu_speedup: false,
206            skip_contract: false,
207            skip_format_parity: false,
208            skip_ptx_parity: false,
209            safetensors_path: None,
210            iterations: 10,
211            warmup: 3,
212            max_tokens: 32,
213            json: false,
214            verbose: false,
215            min_executed: None,
216            previous_report: None,
217            regression_threshold: 0.10,
218            skip_gpu_state: false,
219            skip_metadata: false,
220            skip_capability: false,
221            assert_classifier_head: false,
222        }
223    }
224}
225
226/// Result of a single QA gate
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct GateResult {
229    /// Gate name
230    pub name: String,
231    /// Whether the gate passed
232    pub passed: bool,
233    /// Human-readable result message
234    pub message: String,
235    /// Measured value (if applicable)
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub value: Option<f64>,
238    /// Expected/threshold value (if applicable)
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub threshold: Option<f64>,
241    /// Time taken to run the gate
242    pub duration_ms: u64,
243    /// Whether the gate was skipped
244    pub skipped: bool,
245}
246
247impl GateResult {
248    pub(crate) fn passed(
249        name: &str,
250        message: &str,
251        value: Option<f64>,
252        threshold: Option<f64>,
253        duration: Duration,
254    ) -> Self {
255        Self {
256            name: name.to_string(),
257            passed: true,
258            message: message.to_string(),
259            value,
260            threshold,
261            duration_ms: duration.as_millis() as u64,
262            skipped: false,
263        }
264    }
265
266    pub(crate) fn failed(
267        name: &str,
268        message: &str,
269        value: Option<f64>,
270        threshold: Option<f64>,
271        duration: Duration,
272    ) -> Self {
273        Self {
274            name: name.to_string(),
275            passed: false,
276            message: message.to_string(),
277            value,
278            threshold,
279            duration_ms: duration.as_millis() as u64,
280            skipped: false,
281        }
282    }
283
284    pub(crate) fn skipped(name: &str, reason: &str) -> Self {
285        Self {
286            name: name.to_string(),
287            passed: true, // Skipped gates don't fail
288            message: format!("Skipped: {reason}"),
289            value: None,
290            threshold: None,
291            duration_ms: 0,
292            skipped: true,
293        }
294    }
295}
296
297/// System information captured during QA run
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct SystemInfo {
300    /// CPU model name
301    pub cpu_model: String,
302    /// GPU model name (if available)
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub gpu_model: Option<String>,
305    /// GPU driver version (if available)
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub gpu_driver: Option<String>,
308}
309
310impl SystemInfo {
311    fn capture() -> Self {
312        let cpu_model = std::fs::read_to_string("/proc/cpuinfo")
313            .ok()
314            .and_then(|s| {
315                s.lines()
316                    .find(|l| l.starts_with("model name"))
317                    .and_then(|l| l.split(':').nth(1))
318                    .map(|s| s.trim().to_string())
319            })
320            .unwrap_or_else(|| "unknown".to_string());
321
322        let (gpu_model, gpu_driver) = Self::detect_gpu();
323
324        Self {
325            cpu_model,
326            gpu_model,
327            gpu_driver,
328        }
329    }
330
331    fn detect_gpu() -> (Option<String>, Option<String>) {
332        let output = std::process::Command::new("nvidia-smi")
333            .args(["--query-gpu=name,driver_version", "--format=csv,noheader"])
334            .output()
335            .ok();
336        if let Some(out) = output {
337            if out.status.success() {
338                let text = String::from_utf8_lossy(&out.stdout);
339                let parts: Vec<&str> = text.trim().splitn(2, ',').collect();
340                return (
341                    parts.first().map(|s| s.trim().to_string()),
342                    parts.get(1).map(|s| s.trim().to_string()),
343                );
344            }
345        }
346        (None, None)
347    }
348}
349
350/// Full QA report
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct QaReport {
353    /// Model path
354    pub model: String,
355    /// Whether all gates passed
356    pub passed: bool,
357    /// Individual gate results
358    pub gates: Vec<GateResult>,
359    /// Number of gates that actually executed (not skipped)
360    #[serde(default)]
361    pub gates_executed: usize,
362    /// Number of gates that were skipped
363    #[serde(default)]
364    pub gates_skipped: usize,
365    /// Total duration
366    pub total_duration_ms: u64,
367    /// Timestamp (ISO 8601)
368    pub timestamp: String,
369    /// Summary message
370    pub summary: String,
371    /// System information
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub system_info: Option<SystemInfo>,
374}
375
376/// Run the QA command
377#[allow(clippy::too_many_arguments)]
378#[provable_contracts_macros::contract(
379    "apr-cli-operations-v1",
380    equation = "side_effect_classification"
381)]
382pub fn run(
383    path: &Path,
384    min_tps: Option<f64>,
385    min_speedup: Option<f64>,
386    min_gpu_speedup: Option<f64>,
387    skip_golden: bool,
388    skip_throughput: bool,
389    skip_ollama: bool,
390    skip_gpu_speedup: bool,
391    skip_contract: bool,
392    skip_format_parity: bool,
393    skip_ptx_parity: bool,
394    safetensors_path: Option<std::path::PathBuf>,
395    iterations: usize,
396    warmup: usize,
397    max_tokens: usize,
398    json: bool,
399    verbose: bool,
400    min_executed: Option<usize>,
401    previous_report: Option<std::path::PathBuf>,
402    regression_threshold: Option<f64>,
403    skip_gpu_state: bool,
404    skip_metadata: bool,
405    skip_capability: bool,
406    assert_classifier_head: bool,
407) -> Result<()> {
408    contract_pre_qa_gate_composition!();
409    // GH-2391: every QA gate is `observed >= asserted`. A NaN or negative
410    // assertion makes that comparison unable to distinguish pass from fail, so
411    // the release gate reports a verdict it never reached. Refuse the value.
412    use crate::commands::threshold_arg;
413    threshold_arg::guard_opt("--assert-tps", min_tps, threshold_arg::TOLERANCE)?;
414    threshold_arg::guard_opt("--assert-speedup", min_speedup, threshold_arg::TOLERANCE)?;
415    threshold_arg::guard_opt(
416        "--assert-gpu-speedup",
417        min_gpu_speedup,
418        threshold_arg::TOLERANCE,
419    )?;
420    threshold_arg::guard_opt(
421        "--regression-threshold",
422        regression_threshold,
423        threshold_arg::FRACTION,
424    )?;
425
426    let config = QaConfig {
427        min_tps,
428        min_speedup: min_speedup.unwrap_or(0.2), // Ollama uses llama.cpp optimized kernels
429        min_gpu_speedup: min_gpu_speedup.unwrap_or(2.0), // GPU must be 2x faster (F-PERF-042)
430        skip_golden,
431        skip_throughput,
432        skip_ollama,
433        skip_gpu_speedup,
434        skip_contract,
435        skip_format_parity,
436        skip_ptx_parity,
437        safetensors_path,
438        iterations,
439        warmup,
440        max_tokens,
441        json,
442        verbose,
443        min_executed,
444        previous_report,
445        regression_threshold: regression_threshold.unwrap_or(0.10),
446        skip_gpu_state,
447        skip_metadata,
448        skip_capability,
449        assert_classifier_head,
450    };
451
452    let report = run_qa(path, &config)?;
453
454    if json {
455        println!(
456            "{}",
457            serde_json::to_string_pretty(&report).unwrap_or_default()
458        );
459    }
460
461    if !report.passed {
462        return Err(CliError::ValidationFailed(report.summary));
463    }
464
465    contract_post_qa_gate_composition!(&());
466    Ok(())
467}
468
469/// Dispatch a single QA gate: skip if flagged, otherwise run, then print and collect.
470fn dispatch_gate(
471    gates: &mut Vec<GateResult>,
472    json: bool,
473    skip: bool,
474    name: &str,
475    skip_reason: &str,
476    runner: impl FnOnce() -> Result<GateResult>,
477) -> Result<()> {
478    let result = if skip {
479        GateResult::skipped(name, skip_reason)
480    } else {
481        runner()?
482    };
483    if !json {
484        print_gate_result(&result);
485    }
486    gates.push(result);
487    Ok(())
488}
489
490/// Run all QA gates and produce a report
491/// Human-readable gate name for display.
492fn gate_display_name(name: &str) -> &str {
493    match name {
494        "capability_match" => "Capability Match",
495        "tensor_contract" => "Tensor Contract",
496        "golden_output" => "Golden Output",
497        "throughput" => "Throughput",
498        "ollama_parity" => "Ollama Parity",
499        "gpu_speedup" => "GPU Speedup",
500        "format_parity" => "Format Parity",
501        "ptx_parity" => "PTX Parity",
502        "gpu_state_isolation" => "GPU State Isolation",
503        "performance_regression" => "Perf Regression",
504        "metadata_plausibility" => "Metadata Plausibility",
505        "classifier_head" => "Classifier Head",
506        other => other,
507    }
508}
509
510/// Print the QA summary table and pass/fail badges.
511fn print_qa_summary(gates: &[GateResult], passed: bool, total_duration: Duration) {
512    output::header("QA Summary");
513
514    let gate_rows: Vec<Vec<String>> = gates
515        .iter()
516        .map(|g| {
517            let badge = if g.skipped {
518                output::badge_skip("SKIP")
519            } else if g.passed {
520                output::badge_pass("PASS")
521            } else {
522                output::badge_fail("FAIL")
523            };
524            let measured = g.value.map_or("—".to_string(), |v| format!("{v:.2}"));
525            let threshold = g.threshold.map_or("—".to_string(), |v| format!("{v:.2}"));
526            vec![
527                gate_display_name(&g.name).to_string(),
528                badge,
529                measured,
530                threshold,
531                output::duration_fmt(g.duration_ms),
532            ]
533        })
534        .collect();
535    println!(
536        "{}",
537        output::table(
538            &["Gate", "Status", "Measured", "Threshold", "Duration"],
539            &gate_rows,
540        )
541    );
542
543    println!();
544    if passed {
545        println!("  {}", output::badge_pass("ALL GATES PASSED"));
546    } else {
547        println!("  {}", output::badge_fail("GATES FAILED"));
548        for gate in gates.iter().filter(|g| !g.passed && !g.skipped) {
549            println!("    {} {}", "✗".red(), gate.name);
550        }
551    }
552    output::metric(
553        "Total Duration",
554        output::duration_fmt(total_duration.as_millis() as u64),
555        "",
556    );
557}
558
559include!("qa_gguf.rs");
560include!("output_verification.rs");
561include!("golden_output.rs");
562include!("speedup.rs");
563include!("forward_error.rs");
564include!("gpu_isolation_result.rs");
565include!("qa_08.rs");