Skip to main content

rigspark_core/
reports.rs

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}