1use crate::{
2 advice::verdict,
3 catalog::{Catalog, PerfDataset, resolve},
4 ranking::{AdviceOptions, backends},
5 sizing::{Hardware, KvCacheType, SizingRequest, ValidationError, evaluate},
6};
7use regex::Regex;
8use serde_json::{Value, json};
9use std::sync::LazyLock;
10
11pub fn strip_control(text: &str) -> String {
12 static ANSI: LazyLock<Regex> = LazyLock::new(|| {
13 Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b[@-Z\\-_]").expect("constant ANSI pattern")
14 });
15 ANSI.replace_all(text, "").chars().filter(|ch| !ch.is_control() && !matches!(*ch, '\u{ad}' | '\u{61c}' | '\u{200b}'..='\u{200f}' | '\u{2060}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{2028}' | '\u{2029}' | '\u{feff}')).collect()
16}
17pub fn sanitized(value: &Value) -> Value {
18 match value {
19 Value::String(text) => json!(strip_control(text)),
20 Value::Array(items) => Value::Array(items.iter().map(sanitized).collect()),
21 Value::Object(items) => Value::Object(
22 items
23 .iter()
24 .map(|(key, value)| (strip_control(key), sanitized(value)))
25 .collect(),
26 ),
27 _ => value.clone(),
28 }
29}
30fn text(value: &Value) -> String {
31 value.as_str().map(str::to_owned).unwrap_or_else(|| {
32 value
33 .as_f64()
34 .map_or_else(|| "unknown".into(), |number| number.to_string())
35 })
36}
37pub fn gib(bytes: f64) -> String {
38 format!("{:.1} GiB", bytes / 1073741824.0)
39}
40fn grouped(value: &Value) -> String {
41 if value.is_null() {
42 return "unknown".into();
43 }
44 let raw = text(value);
45 let mut out = String::new();
46 for (index, ch) in raw.chars().enumerate() {
47 if index > 0 && (raw.len() - index).is_multiple_of(3) {
48 out.push(',');
49 }
50 out.push(ch);
51 }
52 out
53}
54pub fn table(columns: &[(&str, bool)], rows: Vec<Vec<String>>) -> String {
55 let headers: Vec<String> = columns
56 .iter()
57 .map(|(name, _)| strip_control(name))
58 .collect();
59 let rows: Vec<Vec<String>> = rows
60 .iter()
61 .map(|row| {
62 (0..columns.len())
63 .map(|index| strip_control(row.get(index).map_or("", String::as_str)))
64 .collect()
65 })
66 .collect();
67 let widths: Vec<usize> = headers
68 .iter()
69 .enumerate()
70 .map(|(index, header)| {
71 rows.iter()
72 .map(|row| row[index].encode_utf16().count())
73 .chain([header.encode_utf16().count()])
74 .max()
75 .unwrap_or(0)
76 })
77 .collect();
78 std::iter::once(&headers)
79 .chain(rows.iter())
80 .map(|row| {
81 row.iter()
82 .enumerate()
83 .map(|(index, cell)| {
84 let padding = " ".repeat(widths[index] - cell.encode_utf16().count());
85 if columns[index].1 {
86 format!("{padding}{cell}")
87 } else {
88 format!("{cell}{padding}")
89 }
90 })
91 .collect::<Vec<_>>()
92 .join(" ")
93 .trim_end_matches(' ')
94 .to_owned()
95 })
96 .collect::<Vec<_>>()
97 .join("\n")
98}
99
100pub fn safe_recommendation_command(report: &Value) -> Option<&str> {
101 let id = report["ranked"].as_array()?.first()?["id"].as_str()?;
102 let command = report["command"].as_str()?;
103 (!id.is_empty()
104 && !id.starts_with('-')
105 && id.bytes().all(|byte| {
106 byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"._:/-".contains(&byte)
107 })
108 && !id.split('/').any(|part| part == "..")
109 && command.len() <= 256
110 && command.strip_prefix("rigspark up ") == Some(id))
111 .then_some(command)
112}
113
114pub fn recommendation_text(report: &Value, options: &AdviceOptions) -> String {
115 let ranked = report["ranked"].as_array().cloned().unwrap_or_default();
116 let misses = report["wontFit"].as_array().cloned().unwrap_or_default();
117 if ranked.is_empty() && misses.is_empty() {
118 return "No models in the catalog.".into();
119 }
120 let hw = &report["hardware"];
121 let kv = KvCacheType::label(options.kv_cache);
122 let note = if let Some(context) = options.context {
123 format!(" — sized at {context}-token context (KV {kv})")
124 } else if options.max_context {
125 format!(" — largest holdable context per model (KV {kv})")
126 } else {
127 String::new()
128 };
129 let mut sections = vec![format!(
130 "Ranked local LLMs for {}/{} ({} {} usable):{note}",
131 text(&hw["arch"]),
132 text(&hw["platform"]),
133 gib(hw["usableMemoryBytes"].as_f64().unwrap_or(0.0)),
134 text(&hw["memoryKind"])
135 )];
136 if ranked.is_empty() {
137 sections.push("No models fit this hardware.".into());
138 } else {
139 let mut columns = vec![
140 ("Rank", true),
141 ("Model", false),
142 ("Params", true),
143 ("Quant", false),
144 ];
145 if options.context.is_some() {
146 columns.extend([("Weights", true), ("KV Cache", true), ("Est. Mem", true)]);
147 } else if options.max_context {
148 columns.extend([
149 ("Est. Mem", true),
150 ("Max Context", true),
151 ("Bound-By", false),
152 ]);
153 } else {
154 columns.push(("Est. Mem", true));
155 }
156 columns.extend([
157 ("Verdict", false),
158 ("Est. tok/s", true),
159 ("Backends", false),
160 ("License", false),
161 ("Score", true),
162 ]);
163 let rows = ranked
164 .iter()
165 .map(|entry| {
166 let mut row = vec![
167 text(&entry["rank"]),
168 text(&entry["id"]),
169 text(&entry["params"]),
170 text(&entry["quant"]),
171 ];
172 if options.context.is_some() {
173 row.push(gib(entry["weightsBytes"].as_f64().unwrap_or(0.0)));
174 row.push(entry["kvCacheBytes"].as_f64().map_or("unknown".into(), gib));
175 }
176 row.push(gib(entry["requiredBytes"].as_f64().unwrap_or(0.0)));
177 if options.max_context {
178 row.push(grouped(&entry["maxContextTokens"]));
179 row.push(text(&entry["boundBy"]));
180 }
181 let verdict = text(&entry["verdict"]);
182 row.push(format!(
183 "{} {verdict}",
184 if verdict == "yes" { "✓" } else { "⚠️" }
185 ));
186 row.push(if entry["estTokPerSec"].is_null() {
187 "unknown".into()
188 } else {
189 format!(
190 "{}–{}",
191 text(&entry["estTokPerSec"]["lowTokPerSec"]),
192 text(&entry["estTokPerSec"]["highTokPerSec"])
193 )
194 });
195 let backends = entry["backends"]
196 .as_array()
197 .map(|items| items.iter().map(text).collect::<Vec<_>>().join(", "))
198 .unwrap_or_default();
199 row.extend([
200 if backends.is_empty() {
201 "—".into()
202 } else {
203 backends
204 },
205 text(&entry["license"]),
206 format!("{:.2}", entry["score"].as_f64().unwrap_or(0.0)),
207 ]);
208 row
209 })
210 .collect();
211 sections.push(table(&columns, rows));
212 if let Some(command) = safe_recommendation_command(report) {
213 sections.push(format!("Run the top pick: {command}"));
214 }
215 }
216 if !misses.is_empty() {
217 sections.push(format!(
218 "Won't fit ({}):\n{}",
219 misses.len(),
220 misses
221 .iter()
222 .map(|entry| format!(
223 " ❌ {} ({})",
224 strip_control(&text(&entry["id"])),
225 text(&entry["reason"])
226 ))
227 .collect::<Vec<_>>()
228 .join("\n")
229 ));
230 }
231 sections.join("\n\n")
232}
233
234pub fn can_run(
235 catalog: &Catalog,
236 hardware: &Hardware,
237 perf: &PerfDataset,
238 query: &str,
239 options: &AdviceOptions,
240) -> Result<(Value, String), ValidationError> {
241 let report = can_run_report(catalog, hardware, perf, query, options)?;
242 Ok((report.json, report.text))
243}
244
245pub struct CanRunReport {
246 pub json: Value,
247 pub text: String,
248 pub evidence: Value,
249}
250
251pub fn can_run_report(
252 catalog: &Catalog,
253 hardware: &Hardware,
254 perf: &PerfDataset,
255 query: &str,
256 options: &AdviceOptions,
257) -> Result<CanRunReport, ValidationError> {
258 options.validate()?;
259 let resolved = resolve(catalog, query).map_err(|error| ValidationError(error.message))?;
260 let mut model = resolved.model.clone();
261 if let Some(quant) = resolved.quant {
262 model.quantizations = vec![quant.clone()];
263 }
264 if let Some(kind) = options.kv_cache {
265 model = model.with_kv_cache(kind)?;
266 }
267 let backend = options.backend.as_deref().unwrap_or("ollama");
268 let report = verdict(&model, hardware, perf, options.context, backend)?;
269 let supported = backends(&model, hardware);
270 let mut output = json!({"model":model.id,"verdict":report["runnable"],"quant":report["quant"]["name"],"reason":report["reason"],"throughput":report["throughput"],"backends":supported,"throughputBackend":backend});
271 let mut lines = Vec::new();
272 if let Some(context) = options.context {
273 output["context"] = json!(context);
274 output["contextFitKnown"] = json!(model.kv_bytes_per_token.is_some());
275 output["requiredBytes"] = report["requiredBytes"].clone();
276 output["usableBytes"] = report["usableBytes"].clone();
277 if options.kv_cache.is_some() {
278 output["kvPrecision"] = json!(KvCacheType::label(options.kv_cache));
279 }
280 lines.push(format!(
281 "Context: {context} tokens (KV {} estimate)",
282 KvCacheType::label(options.kv_cache)
283 ));
284 if model.kv_bytes_per_token.is_none() {
285 lines.push("Requested context fit unknown: attention geometry unavailable; verdict below is based on weights only.".into());
286 }
287 }
288 let id = strip_control(&model.id);
289 let runnable = text(&report["runnable"]);
290 if runnable == "no" {
291 let reason = text(&report["reason"]);
292 let label = match reason.as_str() {
293 "ram-bound" => "not enough RAM",
294 "disk-bound" => "not enough free disk",
295 "context-bound" => "context exceeds the model's limit",
296 _ => "not enough VRAM",
297 };
298 lines.push(format!("❌ {id}: no — does not fit ({reason}: {label})"));
299 } else {
300 let known = report["throughput"]["known"] == true;
301 let low = report["throughput"]["lowTokPerSec"].as_f64().unwrap_or(0.0);
302 let high = report["throughput"]["highTokPerSec"]
303 .as_f64()
304 .unwrap_or(0.0);
305 let detail = if runnable == "yes" {
306 "runs comfortably".into()
307 } else if known {
308 format!(
309 "fits, but ~{} tok/s is below the comfort floor",
310 (((low + high) / 2.0) * 10.0).round() / 10.0
311 )
312 } else {
313 "fits, but throughput can't be estimated for this hardware".into()
314 };
315 lines.push(format!(
316 "{} {id}: {runnable} — {detail}",
317 if runnable == "yes" { "✓" } else { "⚠️" }
318 ));
319 lines.push(format!(
320 "Quant: {}",
321 strip_control(&text(&report["quant"]["name"]))
322 ));
323 lines.push(if known {
324 format!("Estimated throughput: {low}–{high} tok/s")
325 } else {
326 "Estimated throughput: unknown (no performance profile for this hardware)".into()
327 });
328 lines.push(format!(
329 "Backends: {} (throughput scoped to {backend})",
330 if supported.is_empty() {
331 "none".into()
332 } else {
333 supported.join(", ")
334 }
335 ));
336 }
337 let unknown_reason = if report["throughput"]["known"] == true {
338 Value::Null
339 } else if report["runnable"] == "no" {
340 json!("not-evaluated-model-does-not-fit")
341 } else {
342 json!("no-sourced-performance-profile")
343 };
344 let mut evidence = json!({"modelId":model.id,"runnable":report["runnable"],"quant":report["quant"]["name"],"reason":report["reason"],"throughput":report["throughput"],"backends":supported,"throughputBackend":backend,"requiredBytes":report["requiredBytes"],"usableBytes":report["usableBytes"],"throughputEvidence":{"source":"offline-estimate","unknownReason":unknown_reason}});
345 if let Some(context) = options.context {
346 evidence["context"] = json!(context);
347 evidence["contextFitKnown"] = json!(model.kv_bytes_per_token.is_some());
348 }
349 Ok(CanRunReport {
350 json: sanitized(&output),
351 text: lines.join("\n"),
352 evidence,
353 })
354}
355
356pub fn catalog_text(
357 catalog: &Catalog,
358 hardware: &Hardware,
359 all: bool,
360) -> Result<String, ValidationError> {
361 let mut models: Vec<_> = catalog.models.iter().collect();
362 models.sort_by(|left, right| {
363 right
364 .release_date
365 .cmp(&left.release_date)
366 .then_with(|| left.id.cmp(&right.id))
367 });
368 let mut rows = Vec::new();
369 for model in models {
370 let sizing = evaluate(&SizingRequest {
371 model: model.sizing(),
372 hardware: hardware.clone(),
373 context: None,
374 })?;
375 if !all && !sizing.fit.fits {
376 continue;
377 }
378 let smallest = model
379 .quantizations
380 .iter()
381 .enumerate()
382 .min_by(|(left, lq), (right, rq)| {
383 sizing.required[*left]
384 .total_cmp(&sizing.required[*right])
385 .then_with(|| lq.name.cmp(&rq.name))
386 })
387 .map(|(_, quant)| quant)
388 .ok_or_else(|| ValidationError("missing catalog quant".into()))?;
389 let quant = sizing.fit.quant.as_ref().unwrap_or(smallest);
390 rows.push(vec![
391 model.id.clone(),
392 model.params.clone(),
393 if matches!(model.architecture, crate::sizing::Architecture::Moe) {
394 "moe".into()
395 } else {
396 "dense".into()
397 },
398 quant.name.clone(),
399 format!(
400 "{:.1}",
401 sizing.fit.required_bytes.unwrap_or(0.0) / 1073741824.0
402 ),
403 sizing.fit.reason.unwrap_or("fit").into(),
404 model.release_date.clone(),
405 ]);
406 }
407 let header = format!(
408 "Catalog (Filter: {}, shown: {}/{})",
409 if all { "all" } else { "fits" },
410 rows.len(),
411 catalog.models.len()
412 );
413 if rows.is_empty() {
414 return Ok(format!(
415 "{header}\nNo models fit this hardware. Re-run with --all to see the full catalog.\n"
416 ));
417 }
418 Ok(format!(
419 "{header}\n{}\n",
420 table(
421 &[
422 ("Model", false),
423 ("Params", false),
424 ("Arch", false),
425 ("Quant", false),
426 ("Need GiB", true),
427 ("Fit", false),
428 ("Release", false)
429 ],
430 rows
431 )
432 ))
433}