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