Skip to main content

candle_graph/cli/
trace_cli.rs

1//! Evidence CLI engine for trace/10, evidence/4, comparison/6, and atomic bundles.
2
3use std::collections::BTreeSet;
4use std::fs;
5use std::path::{Component, Path, PathBuf};
6
7use anyhow::{bail, ensure, Context, Result};
8use serde::Serialize;
9
10use crate::artifact::{
11    publish_bundle, verify_bundle, verify_consumed_bundle_files, BundleVerificationReceipt,
12};
13use crate::campaign::CaptureState;
14use crate::comparison::{compare_unverified_traces, compare_verified_bundles, ComparisonVerdict};
15use crate::evidence::{build_evidence, EvidencePacket};
16use crate::graph::{ExecutionGraph, GraphNode, GraphNodeKind};
17use crate::nsight::{GpuEvidenceStatus, ProvenanceBindingState};
18use crate::publication::{PublicationReceipt, PublicationStatus, PUBLICATION_SCHEMA};
19use crate::trace::{parse_trace, HealthSeverity};
20
21const QUERY_ROW_LIMIT: usize = 50;
22const QUERY_LABEL_LIMIT: usize = 100;
23const QUERY_DIAGNOSTIC_LIMIT: usize = 50;
24const OVERVIEW_LIST_LIMIT: usize = 20;
25const OVERVIEW_TIMING_SPAN_LIMIT: usize = 5;
26const SUMMARY_SCHEMA: &str = "candle-graph/summary/5";
27const QUERY_SCHEMA: &str = "candle-graph/trace-query/5";
28const OVERVIEW_SCHEMA: &str = "candle-graph/overview/1";
29const VERIFY_SCHEMA: &str = "candle-graph/verify/1";
30const PROTOCOL_SCHEMA: &str = "candle-graph/protocol/1";
31
32/// Emitting-tool identity attached to every CLI envelope.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34pub struct ToolIdentity {
35    pub package: &'static str,
36    pub version: &'static str,
37}
38
39impl ToolIdentity {
40    pub fn current() -> Self {
41        Self {
42            package: "candle-graph",
43            version: env!("CARGO_PKG_VERSION"),
44        }
45    }
46}
47
48/// Label filter for query kinds that carry semantic labels.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum QueryLabelFilter {
51    Exact(String),
52    Prefix(String),
53}
54
55impl QueryLabelFilter {
56    fn matches(&self, value: &str) -> bool {
57        match self {
58            Self::Exact(label) => value == label,
59            Self::Prefix(prefix) => value.starts_with(prefix.as_str()),
60        }
61    }
62
63    fn envelope(filter: Option<&Self>) -> serde_json::Value {
64        serde_json::json!({
65            "label": match filter {
66                Some(Self::Exact(label)) => Some(label.as_str()),
67                _ => None,
68            },
69            "label_prefix": match filter {
70                Some(Self::Prefix(prefix)) => Some(prefix.as_str()),
71                _ => None,
72            },
73        })
74    }
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78struct QueryAvailability {
79    status: GpuEvidenceStatus,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    reason: Option<String>,
82}
83
84impl QueryAvailability {
85    fn is_available(&self) -> bool {
86        self.status == GpuEvidenceStatus::Available
87    }
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub enum TraceQueryKind {
92    SlowestHost,
93    SlowestDevice,
94    Heaviest,
95    Memory,
96    Spans,
97    Tensors,
98    TensorStats,
99    Gradients,
100    Capabilities,
101    GpuStatus,
102    GpuCorrelation,
103    GpuPhases,
104    GpuKernels,
105    GpuAttributionGaps,
106}
107
108impl TraceQueryKind {
109    pub fn as_str(self) -> &'static str {
110        match self {
111            Self::SlowestHost => "slowest-host",
112            Self::SlowestDevice => "slowest-device",
113            Self::Heaviest => "heaviest",
114            Self::Memory => "memory",
115            Self::Spans => "spans",
116            Self::Tensors => "tensors",
117            Self::TensorStats => "tensor-stats",
118            Self::Gradients => "gradients",
119            Self::Capabilities => "capabilities",
120            Self::GpuStatus => "gpu-status",
121            Self::GpuCorrelation => "gpu-correlation",
122            Self::GpuPhases => "gpu-phases",
123            Self::GpuKernels => "gpu-kernels",
124            Self::GpuAttributionGaps => "gpu-attribution-gaps",
125        }
126    }
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
130#[serde(rename_all = "snake_case")]
131enum EvidenceInputKind {
132    RawTrace,
133    VerifiedBundle,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
137struct EvidenceInput {
138    kind: EvidenceInputKind,
139    requested_path: PathBuf,
140    trace_path: PathBuf,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    bundle_root: Option<PathBuf>,
143    evidence_source: &'static str,
144    gpu_identity_bound: bool,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    verification: Option<BundleVerificationReceipt>,
147}
148
149struct LoadedEvidence {
150    packet: EvidencePacket,
151    input: EvidenceInput,
152}
153
154/// Load a raw trace or the verified evidence packet for a finalized bundle/profile directory.
155/// A `trace.jsonl` inside a bundle resolves to that bundle so normalized GPU evidence is retained.
156pub fn load_evidence(input: &Path) -> Result<EvidencePacket> {
157    Ok(load_evidence_input(input)?.packet)
158}
159
160fn load_evidence_input(input: &Path) -> Result<LoadedEvidence> {
161    if !input.exists() {
162        bail!("evidence input does not exist: {}", input.display());
163    }
164
165    if input.is_dir() {
166        if input.join("bundle.json").exists() {
167            return load_verified_bundle(input, input);
168        }
169        let trace = input.join("trace.jsonl");
170        ensure!(
171            trace.is_file(),
172            "input directory is neither a finalized bundle nor a raw-trace directory: {}",
173            input.display()
174        );
175        reject_unverified_augmented_parent(input)?;
176        return load_raw_trace(input, &trace);
177    }
178
179    ensure!(
180        input.is_file(),
181        "evidence input is not a regular file: {}",
182        input.display()
183    );
184    let parent = input.parent().unwrap_or_else(|| Path::new("."));
185    if parent.join("bundle.json").exists() {
186        return load_verified_bundle(input, parent);
187    }
188    reject_unverified_augmented_parent(parent)?;
189    load_raw_trace(input, input)
190}
191
192fn load_raw_trace(requested_path: &Path, trace_path: &Path) -> Result<LoadedEvidence> {
193    let packet = build_evidence(trace_path, None)?;
194    Ok(LoadedEvidence {
195        input: EvidenceInput {
196            kind: EvidenceInputKind::RawTrace,
197            requested_path: requested_path.to_path_buf(),
198            trace_path: trace_path.to_path_buf(),
199            bundle_root: None,
200            evidence_source: "trace_reconstruction",
201            gpu_identity_bound: false,
202            verification: None,
203        },
204        packet,
205    })
206}
207
208fn load_verified_bundle(requested_path: &Path, root: &Path) -> Result<LoadedEvidence> {
209    let verification = verify_bundle(root)
210        .with_context(|| format!("verify evidence bundle {}", root.display()))?;
211    let trace_path = root.join("trace.jsonl");
212    let document = parse_trace(&trace_path)
213        .with_context(|| format!("parse verified bundle trace {}", trace_path.display()))?;
214    ensure!(
215        verification.run_id == document.run.run_id,
216        "verified bundle manifest run ID {:?} does not match trace run ID {:?}",
217        verification.run_id,
218        document.run.run_id
219    );
220
221    let evidence_path = root.join("evidence.json");
222    let packet: EvidencePacket =
223        serde_json::from_slice(&fs::read(&evidence_path).with_context(|| {
224            format!("read verified evidence packet {}", evidence_path.display())
225        })?)
226        .with_context(|| format!("parse verified evidence packet {}", evidence_path.display()))?;
227    packet.validate_schema()?;
228    ensure!(
229        packet.provenance == document.run,
230        "verified evidence packet provenance does not match its trace metadata"
231    );
232    verify_consumed_bundle_files(root, &verification, &["trace.jsonl", "evidence.json"])
233        .with_context(|| {
234            format!(
235                "post-read verify consumed files in evidence bundle {}",
236                root.display()
237            )
238        })?;
239
240    let gpu_identity_bound = packet.gpu.provenance.binding == ProvenanceBindingState::Bound;
241    Ok(LoadedEvidence {
242        packet,
243        input: EvidenceInput {
244            kind: EvidenceInputKind::VerifiedBundle,
245            requested_path: requested_path.to_path_buf(),
246            trace_path,
247            bundle_root: Some(root.to_path_buf()),
248            evidence_source: "verified_evidence_json",
249            gpu_identity_bound,
250            verification: Some(verification),
251        },
252    })
253}
254
255fn reject_unverified_augmented_parent(root: &Path) -> Result<()> {
256    let evidence = root.join("evidence.json");
257    let nsight = root.join("nsight");
258    if evidence.exists() || nsight.exists() {
259        bail!(
260            "input parent {} contains augmented evidence but no bundle.json; refusing to discard unverified GPU evidence and rebuild as trace-only",
261            root.display()
262        );
263    }
264    Ok(())
265}
266
267fn reject_output_inside_verified_bundle(
268    input: &EvidenceInput,
269    output: Option<&Path>,
270) -> Result<()> {
271    let (Some(root), Some(output)) = (input.bundle_root.as_deref(), output) else {
272        return Ok(());
273    };
274    reject_output_inside_bundle_root(root, output)
275}
276
277fn reject_output_inside_bundle_root(root: &Path, output: &Path) -> Result<()> {
278    let resolved_root = fs::canonicalize(root)
279        .with_context(|| format!("resolve verified bundle root {}", root.display()))?;
280    let (resolved_output, traversed_bundle) = resolve_write_path(output, &resolved_root)?;
281    if traversed_bundle || resolved_output.starts_with(&resolved_root) {
282        bail!(
283            "refusing to write command output {} inside verified bundle {}",
284            output.display(),
285            root.display()
286        );
287    }
288    Ok(())
289}
290
291/// Bundle root that would be invalidated by writing next to `input`, if `input` is a finalized
292/// bundle directory or a file directly inside one.
293fn containing_bundle_root(input: &Path) -> Option<&Path> {
294    if input.is_dir() && input.join("bundle.json").is_file() {
295        return Some(input);
296    }
297    input
298        .parent()
299        .filter(|parent| parent.join("bundle.json").is_file())
300}
301
302/// Resolve every existing path component (including symbolic links), while retaining a normalized
303/// suffix for a not-yet-created output. This mirrors the path that `create_dir_all`/`write` would
304/// reach closely enough to reject lexical `..` and pre-existing symlink aliases into a bundle.
305fn resolve_write_path(path: &Path, forbidden_root: &Path) -> Result<(PathBuf, bool)> {
306    let absolute = if path.is_absolute() {
307        path.to_path_buf()
308    } else {
309        std::env::current_dir()
310            .context("resolve current directory for output path")?
311            .join(path)
312    };
313    let mut resolved = PathBuf::new();
314    let mut traversed_forbidden_root = false;
315    for component in absolute.components() {
316        match component {
317            Component::Prefix(prefix) => resolved.push(prefix.as_os_str()),
318            Component::RootDir => resolved.push(component.as_os_str()),
319            Component::CurDir => {}
320            Component::ParentDir => {
321                resolved.pop();
322            }
323            Component::Normal(name) => {
324                resolved.push(name);
325                match fs::symlink_metadata(&resolved) {
326                    Ok(_) => {
327                        resolved = fs::canonicalize(&resolved).with_context(|| {
328                            format!("resolve output path component {}", resolved.display())
329                        })?;
330                    }
331                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
332                    Err(error) => {
333                        return Err(error).with_context(|| {
334                            format!("inspect output path component {}", resolved.display())
335                        });
336                    }
337                }
338            }
339        }
340        traversed_forbidden_root |= resolved.starts_with(forbidden_root);
341    }
342    Ok((resolved, traversed_forbidden_root))
343}
344
345pub fn run_import(trace_path: &Path, output: Option<&Path>) -> Result<()> {
346    let loaded = load_evidence_input(trace_path)?;
347    reject_output_inside_verified_bundle(&loaded.input, output)?;
348    super::write_output(
349        output,
350        (serde_json::to_string_pretty(&loaded.packet)? + "\n").as_bytes(),
351    )
352}
353
354pub fn run_summary(input_path: &Path, output: Option<&Path>, require_valid: bool) -> Result<()> {
355    let loaded = load_evidence_input(input_path)?;
356    reject_output_inside_verified_bundle(&loaded.input, output)?;
357    let evidence = &loaded.packet;
358    let rendered = serde_json::to_string_pretty(&serde_json::json!({
359        "schema": SUMMARY_SCHEMA,
360        "tool": ToolIdentity::current(),
361        "input": &loaded.input,
362        "provenance": &evidence.provenance,
363        "health": &evidence.health,
364        "capabilities": &evidence.capabilities,
365        "findings": &evidence.findings,
366        "gaps": &evidence.gaps,
367        "summary": evidence.graph.as_ref().map(|graph| &graph.summary),
368        "tensor_stats": {
369            "events": evidence.tensor_stats.len(),
370            "non_finite_events": evidence.tensor_stats.iter().filter(|event| event.non_finite > 0).count(),
371        },
372        "timing": &evidence.timing,
373        "memory": &evidence.memory,
374        "gpu": gpu_summary(evidence),
375    }))? + "\n";
376    super::write_output(output, rendered.as_bytes())?;
377    if require_valid {
378        let mut failures = Vec::new();
379        if !evidence.health.structurally_valid {
380            failures.push("structurally_valid is false");
381        }
382        if !evidence.health.capture_complete {
383            failures.push("capture_complete is false");
384        }
385        if !failures.is_empty() {
386            bail!("summary --require-valid: {}", failures.join(", "));
387        }
388    }
389    Ok(())
390}
391
392pub fn run_query(
393    input_path: &Path,
394    kind: TraceQueryKind,
395    filter: Option<&QueryLabelFilter>,
396    output: Option<&Path>,
397) -> Result<()> {
398    let loaded = load_evidence_input(input_path)?;
399    reject_output_inside_verified_bundle(&loaded.input, output)?;
400    let evidence = &loaded.packet;
401    let result = match (kind, filter) {
402        (TraceQueryKind::TensorStats, Some(filter)) => {
403            let matched = evidence
404                .tensor_stats
405                .iter()
406                .filter(|event| filter.matches(&event.label))
407                .collect::<Vec<_>>();
408            filtered_rows(evidence.tensor_stats.len(), matched)
409        }
410        (
411            TraceQueryKind::Spans | TraceQueryKind::Tensors | TraceQueryKind::Gradients,
412            Some(filter),
413        ) => {
414            let graph = evidence
415                .graph
416                .as_ref()
417                .context("query requires a complete, structurally valid capture")?;
418            query_graph_filtered(graph, kind, filter)
419        }
420        (other, Some(_)) => {
421            bail!(
422                "query kind {} does not support label filtering",
423                other.as_str()
424            );
425        }
426        (TraceQueryKind::Memory, None) => serde_json::to_value(&evidence.memory)?,
427        (TraceQueryKind::TensorStats, None) => serde_json::to_value(&evidence.tensor_stats)?,
428        (TraceQueryKind::Capabilities, None) => serde_json::to_value(&evidence.capabilities)?,
429        (TraceQueryKind::GpuStatus, None) => query_gpu_status(evidence),
430        (TraceQueryKind::GpuCorrelation, None) => query_gpu_correlation(evidence),
431        (TraceQueryKind::GpuPhases, None) => query_gpu_phases(evidence),
432        (TraceQueryKind::GpuKernels, None) => query_gpu_kernels(evidence),
433        (TraceQueryKind::GpuAttributionGaps, None) => query_gpu_attribution_gaps(evidence),
434        (other, None) => {
435            let graph = evidence
436                .graph
437                .as_ref()
438                .context("query requires a complete, structurally valid capture")?;
439            query_graph(graph, other)
440        }
441    };
442    let rendered = serde_json::to_string_pretty(&serde_json::json!({
443        "schema": QUERY_SCHEMA,
444        "tool": ToolIdentity::current(),
445        "kind": kind.as_str(),
446        "filter": QueryLabelFilter::envelope(filter),
447        "input": &loaded.input,
448        "capabilities": &evidence.capabilities,
449        "result": result,
450    }))? + "\n";
451    super::write_output(output, rendered.as_bytes())
452}
453
454/// Complete (untruncated) label-filtered rows, so exact export stays possible.
455fn filtered_rows<T: Serialize>(total: usize, matched: Vec<T>) -> serde_json::Value {
456    serde_json::json!({
457        "total": total,
458        "matched": matched.len(),
459        "rows": matched,
460    })
461}
462
463fn query_graph_filtered(
464    graph: &ExecutionGraph,
465    kind: TraceQueryKind,
466    filter: &QueryLabelFilter,
467) -> serde_json::Value {
468    match kind {
469        TraceQueryKind::Spans => {
470            let matched = graph
471                .spans
472                .iter()
473                .filter(|node| filter.matches(&node.name))
474                .collect::<Vec<_>>();
475            let mut result = filtered_rows(graph.spans.len(), matched);
476            result["edges"] = serde_json::Value::Null;
477            result["edges_reason"] = serde_json::Value::String(
478                "edges are omitted for filtered span queries; run without a filter for the full span graph".into(),
479            );
480            result
481        }
482        TraceQueryKind::Tensors => {
483            let matched = graph
484                .tensors
485                .iter()
486                .filter(|tensor| {
487                    filter.matches(tensor.label.as_deref().unwrap_or(&tensor.tensor_id))
488                })
489                .collect::<Vec<_>>();
490            filtered_rows(graph.tensors.len(), matched)
491        }
492        TraceQueryKind::Gradients => {
493            let matched = graph
494                .gradients
495                .iter()
496                .filter(|gradient| {
497                    filter.matches(&gradient.key)
498                        || filter.matches(&format!("{}/{}", gradient.root, gradient.key))
499                })
500                .collect::<Vec<_>>();
501            filtered_rows(graph.gradients.len(), matched)
502        }
503        _ => unreachable!("label filtering is dispatched only for spans, tensors, and gradients"),
504    }
505}
506
507#[cfg(feature = "visualizer")]
508pub fn run_view(trace_path: &Path, output: &Path, nsight_dir: Option<&Path>) -> Result<()> {
509    if let Some(root) = containing_bundle_root(trace_path) {
510        reject_output_inside_bundle_root(root, output)?;
511        ensure!(
512            nsight_dir.is_none(),
513            "bundle inputs already bind their Nsight evidence; drop --nsight-dir for {}",
514            trace_path.display()
515        );
516        let loaded = load_evidence_input(trace_path)?;
517        return super::write_output(
518            Some(output),
519            crate::viewer::render_evidence_html(&loaded.packet).as_bytes(),
520        );
521    }
522    let evidence = build_evidence(trace_path, nsight_dir)?;
523    super::write_output(
524        Some(output),
525        crate::viewer::render_evidence_html(&evidence).as_bytes(),
526    )
527}
528
529pub fn run_compare(
530    baseline: &[PathBuf],
531    candidate: &[PathBuf],
532    unverified_traces: bool,
533    require_eligible: bool,
534    output: Option<&Path>,
535) -> Result<()> {
536    let comparison = if unverified_traces {
537        let parse_all = |paths: &[PathBuf], cohort: &str| -> Result<Vec<_>> {
538            paths
539                .iter()
540                .map(|path| {
541                    parse_trace(path)
542                        .with_context(|| format!("parse unverified {cohort} {}", path.display()))
543                })
544                .collect()
545        };
546        compare_unverified_traces(
547            &parse_all(baseline, "baseline")?,
548            &parse_all(candidate, "candidate")?,
549        )
550    } else {
551        if let Some(output) = output {
552            for root in baseline.iter().chain(candidate) {
553                reject_output_inside_bundle_root(root, output)?;
554            }
555        }
556        compare_verified_bundles(baseline, candidate)?
557    };
558    super::write_output(
559        output,
560        (serde_json::to_string_pretty(&comparison)? + "\n").as_bytes(),
561    )?;
562    if require_eligible && comparison.verdict == ComparisonVerdict::Ineligible {
563        let codes = comparison
564            .reasons
565            .iter()
566            .map(|reason| {
567                serde_json::to_value(reason.code)
568                    .ok()
569                    .and_then(|value| value.as_str().map(str::to_owned))
570                    .expect("comparison reason codes serialize to snake_case strings")
571            })
572            .collect::<BTreeSet<_>>();
573        bail!(
574            "comparison ineligible: {}",
575            codes.into_iter().collect::<Vec<_>>().join(", ")
576        );
577    }
578    Ok(())
579}
580
581pub fn run_report(
582    trace: &Path,
583    nsight_dir: Option<&Path>,
584    bundle: &Path,
585    output: Option<&Path>,
586) -> Result<()> {
587    for ancestor in bundle.ancestors().skip(1) {
588        ensure!(
589            !ancestor.join("bundle.json").is_file(),
590            "refusing to publish bundle {} inside existing bundle {}",
591            bundle.display(),
592            ancestor.display()
593        );
594    }
595    publish_bundle(bundle, trace, nsight_dir)?;
596    let verification = verify_bundle(bundle)
597        .with_context(|| format!("deep-verify published bundle {}", bundle.display()))?;
598    if let Some(output) = output {
599        reject_output_inside_bundle_root(bundle, output)?;
600    }
601    let receipt = PublicationReceipt {
602        schema: PUBLICATION_SCHEMA.into(),
603        status: PublicationStatus::Published,
604        bundle_path: bundle.to_path_buf(),
605        run_id: verification.run_id.clone(),
606        verification,
607    };
608    super::write_output(
609        output,
610        (serde_json::to_string_pretty(&receipt)? + "\n").as_bytes(),
611    )
612}
613
614pub fn run_verify(bundle: &Path, semantic: bool, output: Option<&Path>) -> Result<()> {
615    let receipt = verify_bundle(bundle)?;
616    if let Some(output) = output {
617        reject_output_inside_bundle_root(bundle, output)?;
618    }
619    let semantic_result = if semantic {
620        Some(verify_semantic(bundle)?)
621    } else {
622        None
623    };
624    let rendered = serde_json::to_string_pretty(&serde_json::json!({
625        "schema": VERIFY_SCHEMA,
626        "tool": ToolIdentity::current(),
627        "receipt": receipt,
628        "semantic": semantic_result,
629    }))? + "\n";
630    super::write_output(output, rendered.as_bytes())
631}
632
633/// Rederive the evidence packet from the bundle's retained inputs and require
634/// an exact match against the published `evidence.json`.
635///
636/// No path normalization is needed: the only path fields inside an evidence
637/// packet (`gpu.raw_report.path` and `gpu.source_csv[].path`) are recorded
638/// relative to the Nsight directory root, and publication itself derives
639/// `evidence.json` from the staged in-bundle `trace.jsonl` and `nsight/`
640/// directory — the same paths this rederivation consumes.
641fn verify_semantic(bundle: &Path) -> Result<serde_json::Value> {
642    let nsight = bundle.join("nsight");
643    let nsight_dir = nsight.is_dir().then_some(nsight.as_path());
644    let rederived = build_evidence(&bundle.join("trace.jsonl"), nsight_dir)
645        .with_context(|| format!("rederive evidence for bundle {}", bundle.display()))?;
646    let rederived = serde_json::to_value(&rederived)?;
647    let evidence_path = bundle.join("evidence.json");
648    let published: serde_json::Value = serde_json::from_slice(
649        &fs::read(&evidence_path)
650            .with_context(|| format!("read published evidence {}", evidence_path.display()))?,
651    )
652    .with_context(|| format!("parse published evidence {}", evidence_path.display()))?;
653    if rederived != published {
654        bail!(
655            "semantic verification failed: rederived evidence differs from published evidence.json for {}",
656            bundle.display()
657        );
658    }
659    Ok(serde_json::json!({ "status": "rederived_match" }))
660}
661
662/// Bounded first look at a raw trace or verified bundle for agents: health and
663/// evidence counts, truncated finding/gap lists, and headline scalars only —
664/// never an unbounded collection.
665pub fn run_overview(input_path: &Path, output: Option<&Path>) -> Result<()> {
666    let loaded = load_evidence_input(input_path)?;
667    reject_output_inside_verified_bundle(&loaded.input, output)?;
668    let evidence = &loaded.packet;
669    let health = &evidence.health;
670
671    let graph_summary = evidence.graph.as_ref().map(|graph| &graph.summary);
672    let timing = graph_summary.map(|summary| {
673        serde_json::json!({
674            "outer_wall_time_ns": summary.outer_wall_time_ns,
675            "entrypoint": &summary.entrypoint,
676            "slowest_host_spans": bounded_values(&summary.slowest_host_spans, OVERVIEW_TIMING_SPAN_LIMIT),
677        })
678    });
679    let timing_unavailable_reason = graph_summary.is_none().then_some(
680        "no derived execution graph: the capture is incomplete or structurally invalid, so no timing headline exists",
681    );
682
683    // Only scalar aggregates from the memory profiles; never per-storage lists.
684    let logical = evidence.memory.logical.as_ref();
685    let memory = serde_json::json!({
686        "logical": logical.is_some(),
687        "physical": evidence.memory.physical.is_some(),
688        "logical_totals": logical.map(|profile| serde_json::json!({
689            "storage_allocation_count": profile.storage_allocation_count,
690            "matched_storage_free_count": profile.matched_storage_free_count,
691            "total_allocated_bytes": profile.total_allocated_bytes,
692            "peak_live_bytes": profile.peak.as_ref().map(|peak| peak.live_bytes),
693            "peak_timestamp_ns": profile.peak.as_ref().map(|peak| peak.timestamp_ns),
694        })),
695    });
696
697    let rendered = serde_json::to_string_pretty(&serde_json::json!({
698        "schema": OVERVIEW_SCHEMA,
699        "tool": ToolIdentity::current(),
700        "input": &loaded.input,
701        "provenance": &evidence.provenance,
702        "health": {
703            "structurally_valid": health.structurally_valid,
704            "capture_complete": health.capture_complete,
705            "error_count": health.issues.iter().filter(|issue| issue.severity == HealthSeverity::Error).count(),
706            "warning_count": health.issues.iter().filter(|issue| issue.severity == HealthSeverity::Warning).count(),
707        },
708        "capabilities": &evidence.capabilities,
709        "findings": bounded_values(&evidence.findings, OVERVIEW_LIST_LIMIT),
710        "gaps": bounded_values(&evidence.gaps, OVERVIEW_LIST_LIMIT),
711        "counts": {
712            "tensor_stat_events": evidence.tensor_stats.len(),
713            "tensor_stat_non_finite_events": evidence.tensor_stats.iter().filter(|event| event.non_finite > 0).count(),
714            "graph_spans": evidence.graph.as_ref().map(|graph| graph.spans.len()),
715        },
716        "timing": timing,
717        "timing_unavailable_reason": timing_unavailable_reason,
718        "memory": memory,
719        "gpu": gpu_summary(evidence),
720    }))? + "\n";
721    super::write_output(output, rendered.as_bytes())
722}
723
724/// Emit the tool's complete versioned protocol: every schema it writes or
725/// reads, plus the subcommand catalog, sourced from the crate's exported
726/// constants.
727pub fn run_protocol(output: Option<&Path>) -> Result<()> {
728    let rendered = serde_json::to_string_pretty(&serde_json::json!({
729        "schema": PROTOCOL_SCHEMA,
730        "tool": ToolIdentity::current(),
731        "schemas": {
732            "trace_write": crate::trace::SCHEMA,
733            "trace_read": [crate::trace::schema::PREVIOUS_SCHEMA, crate::trace::SCHEMA],
734            "graph": crate::graph::SCHEMA,
735            "evidence": crate::evidence::SCHEMA,
736            "comparison": crate::comparison::SCHEMA,
737            "bundle": crate::artifact::SCHEMA,
738            "bundle_verification": crate::artifact::VERIFICATION_SCHEMA,
739            "publication": PUBLICATION_SCHEMA,
740            "campaign": crate::campaign::CAMPAIGN_SCHEMA,
741            "campaign_status": crate::campaign::CAMPAIGN_STATUS_SCHEMA,
742            "series": crate::campaign::SERIES_SCHEMA,
743            "summary": SUMMARY_SCHEMA,
744            "query": QUERY_SCHEMA,
745            "overview": OVERVIEW_SCHEMA,
746            "verify": VERIFY_SCHEMA,
747            "gradient_manifest": crate::capability::GRADIENT_MANIFEST_SCHEMA,
748            "nsight_capture": crate::nsight::CAPTURE_MANIFEST_SCHEMA,
749            "viewer": viewer_schema(),
750        },
751        "commands": crate::cli::args::command_catalog(),
752    }))? + "\n";
753    super::write_output(output, rendered.as_bytes())
754}
755
756fn viewer_schema() -> serde_json::Value {
757    #[cfg(feature = "visualizer")]
758    {
759        serde_json::Value::String(crate::viewer::trace_view::SCHEMA.into())
760    }
761    #[cfg(not(feature = "visualizer"))]
762    {
763        serde_json::Value::Null
764    }
765}
766
767/// Reconcile a campaign manifest against the bundles on disk.
768pub fn run_campaign_status(manifest: &Path, output: Option<&Path>) -> Result<()> {
769    let status = crate::campaign::campaign_status(manifest)?;
770    super::write_output(
771        output,
772        (serde_json::to_string_pretty(&status)? + "\n").as_bytes(),
773    )
774}
775
776/// Build a cross-run series report from a fully published campaign manifest or
777/// an explicit ordered list of verified bundle directories.
778pub fn run_series(
779    manifest: Option<&Path>,
780    bundles: &[PathBuf],
781    label_prefix: Option<&str>,
782    output: Option<&Path>,
783) -> Result<()> {
784    let roots = match manifest {
785        Some(manifest_path) => {
786            ensure!(
787                bundles.is_empty(),
788                "series accepts exactly one of --manifest or --bundle"
789            );
790            let status = crate::campaign::campaign_status(manifest_path)?;
791            let unpublished = status
792                .captures
793                .iter()
794                .filter(|capture| !matches!(capture.state, CaptureState::Published { .. }))
795                .map(|capture| {
796                    format!(
797                        "step {} ({}): {}",
798                        capture.capture_step,
799                        capture.bundle,
800                        capture_state_name(&capture.state)
801                    )
802                })
803                .collect::<Vec<_>>();
804            if !unpublished.is_empty() {
805                bail!(
806                    "series requires every planned capture to be published; not published: {}; run `campaign-status` for the full reconciliation",
807                    unpublished.join(", ")
808                );
809            }
810            let base = manifest_path.parent().unwrap_or_else(|| Path::new("."));
811            status
812                .captures
813                .iter()
814                .map(|capture| base.join(&capture.bundle))
815                .collect::<Vec<_>>()
816        }
817        None => {
818            ensure!(
819                !bundles.is_empty(),
820                "series requires exactly one of --manifest or --bundle"
821            );
822            bundles.to_vec()
823        }
824    };
825    let report = crate::campaign::build_series(&roots, label_prefix)?;
826    super::write_output(
827        output,
828        (serde_json::to_string_pretty(&report)? + "\n").as_bytes(),
829    )
830}
831
832fn capture_state_name(state: &CaptureState) -> &'static str {
833    match state {
834        CaptureState::Missing => "missing",
835        CaptureState::Published { .. } => "published",
836        CaptureState::FailedRun { .. } => "failed_run",
837        CaptureState::VerificationFailed { .. } => "verification_failed",
838        CaptureState::IdentityMismatch { .. } => "identity_mismatch",
839    }
840}
841
842fn query_graph(graph: &ExecutionGraph, kind: TraceQueryKind) -> serde_json::Value {
843    match kind {
844        TraceQueryKind::SlowestHost => serde_json::json!({
845            "entrypoint": graph.summary.entrypoint,
846            "outer_wall_time_ns": graph.summary.outer_wall_time_ns,
847            "slowest_host_spans": graph.summary.slowest_host_spans,
848        }),
849        TraceQueryKind::SlowestDevice => serde_json::json!({
850            "entrypoint": graph.summary.entrypoint,
851            "slowest_device_spans": graph.summary.slowest_device_spans,
852        }),
853        TraceQueryKind::Heaviest => serde_json::json!({
854            "entrypoint": graph.summary.entrypoint,
855            "heaviest_spans": graph.summary.heaviest_spans,
856            "heaviest_ops": sorted_nodes(graph, |node| matches!(node.kind, GraphNodeKind::Op) && node.allocated_bytes.is_some(), |node| node.allocated_bytes.unwrap_or(0)),
857        }),
858        TraceQueryKind::Spans => serde_json::json!({ "spans": graph.spans, "edges": graph.edges }),
859        TraceQueryKind::Tensors => serde_json::to_value(&graph.tensors).unwrap_or_default(),
860        TraceQueryKind::Gradients => serde_json::to_value(&graph.gradients).unwrap_or_default(),
861        TraceQueryKind::Memory
862        | TraceQueryKind::TensorStats
863        | TraceQueryKind::Capabilities
864        | TraceQueryKind::GpuStatus
865        | TraceQueryKind::GpuCorrelation
866        | TraceQueryKind::GpuPhases
867        | TraceQueryKind::GpuKernels
868        | TraceQueryKind::GpuAttributionGaps => {
869            unreachable!("handled without graph")
870        }
871    }
872}
873
874fn gpu_summary(evidence: &EvidencePacket) -> serde_json::Value {
875    let correlation = report_availability(
876        evidence,
877        "nvtx_gpu_proj_trace",
878        evidence.gpu.coverage.nvtx_projection,
879    );
880    let phase_attribution = combined_report_availability(
881        evidence,
882        &[
883            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
884            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
885        ],
886    );
887    serde_json::json!({
888        "status": evidence.gpu.status,
889        "reason": &evidence.gpu.reason,
890        "provenance_binding": evidence.gpu.provenance.binding,
891        "correlation": {
892            "status": correlation.status,
893            "reason": &correlation.reason,
894            "complete": correlation.is_available().then_some(evidence.gpu.correlation.complete),
895        },
896        "coverage": &evidence.gpu.coverage,
897        "normalized_rows": gpu_row_counts(evidence),
898        "attributed_phases": {
899            "status": phase_attribution.status,
900            "reason": &phase_attribution.reason,
901            "total": phase_attribution.is_available().then_some(evidence.gpu.phase_attribution.len()),
902        },
903        "diagnostic_count": evidence.gpu.diagnostics.len().saturating_add(evidence.gpu.provenance.diagnostics.len()),
904    })
905}
906
907fn query_gpu_status(evidence: &EvidencePacket) -> serde_json::Value {
908    let diagnostics = bounded_diagnostics(evidence);
909    let correlation = report_availability(
910        evidence,
911        "nvtx_gpu_proj_trace",
912        evidence.gpu.coverage.nvtx_projection,
913    );
914    serde_json::json!({
915        "status": evidence.gpu.status,
916        "reason": &evidence.gpu.reason,
917        "provenance_binding": evidence.gpu.provenance.binding,
918        "capabilities": {
919            "gpu_correlation": &evidence.capabilities.gpu_correlation,
920            "provenance_binding": &evidence.capabilities.provenance_binding,
921        },
922        "coverage": &evidence.gpu.coverage,
923        "correlation": {
924            "status": correlation.status,
925            "reason": &correlation.reason,
926            "complete": correlation.is_available().then_some(evidence.gpu.correlation.complete),
927        },
928        "normalized_rows": gpu_row_counts(evidence),
929        "source_artifacts": {
930            "raw_report": evidence.gpu.raw_report.is_some(),
931            "csv_files": evidence.gpu.source_csv.len(),
932        },
933        "diagnostics": bounded_values(&diagnostics, QUERY_DIAGNOSTIC_LIMIT),
934    })
935}
936
937fn query_gpu_correlation(evidence: &EvidencePacket) -> serde_json::Value {
938    let availability = report_availability(
939        evidence,
940        "nvtx_gpu_proj_trace",
941        evidence.gpu.coverage.nvtx_projection,
942    );
943    let required_reports = required_report_states(
944        evidence,
945        &[("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection)],
946    );
947    if !availability.is_available() {
948        return serde_json::json!({
949            "status": availability.status,
950            "reason": &availability.reason,
951            "provenance_binding": evidence.gpu.provenance.binding,
952            "required_reports": required_reports,
953            "capabilities": {
954                "gpu_correlation": &evidence.capabilities.gpu_correlation,
955                "provenance_binding": &evidence.capabilities.provenance_binding,
956            },
957            "mode": null,
958            "clock_aligned": null,
959            "complete": null,
960            "correlation_reason": null,
961            "ledger": null,
962        });
963    }
964    let ledger = &evidence.gpu.correlation.ledger;
965    let duplicates = ledger
966        .duplicates
967        .iter()
968        .take(QUERY_LABEL_LIMIT)
969        .collect::<Vec<_>>();
970    serde_json::json!({
971        "status": availability.status,
972        "reason": &availability.reason,
973        "provenance_binding": evidence.gpu.provenance.binding,
974        "required_reports": required_reports,
975        "capabilities": {
976            "gpu_correlation": &evidence.capabilities.gpu_correlation,
977            "provenance_binding": &evidence.capabilities.provenance_binding,
978        },
979        "mode": &evidence.gpu.correlation.mode,
980        "clock_aligned": evidence.gpu.correlation.clock_aligned,
981        "complete": evidence.gpu.correlation.complete,
982        "correlation_reason": &evidence.gpu.correlation.reason,
983        "ledger": {
984            "expected": bounded_values(&ledger.expected, QUERY_LABEL_LIMIT),
985            "cpu_only": bounded_values(&ledger.cpu_only, QUERY_LABEL_LIMIT),
986            "observed": bounded_values(&ledger.observed, QUERY_LABEL_LIMIT),
987            "matched": bounded_values(&ledger.matched, QUERY_LABEL_LIMIT),
988            "missing_expected": bounded_values(&ledger.missing_expected, QUERY_LABEL_LIMIT),
989            "unexpected_observed": bounded_values(&ledger.unexpected_observed, QUERY_LABEL_LIMIT),
990            "unexpected_cpu_only": bounded_values(&ledger.unexpected_cpu_only, QUERY_LABEL_LIMIT),
991            "duplicates": {
992                "total": ledger.duplicates.len(),
993                "displayed": duplicates.len(),
994                "truncated": duplicates.len() < ledger.duplicates.len(),
995                "rows": duplicates,
996            },
997        },
998    })
999}
1000
1001fn query_gpu_phases(evidence: &EvidencePacket) -> serde_json::Value {
1002    let projected_availability = report_availability(
1003        evidence,
1004        "nvtx_gpu_proj_trace",
1005        evidence.gpu.coverage.nvtx_projection,
1006    );
1007    let attributed_availability = combined_report_availability(
1008        evidence,
1009        &[
1010            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
1011            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
1012        ],
1013    );
1014    let required_reports = required_report_states(
1015        evidence,
1016        &[
1017            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
1018            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
1019        ],
1020    );
1021
1022    let mut projected = if projected_availability.is_available() {
1023        evidence.gpu.nvtx_ranges.iter().collect::<Vec<_>>()
1024    } else {
1025        Vec::new()
1026    };
1027    projected.sort_by(|left, right| {
1028        right
1029            .projected_duration_ns
1030            .unwrap_or_default()
1031            .cmp(&left.projected_duration_ns.unwrap_or_default())
1032            .then_with(|| left.name.cmp(&right.name))
1033    });
1034    let projected_sample_total = projected.len();
1035    projected.truncate(QUERY_ROW_LIMIT);
1036    let projected_rows = projected
1037        .into_iter()
1038        .map(|row| {
1039            let join_keys = [
1040                row.correlation_id.as_ref().map(|_| "correlation_id"),
1041                row.device.as_ref().map(|_| "device"),
1042                row.context.as_ref().map(|_| "context"),
1043                row.stream.as_ref().map(|_| "stream"),
1044            ]
1045            .into_iter()
1046            .flatten()
1047            .collect::<Vec<_>>();
1048            serde_json::json!({
1049                "name": &row.name,
1050                "semantic_key": &row.semantic_key,
1051                "projected_start_ns": row.projected_start_ns,
1052                "projected_duration_ns": row.projected_duration_ns,
1053                "declared_gpu_operations": row.gpu_operations,
1054                "join_keys": join_keys,
1055            })
1056        })
1057        .collect::<Vec<_>>();
1058    let projected_population_total = projected_availability
1059        .is_available()
1060        .then(|| total_report_rows(evidence, "nvtx_gpu_proj_trace", projected_sample_total));
1061
1062    let mut attributed = if attributed_availability.is_available() {
1063        evidence.gpu.phase_attribution.iter().collect::<Vec<_>>()
1064    } else {
1065        Vec::new()
1066    };
1067    attributed.sort_by(|left, right| {
1068        right
1069            .gpu_busy_ns
1070            .cmp(&left.gpu_busy_ns)
1071            .then_with(|| left.semantic_key.cmp(&right.semantic_key))
1072    });
1073    let attributed_total = attributed_availability
1074        .is_available()
1075        .then_some(attributed.len());
1076    attributed.truncate(QUERY_ROW_LIMIT);
1077
1078    serde_json::json!({
1079        "status": attributed_availability.status,
1080        "reason": &attributed_availability.reason,
1081        "provenance_binding": evidence.gpu.provenance.binding,
1082        "required_reports": required_reports,
1083        "clock_plane": "nsight_projected_not_host_aligned",
1084        "projected_ranges": {
1085            "status": projected_availability.status,
1086            "reason": &projected_availability.reason,
1087            "population_total": projected_population_total,
1088            "retained_sample_total": projected_availability.is_available().then_some(projected_sample_total),
1089            "displayed": projected_rows.len(),
1090            "population_truncated_before_ranking": projected_population_total.map(|total| projected_sample_total < total),
1091            "display_truncated": projected_availability.is_available().then_some(projected_rows.len() < projected_sample_total),
1092            "sample_selection": "earliest_original_start_rows",
1093            "ordering": "projected_duration_ns_desc_within_retained_sample",
1094            "global_duration_ranking": false,
1095            "rows": projected_rows,
1096        },
1097        "attributed_phases": {
1098            "status": attributed_availability.status,
1099            "reason": &attributed_availability.reason,
1100            "population_total": attributed_total,
1101            "displayed": attributed.len(),
1102            "display_truncated": attributed_total.map(|total| attributed.len() < total),
1103            "ordering": "gpu_busy_ns_desc_across_normalized_population",
1104            "rows": attributed,
1105        },
1106    })
1107}
1108
1109fn query_gpu_kernels(evidence: &EvidencePacket) -> serde_json::Value {
1110    let availability = report_availability(
1111        evidence,
1112        "cuda_gpu_kern_sum",
1113        evidence.gpu.coverage.kernel_summary,
1114    );
1115    let mut kernels = if availability.is_available() {
1116        evidence.gpu.kernels.iter().collect::<Vec<_>>()
1117    } else {
1118        Vec::new()
1119    };
1120    kernels.sort_by(|left, right| {
1121        right
1122            .total_ns
1123            .cmp(&left.total_ns)
1124            .then_with(|| left.name.cmp(&right.name))
1125    });
1126    let normalized_display_total = kernels.len();
1127    kernels.truncate(QUERY_ROW_LIMIT);
1128    let total = availability
1129        .is_available()
1130        .then(|| total_report_rows(evidence, "cuda_gpu_kern_sum", normalized_display_total));
1131    serde_json::json!({
1132        "status": availability.status,
1133        "reason": &availability.reason,
1134        "provenance_binding": evidence.gpu.provenance.binding,
1135        "required_reports": required_report_states(
1136            evidence,
1137            &[("cuda_gpu_kern_sum", evidence.gpu.coverage.kernel_summary)],
1138        ),
1139        "clock_plane": "nsight_gpu",
1140        "population_total": total,
1141        "displayed": kernels.len(),
1142        "display_truncated": total.map(|total| kernels.len() < total),
1143        "rows": kernels,
1144    })
1145}
1146
1147fn query_gpu_attribution_gaps(evidence: &EvidencePacket) -> serde_json::Value {
1148    let availability = combined_report_availability(
1149        evidence,
1150        &[
1151            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
1152            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
1153        ],
1154    );
1155    let required_reports = required_report_states(
1156        evidence,
1157        &[
1158            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
1159            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
1160        ],
1161    );
1162    if !availability.is_available() {
1163        return serde_json::json!({
1164            "status": availability.status,
1165            "reason": &availability.reason,
1166            "provenance_binding": evidence.gpu.provenance.binding,
1167            "required_reports": required_reports,
1168            "correlation_complete": null,
1169            "correlation_reason": null,
1170            "missing_expected": null,
1171            "unexpected_observed": null,
1172            "unexpected_cpu_only": null,
1173            "duplicate_labels": null,
1174            "matched_without_exact_gpu_busy_attribution": null,
1175            "attributed_unexpected": null,
1176            "projected_range_rows": null,
1177            "exactly_attributed_phase_rows": null,
1178            "truncated_reports": null,
1179            "diagnostics": bounded_values(&bounded_diagnostics(evidence), QUERY_DIAGNOSTIC_LIMIT),
1180        });
1181    }
1182    let ledger = &evidence.gpu.correlation.ledger;
1183    let attributed = evidence
1184        .gpu
1185        .phase_attribution
1186        .iter()
1187        .map(|phase| phase.semantic_key.as_str())
1188        .collect::<BTreeSet<_>>();
1189    let expected = ledger
1190        .expected
1191        .iter()
1192        .map(String::as_str)
1193        .collect::<BTreeSet<_>>();
1194    let matched_without_attribution = ledger
1195        .matched
1196        .iter()
1197        .filter(|label| !attributed.contains(label.as_str()))
1198        .cloned()
1199        .collect::<Vec<_>>();
1200    let attributed_unexpected = attributed
1201        .difference(&expected)
1202        .map(|label| (*label).to_string())
1203        .collect::<Vec<_>>();
1204    let truncated_reports = evidence
1205        .gpu
1206        .limits
1207        .iter()
1208        .filter(|(_, limit)| limit.truncated)
1209        .map(|(report, limit)| {
1210            serde_json::json!({
1211                "report": report,
1212                "total_rows": limit.total_rows,
1213                "displayed_rows": limit.displayed_rows,
1214            })
1215        })
1216        .take(QUERY_ROW_LIMIT)
1217        .collect::<Vec<_>>();
1218    let diagnostics = bounded_diagnostics(evidence);
1219
1220    serde_json::json!({
1221        "status": availability.status,
1222        "reason": &availability.reason,
1223        "provenance_binding": evidence.gpu.provenance.binding,
1224        "required_reports": required_reports,
1225        "correlation_complete": evidence.gpu.correlation.complete,
1226        "correlation_reason": &evidence.gpu.correlation.reason,
1227        "missing_expected": bounded_values(&ledger.missing_expected, QUERY_LABEL_LIMIT),
1228        "unexpected_observed": bounded_values(&ledger.unexpected_observed, QUERY_LABEL_LIMIT),
1229        "unexpected_cpu_only": bounded_values(&ledger.unexpected_cpu_only, QUERY_LABEL_LIMIT),
1230        "duplicate_labels": bounded_values(&ledger.duplicates, QUERY_LABEL_LIMIT),
1231        "matched_without_exact_gpu_busy_attribution": bounded_values(&matched_without_attribution, QUERY_LABEL_LIMIT),
1232        "attributed_unexpected": bounded_values(&attributed_unexpected, QUERY_LABEL_LIMIT),
1233        "projected_range_rows": total_report_rows(evidence, "nvtx_gpu_proj_trace", evidence.gpu.nvtx_ranges.len()),
1234        "exactly_attributed_phase_rows": evidence.gpu.phase_attribution.len(),
1235        "truncated_reports": {
1236            "total": evidence.gpu.limits.values().filter(|limit| limit.truncated).count(),
1237            "displayed": truncated_reports.len(),
1238            "truncated": truncated_reports.len() < evidence.gpu.limits.values().filter(|limit| limit.truncated).count(),
1239            "rows": truncated_reports,
1240        },
1241        "diagnostics": bounded_values(&diagnostics, QUERY_DIAGNOSTIC_LIMIT),
1242    })
1243}
1244
1245fn gpu_row_counts(evidence: &EvidencePacket) -> serde_json::Value {
1246    serde_json::json!({
1247        "kernels": report_row_count(evidence, "cuda_gpu_kern_sum", evidence.gpu.coverage.kernel_summary, evidence.gpu.kernels.len()),
1248        "runtime_calls": report_row_count(evidence, "cuda_api_sum", evidence.gpu.coverage.runtime_summary, evidence.gpu.runtime_calls.len()),
1249        "memory_operations": report_row_count(evidence, "cuda_gpu_mem_time_sum", evidence.gpu.coverage.memory_summary, evidence.gpu.memory_operations.len()),
1250        "projected_ranges": report_row_count(evidence, "nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection, evidence.gpu.nvtx_ranges.len()),
1251        "gpu_timeline": report_row_count(evidence, "cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline, evidence.gpu.gpu_timeline.len()),
1252    })
1253}
1254
1255fn report_row_count(
1256    evidence: &EvidencePacket,
1257    report_kind: &str,
1258    covered: bool,
1259    fallback: usize,
1260) -> serde_json::Value {
1261    let availability = report_availability(evidence, report_kind, covered);
1262    serde_json::json!({
1263        "status": availability.status,
1264        "reason": &availability.reason,
1265        "total": availability.is_available().then(|| total_report_rows(evidence, report_kind, fallback)),
1266    })
1267}
1268
1269fn report_availability(
1270    evidence: &EvidencePacket,
1271    report_kind: &str,
1272    covered: bool,
1273) -> QueryAvailability {
1274    let parse_failed = evidence
1275        .gpu
1276        .diagnostics
1277        .iter()
1278        .any(|diagnostic| diagnostic.contains(report_kind));
1279    if covered && !parse_failed {
1280        return QueryAvailability {
1281            status: GpuEvidenceStatus::Available,
1282            reason: None,
1283        };
1284    }
1285    let failed = parse_failed || evidence.gpu.status == GpuEvidenceStatus::Failed;
1286    QueryAvailability {
1287        status: if failed {
1288            GpuEvidenceStatus::Failed
1289        } else {
1290            GpuEvidenceStatus::Unavailable
1291        },
1292        reason: Some(if failed {
1293            format!(
1294                "Nsight report `{report_kind}` failed to normalize; its row population is unknown, not zero"
1295            )
1296        } else {
1297            format!(
1298                "Nsight report `{report_kind}` was not normalized; its row population is unknown, not zero"
1299            )
1300        }),
1301    }
1302}
1303
1304fn combined_report_availability(
1305    evidence: &EvidencePacket,
1306    reports: &[(&str, bool)],
1307) -> QueryAvailability {
1308    let unavailable = reports
1309        .iter()
1310        .filter_map(|(report, covered)| {
1311            let availability = report_availability(evidence, report, *covered);
1312            (!availability.is_available()).then_some((*report, availability.status))
1313        })
1314        .collect::<Vec<_>>();
1315    if unavailable.is_empty() {
1316        return QueryAvailability {
1317            status: GpuEvidenceStatus::Available,
1318            reason: None,
1319        };
1320    }
1321    let failed = unavailable
1322        .iter()
1323        .any(|(_, status)| *status == GpuEvidenceStatus::Failed);
1324    let names = unavailable
1325        .iter()
1326        .map(|(report, _)| *report)
1327        .collect::<Vec<_>>();
1328    QueryAvailability {
1329        status: if failed {
1330            GpuEvidenceStatus::Failed
1331        } else {
1332            GpuEvidenceStatus::Unavailable
1333        },
1334        reason: Some(format!(
1335            "required normalized Nsight report(s) unavailable: {}; the derived result is unknown, not zero",
1336            names.join(", ")
1337        )),
1338    }
1339}
1340
1341fn required_report_states(
1342    evidence: &EvidencePacket,
1343    reports: &[(&str, bool)],
1344) -> serde_json::Value {
1345    let states = reports
1346        .iter()
1347        .map(|(report, covered)| {
1348            (
1349                (*report).to_string(),
1350                serde_json::to_value(report_availability(evidence, report, *covered))
1351                    .expect("query availability is serializable"),
1352            )
1353        })
1354        .collect::<std::collections::BTreeMap<_, _>>();
1355    serde_json::to_value(states).expect("required report states are serializable")
1356}
1357
1358fn total_report_rows(evidence: &EvidencePacket, report_kind: &str, fallback: usize) -> usize {
1359    let total = evidence
1360        .gpu
1361        .limits
1362        .iter()
1363        .filter(|(name, _)| name.contains(report_kind))
1364        .fold(0_usize, |sum, (_, limit)| {
1365            sum.saturating_add(limit.total_rows)
1366        });
1367    total.max(fallback)
1368}
1369
1370fn bounded_diagnostics(evidence: &EvidencePacket) -> Vec<String> {
1371    let mut diagnostics = evidence
1372        .gpu
1373        .provenance
1374        .diagnostics
1375        .iter()
1376        .chain(&evidence.gpu.diagnostics)
1377        .cloned()
1378        .collect::<Vec<_>>();
1379    diagnostics.sort();
1380    diagnostics.dedup();
1381    diagnostics
1382}
1383
1384fn bounded_values<T: Serialize>(values: &[T], limit: usize) -> serde_json::Value {
1385    let displayed = values.len().min(limit);
1386    serde_json::json!({
1387        "total": values.len(),
1388        "displayed": displayed,
1389        "truncated": displayed < values.len(),
1390        "rows": &values[..displayed],
1391    })
1392}
1393
1394fn sorted_nodes(
1395    graph: &ExecutionGraph,
1396    include: impl Fn(&GraphNode) -> bool,
1397    value: impl Fn(&GraphNode) -> u64,
1398) -> Vec<&GraphNode> {
1399    let mut nodes = graph
1400        .spans
1401        .iter()
1402        .filter(|node| include(node))
1403        .collect::<Vec<_>>();
1404    nodes.sort_by(|left, right| {
1405        value(right)
1406            .cmp(&value(left))
1407            .then_with(|| left.id.cmp(&right.id))
1408    });
1409    nodes.truncate(50);
1410    nodes
1411}