Skip to main content

harn_cli/commands/run/
reporting.rs

1//! Auxiliary run output, profiling, and provenance receipts.
2//!
3//! The execution path owns when these artifacts are emitted; this module owns
4//! their wire shapes and sinks so the runner itself stays focused on lifecycle.
5
6use std::fs;
7#[cfg(unix)]
8use std::io::{self, Write};
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use serde::Serialize;
14
15use crate::commands::time::{self, PhaseRecord, RunTiming};
16use harn_vm::clock::{now_wall_ms, RealClock};
17use harn_vm::event_log::EventLog;
18use harn_vm::llm::usage::{summarize_usage_cost_certainty, UsageCostCertainty};
19
20use super::{RunAttestationOptions, RunProfileOptions};
21
22/// JSON event-stream configuration for `--json` runs.
23#[derive(Clone, Default)]
24pub struct RunJsonOptions {
25    /// Suppress `stdout` / `stderr` events. Transcript, tool, hook,
26    /// persona, and the terminal result/error events still flow.
27    pub quiet: bool,
28}
29
30/// Post-run summary configuration for `harn run --emit-summary-json`.
31#[derive(Clone, Debug)]
32pub struct RunSummaryOptions {
33    pub sink: RunJsonSink,
34}
35
36#[derive(Clone, Debug)]
37pub struct RunPhaseOptions {
38    pub sink: RunJsonSink,
39}
40
41#[derive(Clone, Debug)]
42pub struct RunRusageOptions {
43    pub sink: RunJsonSink,
44}
45
46#[derive(Clone, Debug, Default)]
47pub struct RunAuxOptions {
48    pub summary: Option<RunSummaryOptions>,
49    pub phase: Option<RunPhaseOptions>,
50    pub rusage: Option<RunRusageOptions>,
51}
52
53#[derive(Clone, Debug, Default)]
54pub struct RunControlOptions {
55    pub timeout: Option<Duration>,
56    pub eager_project_handlers: bool,
57}
58
59#[derive(Clone, Debug)]
60pub struct RunJsonSink {
61    pub target: RunJsonSinkTarget,
62    pub fd_flag: &'static str,
63}
64
65#[derive(Clone, Debug)]
66pub enum RunJsonSinkTarget {
67    /// Append the summary to the captured stderr buffer so it remains
68    /// terminal after all diagnostics that `run_file_with_skill_dirs`
69    /// flushes on return.
70    Stderr,
71    File(PathBuf),
72    Fd(i32),
73}
74
75#[derive(Serialize)]
76struct RunSummary<'a> {
77    schema_version: u32,
78    event: &'static str,
79    wall_time_ms: u64,
80    exit_code: i32,
81    llm: RunSummaryLlm,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    profile: Option<&'a harn_vm::profile::RunProfile>,
84}
85
86#[derive(Serialize)]
87pub(super) struct RunSummaryLlm {
88    /// Completed logical LLM operations retained for wire compatibility.
89    call_count: i64,
90    /// Physical provider requests, including schema, transport, and content retries.
91    provider_call_count: i64,
92    input_tokens: i64,
93    output_tokens: i64,
94    time_ms: i64,
95    /// Exact total when every call is priced.
96    cost_usd: Option<f64>,
97    /// Sum of priced calls; a lower bound when `unpriced_calls` is non-zero.
98    known_cost_usd: f64,
99    /// Calls the catalog prices no rate for. A non-zero count makes
100    /// `cost_usd` null and `known_cost_usd` the explicit lower bound.
101    unpriced_calls: i64,
102    /// Physical provider requests whose token/cache usage is unknown.
103    usage_unknown_calls: i64,
104}
105
106/// v4 distinguishes logical operations from physical provider requests and
107/// retains unknown-usage counts alongside exact or lower-bound cost.
108pub const RUN_SUMMARY_SCHEMA_VERSION: u32 = 4;
109pub const RUN_PHASE_SCHEMA_VERSION: u32 = 2;
110pub const RUN_RUSAGE_SCHEMA_VERSION: u32 = 1;
111
112#[derive(Serialize)]
113struct RunPhaseEvent {
114    schema_version: u32,
115    event: &'static str,
116    phases: Vec<PhaseRecord>,
117}
118
119#[derive(Serialize)]
120struct RunRusageEvent {
121    schema_version: u32,
122    event: &'static str,
123    cpu_ms: u64,
124}
125
126fn run_summary_options_from_args(args: &crate::cli::RunArgs) -> Option<RunSummaryOptions> {
127    args.emit_summary_json.then(|| RunSummaryOptions {
128        sink: build_run_json_sink(args.summary_file.clone(), args.summary_fd, "--summary-fd"),
129    })
130}
131
132pub(crate) fn run_aux_options_from_args(args: &crate::cli::RunArgs) -> RunAuxOptions {
133    RunAuxOptions {
134        summary: run_summary_options_from_args(args),
135        phase: run_phase_options_from_args(args),
136        rusage: run_rusage_options_from_args(args),
137    }
138}
139
140pub(crate) fn run_control_options_from_args(args: &crate::cli::RunArgs) -> RunControlOptions {
141    RunControlOptions {
142        timeout: args.timeout,
143        eager_project_handlers: args.eager_project_handlers,
144    }
145}
146
147fn run_phase_options_from_args(args: &crate::cli::RunArgs) -> Option<RunPhaseOptions> {
148    args.emit_phase_json.then(|| RunPhaseOptions {
149        sink: build_run_json_sink(args.phase_file.clone(), args.phase_fd, "--phase-fd"),
150    })
151}
152
153fn run_rusage_options_from_args(args: &crate::cli::RunArgs) -> Option<RunRusageOptions> {
154    args.emit_rusage_json.then(|| RunRusageOptions {
155        sink: build_run_json_sink(args.rusage_file.clone(), args.rusage_fd, "--rusage-fd"),
156    })
157}
158
159fn build_run_json_sink(
160    file: Option<PathBuf>,
161    fd: Option<i32>,
162    fd_flag: &'static str,
163) -> RunJsonSink {
164    RunJsonSink {
165        target: if let Some(path) = file {
166            RunJsonSinkTarget::File(path)
167        } else if let Some(fd) = fd {
168            RunJsonSinkTarget::Fd(fd)
169        } else {
170            RunJsonSinkTarget::Stderr
171        },
172        fd_flag,
173    }
174}
175
176pub(super) fn render_and_persist_profile_rollup(
177    options: &RunProfileOptions,
178    profile: &harn_vm::profile::RunProfile,
179    stderr: &mut String,
180) -> Result<(), String> {
181    if options.text {
182        stderr.push_str(&harn_vm::profile::render(profile));
183    }
184    if let Some(path) = options.json_path.as_ref() {
185        if let Some(parent) = path.parent() {
186            if !parent.as_os_str().is_empty() {
187                fs::create_dir_all(parent)
188                    .map_err(|error| format!("create {}: {error}", parent.display()))?;
189            }
190        }
191        let json = serde_json::to_string_pretty(profile)
192            .map_err(|error| format!("serialize profile: {error}"))?;
193        fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
194    }
195    Ok(())
196}
197
198fn build_run_summary<'a>(
199    started: Instant,
200    exit_code: i32,
201    profile: Option<&'a harn_vm::profile::RunProfile>,
202    llm: RunSummaryLlm,
203) -> RunSummary<'a> {
204    RunSummary {
205        schema_version: RUN_SUMMARY_SCHEMA_VERSION,
206        event: "run_summary",
207        wall_time_ms: started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
208        exit_code,
209        llm,
210        profile,
211    }
212}
213
214pub(super) fn run_summary_llm_snapshot() -> RunSummaryLlm {
215    let (input_tokens, output_tokens, time_ms, call_count) = harn_vm::llm::peek_trace_summary();
216    let trace = harn_vm::llm::peek_trace();
217    let certainty = summarize_usage_cost_certainty(trace.iter().map(|entry| &entry.usage));
218    run_summary_llm_from_parts(input_tokens, output_tokens, time_ms, call_count, certainty)
219}
220
221fn run_summary_llm_from_parts(
222    input_tokens: i64,
223    output_tokens: i64,
224    time_ms: i64,
225    call_count: i64,
226    certainty: UsageCostCertainty,
227) -> RunSummaryLlm {
228    RunSummaryLlm {
229        call_count,
230        provider_call_count: certainty.provider_call_count,
231        input_tokens,
232        output_tokens,
233        time_ms,
234        cost_usd: (certainty.unpriced_calls == 0).then_some(certainty.known_cost_usd),
235        known_cost_usd: certainty.known_cost_usd,
236        unpriced_calls: certainty.unpriced_calls,
237        usage_unknown_calls: certainty.usage_unknown_calls,
238    }
239}
240
241pub(super) struct RunAuxEmission {
242    pub stderr: String,
243    pub exit_code: i32,
244    pub error: Option<String>,
245}
246
247#[allow(clippy::too_many_arguments)]
248pub(super) fn emit_run_aux_for_exit(
249    summary: Option<&RunSummaryOptions>,
250    phase: Option<&RunPhaseOptions>,
251    rusage: Option<&RunRusageOptions>,
252    started: Instant,
253    exit_code: i32,
254    profile: Option<&harn_vm::profile::RunProfile>,
255    llm: Option<RunSummaryLlm>,
256    timing: Option<&RunTiming>,
257    main_events: u64,
258    cpu_ms_total: Option<u64>,
259    json_mode: bool,
260    stderr: &mut String,
261) -> RunAuxEmission {
262    let mut aux_stderr = String::new();
263    let mut final_exit_code = exit_code;
264    let mut aux_error = None;
265    let aux_target = if json_mode { &mut aux_stderr } else { stderr };
266    let default_timing = RunTiming::default();
267    let timing = timing.unwrap_or(&default_timing);
268
269    if let Some(options) = summary {
270        let llm = llm.unwrap_or_else(run_summary_llm_snapshot);
271        let summary = build_run_summary(started, exit_code, profile, llm);
272        if let Err(error) = emit_raw_json_line(&options.sink, &summary, "run summary", aux_target) {
273            record_aux_error(
274                &mut final_exit_code,
275                &mut aux_error,
276                aux_target,
277                "run summary",
278                error,
279            );
280        }
281    }
282    if let Some(options) = phase {
283        let phase_event = RunPhaseEvent {
284            schema_version: RUN_PHASE_SCHEMA_VERSION,
285            event: "run_phase",
286            phases: time::build_phase_records(timing, main_events),
287        };
288        if let Err(error) = emit_raw_json_line(&options.sink, &phase_event, "run phase", aux_target)
289        {
290            record_aux_error(
291                &mut final_exit_code,
292                &mut aux_error,
293                aux_target,
294                "run phase",
295                error,
296            );
297        }
298    }
299    if let Some(options) = rusage {
300        let rusage_event = RunRusageEvent {
301            schema_version: RUN_RUSAGE_SCHEMA_VERSION,
302            event: "run_rusage",
303            cpu_ms: cpu_ms_total.unwrap_or(0),
304        };
305        if let Err(error) =
306            emit_raw_json_line(&options.sink, &rusage_event, "run rusage", aux_target)
307        {
308            record_aux_error(
309                &mut final_exit_code,
310                &mut aux_error,
311                aux_target,
312                "run rusage",
313                error,
314            );
315        }
316    }
317
318    RunAuxEmission {
319        stderr: aux_stderr,
320        exit_code: final_exit_code,
321        error: aux_error,
322    }
323}
324
325fn record_aux_error(
326    final_exit_code: &mut i32,
327    aux_error: &mut Option<String>,
328    stderr: &mut String,
329    label: &str,
330    error: String,
331) {
332    stderr.push_str(&format!("error: failed to emit {label}: {error}\n"));
333    if *final_exit_code == 0 {
334        *final_exit_code = 1;
335    }
336    if aux_error.is_none() {
337        *aux_error = Some(error);
338    }
339}
340
341fn emit_raw_json_line(
342    sink: &RunJsonSink,
343    value: &impl Serialize,
344    label: &str,
345    stderr: &mut String,
346) -> Result<(), String> {
347    let line =
348        serde_json::to_string(value).map_err(|error| format!("serialize {label}: {error}"))? + "\n";
349    match &sink.target {
350        RunJsonSinkTarget::Stderr => {
351            stderr.push_str(&line);
352            Ok(())
353        }
354        RunJsonSinkTarget::File(path) => write_raw_json_file(path, &line),
355        RunJsonSinkTarget::Fd(fd) => write_raw_json_fd(*fd, &line, sink.fd_flag),
356    }
357}
358
359fn write_raw_json_file(path: &Path, line: &str) -> Result<(), String> {
360    if let Some(parent) = path.parent() {
361        if !parent.as_os_str().is_empty() {
362            fs::create_dir_all(parent)
363                .map_err(|error| format!("create {}: {error}", parent.display()))?;
364        }
365    }
366    fs::write(path, line).map_err(|error| format!("write {}: {error}", path.display()))
367}
368
369#[cfg(unix)]
370fn write_raw_json_fd(fd: i32, line: &str, flag: &str) -> Result<(), String> {
371    use std::fs::File;
372    use std::os::unix::io::FromRawFd;
373
374    if fd < 0 {
375        return Err(format!("invalid {flag} {fd}: must be non-negative"));
376    }
377    let duped = unsafe { libc::dup(fd) };
378    if duped < 0 {
379        return Err(format!(
380            "duplicate {flag} {fd}: {}",
381            io::Error::last_os_error()
382        ));
383    }
384    let mut file = unsafe { File::from_raw_fd(duped) };
385    file.write_all(line.as_bytes())
386        .and_then(|_| file.flush())
387        .map_err(|error| format!("write {flag} {fd}: {error}"))
388}
389
390#[cfg(not(unix))]
391fn write_raw_json_fd(_fd: i32, _line: &str, flag: &str) -> Result<(), String> {
392    Err(format!("{flag} is only supported on Unix platforms"))
393}
394
395pub(super) async fn append_run_provenance_event(
396    log: &Arc<harn_vm::event_log::AnyEventLog>,
397    kind: &str,
398    payload: serde_json::Value,
399) {
400    let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
401        return;
402    };
403    let _ = log
404        .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
405        .await;
406}
407
408pub(super) async fn emit_run_attestation(
409    log: &Arc<harn_vm::event_log::AnyEventLog>,
410    path: &str,
411    store_base: &Path,
412    started_at_ms: i64,
413    exit_code: i32,
414    options: &RunAttestationOptions,
415    stderr: &mut String,
416) -> Result<(), String> {
417    let finished_at_ms = now_ms();
418    let status = if exit_code == 0 { "success" } else { "failure" };
419    append_run_provenance_event(
420        log,
421        "finished",
422        serde_json::json!({
423            "pipeline": path,
424            "status": status,
425            "exit_code": exit_code,
426        }),
427    )
428    .await;
429    log.flush()
430        .await
431        .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
432    let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
433        .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
434    let (signing_key, key_id) =
435        harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
436            .await
437            .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
438    let receipt = harn_vm::build_signed_receipt(
439        log,
440        harn_vm::ReceiptBuildOptions {
441            pipeline: path.to_string(),
442            status: status.to_string(),
443            started_at_ms,
444            finished_at_ms,
445            exit_code,
446            producer_name: "harn-cli".to_string(),
447            producer_version: env!("CARGO_PKG_VERSION").to_string(),
448        },
449        &signing_key,
450        key_id,
451    )
452    .await
453    .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
454    let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
455    if let Some(parent) = receipt_path.parent() {
456        fs::create_dir_all(parent)
457            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
458    }
459    let encoded = serde_json::to_vec_pretty(&receipt)
460        .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
461    fs::write(&receipt_path, encoded)
462        .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
463    stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
464    Ok(())
465}
466
467fn receipt_output_path(
468    store_base: &Path,
469    options: &RunAttestationOptions,
470    receipt_id: &str,
471) -> PathBuf {
472    if let Some(path) = options.receipt_out.as_ref() {
473        return path.clone();
474    }
475    harn_vm::runtime_paths::state_root(store_base)
476        .join("receipts")
477        .join(format!("{receipt_id}.json"))
478}
479
480pub(super) fn now_ms() -> i64 {
481    now_wall_ms(&RealClock::new())
482}
483
484/// Map a script's top-level return value to a process exit code.
485///
486/// - `int n`             → exit n (clamped to 0..=255)
487/// - `Result::Ok(_)`     → exit 0
488/// - `Result::Err(_)`    → exit 1
489/// - anything else       → exit 0
490pub(super) fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
491    use harn_vm::VmValue;
492    match value {
493        VmValue::Int(n) => (*n).clamp(0, 255) as i32,
494        VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
495        _ => 0,
496    }
497}
498
499pub(crate) fn render_trace_summary() -> String {
500    render_trace_entries(&harn_vm::llm::take_trace())
501}
502
503/// Rendering is split from the thread-local read so the money arithmetic can
504/// be tested against constructed entries rather than a live provider call.
505fn render_trace_entries(entries: &[harn_vm::llm::LlmTraceEntry]) -> String {
506    use std::fmt::Write;
507
508    if entries.is_empty() {
509        return String::new();
510    }
511    let mut out = String::new();
512    let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
513    let mut total_input = 0i64;
514    let mut total_output = 0i64;
515    let mut total_ms = 0u64;
516    // Priced and unpriced calls are summed separately. This used to apply one
517    // hardcoded Sonnet-4 rate ($3/$15 per MTok) to every call regardless of
518    // which model served it, so a trace of any other model reported a
519    // confidently wrong dollar figure.
520    //
521    // The price is now read off the entry rather than recomputed here. The
522    // runtime already priced each call through the one owner of per-call cost,
523    // which sees prompt-cache accounting and the accelerated-serving tier;
524    // re-pricing from tokens alone cannot, and would make this summary
525    // disagree with the run's own reported total.
526    for (index, entry) in entries.iter().enumerate() {
527        let certainty = summarize_usage_cost_certainty([&entry.usage]);
528        let _ = writeln!(
529            out,
530            "  #{}: {} | {} in + {} out tokens | {} ms | {}",
531            index + 1,
532            entry.model,
533            entry.usage.input_tokens,
534            entry.usage.output_tokens,
535            entry.duration_ms,
536            render_cost_certainty(certainty),
537        );
538        total_input += entry.usage.input_tokens;
539        total_output += entry.usage.output_tokens;
540        total_ms += entry.duration_ms;
541    }
542    let total_tokens = total_input + total_output;
543    let certainty = summarize_usage_cost_certainty(entries.iter().map(|entry| &entry.usage));
544    let token_label = render_token_certainty(total_tokens, certainty.usage_unknown_calls);
545    let _ = writeln!(
546        out,
547        "  \x1b[1m{} logical call{}, {} provider call{}, {} ({}in + {}out), {} ms, {}\x1b[0m",
548        entries.len(),
549        if entries.len() == 1 { "" } else { "s" },
550        certainty.provider_call_count,
551        if certainty.provider_call_count == 1 {
552            ""
553        } else {
554            "s"
555        },
556        token_label,
557        total_input,
558        total_output,
559        total_ms,
560        render_cost_certainty(certainty),
561    );
562    out
563}
564
565fn render_cost_certainty(certainty: UsageCostCertainty) -> String {
566    if certainty.unpriced_calls == 0 {
567        format!("${:.4}", certainty.known_cost_usd)
568    } else {
569        // This is a floor on real spend, not an estimate. The runtime's
570        // canonical ledger already includes priced siblings from a mixed
571        // retry aggregate in `known_cost_usd`.
572        format!(
573            "≥${:.4} ({} unpriced)",
574            certainty.known_cost_usd, certainty.unpriced_calls
575        )
576    }
577}
578
579fn render_token_certainty(known_tokens: i64, usage_unknown_calls: i64) -> String {
580    if usage_unknown_calls == 0 {
581        format!("{known_tokens} tokens")
582    } else {
583        format!("≥{known_tokens} tokens ({usage_unknown_calls} usage unknown)")
584    }
585}
586
587#[cfg(test)]
588mod trace_summary_pricing_tests {
589    use super::{render_trace_entries, run_summary_llm_from_parts};
590    use harn_vm::llm::{
591        usage::{LlmUsage, UsageCostCertainty},
592        LlmTraceEntry,
593    };
594
595    fn entry(model: &str, cost_usd: Option<f64>) -> LlmTraceEntry {
596        LlmTraceEntry {
597            model: model.to_string(),
598            provider: "anthropic".to_string(),
599            usage: LlmUsage {
600                input_tokens: 1_000,
601                output_tokens: 100,
602                cost_usd,
603                cache_read_tokens: 0,
604                cache_write_tokens: 0,
605                cache_supported: true,
606                cache_hit_ratio: Some(0.0),
607                cache_savings_usd: 0.0,
608                cache_hit: false,
609                served_fast: false,
610                accounting_status: harn_vm::llm::usage::UsageAccountingStatus::Reported,
611                known_cost_usd: cost_usd.unwrap_or(0.0),
612                provider_call_count: 1,
613                unpriced_calls: i64::from(cost_usd.is_none()),
614                usage_unknown_calls: 0,
615            },
616            duration_ms: 5,
617        }
618    }
619
620    /// The summary used to price every call at one hardcoded Sonnet-4 rate
621    /// ($3/$15 per MTok) regardless of which model served it. It now sums the
622    /// price the runtime already computed, so the total is whatever the run
623    /// actually booked.
624    #[test]
625    fn the_total_is_the_sum_of_the_prices_the_runtime_recorded() {
626        let rendered = render_trace_entries(&[
627            entry("claude-sonnet-4-20250514", Some(0.25)),
628            entry("claude-haiku-4-5-20251001", Some(0.0125)),
629        ]);
630        assert!(
631            rendered.contains("$0.2625"),
632            "the total must be the exact sum of the recorded prices: {rendered}"
633        );
634        assert!(
635            !rendered.contains("unpriced"),
636            "no call was unpriced, so nothing should be hedged: {rendered}"
637        );
638    }
639
640    /// An unpriced call must not be silently treated as free. The total
641    /// becomes a floor, and says how many calls it could not account for.
642    #[test]
643    fn an_unpriced_call_makes_the_total_a_floor_rather_than_a_figure() {
644        let rendered = render_trace_entries(&[
645            entry("claude-sonnet-4-20250514", Some(0.25)),
646            entry("some-model-the-catalog-does-not-price", None),
647        ]);
648        assert!(
649            rendered.contains("\u{2265}$0.2500"),
650            "a partially priced total must be marked as a floor: {rendered}"
651        );
652        assert!(
653            rendered.contains("(1 unpriced)"),
654            "the count of unaccounted calls must be stated: {rendered}"
655        );
656        assert!(
657            rendered.contains("unpriced"),
658            "the unpriced call's own row must say so: {rendered}"
659        );
660    }
661
662    /// Two models that priced differently must not collapse to one number.
663    /// That equality was the shape of the original bug.
664    #[test]
665    fn two_models_priced_differently_do_not_collapse_to_one_number() {
666        let rendered = render_trace_entries(&[
667            entry("claude-sonnet-4-20250514", Some(0.2500)),
668            entry("claude-haiku-4-5-20251001", Some(0.0125)),
669        ]);
670        assert!(
671            rendered.contains("$0.2500") && rendered.contains("$0.0125"),
672            "each call must show its own price: {rendered}"
673        );
674    }
675
676    #[test]
677    fn mixed_retry_keeps_known_spend_and_physical_uncertainty() {
678        let mut retried = entry("retrying-model", None);
679        retried.usage.input_tokens = 7;
680        retried.usage.output_tokens = 5;
681        retried.usage.known_cost_usd = 0.0123;
682        retried.usage.provider_call_count = 2;
683        retried.usage.unpriced_calls = 1;
684        retried.usage.usage_unknown_calls = 1;
685
686        let rendered = render_trace_entries(&[retried]);
687        assert!(
688            rendered.contains("1 logical call, 2 provider calls"),
689            "physical retries must not collapse to one logical trace row: {rendered}"
690        );
691        assert!(
692            rendered.contains("≥12 tokens (1 usage unknown)"),
693            "known tokens must be labeled as a lower bound: {rendered}"
694        );
695        assert!(
696            rendered.matches("≥$0.0123 (1 unpriced)").count() >= 2,
697            "both the row and total must retain the priced sibling's lower bound: {rendered}"
698        );
699    }
700
701    #[test]
702    fn json_summary_projects_the_canonical_physical_certainty() {
703        let summary = run_summary_llm_from_parts(
704            7,
705            5,
706            9,
707            1,
708            UsageCostCertainty {
709                known_cost_usd: 0.0123,
710                provider_call_count: 2,
711                unpriced_calls: 1,
712                usage_unknown_calls: 1,
713            },
714        );
715        assert_eq!(summary.call_count, 1);
716        assert_eq!(summary.provider_call_count, 2);
717        assert_eq!(summary.cost_usd, None);
718        assert_eq!(summary.known_cost_usd, 0.0123);
719        assert_eq!(summary.unpriced_calls, 1);
720        assert_eq!(summary.usage_unknown_calls, 1);
721    }
722}