1use 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#[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#[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#[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
186pub 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
323fn 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
334fn 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
425pub 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
774fn 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
803pub 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 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
912pub 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
955pub 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
964pub 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}