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