Skip to main content

rlx_cli/
inspect.rs

1// RLX — versatile ML compiler + runtime.
2// Copyright (C) 2026 Eugene Hauptmann, Nataliya Kosmyna.
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, version 3.
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16use crate::format::WeightFormat;
17use anyhow::{Result, anyhow};
18use rlx_core::gguf_config::{gguf_memory_footprint, gguf_runner_hint};
19use rlx_core::weight_loader::GgufLoader;
20use rlx_core::weight_registry::list_registered_formats;
21use rlx_core::weights::{ResolveOpts, gguf_dir_guide};
22use rlx_gguf::GgufFile;
23use std::collections::BTreeMap;
24use std::path::{Path, PathBuf};
25
26pub fn estimate_qwen35_footprint(raw: &GgufFile) -> (u64, u64) {
27    let fp = gguf_memory_footprint(raw);
28    (fp.f32_bytes, fp.packed_file_bytes)
29}
30
31pub fn fmt_bytes(b: u64) -> String {
32    const GB: f64 = 1024.0 * 1024.0 * 1024.0;
33    const MB: f64 = 1024.0 * 1024.0;
34    let f = b as f64;
35    if f >= GB {
36        format!("{:.2} GB", f / GB)
37    } else if f >= MB {
38        format!("{:.1} MB", f / MB)
39    } else {
40        format!("{b} B")
41    }
42}
43
44pub fn list_mtp_keys(path: &Path) -> Result<Vec<String>> {
45    if WeightFormat::detect(path)? != WeightFormat::Gguf {
46        return Ok(vec![]);
47    }
48    let loader = GgufLoader::from_file(path.to_str().ok_or_else(|| anyhow!("non-utf8 path"))?)?;
49    Ok(loader.mtp_keys())
50}
51
52struct InspectArgs<'a> {
53    path: &'a str,
54    prefer: Option<&'a str>,
55    list_formats: bool,
56    json: bool,
57}
58
59fn json_escape(s: &str) -> String {
60    s.replace('\\', "\\\\")
61        .replace('"', "\\\"")
62        .replace('\n', "\\n")
63}
64
65fn parse_inspect_args(args: &[String]) -> Result<InspectArgs<'_>> {
66    let mut path = None;
67    let mut prefer = None;
68    let mut list_formats = false;
69    let mut json = false;
70    let mut i = 0;
71    while i < args.len() {
72        match args[i].as_str() {
73            "--prefer" | "-p" => {
74                prefer = Some(
75                    args.get(i + 1)
76                        .ok_or_else(|| anyhow!("--prefer requires a substring (e.g. Q4_K_M)"))?
77                        .as_str(),
78                );
79                i += 2;
80            }
81            "--list-formats" => {
82                list_formats = true;
83                i += 1;
84            }
85            "--json" => {
86                json = true;
87                i += 1;
88            }
89            "--help" | "-h" => {
90                print_usage();
91                std::process::exit(0);
92            }
93            s if s.starts_with('-') => {
94                return Err(anyhow!("unknown flag `{s}` (try --help)"));
95            }
96            s => {
97                if path.is_some() {
98                    return Err(anyhow!("unexpected argument `{s}`"));
99                }
100                path = Some(s);
101                i += 1;
102            }
103        }
104    }
105    if path.is_none() && !list_formats {
106        print_usage();
107        return Err(anyhow!("missing path (or use --list-formats)"));
108    }
109    Ok(InspectArgs {
110        path: path.unwrap_or(""),
111        prefer,
112        list_formats,
113        json,
114    })
115}
116
117fn print_usage() {
118    eprintln!(
119        "usage: rlx-inspect <path> [--prefer Q4_K_M] [--list-formats]\n\
120         \n\
121         Examples:\n\
122           rlx-inspect model.gguf\n\
123           rlx-inspect weights/              # lists .gguf files in a directory\n\
124           rlx-inspect weights/ --prefer Q4_K_M\n\
125           rlx-inspect --list-formats        # show registered weight extensions\n\
126           rlx-inspect model.gguf --json   # machine-readable summary"
127    );
128}
129
130pub fn run_inspect(args: &[String]) -> Result<()> {
131    let parsed = parse_inspect_args(args)?;
132    if parsed.list_formats {
133        if parsed.json {
134            print!("[");
135            for (i, reg) in list_registered_formats().iter().enumerate() {
136                if i > 0 {
137                    print!(",");
138                }
139                let exts: Vec<String> = reg.extensions.iter().map(|e| format!("\"{e}\"")).collect();
140                print!(
141                    "{{\"id\":\"{}\",\"extensions\":[{}]}}",
142                    reg.id,
143                    exts.join(",")
144                );
145            }
146            println!("]");
147        } else {
148            println!("registered weight formats:");
149            for reg in list_registered_formats() {
150                println!("  {} → .{}", reg.id, reg.extensions.join(", ."));
151            }
152        }
153        if parsed.path.is_empty() {
154            return Ok(());
155        }
156    }
157
158    let pb: PathBuf = parsed.path.into();
159    if parsed.list_formats && pb.as_os_str().is_empty() {
160        return Ok(());
161    }
162
163    let fmt = WeightFormat::detect(&pb)?;
164    println!("path:   {pb:?}");
165    println!("format: {fmt:?}");
166
167    if pb.is_dir() {
168        let guide = gguf_dir_guide(&pb)?;
169        if !guide.files.is_empty() {
170            println!();
171            guide.print();
172            if let Some(sub) = parsed.prefer {
173                let pick = guide.files.iter().position(|p| {
174                    p.file_name()
175                        .and_then(|s| s.to_str())
176                        .is_some_and(|n| n.contains(sub))
177                });
178                if let Some(idx) = pick {
179                    println!();
180                    println!(
181                        "resolve:  --prefer {sub} → [{}] {:?}",
182                        idx, guide.files[idx]
183                    );
184                    println!(
185                        "rust:     rlx_core::weights::open_map_with(\
186                         LoadOpts::map().prefer_substring(\"{sub}\"), path)?"
187                    );
188                } else {
189                    println!();
190                    println!("resolve:  no file name contains `{sub}`");
191                }
192            }
193            println!();
194        }
195    }
196
197    let inspect_path = if pb.is_dir() {
198        if let Some(sub) = parsed.prefer {
199            let resolved = rlx_core::resolve_weights_file_with_options(
200                &pb,
201                &ResolveOpts::default().prefer_substring(sub),
202            )?;
203            println!("picked:   {resolved:?}");
204            resolved
205        } else if fmt == WeightFormat::Gguf {
206            println!(
207                "hint:     pass a file path, or --prefer Q4_K_M, or inspect one file from the list above"
208            );
209            return Ok(());
210        } else {
211            pb.clone()
212        }
213    } else {
214        pb.clone()
215    };
216
217    match fmt {
218        WeightFormat::Gguf => inspect_gguf(&inspect_path, parsed.json)?,
219        WeightFormat::Safetensors => inspect_safetensors(&inspect_path, parsed.json)?,
220    }
221    Ok(())
222}
223
224fn inspect_gguf(pb: &Path, json: bool) -> Result<()> {
225    let raw = GgufFile::from_path(pb)?;
226    println!("version:  {}", raw.version);
227    println!("tensors:  {}", raw.tensors.len());
228    println!("metadata: {} keys", raw.metadata.len());
229    let arch = raw
230        .metadata
231        .get("general.architecture")
232        .and_then(|v| v.as_str())
233        .unwrap_or("?");
234    let runner = gguf_runner_hint(arch);
235    if json {
236        let (f32_bytes, packed_bytes) = estimate_qwen35_footprint(&raw);
237        let mtp = list_mtp_keys(pb)?;
238        println!(
239            "{{\"format\":\"gguf\",\"path\":\"{}\",\"arch\":\"{}\",\"runner\":\"{}\",\
240             \"tensors\":{},\"f32_bytes\":{},\"packed_bytes\":{},\"mtp_heads\":{}}}",
241            json_escape(&pb.display().to_string()),
242            json_escape(arch),
243            json_escape(runner),
244            raw.tensors.len(),
245            f32_bytes,
246            packed_bytes,
247            mtp.len()
248        );
249        return Ok(());
250    }
251    println!("arch:     {arch}");
252    println!("runner:   {runner}");
253    let mamba = raw
254        .tensors
255        .keys()
256        .any(|k| k.starts_with("blk.0.ssm_") || k == "blk.0.attn_qkv.weight");
257    match (arch, mamba) {
258        ("qwen3", false) | ("qwen36", false) => {
259            println!("compat:   ok — `just qwen3 -- --weights {:?} …`", pb);
260            println!(
261                "rust:     weights::open_with(LoadOpts::loader(), path)?  // runner validates arch"
262            );
263        }
264        ("llama", false) => {
265            println!("compat:   ok — `rlx-llama32` / rlx_models::llama32");
266            println!("rust:     weights::open_with(LoadOpts::loader(), path)?");
267        }
268        ("qwen35", true) | ("qwen35moe", true) | (_, true) => {
269            println!("compat:   ok — `rlx-qwen35 --packed`");
270            println!("rust:     weights::open_with(LoadOpts::loader(), path)?");
271        }
272        ("bert", _) | ("modern-bert", _) | ("nomic-bert", _) | ("nomic-bert-moe", _) => {
273            println!("compat:   ok — `rlx-embed`");
274            println!(
275                "rust:     gguf_validate_arch(path, EMBED_GGUF_ARCHES)?; weights::open_map(path)?"
276            );
277        }
278        ("flux", _) => {
279            println!("compat:   ok — `rlx-flux2` (denoiser GGUF; VAE/TE safetensors)");
280            println!(
281                "rust:     gguf_validate_arch(path, FLUX_GGUF_ARCHES)?; weights::open_map(path)?"
282            );
283        }
284        ("dinov2", _) => {
285            println!("compat:   ok — `rlx-dinov2` (F32 drain; tensor names must match HF/candle)");
286            println!("rust:     rlx_core::load_weight_map(path, DINOV2_GGUF_ARCHES)?");
287        }
288        ("sam3", _) => {
289            println!("compat:   ok — `rlx-sam3`");
290            println!("rust:     rlx_core::load_weight_map(path, SAM3_GGUF_ARCHES)?");
291        }
292        ("sam2", _) => {
293            println!("compat:   ok — `rlx-sam2` (community GGUF; parity not verified)");
294            println!("rust:     rlx_core::load_weight_map(path, SAM2_GGUF_ARCHES)?");
295        }
296        ("sam", _) | ("mobile-sam", _) => {
297            println!("compat:   ok — `rlx-sam` (ViT-H `sam` or MobileSAM `mobile-sam`)");
298            println!("rust:     rlx_core::load_weight_map(path, SAM_GGUF_ARCHES)?");
299        }
300        ("vjepa2", _) | ("vjepa", _) => {
301            println!("compat:   ok — `rlx-vjepa2` (experimental; few public GGUF checkpoints)");
302            println!("rust:     rlx_core::load_weight_map(path, VJEPA2_GGUF_ARCHES)?");
303        }
304        ("w2v-bert", _) | ("wav2vec2", _) | ("wav2vec", _) => {
305            println!(
306                "compat:   ok — `rlx-wav2vec2-bert` (F32 drain; `config.json` beside weights)"
307            );
308            println!("rust:     rlx_core::load_weight_map(path, W2V_BERT_GGUF_ARCHES)?");
309        }
310        _ => {
311            println!(
312                "compat:   unknown — extend via register_gguf_tensor_resolver / WeightFormatRegistration::register"
313            );
314        }
315    }
316    let mut by_dt: BTreeMap<String, usize> = BTreeMap::new();
317    for t in raw.tensors.values() {
318        *by_dt.entry(format!("{:?}", t.dtype)).or_default() += 1;
319    }
320    println!("dtypes:");
321    for (dt, n) in &by_dt {
322        println!("  {dt:>6}: {n}");
323    }
324    let (f32_bytes, packed_bytes) = estimate_qwen35_footprint(&raw);
325    println!(
326        "footprint: F32-dequant ≈ {} / on-disk packed ≈ {} \
327         (LM: use --packed when F32 does not fit)",
328        fmt_bytes(f32_bytes),
329        fmt_bytes(packed_bytes),
330    );
331    let mtp = list_mtp_keys(pb)?;
332    if mtp.is_empty() {
333        println!("mtp:      (none)");
334    } else {
335        println!("mtp:      {} heads", mtp.len());
336        for k in mtp.iter().take(5) {
337            println!("    {k}");
338        }
339    }
340    Ok(())
341}
342
343fn inspect_safetensors(pb: &Path, json: bool) -> Result<()> {
344    let meta = std::fs::metadata(pb)?;
345    if json {
346        println!(
347            "{{\"format\":\"safetensors\",\"path\":\"{}\",\"size_bytes\":{}}}",
348            json_escape(&pb.display().to_string()),
349            meta.len()
350        );
351        return Ok(());
352    }
353    println!("size:     {} bytes", meta.len());
354    println!("rust:     rlx_core::weights::open_map(path)?");
355    println!("(tensor names: use WeightMap::from_file for a full listing)");
356    Ok(())
357}