Skip to main content

candle_graph/cli/
trace_cli.rs

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