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 auto_source = (model.provenance == crate::catalog::EntryProvenance::Auto).then(|| {
272 format!(
273 "Source: auto-sourced from the Ollama library ({})",
274 model.recency_label()
275 )
276 });
277 if auto_source.is_some() {
278 output["provenance"] = json!("auto");
279 }
280 let mut lines = Vec::new();
281 if let Some(context) = options.context {
282 output["context"] = json!(context);
283 output["contextFitKnown"] = json!(model.kv_bytes_per_token.is_some());
284 output["requiredBytes"] = report["requiredBytes"].clone();
285 output["usableBytes"] = report["usableBytes"].clone();
286 if options.kv_cache.is_some() {
287 output["kvPrecision"] = json!(KvCacheType::label(options.kv_cache));
288 }
289 lines.push(format!(
290 "Context: {context} tokens (KV {} estimate)",
291 KvCacheType::label(options.kv_cache)
292 ));
293 if model.kv_bytes_per_token.is_none() {
294 lines.push("Requested context fit unknown: attention geometry unavailable; verdict below is based on weights only.".into());
295 }
296 }
297 let id = strip_control(&model.id);
298 let runnable = text(&report["runnable"]);
299 if runnable == "no" {
300 let reason = text(&report["reason"]);
301 let label = match reason.as_str() {
302 "ram-bound" => "not enough RAM",
303 "disk-bound" => "not enough free disk",
304 "context-bound" => "context exceeds the model's limit",
305 _ => "not enough VRAM",
306 };
307 lines.push(format!("❌ {id}: no — does not fit ({reason}: {label})"));
308 } else {
309 let known = report["throughput"]["known"] == true;
310 let low = report["throughput"]["lowTokPerSec"].as_f64().unwrap_or(0.0);
311 let high = report["throughput"]["highTokPerSec"]
312 .as_f64()
313 .unwrap_or(0.0);
314 let detail = if runnable == "yes" {
315 "runs comfortably".into()
316 } else if known {
317 format!(
318 "fits, but ~{} tok/s is below the comfort floor",
319 (((low + high) / 2.0) * 10.0).round() / 10.0
320 )
321 } else {
322 "fits, but throughput can't be estimated for this hardware".into()
323 };
324 lines.push(format!(
325 "{} {id}: {runnable} — {detail}",
326 if runnable == "yes" { "✓" } else { "⚠️" }
327 ));
328 lines.push(format!(
329 "Quant: {}",
330 strip_control(&text(&report["quant"]["name"]))
331 ));
332 lines.push(if known {
333 format!("Estimated throughput: {low}–{high} tok/s")
334 } else {
335 "Estimated throughput: unknown (no performance profile for this hardware)".into()
336 });
337 lines.push(format!(
338 "Backends: {} (throughput scoped to {backend})",
339 if supported.is_empty() {
340 "none".into()
341 } else {
342 supported.join(", ")
343 }
344 ));
345 }
346 if let Some(line) = auto_source {
347 lines.push(line);
348 }
349 let unknown_reason = if report["throughput"]["known"] == true {
350 Value::Null
351 } else if report["runnable"] == "no" {
352 json!("not-evaluated-model-does-not-fit")
353 } else {
354 json!("no-sourced-performance-profile")
355 };
356 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}});
357 if let Some(context) = options.context {
358 evidence["context"] = json!(context);
359 evidence["contextFitKnown"] = json!(model.kv_bytes_per_token.is_some());
360 }
361 Ok(CanRunReport {
362 json: sanitized(&output),
363 text: lines.join("\n"),
364 evidence,
365 })
366}
367
368pub fn catalog_text(
369 catalog: &Catalog,
370 hardware: &Hardware,
371 all: bool,
372) -> Result<String, ValidationError> {
373 let mut models: Vec<_> = catalog.models.iter().collect();
374 models.sort_by(|left, right| {
375 right
376 .recency()
377 .map(|(day, _)| day)
378 .cmp(&left.recency().map(|(day, _)| day))
379 .then_with(|| left.id.cmp(&right.id))
380 });
381 let mut rows = Vec::new();
382 for model in models {
383 let sizing = evaluate(&SizingRequest {
384 model: model.sizing(),
385 hardware: hardware.clone(),
386 context: None,
387 })?;
388 if !all && !sizing.fit.fits {
389 continue;
390 }
391 let smallest = model
392 .quantizations
393 .iter()
394 .enumerate()
395 .min_by(|(left, lq), (right, rq)| {
396 sizing.required[*left]
397 .total_cmp(&sizing.required[*right])
398 .then_with(|| lq.name.cmp(&rq.name))
399 })
400 .map(|(_, quant)| quant)
401 .ok_or_else(|| ValidationError("missing catalog quant".into()))?;
402 let quant = sizing.fit.quant.as_ref().unwrap_or(smallest);
403 rows.push(vec![
404 model.id.clone(),
405 model.params.clone(),
406 if matches!(model.architecture, crate::sizing::Architecture::Moe) {
407 "moe".into()
408 } else {
409 "dense".into()
410 },
411 quant.name.clone(),
412 format!(
413 "{:.1}",
414 sizing.fit.required_bytes.unwrap_or(0.0) / 1073741824.0
415 ),
416 sizing.fit.reason.unwrap_or("fit").into(),
417 model.recency_label(),
418 ]);
419 }
420 let header = format!(
421 "Catalog (Filter: {}, shown: {}/{})",
422 if all { "all" } else { "fits" },
423 rows.len(),
424 catalog.models.len()
425 );
426 if rows.is_empty() {
427 return Ok(format!(
428 "{header}\nNo models fit this hardware. Re-run with --all to see the full catalog.\n"
429 ));
430 }
431 Ok(format!(
432 "{header}\n{}\n",
433 table(
434 &[
435 ("Model", false),
436 ("Params", false),
437 ("Arch", false),
438 ("Quant", false),
439 ("Need GiB", true),
440 ("Fit", false),
441 ("Release", false)
442 ],
443 rows
444 )
445 ))
446}