Skip to main content

artifactize_core/query/
summary.rs

1use std::{
2    collections::{BTreeMap, BTreeSet},
3    time::Duration,
4};
5
6use serde::Serialize;
7use serde_json::{Value, json};
8use time::OffsetDateTime;
9
10use crate::store::{ExecutionOptions, Request, RequestView, RunView};
11
12/// How a request got a result that another request's execution produced.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "lowercase")]
15pub enum SourceKind {
16    /// A completed local record of the reuse key.
17    Cache,
18    /// The live execution of the reuse key that the request waited for.
19    Joined,
20    /// A record from the remote review store.
21    Remote,
22}
23
24/// The request whose execution produced a reused result.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct Source<'a> {
28    pub run_id: &'a crate::types::RunId,
29    pub request_id: &'a crate::types::RequestId,
30    pub kind: SourceKind,
31}
32
33/// Where a request's result came from: `None` when the request executed itself or has no
34/// result from an execution yet. The one rule behind `source`, `summary.reused`, the text
35/// marker and the monitor.
36pub fn source(request: &Request) -> Option<Source<'_>> {
37    let provenance = request
38        .provenance
39        .as_ref()
40        .filter(|source| source.request_id != request.id)?;
41    let kind = if request.origin.is_some() {
42        SourceKind::Remote
43    } else if request.joined {
44        SourceKind::Joined
45    } else {
46        SourceKind::Cache
47    };
48    Some(Source {
49        run_id: &provenance.run_id,
50        request_id: &provenance.request_id,
51        kind,
52    })
53}
54
55/// The result came from another request's execution, possibly in this Run.
56pub fn reused(request: &Request) -> bool {
57    source(request).is_some()
58}
59
60/// A saved request as output: `source` stands for the saved `joined` flag.
61fn with_source(value: &mut Value, request: &Request) {
62    let object = value.as_object_mut().expect("a request is an object");
63    object.remove("joined");
64    object.insert("source".into(), json!(source(request)));
65    // The reference in the text form `session show` and `session send` take.
66    if let (Some(reference), Some(Value::Object(session))) =
67        (&request.session, object.get_mut("session"))
68    {
69        session.insert("ref".into(), json!(reference.to_string()));
70    }
71}
72
73#[derive(Debug, Default, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct UsageTotals {
76    pub attempts: u64,
77    pub usage_state: UsageState,
78    pub reported_attempts: u64,
79    pub unreported_attempts: u64,
80    pub usage: BTreeMap<String, u64>,
81}
82#[derive(Debug, Default, Serialize)]
83#[serde(rename_all = "lowercase")]
84pub enum UsageState {
85    #[default]
86    None,
87    Unreported,
88    Reported,
89    Partial,
90}
91#[derive(Debug, Default, Serialize)]
92pub struct Kinds {
93    pub total: u64,
94    pub runtime: u64,
95    pub agent: u64,
96    pub human: u64,
97}
98impl Kinds {
99    fn add(&mut self, kind: crate::config::ProfileKind) {
100        self.total += 1;
101        match kind {
102            crate::config::ProfileKind::Runtime => self.runtime += 1,
103            crate::config::ProfileKind::Agent => self.agent += 1,
104            crate::config::ProfileKind::Human => self.human += 1,
105        }
106    }
107}
108#[derive(Debug, Default, Serialize)]
109#[serde(rename_all = "camelCase")]
110pub struct Reuses {
111    #[serde(flatten)]
112    pub kinds: Kinds,
113    pub other_profile: u64,
114}
115#[derive(Debug, Serialize)]
116#[serde(rename_all = "camelCase")]
117pub struct RequestSummary<'a> {
118    pub status: crate::types::RequestStatus,
119    #[serde(
120        rename = "wallMs",
121        serialize_with = "crate::config::validation::milliseconds::serialize"
122    )]
123    pub wall_time: Option<Duration>,
124    pub executor_starts: u64,
125    #[serde(flatten)]
126    pub usage: UsageTotals,
127    pub execution_source: Option<&'a crate::store::Provenance>,
128}
129#[derive(Debug, Serialize)]
130#[serde(rename_all = "camelCase")]
131pub struct RunSummary {
132    pub counts: BTreeMap<crate::types::RequestStatus, u64>,
133    pub executed: Kinds,
134    pub reused: Reuses,
135    #[serde(
136        rename = "wallMs",
137        serialize_with = "crate::config::validation::milliseconds::serialize"
138    )]
139    pub wall_time: Option<Duration>,
140    pub executor_starts: u64,
141    #[serde(flatten)]
142    pub usage: UsageTotals,
143}
144
145pub fn request_output(view: &RequestView, now: OffsetDateTime) -> Value {
146    let mut value = json!(view);
147    let request = &view.request;
148    with_source(&mut value, request);
149    let summary = request_summary(view, now);
150    value["summary"] = json!(summary);
151    value
152}
153
154/// A saved Run as output: the Run, its requests with their `source`, the records that do not
155/// read, and its usage and summary. It is written as it serializes, one request at a time, so
156/// a Run with a large catalog is never built into one JSON value first.
157#[derive(Serialize)]
158pub struct RunOutput<'a> {
159    #[serde(flatten)]
160    run: &'a crate::store::Run,
161    requests: RequestsOutput<'a>,
162    #[serde(skip_serializing_if = "<[_]>::is_empty")]
163    unreadable: &'a [crate::store::Unreadable],
164    usage: Usage,
165    summary: RunSummary,
166}
167
168/// Usage the Run spent on its own executions and saved by reusing results.
169#[derive(Serialize)]
170struct Usage {
171    spent: BTreeMap<String, u64>,
172    saved: BTreeMap<String, u64>,
173}
174
175/// A Run's requests as output, each written as soon as it is built.
176struct RequestsOutput<'a>(&'a [std::sync::Arc<Request>]);
177impl Serialize for RequestsOutput<'_> {
178    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
179        use serde::ser::{Error, SerializeSeq};
180        let mut requests = serializer.serialize_seq(Some(self.0.len()))?;
181        for request in self.0 {
182            let mut value = serde_json::to_value(request).map_err(S::Error::custom)?;
183            with_source(&mut value, request);
184            requests.serialize_element(&value)?;
185        }
186        requests.end()
187    }
188}
189
190pub fn run_output(view: &RunView, now: OffsetDateTime) -> RunOutput<'_> {
191    let (summary, saved) = run_summary(view, now);
192    RunOutput {
193        run: &view.run,
194        requests: RequestsOutput(&view.requests),
195        unreadable: &view.unreadable,
196        usage: Usage {
197            spent: summary.usage.usage.clone(),
198            saved,
199        },
200        summary,
201    }
202}
203
204/// A short name for the profile that produced a request's result: its variant, or its kind
205/// with the Agent backend, model and reasoning.
206pub fn profile_name(profile: &crate::config::StoredProfile, options: &ExecutionOptions) -> String {
207    if let Some(variant) = &options.variant {
208        return variant.to_string();
209    }
210    match profile.kind() {
211        crate::config::ProfileKind::Agent => [
212            options.backend.as_ref().map(|backend| backend.as_str()),
213            options.model.as_deref(),
214            options.reasoning.map(crate::config::Reasoning::as_str),
215        ]
216        .into_iter()
217        .flatten()
218        .collect::<Vec<_>>()
219        .join(" "),
220        kind => kind.name().to_owned(),
221    }
222}
223
224fn local_execution(request: &Request) -> bool {
225    request
226        .provenance
227        .as_ref()
228        .is_some_and(|source| source.request_id == request.id)
229}
230
231fn wall_time(
232    start: crate::types::Timestamp,
233    end: Option<crate::types::Timestamp>,
234    now: OffsetDateTime,
235) -> Option<Duration> {
236    let end = end.map_or(now, crate::types::Timestamp::time);
237    Some(
238        (end - start.time())
239            .max(time::Duration::ZERO)
240            .unsigned_abs(),
241    )
242}
243
244fn usage_totals<'a>(requests: impl IntoIterator<Item = &'a Request>) -> UsageTotals {
245    let (mut attempts, mut reported, mut unreported) = (0, 0, 0);
246    let mut totals = BTreeMap::<String, u64>::new();
247    for request in requests {
248        if let Some(entries) = request.usage.as_ref() {
249            if entries.is_empty() && request.started_at.is_some() {
250                unreported += 1;
251            }
252            for attempt in entries {
253                attempts += 1;
254                if add_usage(&mut totals, attempt) {
255                    reported += 1;
256                } else {
257                    unreported += 1;
258                }
259            }
260        } else if request.execution_id.is_some()
261            && request.profile.kind() != crate::config::ProfileKind::Agent
262        {
263            attempts += 1;
264            unreported += 1;
265        }
266    }
267    UsageTotals {
268        attempts,
269        usage_state: usage_state(reported, unreported),
270        reported_attempts: reported,
271        unreported_attempts: unreported,
272        usage: totals,
273    }
274}
275
276/// Adds one attempt's reported counters; false when it reported none.
277fn add_usage(totals: &mut BTreeMap<String, u64>, attempt: &crate::llm::Attempt) -> bool {
278    for (key, value) in attempt.usage.iter() {
279        let total = totals.entry(key.clone()).or_default();
280        *total = total.saturating_add(*value);
281    }
282    !attempt.usage.is_empty()
283}
284
285fn usage_state(reported: u64, unreported: u64) -> UsageState {
286    match (reported, unreported) {
287        (0, 0) => UsageState::None,
288        (0, _) => UsageState::Unreported,
289        (_, 0) => UsageState::Reported,
290        _ => UsageState::Partial,
291    }
292}
293
294pub fn request_summary(view: &RequestView, now: OffsetDateTime) -> RequestSummary<'_> {
295    let request: &Request = &view.request;
296    RequestSummary {
297        status: request.status(),
298        wall_time: wall_time(request.created_at, request.completed_at(), now),
299        executor_starts: u64::from(
300            local_execution(request)
301                && request.started_at.is_some()
302                && request.profile.kind() != crate::config::ProfileKind::Human,
303        ),
304        usage: usage_totals((!reused(request)).then_some(request)),
305        execution_source: request.provenance.as_ref(),
306    }
307}
308
309pub fn run_summary(view: &RunView, now: OffsetDateTime) -> (RunSummary, BTreeMap<String, u64>) {
310    let mut counts = BTreeMap::new();
311    let mut seen = BTreeSet::new();
312    let local = view
313        .requests
314        .iter()
315        .map(|request| &**request)
316        .filter(|request| {
317            local_execution(request)
318                && request
319                    .execution_id
320                    .as_ref()
321                    .is_some_and(|id| seen.insert(id))
322        });
323    let usage = usage_totals(local);
324    let (mut executed, mut reuses, mut saved) =
325        (Kinds::default(), Reuses::default(), BTreeMap::new());
326    for request in &view.requests {
327        *counts.entry(request.status()).or_default() += 1;
328        if reused(request) {
329            for attempt in request.reused_usage.iter().flatten() {
330                add_usage(&mut saved, attempt);
331            }
332            reuses.other_profile += u64::from(request.profile != request.requested_profile);
333            reuses.kinds.add(request.profile.kind());
334        } else if local_execution(request) {
335            executed.add(request.profile.kind());
336        }
337    }
338    let summary = RunSummary {
339        counts,
340        executed,
341        reused: reuses,
342        wall_time: wall_time(view.run.created_at, view.run.completed_at(), now),
343        executor_starts: view.run.executions_started,
344        usage,
345    };
346    (summary, saved)
347}
348
349impl std::fmt::Display for UsageState {
350    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
351        formatter.write_str(match self {
352            Self::None => "none",
353            Self::Unreported => "unreported",
354            Self::Reported => "reported",
355            Self::Partial => "partial",
356        })
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use serde_json::json;
364    fn view() -> RunView {
365        serde_json::from_value(json!({
366            "id":"run-legacy",
367            "repoPath":"repo",
368            "stateDir":"state",
369            "status":"RUNNING",
370            "createdAt":"2026-01-01T00:00:00Z",
371            "completedAt":null,
372            "selection":{"kind":"all"},
373            "validation":null,
374            "error":null,
375            "requests":[
376                {
377                    "id":"run-legacy-1",
378                    "runId":"run-legacy",
379                    "evalId":"app/check",
380                    "target":"app",
381                    "title":"Check",
382                    "profile":{"kind":"runtime","command":"fixture","args":[]},
383                    "requestedProfile":{
384                        "kind":"runtime",
385                        "command":"fixture",
386                        "args":[],
387                        "timeoutMs":null,
388                    },
389                    "evalDefHash":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
390                    "executionId":"execution-source-1",
391                    "provenance":{
392                        "repoPath":"source",
393                        "runId":"run-source",
394                        "requestId":"run-source-1",
395                        "evalId":"app/check",
396                        "evalDefHash":"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
397                        "completedAt":"2026-01-01T00:00:01Z",
398                    },
399                    "usage":null,
400                    "reusedUsage":[
401                        {
402                            "turn":1,
403                            "attempt":1,
404                            "usage":{"inputTokens":17,"vendorDetail":{"cache":true}},
405                        },
406                    ],
407                    "payload":{},
408                    "references":{},
409                    "deps":[],
410                    "status":"GREEN",
411                    "createdAt":"2026-01-01T00:00:00Z",
412                    "startedAt":null,
413                    "completedAt":"2026-01-01T00:00:01Z",
414                    "cwd":"repo",
415                    "runDir":null,
416                    "argv":null,
417                    "child":null,
418                    "result":{"verdict":"GREEN"},
419                    "error":null,
420                    "errorCode":null,
421                    "blockedReason":null,
422                },
423            ],
424        }))
425        .unwrap()
426    }
427    #[test]
428    fn supplied_time_is_deterministic_and_profile_omission_is_not_null() {
429        let now = "2026-01-01T00:00:05Z"
430            .parse::<crate::types::Timestamp>()
431            .unwrap()
432            .time();
433        let view = view();
434        let output = serde_json::to_value(run_output(&view, now)).unwrap();
435        assert_eq!(output["summary"]["wallMs"], 5000);
436        assert_eq!(output["summary"]["reused"]["otherProfile"], 1);
437        assert_eq!(output["summary"]["reused"]["runtime"], 1);
438        assert_eq!(output["summary"]["attempts"], 0);
439        assert_eq!(output["usage"]["saved"], json!({"inputTokens":17}));
440        assert_eq!(
441            serde_json::to_value(run_output(&view, now)).unwrap(),
442            output
443        );
444        assert_eq!(
445            wall_time("2026-01-01T00:00:06Z".parse().unwrap(), None, now),
446            Some(Duration::ZERO)
447        );
448        let request = RequestView {
449            request: view.requests[0].clone(),
450            claim: None,
451            execution: None,
452            definition: None,
453        };
454        assert_eq!(request_output(&request, now)["summary"]["wallMs"], 1000);
455        assert_eq!(
456            serde_json::to_value(&request.request.profile).unwrap(),
457            json!({"kind":"runtime","command":"fixture","args":[]})
458        );
459        assert_eq!(
460            serde_json::to_value(&request.request.requested_profile).unwrap(),
461            json!({"kind":"runtime","command":"fixture","args":[],"timeoutMs":null})
462        );
463    }
464}