Skip to main content

kernel/artifacts/
provenance.rs

1//! Rendering an artifact's provenance into a one-line summary and a multi-line
2//! detail form. Known schema params come first (in schema order), then every
3//! remaining scalar param alphabetically; `prompt` is pulled out separately.
4
5use crate::records::{JsonValue, ParamSpec};
6
7use super::artifact::Artifact;
8
9/// Provenance rendering helpers.
10pub struct Provenance;
11
12impl Provenance {
13    /// A one-line summary: model, the scalar params, and the duration.
14    pub fn line(artifact: &Artifact, schema: &[ParamSpec]) -> String {
15        let mut parts = vec![artifact.model.clone()];
16        for (key, value) in param_pairs(&artifact.params, schema) {
17            parts.push(format!("{key} {value}"));
18        }
19        parts.push(Self::duration(artifact.duration_ms));
20        parts.join(" ยท ")
21    }
22
23    /// A human duration: `"850 ms"`, `"1.2s"`, `"3m"`, or `"3m 5s"`.
24    pub fn duration(ms: i64) -> String {
25        if ms < 1000 {
26            return format!("{ms} ms");
27        }
28        if ms < 60_000 {
29            return format!("{:.1}s", ms as f64 / 1000.0);
30        }
31        let minutes = ms / 60_000;
32        let seconds = (ms % 60_000) / 1000;
33        if seconds == 0 {
34            format!("{minutes}m")
35        } else {
36            format!("{minutes}m {seconds}s")
37        }
38    }
39
40    /// A multi-line detail form: model, runtime, capability, prompt, params, then
41    /// duration and job.
42    pub fn details(artifact: &Artifact, schema: &[ParamSpec]) -> String {
43        let mut lines = vec![
44            format!("model: {}", artifact.model),
45            format!("runtime: {}", artifact.runtime),
46            format!("capability: {}", artifact.capability.as_ref()),
47        ];
48        lines.extend(prompt_and_param_lines(&artifact.params, schema));
49        lines.push(format!(
50            "duration: {}",
51            Self::duration(artifact.duration_ms)
52        ));
53        lines.push(format!("job: {}", artifact.job_id));
54        lines.join("\n")
55    }
56
57    /// A detail form for a failed generation.
58    pub fn failure_details(
59        model: &str,
60        error: &str,
61        job_id: Option<&str>,
62        params: &JsonValue,
63        schema: &[ParamSpec],
64    ) -> String {
65        let mut lines = vec![format!("model: {model}"), format!("error: {error}")];
66        if let Some(job_id) = job_id {
67            lines.push(format!("job: {job_id}"));
68        }
69        lines.extend(prompt_and_param_lines(params, schema));
70        lines.join("\n")
71    }
72
73    /// The `prompt` string of a params object, if present.
74    pub fn prompt(params: &JsonValue) -> Option<String> {
75        params
76            .as_object()
77            .and_then(|fields| fields.get("prompt"))
78            .and_then(JsonValue::as_str)
79            .map(str::to_owned)
80    }
81}
82
83fn prompt_and_param_lines(params: &JsonValue, schema: &[ParamSpec]) -> Vec<String> {
84    let mut lines = Vec::new();
85    if let Some(prompt) = Provenance::prompt(params) {
86        lines.push(format!("prompt: {prompt}"));
87    }
88    for (key, value) in param_pairs(params, schema) {
89        lines.push(format!("{key}: {value}"));
90    }
91    lines
92}
93
94fn param_pairs(params: &JsonValue, schema: &[ParamSpec]) -> Vec<(String, String)> {
95    let Some(fields) = params.as_object() else {
96        return Vec::new();
97    };
98    let mut keys: Vec<String> = schema
99        .iter()
100        .map(|spec| spec.key.clone())
101        .filter(|key| fields.contains_key(key))
102        .collect();
103    let mut extras: Vec<String> = fields
104        .keys()
105        .filter(|key| *key != "prompt" && !keys.contains(key))
106        .cloned()
107        .collect();
108    extras.sort();
109    keys.append(&mut extras);
110    keys.into_iter()
111        // The schema may itself list "prompt"; it is rendered on its own line.
112        .filter(|key| key != "prompt")
113        .filter_map(|key| {
114            let value = fields.get(&key)?;
115            let rendered = scalar(value)?;
116            Some((key, rendered))
117        })
118        .collect()
119}
120
121fn scalar(value: &JsonValue) -> Option<String> {
122    match value {
123        JsonValue::Int(raw) => Some(raw.to_string()),
124        JsonValue::Double(raw) => Some(raw.to_string()),
125        JsonValue::String(raw) => Some(raw.clone()),
126        JsonValue::Bool(raw) => Some(raw.to_string()),
127        _ => None,
128    }
129}