Skip to main content

rusty_stack/
component_status.rs

1use crate::benchmark_logs;
2use crate::state::{Category, Component};
3use std::collections::HashSet;
4use std::env;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8
9#[derive(Debug, Clone)]
10pub struct VerificationCommand {
11    pub label: String,
12    pub target_id: String,
13    pub program: String,
14    pub args: Vec<String>,
15    pub modules: Vec<String>,
16}
17
18pub fn python_interpreters() -> Vec<String> {
19    let mut candidates = Vec::new();
20    if let Ok(value) = env::var("MLSTACK_PYTHON_BIN") {
21        push_python_candidate(&mut candidates, value);
22    }
23    if let Ok(value) = env::var("AITER_VENV_PYTHON") {
24        push_python_candidate(&mut candidates, value);
25    }
26    if let Ok(value) = env::var("UV_PYTHON") {
27        push_python_candidate(&mut candidates, value);
28    }
29    for key in [
30        "WANDB_VENV_PYTHON",
31        "MEGATRON_VENV_PYTHON",
32        "MPI4PY_VENV_PYTHON",
33        "FLASH_ATTENTION_VENV_PYTHON",
34        "PYTORCH_VENV_PYTHON",
35        "DEEPSPEED_VENV_PYTHON",
36        "VLLM_VENV_PYTHON",
37    ] {
38        if let Ok(value) = env::var(key) {
39            push_python_candidate(&mut candidates, value);
40        }
41    }
42    let home = resolve_component_user_home();
43    let venv = Path::new(&home)
44        .join("rocm_venv")
45        .join("bin")
46        .join("python");
47    if venv.exists() {
48        candidates.push(venv.to_string_lossy().to_string());
49    }
50
51    // Anaconda/Miniconda candidates
52    let conda_path = Path::new(&home)
53        .join("anaconda3")
54        .join("bin")
55        .join("python");
56    if conda_path.exists() {
57        candidates.push(conda_path.to_string_lossy().to_string());
58    }
59    let miniconda_path = Path::new(&home)
60        .join("miniconda3")
61        .join("bin")
62        .join("python");
63    if miniconda_path.exists() {
64        candidates.push(miniconda_path.to_string_lossy().to_string());
65    }
66
67    if Path::new("/usr/local/bin/python3").exists() {
68        candidates.push("/usr/local/bin/python3".to_string());
69    }
70    if command_exists("python3.13") {
71        candidates.push("python3.13".to_string());
72    }
73    if command_exists("python3.12") {
74        candidates.push("python3.12".to_string());
75    }
76    if command_exists("python3") {
77        candidates.push("python3".to_string());
78    }
79    if command_exists("python") {
80        candidates.push("python".to_string());
81    }
82
83    let mut roots = vec![PathBuf::from(&home)];
84    if let Ok(cwd) = env::current_dir() {
85        roots.push(cwd.clone());
86        roots.push(cwd.join("rusty-stack"));
87    }
88    for root in roots {
89        push_component_venv_candidates(&mut candidates, &root);
90    }
91
92    dedupe_python_candidates_in_order(candidates)
93}
94
95fn dedupe_python_candidates_in_order(candidates: Vec<String>) -> Vec<String> {
96    let mut seen = HashSet::new();
97    let mut ordered = Vec::new();
98    for candidate in candidates {
99        if seen.insert(candidate.clone()) {
100            ordered.push(candidate);
101        }
102    }
103    ordered
104}
105
106fn push_component_venv_candidates(candidates: &mut Vec<String>, root: &Path) {
107    for rel in [
108        "wandb_venv/bin/python",
109        "megatron_rocm_venv/bin/python",
110        "Megatron-LM/megatron_rocm_venv/bin/python",
111        "mpi4py_venv/bin/python",
112        "flash_attention_venv/bin/python",
113        "vllm_venv/bin/python",
114        ".mlstack/venvs/aiter/bin/python",
115        ".mlstack/venvs/vllm/bin/python",
116        "pytorch_rocm_venv/bin/python",
117    ] {
118        let candidate = root.join(rel);
119        if candidate.exists() {
120            candidates.push(candidate.to_string_lossy().to_string());
121        }
122    }
123}
124
125fn push_python_candidate(candidates: &mut Vec<String>, value: String) {
126    let trimmed = value.trim();
127    if trimmed.is_empty() {
128        return;
129    }
130    let candidate = trimmed.to_string();
131    if Path::new(&candidate).exists() || command_exists(&candidate) {
132        candidates.push(candidate);
133    }
134}
135
136pub fn is_component_installed(component: &Component, python_candidates: &[String]) -> bool {
137    if component.category == Category::Maintenance {
138        return false;
139    }
140    is_component_installed_by_id(&component.id, python_candidates)
141}
142
143pub fn is_component_installed_by_id(component_id: &str, python_candidates: &[String]) -> bool {
144    let home = resolve_component_user_home();
145    let has_benchmark_logs = has_benchmark_log_dirs();
146    match component_id {
147        // ROCm requires BOTH version file AND functional rocminfo (not just binary existence)
148        // This prevents false positives from partial downloads
149        "rocm" => {
150            // Check for version file in multiple locations
151            let version_exists = path_exists("/opt/rocm/.info/version")
152                || path_exists("/opt/rocm/version")
153                || path_exists("/usr/lib/rocm/.info/version");
154
155            // Check if rocminfo actually works (not just exists)
156            let rocminfo_path = crate::installers::common::utils::resolve_rocminfo_path();
157            let rocminfo_functional = if let Ok(output) = std::process::Command::new(&rocminfo_path)
158                .arg("--version")
159                .output()
160            {
161                output.status.success()
162            } else {
163                false
164            };
165
166            // Require both: version file AND functional rocminfo
167            // This ensures we don't report partial installs as complete
168            version_exists && rocminfo_functional
169        }
170        "pytorch" => python_any(python_candidates, &["torch"]),
171        "triton" => python_any(python_candidates, &["triton"]),
172        "mpi4py" => python_any(python_candidates, &["mpi4py"]),
173        "deepspeed" => python_any(python_candidates, &["deepspeed"]),
174        "ml-stack-core" => {
175            python_any(python_candidates, &["stans_ml_stack"]) || repo_has_ml_stack_core()
176        }
177        // Canonical "flash-attn" id (used by verify/migrate): installed if the
178        // flash_attn package is importable under ANY backend. The concrete TUI
179        // components (flash-attn-triton / flash-attn-ck) below use the stricter
180        // backend-marker check.
181        "flash-attn" => python_any(python_candidates, &["flash_attn", "flash_attn_2"]),
182        // Flash Attention backends install the SAME `flash_attn` package, so
183        // importability alone can't tell them apart. "installed" requires the
184        // module importable AND the backend marker
185        // (~/.mlstack/flash-attention/.backend) to match — this is what stops a
186        // leftover build dir or a manual/unknown install from false-reporting as
187        // installed. Only the last-installed backend (the marker value) reports
188        // installed (mutual exclusivity).
189        "flash-attn-triton" => {
190            python_any(python_candidates, &["flash_attn", "flash_attn_2"])
191                && backend_marker_is("triton")
192        }
193        "flash-attn-ck" => {
194            python_any(python_candidates, &["flash_attn", "flash_attn_2"])
195                && backend_marker_is("ck")
196        }
197        "megatron" => {
198            path_exists(home_path(&home, &["megatron", "Megatron-LM"]))
199                || python_any(python_candidates, &["megatron"])
200        }
201        "vllm" => {
202            python_candidates
203                .iter()
204                .any(|python| python_exec(python, vllm_runtime_check_snippet()))
205                || path_exists(home_path(&home, &["vllm_build"]))
206                || path_exists(home_path(&home, &["vllm_py313"]))
207        }
208        "aiter" => python_any(python_candidates, &["aiter"]),
209        "vllm-studio" => {
210            command_exists("vllm-studio") || path_exists(home_path(&home, &["vllm-studio"]))
211        }
212        "onnx" => {
213            python_candidates
214                .iter()
215                .any(|python| python_exec(python, onnxruntime_present_snippet()))
216                || path_exists(home_path(&home, &["onnxruntime_build"]))
217        }
218        "bitsandbytes" => {
219            python_any(python_candidates, &["bitsandbytes"])
220                || path_exists(home_path(&home, &["ml_stack", "bitsandbytes"]))
221        }
222        // rocm-smi - check multiple locations and verify it works
223        "rocm-smi" => {
224            let rocm_smi_paths = [
225                "/opt/rocm/bin/rocm-smi",
226                "/usr/bin/rocm-smi",
227                "/usr/local/bin/rocm-smi",
228            ];
229            for path in &rocm_smi_paths {
230                if path_exists(path) {
231                    // Verify it actually works
232                    if let Ok(output) = std::process::Command::new(path).arg("--showuse").output() {
233                        if output.status.success() {
234                            return true;
235                        }
236                    }
237                }
238            }
239            // Also check if rocm-smi is in PATH and works
240            if command_exists("rocm-smi") {
241                if let Ok(output) = std::process::Command::new("rocm-smi")
242                    .arg("--showuse")
243                    .output()
244                {
245                    return output.status.success();
246                }
247            }
248            false
249        }
250        "migraphx" => {
251            // Python bindings (Debian/ROCm image) OR the C++ driver (Arch/CachyOS,
252            // where pip `migraphx` is unavailable). Functional only — a driver that
253            // can't load its shared libs (libprotobuf/libabsl SONAME desync from a
254            // partial upgrade) is NOT installed. The old `migraphx_build` dir check
255            // was a false-positive surface (leftover build dir ≠ functional), removed.
256            python_any(python_candidates, &["migraphx"])
257                || crate::installers::components::migraphx_multi::migraphx_driver_functional()
258        }
259        "pytorch-profiler" => python_any(python_candidates, &["torch"]),
260        "wandb" => python_any(python_candidates, &["wandb"]),
261        "fastvideo" => python_candidates.iter().any(|python| {
262            python_exec(
263                python,
264                crate::installers::components::fastvideo::fastvideo_verification_snippet(),
265            )
266        }),
267        "llama-cpp" => {
268            // Use the detection contract from llama_cpp.rs: llama-cli --help must succeed
269            crate::installers::components::llama_cpp::is_llama_cli_functional(&home)
270        }
271        "permanent-env" => env_file_has_permanent(&home_path(&home, &[".mlstack_env"])),
272        "basic-env" => path_exists(home_path(&home, &[".mlstack_env"])),
273        "enhanced-env" => env_file_has_enhanced(&home_path(&home, &[".mlstack_env"])),
274        "vllm-performance" => has_benchmark_logs,
275        "deepspeed-performance" => has_benchmark_logs,
276        "megatron-performance" => has_benchmark_logs,
277        "all-benchmarks" => has_benchmark_logs,
278        "comfyui" => {
279            // Check for ComfyUI installation
280            // Default location: $HOME/ComfyUI
281            // User location: /mnt/e0f7c1a8-b834-4827-b579-0251b006bc1f/ComfyUI/
282            // Check for .git folder and main.py
283            let default_path = home_path(&home, &["ComfyUI"]);
284            path_exists(default_path.join(".git")) || path_exists(default_path.join("main.py"))
285        }
286        "textgen" => {
287            // Check for text-generation-webui installation
288            // Default location: $HOME/text-generation-webui
289            let default_path = home_path(&home, &["text-generation-webui"]);
290            path_exists(default_path.join(".git")) || path_exists(default_path.join("server.py"))
291        }
292        _ => false,
293    }
294}
295
296pub fn verification_commands(
297    component_id: &str,
298    python_candidates: &[String],
299) -> Vec<VerificationCommand> {
300    match component_id {
301        "verify-basic" => basic_verification_commands(python_candidates),
302        "verify-enhanced" => enhanced_verification_commands(python_candidates),
303        "verify-build" => build_verification_commands(python_candidates),
304        _ => Vec::new(),
305    }
306}
307
308pub fn component_verification_commands(
309    component_id: &str,
310    python_candidates: &[String],
311) -> Vec<VerificationCommand> {
312    match component_id {
313        "rocm" => vec![shell_command("ROCm info", "rocm", "rocminfo", &[])],
314        "pytorch" => vec![python_command(
315            "PyTorch",
316            "pytorch",
317            &["torch"],
318            python_candidates,
319            pytorch_diagnostic_snippet(),
320        )],
321        "triton" => vec![python_command(
322            "Triton",
323            "triton",
324            &["triton"],
325            python_candidates,
326            "import triton,sys; import triton.language as tl; import importlib; ok=False\nfor _m in ('triton.runtime.driver','triton.runtime.backends.amd','triton.backends.amd'):\n    try:\n        importlib.import_module(_m); ok=True; break\n    except Exception:\n        pass\nprint(f'Triton {triton.__version__} backend_loaded={ok}'); sys.exit(0 if ok else 1)",
327        )],
328        "mpi4py" => vec![python_command(
329            "MPI4Py",
330            "mpi4py",
331            &["mpi4py"],
332            python_candidates,
333            "from mpi4py import MPI; import sys\ntry:\n    rank=MPI.COMM_WORLD.Get_rank(); ok=rank>=0\nexcept Exception:\n    rank=-1; ok=False\nprint(f'MPI4Py rank={rank} ok={ok}'); sys.exit(0 if ok else 1)",
334        )],
335        "deepspeed" => vec![python_command(
336            "DeepSpeed",
337            "deepspeed",
338            &["deepspeed"],
339            python_candidates,
340            "import deepspeed,sys; ok=False\ntry:\n    from deepspeed.ops.adam import DeepSpeedCPUAdam; ok=True\nexcept Exception:\n    pass\nprint(f'DeepSpeed {deepspeed.__version__} ops_loaded={ok}'); sys.exit(0 if ok else 1)",
341        )],
342        "ml-stack-core" => vec![python_command(
343            "ML Stack Core",
344            "ml-stack-core",
345            &["stans_ml_stack"],
346            python_candidates,
347            "import stans_ml_stack,sys; import importlib; ok=True\nfor _s in ('core','utils','cli','installers'):\n    try:\n        importlib.import_module(f'stans_ml_stack.{_s}')\n    except Exception:\n        ok=False; break\nver=getattr(stans_ml_stack,'__version__','ok')\nprint(f'ML Stack Core {ver} submodule_loaded={ok}'); sys.exit(0 if ok else 1)",
348        )],
349        // Canonical id (verify/migrate): flash_attn importable under ANY backend.
350        "flash-attn" => vec![flash_attention_verification_command(
351            "Flash Attention",
352            "flash-attn",
353            None,
354            python_candidates,
355        )],
356        // Split TUI ids: require the backend marker to match, so a successful
357        // install verifies + a stale/manual one does not.
358        "flash-attn-triton" => vec![flash_attention_verification_command(
359            "Flash Attention (Triton)",
360            "flash-attn-triton",
361            Some("triton"),
362            python_candidates,
363        )],
364        "flash-attn-ck" => vec![flash_attention_verification_command(
365            "Flash Attention (CK)",
366            "flash-attn-ck",
367            Some("ck"),
368            python_candidates,
369        )],
370        "megatron" => vec![python_command(
371            "Megatron-LM",
372            "megatron",
373            &["megatron"],
374            python_candidates,
375            "import megatron,sys; ok=False\ntry:\n    from megatron.core import tensor_parallel; ok=True\nexcept Exception:\n    pass\nprint(f'Megatron-LM functional={ok}'); sys.exit(0 if ok else 1)",
376        )],
377        "vllm" => vec![python_command(
378            "vLLM",
379            "vllm",
380            &["vllm"],
381            python_candidates,
382            vllm_runtime_check_snippet(),
383        )],
384        "aiter" => vec![python_command(
385            "AITER",
386            "aiter",
387            &["aiter"],
388            python_candidates,
389            "import aiter,sys; ok=False\ntry:\n    from aiter import op as _op; ok=True\nexcept Exception:\n    pass\nmods=[x for x in dir(aiter) if not x.startswith('_')]\nver=getattr(aiter,'__version__','?')\nprint(f'AITER {ver} op_loaded={ok} exports={len(mods)}'); sys.exit(0 if ok else 1)",
390        )],
391        "vllm-studio" => vec![shell_command(
392            "vLLM Studio",
393            "vllm-studio",
394            "bun",
395            &["--version"],
396        )],
397        "onnx" => vec![python_command(
398            "ONNX Runtime",
399            "onnx",
400            &["onnxruntime"],
401            python_candidates,
402            "import onnxruntime as ort; import sys; print('Version:', ort.__version__); providers=ort.get_available_providers(); print('Providers:', providers); gpu=[p for p in providers if p in ('ROCMExecutionProvider','MIGraphXExecutionProvider','CUDAExecutionProvider')]; sys.exit(0 if gpu else 1)",
403        )],
404        "bitsandbytes" => vec![python_command(
405            "BITSANDBYTES",
406            "bitsandbytes",
407            &["bitsandbytes"],
408            python_candidates,
409            "import bitsandbytes as bnb; import pathlib; import sys; print('Version:', getattr(bnb, '__version__', 'unknown')); path=pathlib.Path(bnb.__file__).parent; libs=list(path.glob('libbitsandbytes_rocm*.so')); print('ROCm Libs:', libs); sys.exit(0 if libs else 1)",
410        )],
411        "rocm-smi" => vec![shell_command(
412            "ROCm SMI",
413            "rocm-smi",
414            "rocm-smi",
415            &["--showproductname"],
416        )],
417        "migraphx" => vec![python_command(
418            "MIGraphX",
419            "migraphx",
420            &["migraphx"],
421            python_candidates,
422            migraphx_verify_snippet(),
423        )],
424        "pytorch-profiler" => vec![python_command(
425            "PyTorch Profiler",
426            "pytorch-profiler",
427            &["torch"],
428            python_candidates,
429            "from torch.profiler import profile; print(profile)",
430        )],
431        "wandb" => vec![python_command(
432            "Weights & Biases",
433            "wandb",
434            &["wandb"],
435            python_candidates,
436            "import wandb,sys; ok=False\ntry:\n    _r=wandb.init(mode='disabled'); ok=_r is not None; _r.finish()\nexcept Exception:\n    pass\nprint(f'wandb {wandb.__version__} init_run_ok={ok}'); sys.exit(0 if ok else 1)",
437        )],
438        "fastvideo" => vec![python_command(
439            "FastVideo",
440            "fastvideo",
441            &["fastvideo"],
442            python_candidates,
443            crate::installers::components::fastvideo::fastvideo_verification_snippet(),
444        )],
445        "llama-cpp" => vec![shell_command(
446            "llama.cpp",
447            "llama-cpp",
448            "llama-cli",
449            &["--help"],
450        )],
451        "basic-env" => vec![shell_command(
452            "Environment file",
453            "basic-env",
454            "bash",
455            &[
456                "-c",
457                "test -f \"$HOME/.mlstack_env\" && echo OK || exit 1",
458            ],
459        )],
460        "enhanced-env" => vec![shell_command(
461            "Enhanced env",
462            "enhanced-env",
463            "bash",
464            &[
465                "-c",
466                "grep -q \"Enhanced ML Stack Environment Setup Script\" \"$HOME/.mlstack_env\" && echo OK || exit 1",
467            ],
468        )],
469        "permanent-env" => vec![shell_command(
470            "Permanent env",
471            "permanent-env",
472            "bash",
473            &[
474                "-c",
475                "grep -qiE \"Permanent ROCm Environment|Permanent ROCm Env|MLSTACK_PYTHON_BIN\" \"$HOME/.mlstack_env\" && echo OK || exit 1",
476            ],
477        )],
478        // Benchmark components - verify by checking log files exist
479        "mlperf-inference" => vec![benchmark_log_check_command(
480            "MLPerf benchmark logs",
481            "mlperf-inference",
482            "mlperf_inference",
483            "Benchmark logs found",
484            "No benchmark logs yet",
485        )],
486        "rocm-benchmarks" => vec![benchmark_log_check_command(
487            "ROCm benchmark logs",
488            "rocm-benchmarks",
489            "rocm_benchmarks",
490            "Benchmark logs found",
491            "No benchmark logs yet",
492        )],
493        "gpu-memory-bandwidth" => vec![benchmark_log_check_command(
494            "Memory bandwidth logs",
495            "gpu-memory-bandwidth",
496            "gpu_memory_bandwidth",
497            "Benchmark logs found",
498            "No benchmark logs yet",
499        )],
500        "rocm-smi-bench" => vec![benchmark_log_check_command(
501            "ROCm SMI logs",
502            "rocm-smi-bench",
503            "rocm_smi_benchmarks",
504            "Benchmark logs found",
505            "No benchmark logs yet",
506        )],
507        "pytorch-performance" => vec![benchmark_log_check_command(
508            "PyTorch benchmark logs",
509            "pytorch-performance",
510            "pytorch_performance",
511            "Benchmark logs found",
512            "No benchmark logs yet",
513        )],
514        "vllm-performance" => vec![benchmark_log_check_command(
515            "vLLM benchmark logs",
516            "vllm-performance",
517            "vllm_benchmarks",
518            "Benchmark logs found",
519            "No benchmark logs yet",
520        )],
521        "deepspeed-performance" => vec![benchmark_log_check_command(
522            "DeepSpeed benchmark logs",
523            "deepspeed-performance",
524            "deepspeed_benchmarks",
525            "Benchmark logs found",
526            "No benchmark logs yet",
527        )],
528        "megatron-performance" => vec![benchmark_log_check_command(
529            "Megatron benchmark logs",
530            "megatron-performance",
531            "megatron_benchmarks",
532            "Benchmark logs found",
533            "No benchmark logs yet",
534        )],
535        "all-benchmarks" => vec![benchmark_log_check_command(
536            "Full suite logs",
537            "all-benchmarks",
538            "full_benchmarks",
539            "Suite logs found",
540            "No suite logs yet",
541        )],
542        "comfyui" => vec![{
543            // F2: verify against the env's interpreter, not a hardcoded python3.
544            // Resolve the same python the installers pin (MLSTACK_PYTHON_BIN /
545            // UV_PYTHON / first candidate), then import from the install dir.
546            let py = python_candidates
547                .first()
548                .cloned()
549                .or_else(|| env::var("MLSTACK_PYTHON_BIN").ok().filter(|s| !s.is_empty()))
550                .or_else(|| env::var("UV_PYTHON").ok().filter(|s| !s.is_empty()))
551                .unwrap_or_else(|| "python3".to_string());
552            shell_command(
553                "ComfyUI",
554                "comfyui",
555                "bash",
556                &[
557                    "-c",
558                    &format!(
559                        "cd \"$HOME/ComfyUI\" 2>/dev/null && {py} -c 'import folder_paths' 2>/dev/null && echo 'comfyui functional' || exit 1"
560                    ),
561                ],
562            )
563        }],
564        "textgen" => vec![{
565            // F2: verify against the env's interpreter, not a hardcoded python3.
566            let py = python_candidates
567                .first()
568                .cloned()
569                .or_else(|| env::var("MLSTACK_PYTHON_BIN").ok().filter(|s| !s.is_empty()))
570                .or_else(|| env::var("UV_PYTHON").ok().filter(|s| !s.is_empty()))
571                .unwrap_or_else(|| "python3".to_string());
572            shell_command(
573                "text-generation-webui",
574                "textgen",
575                "bash",
576                &[
577                    "-c",
578                    &format!(
579                        "cd \"$HOME/text-generation-webui\" 2>/dev/null && {py} -c 'import server' 2>/dev/null && echo 'textgen functional' || exit 1"
580                    ),
581                ],
582            )
583        }],
584        _ => Vec::new(),
585    }
586}
587
588pub fn modules_available(modules: &[String], python_candidates: &[String]) -> bool {
589    if modules.is_empty() {
590        return true;
591    }
592    let module_refs: Vec<&str> = modules.iter().map(|s| s.as_str()).collect();
593    python_any(python_candidates, &module_refs)
594}
595
596fn basic_verification_commands(python_candidates: &[String]) -> Vec<VerificationCommand> {
597    vec![
598        shell_command("ROCm info", "rocm", "rocminfo", &[]),
599        python_command(
600            "PyTorch",
601            "pytorch",
602            &["torch"],
603            python_candidates,
604            pytorch_diagnostic_snippet(),
605        ),
606        python_command(
607            "Triton",
608            "triton",
609            &["triton"],
610            python_candidates,
611            "import triton; import triton._C; print(f'Triton {triton.__version__} ok')",
612        ),
613        python_command(
614            "MPI4Py",
615            "mpi4py",
616            &["mpi4py"],
617            python_candidates,
618            "from mpi4py import MPI; import sys\ntry:\n    rank=MPI.COMM_WORLD.Get_rank(); ok=rank>=0\nexcept Exception:\n    rank=-1; ok=False\nprint(f'MPI4Py rank={rank} ok={ok}'); sys.exit(0 if ok else 1)",
619        ),
620        python_command(
621            "DeepSpeed",
622            "deepspeed",
623            &["deepspeed", "einops"],
624            python_candidates,
625            "import deepspeed; import einops; print(deepspeed.__version__)",
626        ),
627    ]
628}
629
630fn enhanced_verification_commands(python_candidates: &[String]) -> Vec<VerificationCommand> {
631    let mut steps = basic_verification_commands(python_candidates);
632    steps.extend(vec![
633        flash_attention_verification_command(
634            "Flash Attention",
635            "flash-attn",
636            None,
637            python_candidates,
638        ),
639        python_command(
640            "vLLM",
641            "vllm",
642            &["vllm"],
643            python_candidates,
644            vllm_runtime_check_snippet(),
645        ),
646        python_command(
647            "AITER",
648            "aiter",
649            &["aiter"],
650            python_candidates,
651            "import aiter; print(getattr(aiter, '__version__', 'ok'))",
652        ),
653        python_command(
654            "ONNX Runtime",
655            "onnx",
656            &["onnxruntime"],
657            python_candidates,
658            "import onnxruntime as ort; import pathlib; import os; import sys; print('Version:', ort.__version__); base=pathlib.Path(ort.__file__).parent; libs=list(base.rglob('libonnxruntime_providers_rocm.so')); [os.environ.update({'ORT_ROCM_EP_PROVIDER_PATH': str(l)}) for l in libs[:1]]; providers=ort.get_available_providers(); print('Providers:', providers); sys.exit(0 if 'ROCMExecutionProvider' in providers else 1)",
659        ),
660        python_command(
661            "BITSANDBYTES",
662            "bitsandbytes",
663            &["bitsandbytes"],
664            python_candidates,
665            "import bitsandbytes as bnb; import pathlib; import sys; print('Version:', getattr(bnb, '__version__', 'unknown')); path=pathlib.Path(bnb.__file__).parent; libs=list(path.glob('libbitsandbytes_rocm*.so')); print('ROCm Libs:', libs); sys.exit(0 if libs else 1)",
666        ),
667        shell_command("ROCm SMI", "rocm-smi", "rocm-smi", &["--showproductname"]),
668        python_command(
669            "MIGraphX",
670            "migraphx",
671            &["migraphx"],
672            python_candidates,
673            migraphx_verify_snippet(),
674        ),
675        python_command(
676            "PyTorch Profiler",
677            "pytorch-profiler",
678            &["torch"],
679            python_candidates,
680            "from torch.profiler import profile; print(profile)",
681        ),
682        python_command(
683            "Weights & Biases",
684            "wandb",
685            &["wandb"],
686            python_candidates,
687            "import wandb,sys; ok=False\ntry:\n    _r=wandb.init(mode='disabled'); ok=_r is not None; _r.finish()\nexcept Exception:\n    pass\nprint(f'wandb {wandb.__version__} init_run_ok={ok}'); sys.exit(0 if ok else 1)",
688        ),
689        python_command(
690            "FastVideo",
691            "fastvideo",
692            &["fastvideo"],
693            python_candidates,
694            crate::installers::components::fastvideo::fastvideo_verification_snippet(),
695        ),
696        shell_command("vLLM Studio", "vllm-studio", "bun", &["--version"]),
697    ]);
698    steps
699}
700
701fn build_verification_commands(python_candidates: &[String]) -> Vec<VerificationCommand> {
702    let mut steps = basic_verification_commands(python_candidates);
703    steps.extend(vec![
704        python_command(
705            "ONNX Runtime",
706            "onnx",
707            &["onnxruntime"],
708            python_candidates,
709            "import onnxruntime as ort; import pathlib; import os; import sys; print('Version:', ort.__version__); base=pathlib.Path(ort.__file__).parent; libs=list(base.rglob('libonnxruntime_providers_rocm.so')); [os.environ.update({'ORT_ROCM_EP_PROVIDER_PATH': str(l)}) for l in libs[:1]]; providers=ort.get_available_providers(); print('Providers:', providers); sys.exit(0 if 'ROCMExecutionProvider' in providers else 1)",
710        ),
711        flash_attention_verification_command(
712            "Flash Attention (build)",
713            "flash-attn",
714            None,
715            python_candidates,
716        ),
717        python_command(
718            "MIGraphX",
719            "migraphx",
720            &["migraphx"],
721            python_candidates,
722            migraphx_verify_snippet(),
723        ),
724    ]);
725    steps
726}
727
728fn python_command(
729    label: &str,
730    target_id: &str,
731    modules: &[&str],
732    python_candidates: &[String],
733    code: &str,
734) -> VerificationCommand {
735    let program = select_python_for_modules(modules, python_candidates)
736        .or_else(|| python_candidates.first().cloned())
737        .unwrap_or_else(|| "python3".to_string());
738
739    VerificationCommand {
740        label: label.to_string(),
741        target_id: target_id.to_string(),
742        program,
743        args: vec!["-c".to_string(), code.to_string()],
744        modules: modules.iter().map(|m| m.to_string()).collect(),
745    }
746}
747
748/// PyTorch diagnostic verification snippet.
749///
750/// This performs a multi-tier check:
751/// 1. Can we import torch? (basic install check)
752/// 2. Is torch.version.hip set? (ROCm build check)
753/// 3. Is torch.cuda.is_available()? (runtime HIP check)
754/// 4. Diagnostics for why HIP might not be available
755///
756/// Verification reflects FUNCTIONALITY, not just import: it exits non-zero if
757/// torch is not a ROCm build (`torch.version.hip` is None) OR the HIP runtime
758/// is unavailable (`torch.cuda.is_available()` is False). The diagnostic tiers
759/// print actionable guidance. Whether a pytorch-verify failure should block
760/// sibling components is an orchestrator-layer concern — NOT a reason to lie
761/// about pytorch's status. (Stage 4 tenet: verify functionality, fail loud.)
762fn pytorch_diagnostic_snippet() -> &'static str {
763    r#"import torch, sys, os, ctypes.util
764
765# Tier 1: Basic torch import (already verified by module check)
766print(f'torch version: {torch.__version__}')
767
768# Tier 2: Check if this is a ROCm build
769hip_version = getattr(torch.version, 'hip', None)
770cuda_version = getattr(torch.version, 'cuda', None)
771print(f'hip: {hip_version}')
772print(f'cuda_build: {cuda_version}')
773
774# Tier 3: Runtime HIP/CUDA availability
775try:
776    hip_available = torch.cuda.is_available()
777    # 'cuda' here is AVAILABILITY (CUDA-via-HIP on ROCm), NOT torch.version.cuda
778    # (which is None on a ROCm build — that's the CUDA *toolkit* version torch was
779    # built against, not whether CUDA-style code can run). is_available()==True
780    # means CUDA works through HIP, which is the user-facing answer.
781    print(f'cuda: {hip_available}')
782    if hip_available:
783        device_count = torch.cuda.device_count()
784        print(f'device_count: {device_count}')
785        if device_count > 0:
786            print(f'device_name: {torch.cuda.get_device_name(0)}')
787except Exception as e:
788    hip_available = False
789    print(f'torch.cuda.is_available() error: {e}')
790
791# Tier 4: Diagnostics if HIP not available
792if not hip_available:
793    print('--- ROCm HIP Diagnostics ---')
794
795    # Check LD_LIBRARY_PATH
796    ld_path = os.environ.get('LD_LIBRARY_PATH', '')
797    print(f'LD_LIBRARY_PATH: {ld_path if ld_path else "(not set)"}')
798
799    # Check ROCm_PATH
800    rocm_path = os.environ.get('ROCM_PATH', '')
801    print(f'ROCM_PATH: {rocm_path if rocm_path else "(not set)"}')
802
803    # Check if ROCm libraries exist
804    rocm_lib_dirs = ['/opt/rocm/lib', '/opt/rocm/hip/lib']
805    for d in rocm_lib_dirs:
806        exists = os.path.isdir(d)
807        print(f'{d}: {"exists" if exists else "not found"}')
808
809    # Check if libamdhip64.so can be found
810    hip_lib = ctypes.util.find_library('amdhip64')
811    print(f'libamdhip64: {hip_lib if hip_lib else "not found"}')
812
813    # Check Python executable path
814    print(f'python: {sys.executable}')
815
816    # Provide actionable guidance
817    if hip_version is None and cuda_version is None:
818        print('HINT: torch.version.hip and torch.version.cuda are both None.')
819        print('  This torch build may not have GPU support.')
820        print('  Reinstall with ROCm index: pip install torch --index-url https://download.pytorch.org/whl/rocm7.2/')
821    elif hip_version is not None and not hip_available:
822        print('HINT: torch.version.hip is set but HIP runtime not available.')
823        print('  Possible causes:')
824        print('  1. LD_LIBRARY_PATH does not include /opt/rocm/lib')
825        print('  2. Running in a container/environment without GPU access')
826        print('  3. ROCm drivers not loaded (check with: rocminfo)')
827        print('  Fix: source ~/.mlstack_env or set LD_LIBRARY_PATH=/opt/rocm/lib')
828
829# Stage 4: exit code reflects FUNCTIONAL HIP availability on this ROCm stack.
830# A CPU-only/CUDA torch build, or a ROCm build whose HIP runtime is
831# unreachable, must FAIL verification — not silently pass. The diagnostics
832# above guide the fix; downstream gating belongs in the orchestrator layer,
833# not a lying exit code.
834ok = (hip_version is not None) and hip_available
835sys.exit(0 if ok else 1)"#
836}
837
838fn vllm_runtime_check_snippet() -> &'static str {
839    r#"import importlib
840import vllm
841import cachetools
842import cbor2
843import gguf
844import pybase64
845import ijson
846import mistral_common
847import openai_harmony
848
849loaded = []
850errs = []
851for name in ("vllm._C", "vllm._rocm_C"):
852    try:
853        importlib.import_module(name)
854        loaded.append(name)
855    except Exception as exc:
856        errs.append(f"{name}: {exc}")
857
858if not loaded:
859    raise SystemExit("vLLM native extension load failure: " + " | ".join(errs))
860
861print(vllm.__version__)"#
862}
863
864fn shell_command(
865    label: &str,
866    target_id: &str,
867    program: &str,
868    args: &[&str],
869) -> VerificationCommand {
870    VerificationCommand {
871        label: label.to_string(),
872        target_id: target_id.to_string(),
873        program: program.to_string(),
874        args: args.iter().map(|s| s.to_string()).collect(),
875        modules: Vec::new(),
876    }
877}
878
879fn has_benchmark_log_dirs() -> bool {
880    benchmark_logs::benchmark_log_directories()
881        .iter()
882        .any(|dir| {
883            fs::read_dir(dir)
884                .ok()
885                .map(|entries| {
886                    entries
887                        .filter_map(|entry| entry.ok())
888                        .any(|entry| entry.path().is_file())
889                })
890                .unwrap_or(false)
891        })
892}
893
894fn benchmark_log_check_command(
895    label: &str,
896    target_id: &str,
897    pattern: &str,
898    success_message: &str,
899    failure_message: &str,
900) -> VerificationCommand {
901    let script = benchmark_log_check_script(pattern, success_message, failure_message);
902    shell_command(label, target_id, "bash", &["-c", &script])
903}
904
905fn benchmark_log_check_script(
906    pattern: &str,
907    success_message: &str,
908    failure_message: &str,
909) -> String {
910    format!(
911        "dirs=(\"${{MLSTACK_LOG_DIR:-}}\" \"$HOME/.rusty-stack/logs\" \"${{TMPDIR:-/tmp}}/rusty-stack/logs\")\n\
912         found=0\n\
913         for dir in \"${{dirs[@]}}\"; do\n\
914             if [ -n \"$dir\" ] && [ -d \"$dir\" ]; then\n\
915                 if ls \"$dir\" | grep -q \"{}\"; then\n\
916                     echo \"{}\"\n\
917                     found=1\n\
918                     break\n\
919                 fi\n\
920             fi\n\
921         done\n\
922         if [ \"$found\" -ne 1 ]; then\n\
923             echo \"{}\"\n\
924         fi",
925        pattern, success_message, failure_message
926    )
927}
928
929fn python_any(python_candidates: &[String], modules: &[&str]) -> bool {
930    python_candidates.iter().any(|python| {
931        modules
932            .iter()
933            .any(|module| python_has_module(python, module))
934    })
935}
936
937fn python_has_module(python: &str, module: &str) -> bool {
938    python_exec(
939        python,
940        &format!(
941            "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('{module}') else 1)"
942        ),
943    )
944}
945
946/// Detection snippet for `onnxruntime`: requires a REAL installed package, not
947/// a CWD namespace-package shadow.
948///
949/// `python_has_module`/`find_spec` alone is fooled by a repo-local
950/// `onnxruntime/` source directory (e.g. a clone checked out inside the repo):
951/// it resolves as a namespace package (`find_spec` returns a spec, but
952/// `origin` is `None`), so a bare directory reports "installed" while
953/// `import onnxruntime` yields a hollow module with no `__version__`/providers.
954/// This strips CWD from `sys.path` (so the env package wins) AND requires a
955/// non-`None` `origin` (a real `__init__.py`-backed package). Same footgun
956/// fixed for migraphx in `migraphx_verify_snippet`.
957fn onnxruntime_present_snippet() -> &'static str {
958    "import sys, os, importlib.util; \
959     sys.path = [p for p in sys.path if p and os.path.abspath(p.rstrip('/')) != os.path.abspath(os.getcwd())]; \
960     s = importlib.util.find_spec('onnxruntime'); \
961     sys.exit(0 if (s is not None and s.origin is not None) else 1)"
962}
963
964fn python_exec(python: &str, code: &str) -> bool {
965    let mut cmd = Command::new(python);
966    cmd.arg("-c")
967        .arg(code)
968        .stdout(Stdio::null())
969        .stderr(Stdio::null());
970
971    if let Ok(existing) = env::var("PYTHONPATH") {
972        let mut path = existing;
973        let extra_paths = python_search_paths();
974        if !extra_paths.is_empty() {
975            if !path.is_empty() {
976                path.push(':');
977            }
978            path.push_str(&extra_paths.join(":"));
979        }
980        cmd.env("PYTHONPATH", path);
981    } else {
982        let extra_paths = python_search_paths();
983        if !extra_paths.is_empty() {
984            cmd.env("PYTHONPATH", extra_paths.join(":"));
985        }
986    }
987
988    let home = resolve_component_user_home();
989    let ld_path = component_status_ld_library_path(&home);
990    if !ld_path.is_empty() {
991        cmd.env("LD_LIBRARY_PATH", ld_path);
992    }
993
994    cmd.status().map(|status| status.success()).unwrap_or(false)
995}
996
997fn push_unique_path(paths: &mut Vec<String>, value: &str) {
998    let value = value.trim();
999    if value.is_empty() {
1000        return;
1001    }
1002    if paths.iter().any(|existing| existing == value) {
1003        return;
1004    }
1005    paths.push(value.to_string());
1006}
1007
1008fn component_user_home_from_passwd(user_name: &str) -> Option<String> {
1009    let target = user_name.trim();
1010    if target.is_empty() {
1011        return None;
1012    }
1013    let passwd = fs::read_to_string("/etc/passwd").ok()?;
1014    passwd.lines().find_map(|line| {
1015        if line.trim().is_empty() || line.starts_with('#') {
1016            return None;
1017        }
1018        let fields: Vec<&str> = line.split(':').collect();
1019        if fields.len() < 6 || fields[0] != target {
1020            return None;
1021        }
1022        let home = fields[5].trim();
1023        if home.is_empty() {
1024            None
1025        } else {
1026            Some(home.to_string())
1027        }
1028    })
1029}
1030
1031fn component_passwd_homes_with_mlstack() -> Vec<String> {
1032    let mut homes = Vec::new();
1033    let Ok(passwd) = fs::read_to_string("/etc/passwd") else {
1034        return homes;
1035    };
1036
1037    for line in passwd.lines() {
1038        if line.trim().is_empty() || line.starts_with('#') {
1039            continue;
1040        }
1041        let fields: Vec<&str> = line.split(':').collect();
1042        if fields.len() < 6 {
1043            continue;
1044        }
1045        let home = fields[5].trim();
1046        if home.is_empty() {
1047            continue;
1048        }
1049        if Path::new(home).join(".mlstack").is_dir()
1050            || Path::new(home).join(".mlstack_env").is_file()
1051        {
1052            push_unique_path(&mut homes, home);
1053        }
1054    }
1055
1056    homes
1057}
1058
1059fn component_candidate_homes(preferred_home: &str) -> Vec<String> {
1060    let mut homes = Vec::new();
1061    push_unique_path(&mut homes, preferred_home);
1062
1063    if let Ok(value) = env::var("MLSTACK_USER_HOME") {
1064        push_unique_path(&mut homes, &value);
1065    }
1066    if let Ok(value) = env::var("HOME") {
1067        push_unique_path(&mut homes, &value);
1068    }
1069
1070    for key in ["SUDO_USER", "USER", "LOGNAME"] {
1071        if let Ok(user_name) = env::var(key) {
1072            if let Some(home) = component_user_home_from_passwd(&user_name) {
1073                push_unique_path(&mut homes, &home);
1074            }
1075        }
1076    }
1077
1078    for home in component_passwd_homes_with_mlstack() {
1079        push_unique_path(&mut homes, &home);
1080    }
1081
1082    homes
1083}
1084
1085fn resolve_component_user_home() -> String {
1086    if let Ok(home) = env::var("MLSTACK_USER_HOME") {
1087        return home;
1088    }
1089
1090    let fallback = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
1091    let candidates = component_candidate_homes(&fallback);
1092
1093    for home in &candidates {
1094        if Path::new(home).join(".mlstack_env").is_file() {
1095            return home.clone();
1096        }
1097    }
1098    for home in &candidates {
1099        if Path::new(home).join(".mlstack").is_dir() {
1100            return home.clone();
1101        }
1102    }
1103
1104    candidates.into_iter().next().unwrap_or(fallback)
1105}
1106
1107fn component_status_mpi_compat_dirs(home: &str) -> Vec<String> {
1108    let mut dirs = Vec::new();
1109    for home in component_candidate_homes(home) {
1110        let mlstack_dir = Path::new(&home).join(".mlstack");
1111        let primary = mlstack_dir.join("libmpi-compat");
1112        if primary.is_dir() {
1113            dirs.push(primary.to_string_lossy().to_string());
1114        }
1115
1116        if let Ok(entries) = fs::read_dir(&mlstack_dir) {
1117            for entry in entries.flatten() {
1118                let path = entry.path();
1119                if !path.is_dir() {
1120                    continue;
1121                }
1122                let Some(name) = path.file_name().and_then(|value| value.to_str()) else {
1123                    continue;
1124                };
1125                if name.starts_with("libmpi-compat-user-") {
1126                    dirs.push(path.to_string_lossy().to_string());
1127                }
1128            }
1129        }
1130    }
1131
1132    dirs.sort();
1133    dirs.dedup();
1134    dirs
1135}
1136
1137fn component_status_ld_library_path(home: &str) -> String {
1138    let mut paths = Vec::new();
1139
1140    for dir in component_status_mpi_compat_dirs(home) {
1141        push_unique_path(&mut paths, &dir);
1142    }
1143
1144    for rocm_path in ["/opt/rocm/lib", "/opt/rocm/hip/lib", "/opt/rocm/opencl/lib"] {
1145        if Path::new(rocm_path).exists() {
1146            push_unique_path(&mut paths, rocm_path);
1147        }
1148    }
1149
1150    if let Ok(existing) = env::var("LD_LIBRARY_PATH") {
1151        for part in existing.split(':') {
1152            push_unique_path(&mut paths, part);
1153        }
1154    }
1155
1156    paths.join(":")
1157}
1158
1159fn command_exists(command: &str) -> bool {
1160    Command::new("bash")
1161        .arg("-c")
1162        .arg(format!("command -v {}", command))
1163        .stdout(Stdio::null())
1164        .stderr(Stdio::null())
1165        .status()
1166        .map(|status| status.success())
1167        .unwrap_or(false)
1168}
1169
1170pub fn python_search_paths() -> Vec<String> {
1171    let home = resolve_component_user_home();
1172    let mut paths = vec![
1173        format!("{}/pytorch", home),
1174        format!("{}/.local/lib/python3.13/site-packages", home),
1175        format!("{}/.local/lib/python3.12/site-packages", home),
1176        format!("{}/rocm_venv/lib/python3.13/site-packages", home),
1177        format!("{}/rocm_venv/lib/python3.12/site-packages", home),
1178        format!("{}/megatron/Megatron-LM", home),
1179        format!("{}/ml_stack/bitsandbytes/bitsandbytes", home),
1180    ];
1181
1182    // Dynamically add /opt/rocm/lib/python*/site-packages and /opt/rocm/lib
1183    let rocm_lib = Path::new("/opt/rocm/lib");
1184    if rocm_lib.exists() {
1185        paths.push(rocm_lib.to_string_lossy().to_string());
1186        if let Ok(entries) = fs::read_dir(rocm_lib) {
1187            for entry in entries.flatten() {
1188                let path = entry.path();
1189                if path.is_dir() {
1190                    if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1191                        if name.starts_with("python") {
1192                            let sp = path.join("site-packages");
1193                            if sp.exists() {
1194                                paths.push(sp.to_string_lossy().to_string());
1195                            }
1196                        }
1197                    }
1198                }
1199            }
1200        }
1201    }
1202
1203    paths
1204}
1205
1206fn select_python_for_modules(modules: &[&str], python_candidates: &[String]) -> Option<String> {
1207    for key in ["MLSTACK_PYTHON_BIN", "UV_PYTHON"] {
1208        if let Ok(value) = env::var(key) {
1209            let value = value.trim();
1210            if !value.is_empty() {
1211                if modules.is_empty() {
1212                    return Some(value.to_string());
1213                }
1214                for module in modules {
1215                    if python_has_module(value, module) {
1216                        return Some(value.to_string());
1217                    }
1218                }
1219            }
1220        }
1221    }
1222
1223    for python in python_candidates {
1224        for module in modules {
1225            if python_has_module(python, module) {
1226                return Some(python.clone());
1227            }
1228        }
1229    }
1230    None
1231}
1232
1233fn path_exists<P: AsRef<Path>>(path: P) -> bool {
1234    path.as_ref().exists()
1235}
1236
1237fn home_path(home: &str, parts: &[&str]) -> PathBuf {
1238    let mut path = PathBuf::from(home);
1239    for part in parts {
1240        path.push(part);
1241    }
1242    path
1243}
1244
1245/// Read the active Flash Attention backend marker
1246/// (`~/.mlstack/flash-attention/.backend`) and return `true` iff it equals
1247/// `name`. The installer writes this file on success; detection requires it so
1248/// that a leftover build directory or a manual/unknown install can NEVER
1249/// false-report Flash Attention as "installed".
1250fn backend_marker_is(name: &str) -> bool {
1251    let home = resolve_component_user_home();
1252    let marker = home_path(&home, &[".mlstack", "flash-attention", ".backend"]);
1253    std::fs::read_to_string(&marker)
1254        .ok()
1255        .map(|s| s.trim() == name)
1256        .unwrap_or(false)
1257}
1258
1259/// Multi-modal MIGraphX verification snippet (run via `python -c`).
1260///
1261/// Two hardening fixes over the naive `import migraphx; hasattr(parse_onnx)`:
1262///
1263/// 1. **CWD excluded from `sys.path`** — otherwise a repo-local `migraphx`
1264///    stub (e.g. `core/migraphx/migraphx/__init__.py`) shadows a real install
1265///    when rusty-stack runs from the repo root, importing cleanly but without
1266///    `parse_onnx`, producing a false `parse_onnx=False` failure.
1267/// 2. **Driver fallback on *any* incomplete python result** — the previous
1268///    logic only fell back to `migraphx-driver --version` on `ImportError`. A
1269///    stub that imports without `parse_onnx` never reached the fallback. Now
1270///    the C++ driver is tried whenever the python bindings are missing OR
1271///    incomplete. On Arch/CachyOS (SystemOnly support: no pip `migraphx`
1272///    wheel) the driver IS the install target, so a clean driver load = pass.
1273///    This still catches a broken shared-library state (SONAME desync): the
1274///    driver won't load, so the check can never false-pass.
1275fn migraphx_verify_snippet() -> &'static str {
1276    r#"
1277import os, sys, subprocess
1278# (1) Drop CWD / '' entries so a repo-local migraphx stub can't shadow a real install.
1279sys.path = [p for p in sys.path if p and os.path.abspath(p.rstrip('/')) != os.path.abspath(os.getcwd())]
1280ok = False
1281ver = 'ok'
1282via = 'none'
1283try:
1284    import migraphx
1285    if hasattr(migraphx, 'parse_onnx'):
1286        ok = True
1287        via = 'python'
1288        ver = getattr(migraphx, '__version__', 'ok')
1289    else:
1290        ver = getattr(migraphx, '__version__', 'stub')
1291except ImportError:
1292    ver = 'absent'
1293# (2) Fall through to the C++ driver whenever python bindings are missing OR
1294#     incomplete (no parse_onnx). On Arch/CachyOS the driver is the install target.
1295if not ok:
1296    r = subprocess.run(['/opt/rocm/bin/migraphx-driver', '--version'], capture_output=True)
1297    if r.returncode == 0:
1298        ok = True
1299        via = 'driver'
1300        ver = 'driver'
1301print(f'MIGraphX {ver} parse_onnx={ok} via={via}')
1302sys.exit(0 if ok else 1)
1303"#
1304}
1305
1306/// Flash Attention verification command (run via `python -c`).
1307///
1308/// Verifies `flash_attn` importability (the hard minimum — both ROCm backends
1309/// install the same `flash_attn` package). When `expected_backend` is `Some`,
1310/// ALSO requires the `~/.mlstack/flash-attention/.backend` marker to match, so a
1311/// successful `flash-attn-triton`/`flash-attn-ck` install reaches final success
1312/// while a stale or manual install cannot. Never accepts the legacy
1313/// `flash_attention_amd` module — detection dropped it, so verification must too.
1314fn flash_attention_verification_command(
1315    label: &str,
1316    target_id: &str,
1317    expected_backend: Option<&str>,
1318    python_candidates: &[String],
1319) -> VerificationCommand {
1320    let marker_block = match expected_backend {
1321        Some(name) => format!(
1322            "home=pathlib.Path(os.environ.get('HOME',''))\nmarker=home/'.mlstack'/'flash-attention'/'.backend'\nif not marker.exists() or marker.read_text().strip()!='{name}':\n    print(f'backend marker mismatch (expected {name})');sys.exit(1)\n"
1323        ),
1324        None => String::new(),
1325    };
1326    let code = format!(
1327        "import importlib,sys,os,pathlib\nm=importlib.import_module('flash_attn')\nver=getattr(m,'__version__','unknown')\nhas_func=hasattr(m,'flash_attn_func') or hasattr(m,'flash_attn_qkvpacked_func')\n{marker_block}print(f'flash_attn {{ver}} func={{has_func}}')"
1328    );
1329    python_command(label, target_id, &["flash_attn"], python_candidates, &code)
1330}
1331
1332fn env_file_has_enhanced(path: &Path) -> bool {
1333    if let Ok(contents) = fs::read_to_string(path) {
1334        return contents.contains("Enhanced ML Stack Environment Setup Script");
1335    }
1336    false
1337}
1338
1339fn env_file_has_permanent(path: &Path) -> bool {
1340    if let Ok(contents) = fs::read_to_string(path) {
1341        let has_marker = contents.contains("Permanent ROCm Environment")
1342            || contents.contains("Permanent ROCm Env")
1343            || (contents.contains("ML Stack Environment File")
1344                && contents.contains("MLSTACK_PYTHON_BIN"));
1345        if !has_marker {
1346            return false;
1347        }
1348        // Reject gfx000 — invalid placeholder arch
1349        // Validate arch pattern: gfx(9|10|11|12)NN
1350        let valid_arch_prefixes = ["gfx9", "gfx10", "gfx11", "gfx12"];
1351        let mut has_gpu_arch_line = false;
1352        for line in contents.lines() {
1353            let trimmed = line.trim();
1354            // The env file is a sourced shell script, so assignments carry an
1355            // `export ` prefix (e.g. `export GPU_ARCH=gfx1100`). Strip it before
1356            // matching, or the check never finds the arch line and a perfectly
1357            // valid env reports "not configured". Note `GPU_ARCHS=` / other
1358            // `*_ARCH=` lines do NOT start with `GPU_ARCH=` and are correctly
1359            // ignored.
1360            let assign = trimmed.strip_prefix("export ").unwrap_or(trimmed);
1361            if assign.starts_with("GPU_ARCH=") {
1362                has_gpu_arch_line = true;
1363                if let Some(rest) = assign.split("GPU_ARCH=").nth(1) {
1364                    let arch = rest.split_whitespace().next().unwrap_or(rest);
1365                    if arch == "gfx000" {
1366                        return false;
1367                    }
1368                    // Check if arch starts with a valid prefix
1369                    if !valid_arch_prefixes
1370                        .iter()
1371                        .any(|prefix| arch.starts_with(prefix))
1372                    {
1373                        return false;
1374                    }
1375                }
1376            }
1377        }
1378        // Require a GPU_ARCH= line to be present
1379        has_gpu_arch_line
1380    } else {
1381        false
1382    }
1383}
1384
1385fn repo_has_ml_stack_core() -> bool {
1386    if let Ok(root) = env::var("MLSTACK_REPO_ROOT") {
1387        return path_exists(Path::new(&root).join("stans_ml_stack"));
1388    }
1389    false
1390}
1391
1392// ===========================================================================
1393// Tests
1394// ===========================================================================
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::*;
1399
1400    #[test]
1401    fn test_fastvideo_verification_uses_shared_compiled_kernel_smoke_test() {
1402        let candidates = vec!["python3".to_string()];
1403        let commands = component_verification_commands("fastvideo", &candidates);
1404        assert_eq!(commands.len(), 1);
1405
1406        let command = &commands[0];
1407        assert_eq!(command.label, "FastVideo");
1408        assert_eq!(command.target_id, "fastvideo");
1409        assert_eq!(command.modules, vec!["fastvideo".to_string()]);
1410        assert_eq!(
1411            command.args,
1412            vec![
1413                "-c".to_string(),
1414                crate::installers::components::fastvideo::fastvideo_verification_snippet()
1415                    .to_string(),
1416            ]
1417        );
1418
1419        let snippet = &command.args[1];
1420        for anchor in [
1421            "from fastvideo_kernel import int8_quant",
1422            "range(int(discovered.stdout.strip()))",
1423            "int8_quant(source)",
1424            "torch.cuda.synchronize()",
1425            "raise SystemExit",
1426        ] {
1427            assert!(
1428                snippet.contains(anchor),
1429                "missing verification anchor: {anchor}"
1430            );
1431        }
1432
1433        let enhanced = enhanced_verification_commands(&candidates);
1434        let enhanced_fastvideo: Vec<_> = enhanced
1435            .iter()
1436            .filter(|candidate| candidate.target_id == "fastvideo")
1437            .collect();
1438        assert_eq!(enhanced_fastvideo.len(), 1);
1439        assert_eq!(
1440            enhanced_fastvideo[0].args[1],
1441            crate::installers::components::fastvideo::fastvideo_verification_snippet()
1442        );
1443        assert_eq!(enhanced_fastvideo[0].modules, vec!["fastvideo".to_string()]);
1444    }
1445
1446    #[test]
1447    fn test_fastvideo_installation_status_requires_compiled_kernel_smoke_test() {
1448        use std::os::unix::fs::PermissionsExt;
1449        use std::time::{SystemTime, UNIX_EPOCH};
1450
1451        let unique = SystemTime::now()
1452            .duration_since(UNIX_EPOCH)
1453            .expect("system clock must be after Unix epoch")
1454            .as_nanos();
1455        let temp_dir = env::temp_dir().join(format!(
1456            "rusty-stack-fastvideo-status-{}-{unique}",
1457            std::process::id()
1458        ));
1459        fs::create_dir_all(&temp_dir).expect("create temporary FastVideo status test directory");
1460
1461        let import_only = temp_dir.join("import-only-python");
1462        fs::write(
1463            &import_only,
1464            "#!/bin/sh\ncase \"$2\" in\n  *\"find_spec\"*\"fastvideo\"*) exit 0 ;;\n  *) exit 1 ;;\nesac\n",
1465        )
1466        .expect("write import-only fake Python");
1467        let mut permissions = fs::metadata(&import_only)
1468            .expect("stat import-only fake Python")
1469            .permissions();
1470        permissions.set_mode(0o755);
1471        fs::set_permissions(&import_only, permissions)
1472            .expect("make import-only fake Python executable");
1473
1474        assert!(
1475            !is_component_installed_by_id(
1476                "fastvideo",
1477                &[import_only.to_string_lossy().into_owned()]
1478            ),
1479            "an import spec alone must not report FastVideo installed"
1480        );
1481
1482        let functional = temp_dir.join("functional-python");
1483        fs::write(
1484            &functional,
1485            "#!/bin/sh\ncase \"$2\" in\n  *\"import fastvideo\"*\"from fastvideo_kernel import int8_quant\"*\"int8_quant(source)\"*) exit 0 ;;\n  *) exit 1 ;;\nesac\n",
1486        )
1487        .expect("write functional fake Python");
1488        let mut permissions = fs::metadata(&functional)
1489            .expect("stat functional fake Python")
1490            .permissions();
1491        permissions.set_mode(0o755);
1492        fs::set_permissions(&functional, permissions)
1493            .expect("make functional fake Python executable");
1494
1495        assert!(
1496            is_component_installed_by_id("fastvideo", &[functional.to_string_lossy().into_owned()]),
1497            "the shared compiled-kernel smoke must report FastVideo installed"
1498        );
1499
1500        fs::remove_dir_all(&temp_dir).expect("remove temporary FastVideo status test directory");
1501    }
1502
1503    #[test]
1504    fn test_pytorch_diagnostic_snippet_exits_strict() {
1505        // Stage 4: the snippet must FAIL (exit 1) when torch is not a ROCm build
1506        // or the HIP runtime is unavailable — it must NOT soft-pass with an
1507        // unconditional exit 0. Downstream gating is an orchestrator concern.
1508        let snippet = pytorch_diagnostic_snippet();
1509        assert!(
1510            snippet.contains("(hip_version is not None) and hip_available"),
1511            "Should gate the exit on functional HIP availability"
1512        );
1513        assert!(
1514            snippet.contains("sys.exit(0 if ok else 1)"),
1515            "Should exit non-zero when HIP is unavailable (fail loud)"
1516        );
1517        assert!(
1518            !snippet.contains("\nsys.exit(0)\n"),
1519            "Should NOT have an unconditional soft-pass sys.exit(0)"
1520        );
1521    }
1522
1523    #[test]
1524    fn test_pytorch_diagnostic_snippet_checks_hip() {
1525        // The snippet should check for HIP availability to provide diagnostics
1526        let snippet = pytorch_diagnostic_snippet();
1527        assert!(
1528            snippet.contains("torch.version"),
1529            "Should check torch.version for HIP info"
1530        );
1531        assert!(
1532            snippet.contains("torch.cuda.is_available()"),
1533            "Should check torch.cuda.is_available() for runtime HIP"
1534        );
1535    }
1536
1537    #[test]
1538    fn test_pytorch_diagnostic_snippet_provides_hints() {
1539        // The snippet should provide actionable hints when HIP is not available
1540        let snippet = pytorch_diagnostic_snippet();
1541        assert!(
1542            snippet.contains("HINT:"),
1543            "Should provide HINT messages for troubleshooting"
1544        );
1545        assert!(
1546            snippet.contains("LD_LIBRARY_PATH"),
1547            "Should mention LD_LIBRARY_PATH as a possible cause"
1548        );
1549        assert!(
1550            snippet.contains("/opt/rocm/lib"),
1551            "Should mention ROCm lib path"
1552        );
1553    }
1554
1555    #[test]
1556    fn test_pytorch_diagnostic_snippet_checks_rocm_libs() {
1557        // The snippet should check for ROCm library existence
1558        let snippet = pytorch_diagnostic_snippet();
1559        assert!(
1560            snippet.contains("libamdhip64"),
1561            "Should check for libamdhip64 (core HIP library)"
1562        );
1563        assert!(
1564            snippet.contains("/opt/rocm/lib"),
1565            "Should check /opt/rocm/lib directory"
1566        );
1567    }
1568
1569    #[test]
1570    fn test_pytorch_component_verification_uses_diagnostic() {
1571        // Verify that the pytorch component verification command uses
1572        // the diagnostic snippet (not the old strict check)
1573        let candidates = vec!["python3".to_string()];
1574        let cmds = component_verification_commands("pytorch", &candidates);
1575        assert_eq!(
1576            cmds.len(),
1577            1,
1578            "Should have exactly one verification command"
1579        );
1580        let cmd = &cmds[0];
1581        assert_eq!(cmd.label, "PyTorch");
1582        assert_eq!(cmd.target_id, "pytorch");
1583        // Stage 4: must use the strict FUNCTIONAL check — fail when torch has
1584        // no ROCm build or the HIP runtime is unavailable (no soft-pass).
1585        let empty = String::new();
1586        let code = cmd.args.get(1).unwrap_or(&empty);
1587        assert!(
1588            code.contains("(hip_version is not None) and hip_available"),
1589            "Should gate the exit on functional HIP availability (Stage 4)"
1590        );
1591        assert!(
1592            code.contains("sys.exit(0 if ok else 1)"),
1593            "Should exit non-zero when HIP is unavailable (Stage 4: fail loud)"
1594        );
1595    }
1596
1597    #[test]
1598    fn test_pytorch_basic_verification_uses_diagnostic() {
1599        // Verify that basic verification also uses the strict functional check
1600        let candidates = vec!["python3".to_string()];
1601        let cmds = basic_verification_commands(&candidates);
1602        let pytorch_cmd = cmds.iter().find(|c| c.target_id == "pytorch");
1603        assert!(
1604            pytorch_cmd.is_some(),
1605            "Should have a PyTorch verification command"
1606        );
1607        let cmd = pytorch_cmd.unwrap();
1608        let empty = String::new();
1609        let code = cmd.args.get(1).unwrap_or(&empty);
1610        assert!(
1611            code.contains("(hip_version is not None) and hip_available"),
1612            "Basic verification should gate exit on functional HIP (Stage 4)"
1613        );
1614        assert!(
1615            code.contains("sys.exit(0 if ok else 1)"),
1616            "Basic verification should fail loud when HIP unavailable (Stage 4)"
1617        );
1618    }
1619
1620    #[test]
1621    fn test_pytorch_installation_check_only_imports() {
1622        // The is_component_installed_by_id check for pytorch should only
1623        // check if the torch module is importable (not check HIP).
1624        // This is the correct basic detection — HIP availability is a
1625        // verification concern, not an installation concern.
1626        let candidates = vec!["python3".to_string()];
1627        // We can't assert the result since it depends on the system state,
1628        // but we can verify the function doesn't panic
1629        let _ = is_component_installed_by_id("pytorch", &candidates);
1630    }
1631
1632    #[test]
1633    fn test_verification_command_structure() {
1634        // Verify the VerificationCommand struct for pytorch is well-formed
1635        let candidates = vec!["python3".to_string()];
1636        let cmds = component_verification_commands("pytorch", &candidates);
1637        assert_eq!(cmds.len(), 1);
1638        let cmd = &cmds[0];
1639        assert!(!cmd.label.is_empty());
1640        assert!(!cmd.target_id.is_empty());
1641        assert!(!cmd.program.is_empty());
1642        assert_eq!(cmd.args.len(), 2, "Should have -c and the code");
1643        assert_eq!(cmd.args[0], "-c");
1644        assert!(!cmd.args[1].is_empty());
1645        assert!(cmd.modules.contains(&"torch".to_string()));
1646    }
1647
1648    #[test]
1649    fn test_migraphx_snippet_excludes_cwd_and_falls_back_to_driver() {
1650        // Regression: a repo-local `migraphx` stub imported cleanly but had no
1651        // parse_onnx, and the old snippet only fell back to the driver on
1652        // ImportError — so the stub produced a false failure. The new snippet
1653        // must (a) strip CWD from sys.path and (b) run the driver whenever the
1654        // python bindings are missing OR incomplete.
1655        let s = migraphx_verify_snippet();
1656
1657        // (a) CWD excluded from sys.path (no repo-stub shadowing).
1658        assert!(
1659            s.contains("getcwd()"),
1660            "snippet must drop CWD from sys.path; got: {s}"
1661        );
1662        assert!(
1663            s.contains("sys.path = [p for p in sys.path"),
1664            "snippet must filter sys.path; got: {s}"
1665        );
1666
1667        // (b) Driver fallback is OUTSIDE the try/except — keyed on `if not ok`,
1668        // so an incomplete import (stub without parse_onnx) still reaches it.
1669        assert!(
1670            s.contains("if not ok:"),
1671            "snippet must fall back to the driver whenever python bindings are incomplete"
1672        );
1673        assert!(s.contains("migraphx-driver"));
1674        assert!(s.contains("--version"));
1675        // parse_onnx is only accepted inside the successful python branch.
1676        assert!(s.contains("hasattr(migraphx, 'parse_onnx')"));
1677        // Exit reflects final ok state (driver fallback can flip it to pass).
1678        assert!(s.contains("sys.exit(0 if ok else 1)"));
1679    }
1680
1681    #[test]
1682    fn test_onnx_present_snippet_strips_cwd_and_requires_real_package() {
1683        // Regression: a repo-local `onnxruntime/` source directory resolves as a
1684        // namespace package (find_spec returns a spec, origin is None), so the
1685        // old `python_any(&["onnxruntime"])` reported "installed" while
1686        // `import onnxruntime` had no __version__/providers. The snippet must
1687        // (a) strip CWD from sys.path and (b) require a real package (origin).
1688        let s = onnxruntime_present_snippet();
1689        assert!(
1690            s.contains("getcwd()"),
1691            "snippet must drop CWD from sys.path; got: {s}"
1692        );
1693        assert!(
1694            s.contains("sys.path = [p for p in sys.path"),
1695            "snippet must filter sys.path; got: {s}"
1696        );
1697        assert!(
1698            s.contains("origin is not None"),
1699            "snippet must require a real package (non-None origin), not a namespace shadow; got: {s}"
1700        );
1701    }
1702}
1703
1704#[cfg(test)]
1705mod component_status_tests;