1use 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}