Skip to main content

candle_graph/cli/
trace_cli.rs

1//! Evidence CLI engine for trace/10, evidence/4, comparison/5, 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::comparison::{compare_unverified_traces, compare_verified_bundles};
14use crate::evidence::{build_evidence, EvidencePacket};
15use crate::graph::{ExecutionGraph, GraphNode, GraphNodeKind};
16use crate::nsight::{GpuEvidenceStatus, ProvenanceBindingState};
17use crate::trace::parse_trace;
18
19const QUERY_ROW_LIMIT: usize = 50;
20const QUERY_LABEL_LIMIT: usize = 100;
21const QUERY_DIAGNOSTIC_LIMIT: usize = 50;
22const SUMMARY_SCHEMA: &str = "candle-graph/summary/4";
23const QUERY_SCHEMA: &str = "candle-graph/trace-query/4";
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26struct QueryAvailability {
27    status: GpuEvidenceStatus,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    reason: Option<String>,
30}
31
32impl QueryAvailability {
33    fn is_available(&self) -> bool {
34        self.status == GpuEvidenceStatus::Available
35    }
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum TraceQueryKind {
40    SlowestHost,
41    SlowestDevice,
42    Heaviest,
43    Memory,
44    Spans,
45    Tensors,
46    TensorStats,
47    Gradients,
48    Capabilities,
49    GpuStatus,
50    GpuCorrelation,
51    GpuPhases,
52    GpuKernels,
53    GpuAttributionGaps,
54}
55
56impl TraceQueryKind {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            Self::SlowestHost => "slowest-host",
60            Self::SlowestDevice => "slowest-device",
61            Self::Heaviest => "heaviest",
62            Self::Memory => "memory",
63            Self::Spans => "spans",
64            Self::Tensors => "tensors",
65            Self::TensorStats => "tensor-stats",
66            Self::Gradients => "gradients",
67            Self::Capabilities => "capabilities",
68            Self::GpuStatus => "gpu-status",
69            Self::GpuCorrelation => "gpu-correlation",
70            Self::GpuPhases => "gpu-phases",
71            Self::GpuKernels => "gpu-kernels",
72            Self::GpuAttributionGaps => "gpu-attribution-gaps",
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "snake_case")]
79enum EvidenceInputKind {
80    RawTrace,
81    VerifiedBundle,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85struct EvidenceInput {
86    kind: EvidenceInputKind,
87    requested_path: PathBuf,
88    trace_path: PathBuf,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    bundle_root: Option<PathBuf>,
91    evidence_source: &'static str,
92    gpu_identity_bound: bool,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    verification: Option<BundleVerificationReceipt>,
95}
96
97struct LoadedEvidence {
98    packet: EvidencePacket,
99    input: EvidenceInput,
100}
101
102/// Load a raw trace or the verified evidence packet for a finalized bundle/profile directory.
103/// A `trace.jsonl` inside a bundle resolves to that bundle so normalized GPU evidence is retained.
104pub fn load_evidence(input: &Path) -> Result<EvidencePacket> {
105    Ok(load_evidence_input(input)?.packet)
106}
107
108fn load_evidence_input(input: &Path) -> Result<LoadedEvidence> {
109    if !input.exists() {
110        bail!("evidence input does not exist: {}", input.display());
111    }
112
113    if input.is_dir() {
114        if input.join("bundle.json").exists() {
115            return load_verified_bundle(input, input);
116        }
117        let trace = input.join("trace.jsonl");
118        ensure!(
119            trace.is_file(),
120            "input directory is neither a finalized bundle nor a raw-trace directory: {}",
121            input.display()
122        );
123        reject_unverified_augmented_parent(input)?;
124        return load_raw_trace(input, &trace);
125    }
126
127    ensure!(
128        input.is_file(),
129        "evidence input is not a regular file: {}",
130        input.display()
131    );
132    let parent = input.parent().unwrap_or_else(|| Path::new("."));
133    if parent.join("bundle.json").exists() {
134        return load_verified_bundle(input, parent);
135    }
136    reject_unverified_augmented_parent(parent)?;
137    load_raw_trace(input, input)
138}
139
140fn load_raw_trace(requested_path: &Path, trace_path: &Path) -> Result<LoadedEvidence> {
141    let packet = build_evidence(trace_path, None)?;
142    Ok(LoadedEvidence {
143        input: EvidenceInput {
144            kind: EvidenceInputKind::RawTrace,
145            requested_path: requested_path.to_path_buf(),
146            trace_path: trace_path.to_path_buf(),
147            bundle_root: None,
148            evidence_source: "trace_reconstruction",
149            gpu_identity_bound: false,
150            verification: None,
151        },
152        packet,
153    })
154}
155
156fn load_verified_bundle(requested_path: &Path, root: &Path) -> Result<LoadedEvidence> {
157    let verification = verify_bundle(root)
158        .with_context(|| format!("verify evidence bundle {}", root.display()))?;
159    let trace_path = root.join("trace.jsonl");
160    let document = parse_trace(&trace_path)
161        .with_context(|| format!("parse verified bundle trace {}", trace_path.display()))?;
162    ensure!(
163        verification.run_id == document.run.run_id,
164        "verified bundle manifest run ID {:?} does not match trace run ID {:?}",
165        verification.run_id,
166        document.run.run_id
167    );
168
169    let evidence_path = root.join("evidence.json");
170    let packet: EvidencePacket =
171        serde_json::from_slice(&fs::read(&evidence_path).with_context(|| {
172            format!("read verified evidence packet {}", evidence_path.display())
173        })?)
174        .with_context(|| format!("parse verified evidence packet {}", evidence_path.display()))?;
175    packet.validate_schema()?;
176    ensure!(
177        packet.provenance == document.run,
178        "verified evidence packet provenance does not match its trace metadata"
179    );
180    verify_consumed_bundle_files(root, &verification, &["trace.jsonl", "evidence.json"])
181        .with_context(|| {
182            format!(
183                "post-read verify consumed files in evidence bundle {}",
184                root.display()
185            )
186        })?;
187
188    let gpu_identity_bound = packet.gpu.provenance.binding == ProvenanceBindingState::Bound;
189    Ok(LoadedEvidence {
190        packet,
191        input: EvidenceInput {
192            kind: EvidenceInputKind::VerifiedBundle,
193            requested_path: requested_path.to_path_buf(),
194            trace_path,
195            bundle_root: Some(root.to_path_buf()),
196            evidence_source: "verified_evidence_json",
197            gpu_identity_bound,
198            verification: Some(verification),
199        },
200    })
201}
202
203fn reject_unverified_augmented_parent(root: &Path) -> Result<()> {
204    let evidence = root.join("evidence.json");
205    let nsight = root.join("nsight");
206    if evidence.exists() || nsight.exists() {
207        bail!(
208            "input parent {} contains augmented evidence but no bundle.json; refusing to discard unverified GPU evidence and rebuild as trace-only",
209            root.display()
210        );
211    }
212    Ok(())
213}
214
215fn reject_output_inside_verified_bundle(
216    input: &EvidenceInput,
217    output: Option<&Path>,
218) -> Result<()> {
219    let (Some(root), Some(output)) = (input.bundle_root.as_deref(), output) else {
220        return Ok(());
221    };
222    reject_output_inside_bundle_root(root, output)
223}
224
225fn reject_output_inside_bundle_root(root: &Path, output: &Path) -> Result<()> {
226    let resolved_root = fs::canonicalize(root)
227        .with_context(|| format!("resolve verified bundle root {}", root.display()))?;
228    let (resolved_output, traversed_bundle) = resolve_write_path(output, &resolved_root)?;
229    if traversed_bundle || resolved_output.starts_with(&resolved_root) {
230        bail!(
231            "refusing to write command output {} inside verified bundle {}",
232            output.display(),
233            root.display()
234        );
235    }
236    Ok(())
237}
238
239/// Bundle root that would be invalidated by writing next to `input`, if `input` is a finalized
240/// bundle directory or a file directly inside one.
241fn containing_bundle_root(input: &Path) -> Option<&Path> {
242    if input.is_dir() && input.join("bundle.json").is_file() {
243        return Some(input);
244    }
245    input
246        .parent()
247        .filter(|parent| parent.join("bundle.json").is_file())
248}
249
250/// Resolve every existing path component (including symbolic links), while retaining a normalized
251/// suffix for a not-yet-created output. This mirrors the path that `create_dir_all`/`write` would
252/// reach closely enough to reject lexical `..` and pre-existing symlink aliases into a bundle.
253fn resolve_write_path(path: &Path, forbidden_root: &Path) -> Result<(PathBuf, bool)> {
254    let absolute = if path.is_absolute() {
255        path.to_path_buf()
256    } else {
257        std::env::current_dir()
258            .context("resolve current directory for output path")?
259            .join(path)
260    };
261    let mut resolved = PathBuf::new();
262    let mut traversed_forbidden_root = false;
263    for component in absolute.components() {
264        match component {
265            Component::Prefix(prefix) => resolved.push(prefix.as_os_str()),
266            Component::RootDir => resolved.push(component.as_os_str()),
267            Component::CurDir => {}
268            Component::ParentDir => {
269                resolved.pop();
270            }
271            Component::Normal(name) => {
272                resolved.push(name);
273                match fs::symlink_metadata(&resolved) {
274                    Ok(_) => {
275                        resolved = fs::canonicalize(&resolved).with_context(|| {
276                            format!("resolve output path component {}", resolved.display())
277                        })?;
278                    }
279                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
280                    Err(error) => {
281                        return Err(error).with_context(|| {
282                            format!("inspect output path component {}", resolved.display())
283                        });
284                    }
285                }
286            }
287        }
288        traversed_forbidden_root |= resolved.starts_with(forbidden_root);
289    }
290    Ok((resolved, traversed_forbidden_root))
291}
292
293pub fn run_import(trace_path: &Path, output: Option<&Path>) -> Result<()> {
294    let loaded = load_evidence_input(trace_path)?;
295    reject_output_inside_verified_bundle(&loaded.input, output)?;
296    super::write_output(
297        output,
298        (serde_json::to_string_pretty(&loaded.packet)? + "\n").as_bytes(),
299    )
300}
301
302pub fn run_summary(input_path: &Path, output: Option<&Path>) -> Result<()> {
303    let loaded = load_evidence_input(input_path)?;
304    reject_output_inside_verified_bundle(&loaded.input, output)?;
305    let evidence = &loaded.packet;
306    let rendered = serde_json::to_string_pretty(&serde_json::json!({
307        "schema": SUMMARY_SCHEMA,
308        "input": &loaded.input,
309        "provenance": &evidence.provenance,
310        "health": &evidence.health,
311        "capabilities": &evidence.capabilities,
312        "findings": &evidence.findings,
313        "gaps": &evidence.gaps,
314        "summary": evidence.graph.as_ref().map(|graph| &graph.summary),
315        "tensor_stats": {
316            "events": evidence.tensor_stats.len(),
317            "non_finite_events": evidence.tensor_stats.iter().filter(|event| event.non_finite > 0).count(),
318        },
319        "timing": &evidence.timing,
320        "memory": &evidence.memory,
321        "gpu": gpu_summary(evidence),
322    }))? + "\n";
323    super::write_output(output, rendered.as_bytes())
324}
325
326pub fn run_query(input_path: &Path, kind: TraceQueryKind, output: Option<&Path>) -> Result<()> {
327    let loaded = load_evidence_input(input_path)?;
328    reject_output_inside_verified_bundle(&loaded.input, output)?;
329    let evidence = &loaded.packet;
330    let result = match kind {
331        TraceQueryKind::Memory => serde_json::to_value(&evidence.memory)?,
332        TraceQueryKind::TensorStats => serde_json::to_value(&evidence.tensor_stats)?,
333        TraceQueryKind::Capabilities => serde_json::to_value(&evidence.capabilities)?,
334        TraceQueryKind::GpuStatus => query_gpu_status(evidence),
335        TraceQueryKind::GpuCorrelation => query_gpu_correlation(evidence),
336        TraceQueryKind::GpuPhases => query_gpu_phases(evidence),
337        TraceQueryKind::GpuKernels => query_gpu_kernels(evidence),
338        TraceQueryKind::GpuAttributionGaps => query_gpu_attribution_gaps(evidence),
339        other => {
340            let graph = evidence
341                .graph
342                .as_ref()
343                .context("query requires a complete, structurally valid capture")?;
344            query_graph(graph, other)
345        }
346    };
347    let rendered = serde_json::to_string_pretty(&serde_json::json!({
348        "schema": QUERY_SCHEMA,
349        "kind": kind.as_str(),
350        "input": &loaded.input,
351        "capabilities": &evidence.capabilities,
352        "result": result,
353    }))? + "\n";
354    super::write_output(output, rendered.as_bytes())
355}
356
357#[cfg(feature = "visualizer")]
358pub fn run_view(trace_path: &Path, output: &Path, nsight_dir: Option<&Path>) -> Result<()> {
359    if let Some(root) = containing_bundle_root(trace_path) {
360        reject_output_inside_bundle_root(root, output)?;
361    }
362    let evidence = build_evidence(trace_path, nsight_dir)?;
363    super::write_output(
364        Some(output),
365        crate::viewer::render_evidence_html(&evidence).as_bytes(),
366    )
367}
368
369pub fn run_compare(
370    baseline: &[PathBuf],
371    candidate: &[PathBuf],
372    unverified_traces: bool,
373    output: Option<&Path>,
374) -> Result<()> {
375    let comparison = if unverified_traces {
376        let parse_all = |paths: &[PathBuf], cohort: &str| -> Result<Vec<_>> {
377            paths
378                .iter()
379                .map(|path| {
380                    parse_trace(path)
381                        .with_context(|| format!("parse unverified {cohort} {}", path.display()))
382                })
383                .collect()
384        };
385        compare_unverified_traces(
386            &parse_all(baseline, "baseline")?,
387            &parse_all(candidate, "candidate")?,
388        )
389    } else {
390        if let Some(output) = output {
391            for root in baseline.iter().chain(candidate) {
392                reject_output_inside_bundle_root(root, output)?;
393            }
394        }
395        compare_verified_bundles(baseline, candidate)?
396    };
397    super::write_output(
398        output,
399        (serde_json::to_string_pretty(&comparison)? + "\n").as_bytes(),
400    )
401}
402
403pub fn run_report(trace: &Path, nsight_dir: Option<&Path>, bundle: &Path) -> Result<()> {
404    for ancestor in bundle.ancestors().skip(1) {
405        ensure!(
406            !ancestor.join("bundle.json").is_file(),
407            "refusing to publish bundle {} inside existing bundle {}",
408            bundle.display(),
409            ancestor.display()
410        );
411    }
412    publish_bundle(bundle, trace, nsight_dir)?;
413    Ok(())
414}
415
416pub fn run_verify(bundle: &Path, output: Option<&Path>) -> Result<()> {
417    let receipt = verify_bundle(bundle)?;
418    if let Some(output) = output {
419        reject_output_inside_bundle_root(bundle, output)?;
420    }
421    super::write_output(
422        output,
423        (serde_json::to_string_pretty(&receipt)? + "\n").as_bytes(),
424    )
425}
426
427fn query_graph(graph: &ExecutionGraph, kind: TraceQueryKind) -> serde_json::Value {
428    match kind {
429        TraceQueryKind::SlowestHost => serde_json::json!({
430            "entrypoint": graph.summary.entrypoint,
431            "outer_wall_time_ns": graph.summary.outer_wall_time_ns,
432            "slowest_host_spans": graph.summary.slowest_host_spans,
433        }),
434        TraceQueryKind::SlowestDevice => serde_json::json!({
435            "entrypoint": graph.summary.entrypoint,
436            "slowest_device_spans": graph.summary.slowest_device_spans,
437        }),
438        TraceQueryKind::Heaviest => serde_json::json!({
439            "entrypoint": graph.summary.entrypoint,
440            "heaviest_spans": graph.summary.heaviest_spans,
441            "heaviest_ops": sorted_nodes(graph, |node| matches!(node.kind, GraphNodeKind::Op) && node.allocated_bytes.is_some(), |node| node.allocated_bytes.unwrap_or(0)),
442        }),
443        TraceQueryKind::Spans => serde_json::json!({ "spans": graph.spans, "edges": graph.edges }),
444        TraceQueryKind::Tensors => serde_json::to_value(&graph.tensors).unwrap_or_default(),
445        TraceQueryKind::Gradients => serde_json::to_value(&graph.gradients).unwrap_or_default(),
446        TraceQueryKind::Memory
447        | TraceQueryKind::TensorStats
448        | TraceQueryKind::Capabilities
449        | TraceQueryKind::GpuStatus
450        | TraceQueryKind::GpuCorrelation
451        | TraceQueryKind::GpuPhases
452        | TraceQueryKind::GpuKernels
453        | TraceQueryKind::GpuAttributionGaps => {
454            unreachable!("handled without graph")
455        }
456    }
457}
458
459fn gpu_summary(evidence: &EvidencePacket) -> serde_json::Value {
460    let correlation = report_availability(
461        evidence,
462        "nvtx_gpu_proj_trace",
463        evidence.gpu.coverage.nvtx_projection,
464    );
465    let phase_attribution = combined_report_availability(
466        evidence,
467        &[
468            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
469            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
470        ],
471    );
472    serde_json::json!({
473        "status": evidence.gpu.status,
474        "reason": &evidence.gpu.reason,
475        "provenance_binding": evidence.gpu.provenance.binding,
476        "correlation": {
477            "status": correlation.status,
478            "reason": &correlation.reason,
479            "complete": correlation.is_available().then_some(evidence.gpu.correlation.complete),
480        },
481        "coverage": &evidence.gpu.coverage,
482        "normalized_rows": gpu_row_counts(evidence),
483        "attributed_phases": {
484            "status": phase_attribution.status,
485            "reason": &phase_attribution.reason,
486            "total": phase_attribution.is_available().then_some(evidence.gpu.phase_attribution.len()),
487        },
488        "diagnostic_count": evidence.gpu.diagnostics.len().saturating_add(evidence.gpu.provenance.diagnostics.len()),
489    })
490}
491
492fn query_gpu_status(evidence: &EvidencePacket) -> serde_json::Value {
493    let diagnostics = bounded_diagnostics(evidence);
494    let correlation = report_availability(
495        evidence,
496        "nvtx_gpu_proj_trace",
497        evidence.gpu.coverage.nvtx_projection,
498    );
499    serde_json::json!({
500        "status": evidence.gpu.status,
501        "reason": &evidence.gpu.reason,
502        "provenance_binding": evidence.gpu.provenance.binding,
503        "capabilities": {
504            "gpu_correlation": &evidence.capabilities.gpu_correlation,
505            "provenance_binding": &evidence.capabilities.provenance_binding,
506        },
507        "coverage": &evidence.gpu.coverage,
508        "correlation": {
509            "status": correlation.status,
510            "reason": &correlation.reason,
511            "complete": correlation.is_available().then_some(evidence.gpu.correlation.complete),
512        },
513        "normalized_rows": gpu_row_counts(evidence),
514        "source_artifacts": {
515            "raw_report": evidence.gpu.raw_report.is_some(),
516            "csv_files": evidence.gpu.source_csv.len(),
517        },
518        "diagnostics": bounded_values(&diagnostics, QUERY_DIAGNOSTIC_LIMIT),
519    })
520}
521
522fn query_gpu_correlation(evidence: &EvidencePacket) -> serde_json::Value {
523    let availability = report_availability(
524        evidence,
525        "nvtx_gpu_proj_trace",
526        evidence.gpu.coverage.nvtx_projection,
527    );
528    let required_reports = required_report_states(
529        evidence,
530        &[("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection)],
531    );
532    if !availability.is_available() {
533        return serde_json::json!({
534            "status": availability.status,
535            "reason": &availability.reason,
536            "provenance_binding": evidence.gpu.provenance.binding,
537            "required_reports": required_reports,
538            "capabilities": {
539                "gpu_correlation": &evidence.capabilities.gpu_correlation,
540                "provenance_binding": &evidence.capabilities.provenance_binding,
541            },
542            "mode": null,
543            "clock_aligned": null,
544            "complete": null,
545            "correlation_reason": null,
546            "ledger": null,
547        });
548    }
549    let ledger = &evidence.gpu.correlation.ledger;
550    let duplicates = ledger
551        .duplicates
552        .iter()
553        .take(QUERY_LABEL_LIMIT)
554        .collect::<Vec<_>>();
555    serde_json::json!({
556        "status": availability.status,
557        "reason": &availability.reason,
558        "provenance_binding": evidence.gpu.provenance.binding,
559        "required_reports": required_reports,
560        "capabilities": {
561            "gpu_correlation": &evidence.capabilities.gpu_correlation,
562            "provenance_binding": &evidence.capabilities.provenance_binding,
563        },
564        "mode": &evidence.gpu.correlation.mode,
565        "clock_aligned": evidence.gpu.correlation.clock_aligned,
566        "complete": evidence.gpu.correlation.complete,
567        "correlation_reason": &evidence.gpu.correlation.reason,
568        "ledger": {
569            "expected": bounded_values(&ledger.expected, QUERY_LABEL_LIMIT),
570            "cpu_only": bounded_values(&ledger.cpu_only, QUERY_LABEL_LIMIT),
571            "observed": bounded_values(&ledger.observed, QUERY_LABEL_LIMIT),
572            "matched": bounded_values(&ledger.matched, QUERY_LABEL_LIMIT),
573            "missing_expected": bounded_values(&ledger.missing_expected, QUERY_LABEL_LIMIT),
574            "unexpected_observed": bounded_values(&ledger.unexpected_observed, QUERY_LABEL_LIMIT),
575            "unexpected_cpu_only": bounded_values(&ledger.unexpected_cpu_only, QUERY_LABEL_LIMIT),
576            "duplicates": {
577                "total": ledger.duplicates.len(),
578                "displayed": duplicates.len(),
579                "truncated": duplicates.len() < ledger.duplicates.len(),
580                "rows": duplicates,
581            },
582        },
583    })
584}
585
586fn query_gpu_phases(evidence: &EvidencePacket) -> serde_json::Value {
587    let projected_availability = report_availability(
588        evidence,
589        "nvtx_gpu_proj_trace",
590        evidence.gpu.coverage.nvtx_projection,
591    );
592    let attributed_availability = combined_report_availability(
593        evidence,
594        &[
595            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
596            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
597        ],
598    );
599    let required_reports = required_report_states(
600        evidence,
601        &[
602            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
603            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
604        ],
605    );
606
607    let mut projected = if projected_availability.is_available() {
608        evidence.gpu.nvtx_ranges.iter().collect::<Vec<_>>()
609    } else {
610        Vec::new()
611    };
612    projected.sort_by(|left, right| {
613        right
614            .projected_duration_ns
615            .unwrap_or_default()
616            .cmp(&left.projected_duration_ns.unwrap_or_default())
617            .then_with(|| left.name.cmp(&right.name))
618    });
619    let projected_sample_total = projected.len();
620    projected.truncate(QUERY_ROW_LIMIT);
621    let projected_rows = projected
622        .into_iter()
623        .map(|row| {
624            let join_keys = [
625                row.correlation_id.as_ref().map(|_| "correlation_id"),
626                row.device.as_ref().map(|_| "device"),
627                row.context.as_ref().map(|_| "context"),
628                row.stream.as_ref().map(|_| "stream"),
629            ]
630            .into_iter()
631            .flatten()
632            .collect::<Vec<_>>();
633            serde_json::json!({
634                "name": &row.name,
635                "semantic_key": &row.semantic_key,
636                "projected_start_ns": row.projected_start_ns,
637                "projected_duration_ns": row.projected_duration_ns,
638                "declared_gpu_operations": row.gpu_operations,
639                "join_keys": join_keys,
640            })
641        })
642        .collect::<Vec<_>>();
643    let projected_population_total = projected_availability
644        .is_available()
645        .then(|| total_report_rows(evidence, "nvtx_gpu_proj_trace", projected_sample_total));
646
647    let mut attributed = if attributed_availability.is_available() {
648        evidence.gpu.phase_attribution.iter().collect::<Vec<_>>()
649    } else {
650        Vec::new()
651    };
652    attributed.sort_by(|left, right| {
653        right
654            .gpu_busy_ns
655            .cmp(&left.gpu_busy_ns)
656            .then_with(|| left.semantic_key.cmp(&right.semantic_key))
657    });
658    let attributed_total = attributed_availability
659        .is_available()
660        .then_some(attributed.len());
661    attributed.truncate(QUERY_ROW_LIMIT);
662
663    serde_json::json!({
664        "status": attributed_availability.status,
665        "reason": &attributed_availability.reason,
666        "provenance_binding": evidence.gpu.provenance.binding,
667        "required_reports": required_reports,
668        "clock_plane": "nsight_projected_not_host_aligned",
669        "projected_ranges": {
670            "status": projected_availability.status,
671            "reason": &projected_availability.reason,
672            "population_total": projected_population_total,
673            "retained_sample_total": projected_availability.is_available().then_some(projected_sample_total),
674            "displayed": projected_rows.len(),
675            "population_truncated_before_ranking": projected_population_total.map(|total| projected_sample_total < total),
676            "display_truncated": projected_availability.is_available().then_some(projected_rows.len() < projected_sample_total),
677            "sample_selection": "earliest_original_start_rows",
678            "ordering": "projected_duration_ns_desc_within_retained_sample",
679            "global_duration_ranking": false,
680            "rows": projected_rows,
681        },
682        "attributed_phases": {
683            "status": attributed_availability.status,
684            "reason": &attributed_availability.reason,
685            "population_total": attributed_total,
686            "displayed": attributed.len(),
687            "display_truncated": attributed_total.map(|total| attributed.len() < total),
688            "ordering": "gpu_busy_ns_desc_across_normalized_population",
689            "rows": attributed,
690        },
691    })
692}
693
694fn query_gpu_kernels(evidence: &EvidencePacket) -> serde_json::Value {
695    let availability = report_availability(
696        evidence,
697        "cuda_gpu_kern_sum",
698        evidence.gpu.coverage.kernel_summary,
699    );
700    let mut kernels = if availability.is_available() {
701        evidence.gpu.kernels.iter().collect::<Vec<_>>()
702    } else {
703        Vec::new()
704    };
705    kernels.sort_by(|left, right| {
706        right
707            .total_ns
708            .cmp(&left.total_ns)
709            .then_with(|| left.name.cmp(&right.name))
710    });
711    let normalized_display_total = kernels.len();
712    kernels.truncate(QUERY_ROW_LIMIT);
713    let total = availability
714        .is_available()
715        .then(|| total_report_rows(evidence, "cuda_gpu_kern_sum", normalized_display_total));
716    serde_json::json!({
717        "status": availability.status,
718        "reason": &availability.reason,
719        "provenance_binding": evidence.gpu.provenance.binding,
720        "required_reports": required_report_states(
721            evidence,
722            &[("cuda_gpu_kern_sum", evidence.gpu.coverage.kernel_summary)],
723        ),
724        "clock_plane": "nsight_gpu",
725        "population_total": total,
726        "displayed": kernels.len(),
727        "display_truncated": total.map(|total| kernels.len() < total),
728        "rows": kernels,
729    })
730}
731
732fn query_gpu_attribution_gaps(evidence: &EvidencePacket) -> serde_json::Value {
733    let availability = combined_report_availability(
734        evidence,
735        &[
736            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
737            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
738        ],
739    );
740    let required_reports = required_report_states(
741        evidence,
742        &[
743            ("nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection),
744            ("cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline),
745        ],
746    );
747    if !availability.is_available() {
748        return serde_json::json!({
749            "status": availability.status,
750            "reason": &availability.reason,
751            "provenance_binding": evidence.gpu.provenance.binding,
752            "required_reports": required_reports,
753            "correlation_complete": null,
754            "correlation_reason": null,
755            "missing_expected": null,
756            "unexpected_observed": null,
757            "unexpected_cpu_only": null,
758            "duplicate_labels": null,
759            "matched_without_exact_gpu_busy_attribution": null,
760            "attributed_unexpected": null,
761            "projected_range_rows": null,
762            "exactly_attributed_phase_rows": null,
763            "truncated_reports": null,
764            "diagnostics": bounded_values(&bounded_diagnostics(evidence), QUERY_DIAGNOSTIC_LIMIT),
765        });
766    }
767    let ledger = &evidence.gpu.correlation.ledger;
768    let attributed = evidence
769        .gpu
770        .phase_attribution
771        .iter()
772        .map(|phase| phase.semantic_key.as_str())
773        .collect::<BTreeSet<_>>();
774    let expected = ledger
775        .expected
776        .iter()
777        .map(String::as_str)
778        .collect::<BTreeSet<_>>();
779    let matched_without_attribution = ledger
780        .matched
781        .iter()
782        .filter(|label| !attributed.contains(label.as_str()))
783        .cloned()
784        .collect::<Vec<_>>();
785    let attributed_unexpected = attributed
786        .difference(&expected)
787        .map(|label| (*label).to_string())
788        .collect::<Vec<_>>();
789    let truncated_reports = evidence
790        .gpu
791        .limits
792        .iter()
793        .filter(|(_, limit)| limit.truncated)
794        .map(|(report, limit)| {
795            serde_json::json!({
796                "report": report,
797                "total_rows": limit.total_rows,
798                "displayed_rows": limit.displayed_rows,
799            })
800        })
801        .take(QUERY_ROW_LIMIT)
802        .collect::<Vec<_>>();
803    let diagnostics = bounded_diagnostics(evidence);
804
805    serde_json::json!({
806        "status": availability.status,
807        "reason": &availability.reason,
808        "provenance_binding": evidence.gpu.provenance.binding,
809        "required_reports": required_reports,
810        "correlation_complete": evidence.gpu.correlation.complete,
811        "correlation_reason": &evidence.gpu.correlation.reason,
812        "missing_expected": bounded_values(&ledger.missing_expected, QUERY_LABEL_LIMIT),
813        "unexpected_observed": bounded_values(&ledger.unexpected_observed, QUERY_LABEL_LIMIT),
814        "unexpected_cpu_only": bounded_values(&ledger.unexpected_cpu_only, QUERY_LABEL_LIMIT),
815        "duplicate_labels": bounded_values(&ledger.duplicates, QUERY_LABEL_LIMIT),
816        "matched_without_exact_gpu_busy_attribution": bounded_values(&matched_without_attribution, QUERY_LABEL_LIMIT),
817        "attributed_unexpected": bounded_values(&attributed_unexpected, QUERY_LABEL_LIMIT),
818        "projected_range_rows": total_report_rows(evidence, "nvtx_gpu_proj_trace", evidence.gpu.nvtx_ranges.len()),
819        "exactly_attributed_phase_rows": evidence.gpu.phase_attribution.len(),
820        "truncated_reports": {
821            "total": evidence.gpu.limits.values().filter(|limit| limit.truncated).count(),
822            "displayed": truncated_reports.len(),
823            "truncated": truncated_reports.len() < evidence.gpu.limits.values().filter(|limit| limit.truncated).count(),
824            "rows": truncated_reports,
825        },
826        "diagnostics": bounded_values(&diagnostics, QUERY_DIAGNOSTIC_LIMIT),
827    })
828}
829
830fn gpu_row_counts(evidence: &EvidencePacket) -> serde_json::Value {
831    serde_json::json!({
832        "kernels": report_row_count(evidence, "cuda_gpu_kern_sum", evidence.gpu.coverage.kernel_summary, evidence.gpu.kernels.len()),
833        "runtime_calls": report_row_count(evidence, "cuda_api_sum", evidence.gpu.coverage.runtime_summary, evidence.gpu.runtime_calls.len()),
834        "memory_operations": report_row_count(evidence, "cuda_gpu_mem_time_sum", evidence.gpu.coverage.memory_summary, evidence.gpu.memory_operations.len()),
835        "projected_ranges": report_row_count(evidence, "nvtx_gpu_proj_trace", evidence.gpu.coverage.nvtx_projection, evidence.gpu.nvtx_ranges.len()),
836        "gpu_timeline": report_row_count(evidence, "cuda_gpu_trace", evidence.gpu.coverage.gpu_timeline, evidence.gpu.gpu_timeline.len()),
837    })
838}
839
840fn report_row_count(
841    evidence: &EvidencePacket,
842    report_kind: &str,
843    covered: bool,
844    fallback: usize,
845) -> serde_json::Value {
846    let availability = report_availability(evidence, report_kind, covered);
847    serde_json::json!({
848        "status": availability.status,
849        "reason": &availability.reason,
850        "total": availability.is_available().then(|| total_report_rows(evidence, report_kind, fallback)),
851    })
852}
853
854fn report_availability(
855    evidence: &EvidencePacket,
856    report_kind: &str,
857    covered: bool,
858) -> QueryAvailability {
859    let parse_failed = evidence
860        .gpu
861        .diagnostics
862        .iter()
863        .any(|diagnostic| diagnostic.contains(report_kind));
864    if covered && !parse_failed {
865        return QueryAvailability {
866            status: GpuEvidenceStatus::Available,
867            reason: None,
868        };
869    }
870    let failed = parse_failed || evidence.gpu.status == GpuEvidenceStatus::Failed;
871    QueryAvailability {
872        status: if failed {
873            GpuEvidenceStatus::Failed
874        } else {
875            GpuEvidenceStatus::Unavailable
876        },
877        reason: Some(if failed {
878            format!(
879                "Nsight report `{report_kind}` failed to normalize; its row population is unknown, not zero"
880            )
881        } else {
882            format!(
883                "Nsight report `{report_kind}` was not normalized; its row population is unknown, not zero"
884            )
885        }),
886    }
887}
888
889fn combined_report_availability(
890    evidence: &EvidencePacket,
891    reports: &[(&str, bool)],
892) -> QueryAvailability {
893    let unavailable = reports
894        .iter()
895        .filter_map(|(report, covered)| {
896            let availability = report_availability(evidence, report, *covered);
897            (!availability.is_available()).then_some((*report, availability.status))
898        })
899        .collect::<Vec<_>>();
900    if unavailable.is_empty() {
901        return QueryAvailability {
902            status: GpuEvidenceStatus::Available,
903            reason: None,
904        };
905    }
906    let failed = unavailable
907        .iter()
908        .any(|(_, status)| *status == GpuEvidenceStatus::Failed);
909    let names = unavailable
910        .iter()
911        .map(|(report, _)| *report)
912        .collect::<Vec<_>>();
913    QueryAvailability {
914        status: if failed {
915            GpuEvidenceStatus::Failed
916        } else {
917            GpuEvidenceStatus::Unavailable
918        },
919        reason: Some(format!(
920            "required normalized Nsight report(s) unavailable: {}; the derived result is unknown, not zero",
921            names.join(", ")
922        )),
923    }
924}
925
926fn required_report_states(
927    evidence: &EvidencePacket,
928    reports: &[(&str, bool)],
929) -> serde_json::Value {
930    let states = reports
931        .iter()
932        .map(|(report, covered)| {
933            (
934                (*report).to_string(),
935                serde_json::to_value(report_availability(evidence, report, *covered))
936                    .expect("query availability is serializable"),
937            )
938        })
939        .collect::<std::collections::BTreeMap<_, _>>();
940    serde_json::to_value(states).expect("required report states are serializable")
941}
942
943fn total_report_rows(evidence: &EvidencePacket, report_kind: &str, fallback: usize) -> usize {
944    let total = evidence
945        .gpu
946        .limits
947        .iter()
948        .filter(|(name, _)| name.contains(report_kind))
949        .fold(0_usize, |sum, (_, limit)| {
950            sum.saturating_add(limit.total_rows)
951        });
952    total.max(fallback)
953}
954
955fn bounded_diagnostics(evidence: &EvidencePacket) -> Vec<String> {
956    let mut diagnostics = evidence
957        .gpu
958        .provenance
959        .diagnostics
960        .iter()
961        .chain(&evidence.gpu.diagnostics)
962        .cloned()
963        .collect::<Vec<_>>();
964    diagnostics.sort();
965    diagnostics.dedup();
966    diagnostics
967}
968
969fn bounded_values<T: Serialize>(values: &[T], limit: usize) -> serde_json::Value {
970    let displayed = values.len().min(limit);
971    serde_json::json!({
972        "total": values.len(),
973        "displayed": displayed,
974        "truncated": displayed < values.len(),
975        "rows": &values[..displayed],
976    })
977}
978
979fn sorted_nodes(
980    graph: &ExecutionGraph,
981    include: impl Fn(&GraphNode) -> bool,
982    value: impl Fn(&GraphNode) -> u64,
983) -> Vec<&GraphNode> {
984    let mut nodes = graph
985        .spans
986        .iter()
987        .filter(|node| include(node))
988        .collect::<Vec<_>>();
989    nodes.sort_by(|left, right| {
990        value(right)
991            .cmp(&value(left))
992            .then_with(|| left.id.cmp(&right.id))
993    });
994    nodes.truncate(50);
995    nodes
996}