Skip to main content

candle_graph/
nsight.rs

1//! Stable normalization seam for official `nsys stats --format csv` reports.
2
3use std::collections::{BTreeMap, BTreeSet, HashMap};
4use std::fs;
5use std::io::Read;
6use std::path::{Component, Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11const SUMMARY_DISPLAY_LIMIT: usize = 100;
12const TIMELINE_DISPLAY_LIMIT: usize = 500;
13const CAPTURE_MANIFEST: &str = "capture-manifest.json";
14pub const CAPTURE_MANIFEST_SCHEMA: &str = "candle-graph/nsight-capture/1";
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum GpuEvidenceStatus {
19    Available,
20    Unavailable,
21    Failed,
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum ProvenanceBindingState {
27    Bound,
28    Partial,
29    Mismatch,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33pub struct NsightArtifact {
34    pub path: String,
35    pub size_bytes: u64,
36    pub sha256: String,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct CaptureManifest {
41    pub schema: String,
42    pub run: CaptureRun,
43    pub correlation: CaptureCorrelation,
44    pub tool: CaptureTool,
45    pub commands: Vec<String>,
46    pub hardware: CaptureHardware,
47    pub source_revisions: BTreeMap<String, String>,
48    /// All application labels whose exact cardinality is required by the trace contract.
49    pub required_semantic_labels: Vec<String>,
50    /// The required-label subset expected in `nvtx_gpu_proj_trace`.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub gpu_expected_semantic_labels: Vec<String>,
53    /// The required-label subset that must be absent from `nvtx_gpu_proj_trace`.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub cpu_only_semantic_labels: Vec<String>,
56    pub artifacts: Vec<ManifestArtifact>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct CaptureRun {
61    pub id: String,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub started_at: Option<String>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct CaptureCorrelation {
68    pub id: String,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct CaptureTool {
73    pub name: String,
74    pub version: String,
75}
76
77#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
78pub struct CaptureHardware {
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub host: Option<String>,
81    #[serde(default)]
82    pub devices: Vec<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86pub struct ManifestArtifact {
87    pub path: String,
88    pub size_bytes: u64,
89    pub sha256: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct NsightProvenance {
94    pub binding: ProvenanceBindingState,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub manifest: Option<CaptureManifest>,
97    #[serde(default)]
98    pub diagnostics: Vec<String>,
99}
100
101#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
102pub struct NsightSummaryRow {
103    pub name: String,
104    pub total_ns: u64,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub count: Option<u64>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub average_ns: Option<u64>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub minimum_ns: Option<u64>,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub maximum_ns: Option<u64>,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub category: Option<String>,
115}
116
117#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118pub struct NsightTimelineRow {
119    pub name: String,
120    pub kind: String,
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub device: Option<String>,
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub context: Option<String>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub stream: Option<String>,
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub correlation_id: Option<String>,
129    #[serde(default, skip_serializing_if = "Option::is_none")]
130    pub semantic_key: Option<String>,
131    pub start_ns: u64,
132    pub duration_ns: u64,
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub projected_start_ns: Option<u64>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub projected_duration_ns: Option<u64>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub gpu_operations: Option<u64>,
139}
140
141#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
142pub struct NsightCoverage {
143    pub kernel_summary: bool,
144    pub runtime_summary: bool,
145    pub memory_summary: bool,
146    pub nvtx_projection: bool,
147    pub gpu_timeline: bool,
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct CorrelationDuplicate {
152    pub semantic_key: String,
153    pub occurrences: usize,
154}
155
156#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
157pub struct NsightCorrelationLedger {
158    pub expected: Vec<String>,
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub cpu_only: Vec<String>,
161    pub observed: Vec<String>,
162    pub matched: Vec<String>,
163    pub missing_expected: Vec<String>,
164    pub unexpected_observed: Vec<String>,
165    #[serde(default, skip_serializing_if = "Vec::is_empty")]
166    pub unexpected_cpu_only: Vec<String>,
167    pub duplicates: Vec<CorrelationDuplicate>,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct NsightCorrelation {
172    pub mode: String,
173    pub clock_aligned: bool,
174    pub complete: bool,
175    pub ledger: NsightCorrelationLedger,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub reason: Option<String>,
178}
179
180#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
181pub struct ReportLimit {
182    pub total_rows: usize,
183    pub displayed_rows: usize,
184    pub truncated: bool,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct PhaseGpuAttribution {
189    pub semantic_key: String,
190    /// Nsight projected and GPU intervals share an Nsight time plane, but are not aligned to the
191    /// host trace clock.
192    pub clock_aligned: bool,
193    pub projected_start_ns: u64,
194    pub projected_duration_ns: u64,
195    pub gpu_busy_ns: u64,
196    pub gpu_operation_count: usize,
197    pub join_keys: Vec<String>,
198}
199
200#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201pub struct NsightEvidence {
202    pub status: GpuEvidenceStatus,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub reason: Option<String>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub raw_report: Option<NsightArtifact>,
207    #[serde(default)]
208    pub source_csv: Vec<NsightArtifact>,
209    pub provenance: NsightProvenance,
210    pub coverage: NsightCoverage,
211    pub correlation: NsightCorrelation,
212    #[serde(default)]
213    pub diagnostics: Vec<String>,
214    #[serde(default)]
215    pub limits: BTreeMap<String, ReportLimit>,
216    #[serde(default)]
217    pub kernels: Vec<NsightSummaryRow>,
218    #[serde(default)]
219    pub runtime_calls: Vec<NsightSummaryRow>,
220    #[serde(default)]
221    pub memory_operations: Vec<NsightSummaryRow>,
222    #[serde(default)]
223    pub nvtx_ranges: Vec<NsightTimelineRow>,
224    #[serde(default)]
225    pub gpu_timeline: Vec<NsightTimelineRow>,
226    #[serde(default)]
227    pub phase_attribution: Vec<PhaseGpuAttribution>,
228}
229
230impl NsightEvidence {
231    /// Bind normalized artifacts to the application trace that is consuming them.
232    pub fn bind_to_trace(&mut self, run_id: &str, correlation_id: &str) {
233        let Some(manifest) = self.provenance.manifest.as_ref() else {
234            self.provenance.binding = ProvenanceBindingState::Partial;
235            self.provenance
236                .diagnostics
237                .push("Trace binding cannot be verified without capture-manifest.json".into());
238            return;
239        };
240        let mut mismatches = Vec::new();
241        if manifest.run.id != run_id {
242            mismatches.push(format!(
243                "manifest run `{}` does not match trace run `{run_id}`",
244                manifest.run.id
245            ));
246        }
247        if manifest.correlation.id != correlation_id {
248            mismatches.push(format!(
249                "manifest correlation `{}` does not match trace correlation `{correlation_id}`",
250                manifest.correlation.id
251            ));
252        }
253        if mismatches.is_empty() && self.provenance.binding != ProvenanceBindingState::Mismatch {
254            self.provenance.binding = ProvenanceBindingState::Bound;
255        } else if !mismatches.is_empty() {
256            self.provenance.binding = ProvenanceBindingState::Mismatch;
257            self.provenance.diagnostics.extend(mismatches);
258        }
259    }
260
261    pub fn unavailable(reason: impl Into<String>) -> Self {
262        Self {
263            status: GpuEvidenceStatus::Unavailable,
264            reason: Some(reason.into()),
265            raw_report: None,
266            source_csv: Vec::new(),
267            provenance: NsightProvenance {
268                binding: ProvenanceBindingState::Partial,
269                manifest: None,
270                diagnostics: vec!["No capture manifest was loaded".into()],
271            },
272            coverage: NsightCoverage::default(),
273            correlation: empty_correlation(),
274            diagnostics: Vec::new(),
275            limits: BTreeMap::new(),
276            kernels: Vec::new(),
277            runtime_calls: Vec::new(),
278            memory_operations: Vec::new(),
279            nvtx_ranges: Vec::new(),
280            gpu_timeline: Vec::new(),
281            phase_attribution: Vec::new(),
282        }
283    }
284
285    /// Load official CSV reports. A missing or invalid capture manifest lowers provenance
286    /// confidence without suppressing independently parseable GPU evidence.
287    pub fn load_optional(dir: Option<&Path>, expected_semantic_keys: &[String]) -> Self {
288        Self::load_optional_with_semantic_contract(
289            dir,
290            expected_semantic_keys,
291            expected_semantic_keys,
292            &[],
293        )
294    }
295
296    pub fn load_optional_with_semantic_contract(
297        dir: Option<&Path>,
298        required_application_labels: &[String],
299        gpu_expected_semantic_keys: &[String],
300        cpu_only_semantic_keys: &[String],
301    ) -> Self {
302        let Some(dir) = dir else {
303            return Self::unavailable("Nsight capture was not requested");
304        };
305        match Self::load_with_semantic_contract(
306            dir,
307            required_application_labels,
308            gpu_expected_semantic_keys,
309            cpu_only_semantic_keys,
310        ) {
311            Ok(evidence) => evidence,
312            Err(error) => Self {
313                status: GpuEvidenceStatus::Failed,
314                reason: Some(error.to_string()),
315                ..Self::unavailable("Nsight report normalization failed")
316            },
317        }
318    }
319
320    pub fn load(dir: &Path, expected_semantic_keys: &[String]) -> anyhow::Result<Self> {
321        Self::load_with_semantic_contract(dir, expected_semantic_keys, expected_semantic_keys, &[])
322    }
323
324    pub fn load_with_semantic_contract(
325        dir: &Path,
326        required_application_labels: &[String],
327        gpu_expected_semantic_keys: &[String],
328        cpu_only_semantic_keys: &[String],
329    ) -> anyhow::Result<Self> {
330        anyhow::ensure!(
331            dir.is_dir(),
332            "Nsight report directory does not exist: {}",
333            dir.display()
334        );
335        let mut files: Vec<PathBuf> = fs::read_dir(dir)?
336            .filter_map(Result::ok)
337            .map(|entry| entry.path())
338            .filter(|path| path.is_file())
339            .collect();
340        files.sort();
341
342        let raw_report_path = files.iter().find(|path| extension(path) == "nsys-rep");
343        let raw_report = raw_report_path
344            .map(|path| artifact_metadata(dir, path))
345            .transpose()?;
346        let mut result = Self {
347            status: GpuEvidenceStatus::Unavailable,
348            reason: None,
349            raw_report,
350            source_csv: Vec::new(),
351            provenance: load_provenance(dir),
352            coverage: NsightCoverage::default(),
353            correlation: empty_correlation(),
354            diagnostics: Vec::new(),
355            limits: BTreeMap::new(),
356            kernels: Vec::new(),
357            runtime_calls: Vec::new(),
358            memory_operations: Vec::new(),
359            nvtx_ranges: Vec::new(),
360            gpu_timeline: Vec::new(),
361            phase_attribution: Vec::new(),
362        };
363
364        let mut all_nvtx_ranges = Vec::new();
365        let mut all_gpu_timeline = Vec::new();
366        for path in files.iter().filter(|path| extension(path) == "csv") {
367            let name = path
368                .file_name()
369                .and_then(|x| x.to_str())
370                .unwrap_or_default();
371            let parsed: anyhow::Result<Option<ReportLimit>> = if name.contains("cuda_gpu_kern_sum")
372            {
373                parse_summary(path).map(|rows| {
374                    let (rows, limit) = truncate_rows(rows, SUMMARY_DISPLAY_LIMIT);
375                    result.kernels.extend(rows);
376                    result.coverage.kernel_summary = true;
377                    Some(limit)
378                })
379            } else if name.contains("cuda_api_sum") {
380                parse_summary(path).map(|rows| {
381                    let (rows, limit) = truncate_rows(rows, SUMMARY_DISPLAY_LIMIT);
382                    result.runtime_calls.extend(rows);
383                    result.coverage.runtime_summary = true;
384                    Some(limit)
385                })
386            } else if name.contains("cuda_gpu_mem_time_sum") {
387                parse_summary(path).map(|rows| {
388                    let (rows, limit) = truncate_rows(rows, SUMMARY_DISPLAY_LIMIT);
389                    result.memory_operations.extend(rows);
390                    result.coverage.memory_summary = true;
391                    Some(limit)
392                })
393            } else if name.contains("nvtx_gpu_proj_trace") {
394                parse_timeline(path, "nvtx_range", true).map(|rows| {
395                    let limit = report_limit(rows.len(), TIMELINE_DISPLAY_LIMIT);
396                    all_nvtx_ranges.extend(rows);
397                    result.coverage.nvtx_projection = true;
398                    Some(limit)
399                })
400            } else if name.contains("cuda_gpu_trace") {
401                parse_timeline(path, "gpu_operation", false).map(|rows| {
402                    let limit = report_limit(rows.len(), TIMELINE_DISPLAY_LIMIT);
403                    all_gpu_timeline.extend(rows);
404                    result.coverage.gpu_timeline = true;
405                    Some(limit)
406                })
407            } else {
408                Ok(None)
409            };
410            match parsed {
411                Ok(Some(limit)) => {
412                    result.source_csv.push(artifact_metadata(dir, path)?);
413                    result.limits.insert(name.to_string(), limit);
414                }
415                Ok(None) => {}
416                Err(error) => result
417                    .diagnostics
418                    .push(format!("{}: {error}", path.display())),
419            }
420        }
421
422        result.correlation = build_correlation(
423            gpu_expected_semantic_keys,
424            cpu_only_semantic_keys,
425            &all_nvtx_ranges,
426            &all_gpu_timeline,
427        );
428        result.phase_attribution = attribute_gpu_phases(&all_nvtx_ranges, &all_gpu_timeline);
429        all_nvtx_ranges.sort_by(|left, right| {
430            left.start_ns
431                .cmp(&right.start_ns)
432                .then_with(|| left.name.cmp(&right.name))
433        });
434        all_gpu_timeline.sort_by(|left, right| {
435            left.start_ns
436                .cmp(&right.start_ns)
437                .then_with(|| left.name.cmp(&right.name))
438        });
439        all_nvtx_ranges.truncate(TIMELINE_DISPLAY_LIMIT);
440        all_gpu_timeline.truncate(TIMELINE_DISPLAY_LIMIT);
441        result.nvtx_ranges = all_nvtx_ranges;
442        result.gpu_timeline = all_gpu_timeline;
443
444        validate_provenance(
445            dir,
446            required_application_labels,
447            gpu_expected_semantic_keys,
448            cpu_only_semantic_keys,
449            &result.source_csv,
450            result.raw_report.as_ref(),
451            &mut result.provenance,
452        );
453        let useful_rows = result.kernels.len()
454            + result.runtime_calls.len()
455            + result.memory_operations.len()
456            + result.nvtx_ranges.len()
457            + result.gpu_timeline.len();
458        if useful_rows == 0 {
459            result.reason = status_reason(dir)
460                .or_else(|| Some("No supported nsys stats CSV reports were found".into()));
461        } else {
462            result.status = GpuEvidenceStatus::Available;
463        }
464        Ok(result)
465    }
466}
467
468fn empty_correlation() -> NsightCorrelation {
469    NsightCorrelation {
470        mode: "none".into(),
471        clock_aligned: false,
472        complete: false,
473        ledger: NsightCorrelationLedger::default(),
474        reason: Some("No projected NVTX ranges were normalized".into()),
475    }
476}
477
478fn load_provenance(dir: &Path) -> NsightProvenance {
479    let path = dir.join(CAPTURE_MANIFEST);
480    match fs::read_to_string(&path) {
481        Ok(json) => match serde_json::from_str::<CaptureManifest>(&json) {
482            Ok(manifest) => NsightProvenance {
483                binding: ProvenanceBindingState::Partial,
484                manifest: Some(manifest),
485                diagnostics: Vec::new(),
486            },
487            Err(error) => NsightProvenance {
488                binding: ProvenanceBindingState::Partial,
489                manifest: None,
490                diagnostics: vec![format!("Invalid {}: {error}", path.display())],
491            },
492        },
493        Err(error) if error.kind() == std::io::ErrorKind::NotFound => NsightProvenance {
494            binding: ProvenanceBindingState::Partial,
495            manifest: None,
496            diagnostics: vec![format!("{} is absent", path.display())],
497        },
498        Err(error) => NsightProvenance {
499            binding: ProvenanceBindingState::Partial,
500            manifest: None,
501            diagnostics: vec![format!("Could not read {}: {error}", path.display())],
502        },
503    }
504}
505
506fn validate_provenance(
507    dir: &Path,
508    required_application_labels: &[String],
509    gpu_expected_semantic_keys: &[String],
510    cpu_only_semantic_keys: &[String],
511    csv: &[NsightArtifact],
512    raw_report: Option<&NsightArtifact>,
513    provenance: &mut NsightProvenance,
514) {
515    let Some(manifest) = provenance.manifest.as_ref() else {
516        return;
517    };
518    let mut mismatches = Vec::new();
519    if manifest.schema != CAPTURE_MANIFEST_SCHEMA {
520        mismatches.push(format!(
521            "manifest schema `{}` is unsupported; expected `{CAPTURE_MANIFEST_SCHEMA}`",
522            manifest.schema
523        ));
524    }
525    if raw_report.is_none() {
526        mismatches.push("a bound capture requires a retained raw .nsys-rep artifact".into());
527    }
528    let mut retained = csv
529        .iter()
530        .chain(raw_report)
531        .cloned()
532        .map(|artifact| (artifact.path.clone(), artifact))
533        .collect::<HashMap<_, _>>();
534    let mut declared = BTreeSet::new();
535
536    let manifest_labels = sorted_unique(manifest.required_semantic_labels.iter().cloned());
537    if manifest_labels.len() != manifest.required_semantic_labels.len() {
538        mismatches.push("manifest required application labels contain duplicates".into());
539    }
540    let expected_labels = sorted_unique(required_application_labels.iter().cloned());
541    if manifest_labels != expected_labels {
542        mismatches.push(format!(
543            "manifest required application labels do not match the application expectation: expected {expected_labels:?}, declared {manifest_labels:?}"
544        ));
545    }
546    let manifest_uses_legacy_semantics = manifest.gpu_expected_semantic_labels.is_empty()
547        && manifest.cpu_only_semantic_labels.is_empty();
548    let manifest_gpu_labels = if manifest_uses_legacy_semantics {
549        manifest_labels.clone()
550    } else {
551        sorted_unique(manifest.gpu_expected_semantic_labels.iter().cloned())
552    };
553    let manifest_cpu_labels = sorted_unique(manifest.cpu_only_semantic_labels.iter().cloned());
554    if !manifest_uses_legacy_semantics
555        && manifest_gpu_labels.len() != manifest.gpu_expected_semantic_labels.len()
556    {
557        mismatches.push("manifest GPU-expected labels contain duplicates".into());
558    }
559    if manifest_cpu_labels.len() != manifest.cpu_only_semantic_labels.len() {
560        mismatches.push("manifest CPU-only labels contain duplicates".into());
561    }
562    let expected_gpu_labels = sorted_unique(gpu_expected_semantic_keys.iter().cloned());
563    let expected_cpu_labels = sorted_unique(cpu_only_semantic_keys.iter().cloned());
564    if manifest_gpu_labels != expected_gpu_labels {
565        mismatches.push(format!(
566            "manifest GPU-expected labels do not match the application expectation: expected {expected_gpu_labels:?}, declared {manifest_gpu_labels:?}"
567        ));
568    }
569    if manifest_cpu_labels != expected_cpu_labels {
570        mismatches.push(format!(
571            "manifest CPU-only labels do not match the application expectation: expected {expected_cpu_labels:?}, declared {manifest_cpu_labels:?}"
572        ));
573    }
574
575    for artifact in &manifest.artifacts {
576        if !declared.insert(artifact.path.as_str()) {
577            mismatches.push(format!(
578                "manifest declares `{}` more than once",
579                artifact.path
580            ));
581            continue;
582        }
583        if !is_safe_relative_path(&artifact.path) {
584            mismatches.push(format!(
585                "manifest artifact path `{}` is not a safe relative path",
586                artifact.path
587            ));
588            continue;
589        }
590        if !retained.contains_key(&artifact.path) {
591            let path = dir.join(&artifact.path);
592            if path.is_file() {
593                match artifact_metadata(dir, &path) {
594                    Ok(metadata) => {
595                        retained.insert(artifact.path.clone(), metadata);
596                    }
597                    Err(error) => mismatches.push(format!(
598                        "manifest artifact `{}` could not be hashed: {error}",
599                        artifact.path
600                    )),
601                }
602            }
603        }
604        let actual = retained.get(&artifact.path);
605        match actual {
606            None => mismatches.push(format!("manifest artifact `{}` is missing", artifact.path)),
607            Some(actual) => {
608                if actual.size_bytes != artifact.size_bytes {
609                    mismatches.push(format!(
610                        "manifest artifact `{}` size mismatch: expected {}, observed {}",
611                        artifact.path, artifact.size_bytes, actual.size_bytes
612                    ));
613                }
614                if !actual.sha256.eq_ignore_ascii_case(&artifact.sha256) {
615                    mismatches.push(format!(
616                        "manifest artifact `{}` SHA-256 mismatch",
617                        artifact.path
618                    ));
619                }
620            }
621        }
622    }
623    for artifact in csv.iter().chain(raw_report) {
624        if !declared.contains(artifact.path.as_str()) {
625            mismatches.push(format!(
626                "retained artifact `{}` is absent from the manifest",
627                artifact.path
628            ));
629        }
630    }
631    if !mismatches.is_empty() {
632        provenance.binding = ProvenanceBindingState::Mismatch;
633        provenance.diagnostics.extend(mismatches);
634    }
635}
636
637fn is_safe_relative_path(value: &str) -> bool {
638    let path = Path::new(value);
639    !path.as_os_str().is_empty()
640        && !path.is_absolute()
641        && path
642            .components()
643            .all(|component| matches!(component, Component::Normal(_)))
644}
645
646fn artifact_metadata(root: &Path, path: &Path) -> anyhow::Result<NsightArtifact> {
647    let relative = path.strip_prefix(root).unwrap_or(path);
648    let mut file = fs::File::open(path)?;
649    let size_bytes = file.metadata()?.len();
650    let mut hasher = Sha256::new();
651    let mut buffer = [0_u8; 64 * 1024];
652    loop {
653        let read = file.read(&mut buffer)?;
654        if read == 0 {
655            break;
656        }
657        hasher.update(&buffer[..read]);
658    }
659    Ok(NsightArtifact {
660        path: relative.to_string_lossy().replace('\\', "/"),
661        size_bytes,
662        sha256: format!("{:x}", hasher.finalize()),
663    })
664}
665
666fn build_correlation(
667    expected: &[String],
668    cpu_only: &[String],
669    ranges: &[NsightTimelineRow],
670    gpu_timeline: &[NsightTimelineRow],
671) -> NsightCorrelation {
672    if ranges.is_empty() {
673        let mut correlation = empty_correlation();
674        correlation.ledger.expected = sorted_unique(expected.iter().cloned());
675        correlation.ledger.cpu_only = sorted_unique(cpu_only.iter().cloned());
676        correlation.ledger.missing_expected = correlation.ledger.expected.clone();
677        return correlation;
678    }
679
680    let expected = sorted_unique(expected.iter().cloned());
681    let cpu_only = sorted_unique(cpu_only.iter().cloned());
682    let mut counts = BTreeMap::<String, usize>::new();
683    for key in ranges.iter().filter_map(|row| row.semantic_key.as_ref()) {
684        *counts.entry(key.clone()).or_default() += 1;
685    }
686    let observed = counts.keys().cloned().collect::<Vec<_>>();
687    let expected_set = expected.iter().map(String::as_str).collect::<BTreeSet<_>>();
688    let cpu_only_set = cpu_only.iter().map(String::as_str).collect::<BTreeSet<_>>();
689    let observed_set = observed.iter().map(String::as_str).collect::<BTreeSet<_>>();
690    let matched = expected_set
691        .intersection(&observed_set)
692        .map(|key| (*key).to_string())
693        .collect();
694    let missing_expected = expected_set
695        .difference(&observed_set)
696        .map(|key| (*key).to_string())
697        .collect();
698    let known_application_set = expected_set
699        .union(&cpu_only_set)
700        .copied()
701        .collect::<BTreeSet<_>>();
702    let unexpected_observed = observed_set
703        .difference(&known_application_set)
704        .map(|key| (*key).to_string())
705        .collect();
706    let unexpected_cpu_only = observed_set
707        .intersection(&cpu_only_set)
708        .map(|key| (*key).to_string())
709        .collect();
710    let duplicates = counts
711        .into_iter()
712        .filter(|(_, occurrences)| *occurrences > 1)
713        .map(|(semantic_key, occurrences)| CorrelationDuplicate {
714            semantic_key,
715            occurrences,
716        })
717        .collect::<Vec<_>>();
718    let mut joinable_count_checks = 0_usize;
719    let mut unjoined_projection_rows = 0_usize;
720    let all_ranges_qualified = ranges
721        .iter()
722        .filter(|row| {
723            row.semantic_key
724                .as_ref()
725                .is_some_and(|key| expected_set.contains(key.as_str()))
726        })
727        .all(|row| match row.gpu_operations {
728            Some(0) => false,
729            Some(expected_count)
730                if !gpu_timeline.is_empty() && !phase_join_keys(row).is_empty() =>
731            {
732                joinable_count_checks += 1;
733                matching_gpu_operations(row, gpu_timeline)
734                    .is_some_and(|operations| operations.len() as u64 == expected_count)
735            }
736            _ => {
737                unjoined_projection_rows += 1;
738                true
739            }
740        });
741    let ledger = NsightCorrelationLedger {
742        expected,
743        cpu_only,
744        observed,
745        matched,
746        missing_expected,
747        unexpected_observed,
748        unexpected_cpu_only,
749        duplicates,
750    };
751    let complete = !ledger.expected.is_empty()
752        && ledger.missing_expected.is_empty()
753        && ledger.unexpected_observed.is_empty()
754        && ledger.unexpected_cpu_only.is_empty()
755        && ledger.duplicates.is_empty()
756        && all_ranges_qualified;
757    let reason = if complete {
758        format!(
759            "GPU-expected and observed semantic labels match exactly; {joinable_count_checks} joinable GPU operation count checks passed and {unjoined_projection_rows} projected rows rely on the official projection report; Candle and Nsight clocks remain separate"
760        )
761    } else {
762        format!(
763            "Correlation is incomplete: {} missing GPU-expected, {} unexpected observed, {} CPU-only projected, {} duplicate labels, joinable GPU operation counts complete={all_ranges_qualified}; clocks remain separate",
764            ledger.missing_expected.len(),
765            ledger.unexpected_observed.len(),
766            ledger.unexpected_cpu_only.len(),
767            ledger.duplicates.len()
768        )
769    };
770    NsightCorrelation {
771        mode: "nvtx_projected_range".into(),
772        clock_aligned: false,
773        complete,
774        ledger,
775        reason: Some(reason),
776    }
777}
778
779fn sorted_unique(values: impl Iterator<Item = String>) -> Vec<String> {
780    values.collect::<BTreeSet<_>>().into_iter().collect()
781}
782
783fn attribute_gpu_phases(
784    ranges: &[NsightTimelineRow],
785    gpu_timeline: &[NsightTimelineRow],
786) -> Vec<PhaseGpuAttribution> {
787    ranges
788        .iter()
789        .filter_map(|range| {
790            let semantic_key = range.semantic_key.clone()?;
791            let start = range.projected_start_ns?;
792            let duration = range.projected_duration_ns?;
793            let end = start.saturating_add(duration);
794            let operations = matching_gpu_operations(range, gpu_timeline)?;
795            if range.gpu_operations? != operations.len() as u64 {
796                return None;
797            }
798            let mut intersections = Vec::new();
799            for operation in &operations {
800                let operation_end = operation.start_ns.saturating_add(operation.duration_ns);
801                let overlap_start = start.max(operation.start_ns);
802                let overlap_end = end.min(operation_end);
803                if overlap_start < overlap_end {
804                    intersections.push((overlap_start, overlap_end));
805                }
806            }
807            Some(PhaseGpuAttribution {
808                semantic_key,
809                clock_aligned: false,
810                projected_start_ns: start,
811                projected_duration_ns: duration,
812                gpu_busy_ns: interval_union_duration(&mut intersections),
813                gpu_operation_count: operations.len(),
814                join_keys: phase_join_keys(range),
815            })
816        })
817        .collect()
818}
819
820fn matching_gpu_operations<'a>(
821    range: &NsightTimelineRow,
822    gpu_timeline: &'a [NsightTimelineRow],
823) -> Option<Vec<&'a NsightTimelineRow>> {
824    let join_keys = phase_join_keys(range);
825    if join_keys.is_empty() {
826        return None;
827    }
828    let start = range.projected_start_ns?;
829    let end = start.saturating_add(range.projected_duration_ns?);
830    Some(
831        gpu_timeline
832            .iter()
833            .filter(|operation| {
834                range
835                    .correlation_id
836                    .as_ref()
837                    .is_none_or(|value| operation.correlation_id.as_ref() == Some(value))
838                    && range
839                        .device
840                        .as_ref()
841                        .is_none_or(|value| operation.device.as_ref() == Some(value))
842                    && range
843                        .context
844                        .as_ref()
845                        .is_none_or(|value| operation.context.as_ref() == Some(value))
846                    && range
847                        .stream
848                        .as_ref()
849                        .is_none_or(|value| operation.stream.as_ref() == Some(value))
850                    && operation.start_ns < end
851                    && operation.start_ns.saturating_add(operation.duration_ns) > start
852            })
853            .collect(),
854    )
855}
856
857fn phase_join_keys(range: &NsightTimelineRow) -> Vec<String> {
858    [
859        ("correlation_id", range.correlation_id.as_ref()),
860        ("device", range.device.as_ref()),
861        ("context", range.context.as_ref()),
862        ("stream", range.stream.as_ref()),
863    ]
864    .into_iter()
865    .filter_map(|(name, value)| value.map(|_| name.to_string()))
866    .collect()
867}
868
869fn interval_union_duration(intervals: &mut [(u64, u64)]) -> u64 {
870    intervals.sort_unstable();
871    let mut total = 0_u64;
872    let mut current: Option<(u64, u64)> = None;
873    for &(start, end) in intervals.iter() {
874        current = match current {
875            None => Some((start, end)),
876            Some((current_start, current_end)) if start <= current_end => {
877                Some((current_start, current_end.max(end)))
878            }
879            Some((current_start, current_end)) => {
880                total = total.saturating_add(current_end.saturating_sub(current_start));
881                Some((start, end))
882            }
883        };
884    }
885    if let Some((start, end)) = current {
886        total = total.saturating_add(end.saturating_sub(start));
887    }
888    total
889}
890
891fn parse_summary(path: &Path) -> anyhow::Result<Vec<NsightSummaryRow>> {
892    let mut reader = csv::ReaderBuilder::new().flexible(true).from_path(path)?;
893    let headers = normalized_headers(reader.headers()?);
894    anyhow::ensure!(
895        has_header(&headers, &["name", "operation", "range", "kernel_name"]),
896        "missing operation/name column"
897    );
898    anyhow::ensure!(
899        has_header(&headers, &["total_time_ns", "total_ns"]),
900        "missing total-time nanoseconds column"
901    );
902    let mut rows = Vec::new();
903    for record in reader.records() {
904        let record = record?;
905        let name = field(
906            &headers,
907            &record,
908            &["name", "operation", "range", "kernel_name"],
909        )
910        .unwrap_or_default()
911        .trim()
912        .to_string();
913        if name.is_empty() {
914            continue;
915        }
916        rows.push(NsightSummaryRow {
917            name,
918            total_ns: required_number(
919                field(&headers, &record, &["total_time_ns", "total_ns"]),
920                "total_ns",
921            )?,
922            count: optional_number(
923                field(
924                    &headers,
925                    &record,
926                    &["instances", "num_calls", "operations", "count"],
927                ),
928                "count",
929            )?,
930            average_ns: optional_number(
931                field(&headers, &record, &["avg_ns", "average_ns"]),
932                "average_ns",
933            )?,
934            minimum_ns: optional_number(
935                field(&headers, &record, &["min_ns", "minimum_ns"]),
936                "minimum_ns",
937            )?,
938            maximum_ns: optional_number(
939                field(&headers, &record, &["max_ns", "maximum_ns"]),
940                "maximum_ns",
941            )?,
942            category: field(&headers, &record, &["category"]).map(str::to_string),
943        });
944    }
945    rows.sort_by_key(|row| std::cmp::Reverse(row.total_ns));
946    Ok(rows)
947}
948
949fn parse_timeline(
950    path: &Path,
951    kind: &str,
952    projected: bool,
953) -> anyhow::Result<Vec<NsightTimelineRow>> {
954    let mut reader = csv::ReaderBuilder::new().flexible(true).from_path(path)?;
955    let headers = normalized_headers(reader.headers()?);
956    anyhow::ensure!(
957        has_header(&headers, &["name", "operation", "range", "kernel_name"]),
958        "missing operation/name column"
959    );
960    let start_headers: &[&str] = if projected {
961        &["orig_start_ns", "orig_start", "start_ns", "start"]
962    } else {
963        &["start_ns", "start"]
964    };
965    let duration_headers: &[&str] = if projected {
966        &[
967            "orig_duration_ns",
968            "orig_duration",
969            "duration_ns",
970            "duration",
971            "dur_ns",
972        ]
973    } else {
974        &["duration_ns", "duration", "dur_ns"]
975    };
976    anyhow::ensure!(
977        has_header(&headers, start_headers) && has_header(&headers, duration_headers),
978        "missing start/duration nanoseconds columns"
979    );
980    if projected {
981        anyhow::ensure!(
982            has_header(
983                &headers,
984                &["projected_start_ns", "projected_start", "proj_start_ns"]
985            ) && has_header(
986                &headers,
987                &["projected_duration_ns", "projected_duration", "proj_dur_ns"]
988            ),
989            "missing projected start/duration nanoseconds columns"
990        );
991    }
992    let mut rows = Vec::new();
993    for record in reader.records() {
994        let record = record?;
995        let name = field(
996            &headers,
997            &record,
998            &["name", "operation", "range", "kernel_name"],
999        )
1000        .unwrap_or_default()
1001        .trim()
1002        .to_string();
1003        if name.is_empty() {
1004            continue;
1005        }
1006        let start_ns = required_number(field(&headers, &record, start_headers), "start_ns")?;
1007        let duration_ns =
1008            required_number(field(&headers, &record, duration_headers), "duration_ns")?;
1009        anyhow::ensure!(duration_ns > 0, "timeline row `{name}` has zero duration");
1010        let projected_start_ns = optional_number(
1011            field(
1012                &headers,
1013                &record,
1014                &["projected_start_ns", "projected_start", "proj_start_ns"],
1015            ),
1016            "projected_start_ns",
1017        )?;
1018        let projected_duration_ns = optional_number(
1019            field(
1020                &headers,
1021                &record,
1022                &["projected_duration_ns", "projected_duration", "proj_dur_ns"],
1023            ),
1024            "projected_duration_ns",
1025        )?;
1026        if projected {
1027            anyhow::ensure!(
1028                projected_start_ns.is_some()
1029                    && projected_duration_ns.is_some_and(|duration| duration > 0),
1030                "projected timeline row `{name}` has invalid projected timing"
1031            );
1032        }
1033        rows.push(NsightTimelineRow {
1034            semantic_key: (kind == "nvtx_range").then(|| name.clone()),
1035            name,
1036            kind: kind.into(),
1037            device: field(&headers, &record, &["device", "device_id"]).map(str::to_string),
1038            context: field(&headers, &record, &["context", "context_id", "ctx"])
1039                .map(str::to_string),
1040            stream: field(&headers, &record, &["stream", "stream_id", "strm"]).map(str::to_string),
1041            correlation_id: field(&headers, &record, &["correlation_id", "corrid", "corr_id"])
1042                .map(str::to_string),
1043            start_ns,
1044            duration_ns,
1045            projected_start_ns,
1046            projected_duration_ns,
1047            gpu_operations: optional_number(
1048                field(
1049                    &headers,
1050                    &record,
1051                    &["num_gpu_ops", "numgpuops", "gpu_operations"],
1052                ),
1053                "gpu_operations",
1054            )?,
1055        });
1056    }
1057    rows.sort_by_key(|row| row.start_ns);
1058    Ok(rows)
1059}
1060
1061fn truncate_rows<T>(mut rows: Vec<T>, limit: usize) -> (Vec<T>, ReportLimit) {
1062    let report_limit = report_limit(rows.len(), limit);
1063    rows.truncate(limit);
1064    (rows, report_limit)
1065}
1066
1067fn report_limit(total_rows: usize, limit: usize) -> ReportLimit {
1068    ReportLimit {
1069        total_rows,
1070        displayed_rows: total_rows.min(limit),
1071        truncated: total_rows > limit,
1072    }
1073}
1074
1075fn has_header(headers: &[String], names: &[&str]) -> bool {
1076    names
1077        .iter()
1078        .any(|name| headers.iter().any(|header| header == name))
1079}
1080
1081fn normalized_headers(headers: &csv::StringRecord) -> Vec<String> {
1082    headers
1083        .iter()
1084        .map(|header| {
1085            header
1086                .trim()
1087                .to_ascii_lowercase()
1088                .replace(['(', ')', '%'], "")
1089                .replace([' ', '-', '/'], "_")
1090                .trim_matches('_')
1091                .to_string()
1092        })
1093        .collect()
1094}
1095
1096fn field<'a>(headers: &[String], record: &'a csv::StringRecord, names: &[&str]) -> Option<&'a str> {
1097    names.iter().find_map(|name| {
1098        headers
1099            .iter()
1100            .position(|header| header == name)
1101            .and_then(|index| record.get(index))
1102    })
1103}
1104
1105fn required_number(value: Option<&str>, label: &str) -> anyhow::Result<u64> {
1106    optional_number(value, label)?.ok_or_else(|| anyhow::anyhow!("missing required {label} value"))
1107}
1108
1109fn optional_number(value: Option<&str>, label: &str) -> anyhow::Result<Option<u64>> {
1110    let Some(value) = value else {
1111        return Ok(None);
1112    };
1113    let cleaned = value.trim().replace(',', "");
1114    if cleaned.is_empty() {
1115        return Ok(None);
1116    }
1117    if let Ok(value) = cleaned.parse::<u64>() {
1118        return Ok(Some(value));
1119    }
1120    let value = cleaned
1121        .parse::<f64>()
1122        .map_err(|_| anyhow::anyhow!("invalid {label} value {cleaned:?}"))?;
1123    anyhow::ensure!(value.is_finite(), "non-finite {label} value {cleaned:?}");
1124    anyhow::ensure!(
1125        !value.is_sign_negative(),
1126        "negative {label} value {cleaned:?}"
1127    );
1128    // 2^64 is exactly representable as f64; every valid u64 converts to a smaller f64 value.
1129    anyhow::ensure!(
1130        value < 18_446_744_073_709_551_616.0,
1131        "overflowing {label} value {cleaned:?}"
1132    );
1133    Ok(Some(value as u64))
1134}
1135
1136fn extension(path: &Path) -> &str {
1137    path.extension()
1138        .and_then(|value| value.to_str())
1139        .unwrap_or_default()
1140}
1141
1142fn status_reason(dir: &Path) -> Option<String> {
1143    let content = fs::read_to_string(dir.join("status.txt")).ok()?;
1144    content
1145        .lines()
1146        .find_map(|line| line.strip_prefix("reason=").map(str::to_string))
1147}
1148
1149#[cfg(test)]
1150mod tests {
1151    use super::*;
1152
1153    fn temp_dir(label: &str) -> PathBuf {
1154        let dir = std::env::temp_dir().join(format!(
1155            "candle-graph-nsys-{label}-{}-{:?}",
1156            std::process::id(),
1157            std::thread::current().id()
1158        ));
1159        let _ = fs::remove_dir_all(&dir);
1160        fs::create_dir_all(&dir).unwrap();
1161        dir
1162    }
1163
1164    #[test]
1165    fn normalizes_official_summary_headers_and_hashes_source() {
1166        let dir = temp_dir("summary");
1167        let path = dir.join("sample_cuda_gpu_kern_sum.csv");
1168        fs::write(&path, "Time (%),Total Time (ns),Instances,Avg (ns),Min (ns),Max (ns),Name\n50.0,1200,3,400,200,600,gemm\n").unwrap();
1169        let evidence = NsightEvidence::load(&dir, &[]).unwrap();
1170        assert_eq!(evidence.status, GpuEvidenceStatus::Available);
1171        assert_eq!(evidence.kernels[0].name, "gemm");
1172        assert_eq!(evidence.kernels[0].total_ns, 1200);
1173        assert_eq!(evidence.source_csv[0].sha256.len(), 64);
1174        assert_eq!(
1175            evidence.source_csv[0].size_bytes,
1176            fs::metadata(path).unwrap().len()
1177        );
1178        assert_eq!(evidence.provenance.binding, ProvenanceBindingState::Partial);
1179        let _ = fs::remove_dir_all(dir);
1180    }
1181
1182    #[test]
1183    fn malformed_report_is_diagnostic_not_available() {
1184        let dir = temp_dir("bad");
1185        fs::write(dir.join("bad_cuda_api_sum.csv"), "Unknown,Value\nx,1\n").unwrap();
1186        let evidence = NsightEvidence::load(&dir, &[]).unwrap();
1187        assert_eq!(evidence.status, GpuEvidenceStatus::Unavailable);
1188        assert!(!evidence.diagnostics.is_empty());
1189        let _ = fs::remove_dir_all(dir);
1190    }
1191
1192    #[test]
1193    fn required_summary_numbers_reject_malformed_negative_non_finite_and_overflow() {
1194        let dir = temp_dir("strict-summary-numbers");
1195        let path = dir.join("sample_cuda_gpu_kern_sum.csv");
1196        for value in ["garbage", "-1", "NaN", "inf", "18446744073709551616"] {
1197            fs::write(
1198                &path,
1199                format!("Total Time (ns),Instances,Name\n{value},1,gemm\n"),
1200            )
1201            .unwrap();
1202            assert!(
1203                parse_summary(&path).is_err(),
1204                "summary value {value:?} must fail"
1205            );
1206        }
1207        fs::write(&path, "Total Time (ns),Instances,Name\n1,,gemm\n").unwrap();
1208        assert_eq!(parse_summary(&path).unwrap()[0].count, None);
1209        let _ = fs::remove_dir_all(dir);
1210    }
1211
1212    #[test]
1213    fn optional_timeline_numbers_are_none_only_when_missing_and_fail_when_malformed() {
1214        let dir = temp_dir("strict-optional-numbers");
1215        let path = dir.join("sample_cuda_gpu_trace.csv");
1216        fs::write(&path, "Name,Start (ns),Duration (ns)\ngemm,1,2\n").unwrap();
1217        let row = parse_timeline(&path, "gpu_operation", false)
1218            .unwrap()
1219            .remove(0);
1220        assert_eq!(row.gpu_operations, None);
1221        assert_eq!(row.projected_start_ns, None);
1222
1223        fs::write(
1224            &path,
1225            "Name,Start (ns),Duration (ns),Num GPU Ops\ngemm,1,2,NaN\n",
1226        )
1227        .unwrap();
1228        assert!(parse_timeline(&path, "gpu_operation", false).is_err());
1229        fs::write(
1230            &path,
1231            "Name,Start (ns),Duration (ns),Num GPU Ops\ngemm,1,2,-1\n",
1232        )
1233        .unwrap();
1234        assert!(parse_timeline(&path, "gpu_operation", false).is_err());
1235        fs::write(
1236            &path,
1237            "Name,Start (ns),Duration (ns),Num GPU Ops\ngemm,1,2,1e100\n",
1238        )
1239        .unwrap();
1240        assert!(parse_timeline(&path, "gpu_operation", false).is_err());
1241        let _ = fs::remove_dir_all(dir);
1242    }
1243
1244    #[test]
1245    fn parses_official_nvtx_projection_columns_without_gpu_join_identifiers() {
1246        let dir = temp_dir("official-nvtx-projection");
1247        fs::write(
1248            dir.join("sample_nvtx_gpu_proj_trace.csv"),
1249            include_str!("../tests/fixtures/nsight/nvtx_gpu_proj_trace.csv"),
1250        )
1251        .unwrap();
1252        let required = vec!["pipeline/gpu".into(), "pipeline/prepare".into()];
1253        let gpu_expected = vec!["pipeline/gpu".into()];
1254        let cpu_only = vec!["pipeline/prepare".into()];
1255        let evidence =
1256            NsightEvidence::load_with_semantic_contract(&dir, &required, &gpu_expected, &cpu_only)
1257                .unwrap();
1258
1259        assert_eq!(evidence.status, GpuEvidenceStatus::Available);
1260        assert!(evidence.correlation.complete);
1261        assert_eq!(evidence.correlation.ledger.expected, gpu_expected);
1262        assert_eq!(evidence.correlation.ledger.cpu_only, cpu_only);
1263        assert!(evidence.correlation.ledger.missing_expected.is_empty());
1264        assert!(evidence.correlation.ledger.unexpected_cpu_only.is_empty());
1265        assert_eq!(evidence.nvtx_ranges[0].start_ns, 700);
1266        assert_eq!(evidence.nvtx_ranges[0].duration_ns, 600);
1267        assert_eq!(evidence.nvtx_ranges[0].projected_start_ns, Some(1_000));
1268        assert_eq!(evidence.nvtx_ranges[0].projected_duration_ns, Some(250));
1269        assert_eq!(evidence.nvtx_ranges[0].gpu_operations, Some(3));
1270        assert_eq!(evidence.nvtx_ranges[0].correlation_id, None);
1271        assert_eq!(evidence.nvtx_ranges[0].device, None);
1272        assert!(evidence.phase_attribution.is_empty());
1273        let _ = fs::remove_dir_all(dir);
1274    }
1275
1276    #[test]
1277    fn recognizes_official_cuda_context_and_stream_aliases() {
1278        let dir = temp_dir("official-cuda-aliases");
1279        let path = dir.join("sample_cuda_gpu_trace.csv");
1280        fs::write(
1281            &path,
1282            include_str!("../tests/fixtures/nsight/cuda_gpu_trace.csv"),
1283        )
1284        .unwrap();
1285
1286        let row = parse_timeline(&path, "gpu_operation", false)
1287            .unwrap()
1288            .remove(0);
1289        assert_eq!(row.context.as_deref(), Some("7"));
1290        assert_eq!(row.stream.as_deref(), Some("13"));
1291        assert_eq!(row.correlation_id.as_deref(), Some("41"));
1292        let _ = fs::remove_dir_all(dir);
1293    }
1294
1295    #[test]
1296    fn legacy_capture_manifest_deserializes_with_all_required_labels_gpu_expected() {
1297        let json = serde_json::json!({
1298            "schema": CAPTURE_MANIFEST_SCHEMA,
1299            "run": { "id": "run-1" },
1300            "correlation": { "id": "update-1" },
1301            "tool": { "name": "nsys", "version": "test" },
1302            "commands": ["nsys profile app"],
1303            "hardware": {},
1304            "source_revisions": {},
1305            "required_semantic_labels": ["pipeline/gpu"],
1306            "artifacts": []
1307        });
1308        let manifest: CaptureManifest = serde_json::from_value(json).unwrap();
1309        assert!(manifest.gpu_expected_semantic_labels.is_empty());
1310        assert!(manifest.cpu_only_semantic_labels.is_empty());
1311        assert_eq!(
1312            manifest.required_semantic_labels,
1313            vec!["pipeline/gpu".to_string()]
1314        );
1315    }
1316
1317    #[test]
1318    fn gpu_projection_of_explicit_cpu_only_span_is_reported_and_incomplete() {
1319        let dir = temp_dir("cpu-only-projected");
1320        let mut csv = include_str!("../tests/fixtures/nsight/nvtx_gpu_proj_trace.csv").to_string();
1321        csv.push_str("pipeline/prepare,1300,40,1260,90,Push/Pop,4242,17,1,0,0,2,0,2\n");
1322        fs::write(dir.join("sample_nvtx_gpu_proj_trace.csv"), csv).unwrap();
1323        let required = vec!["pipeline/gpu".into(), "pipeline/prepare".into()];
1324        let evidence = NsightEvidence::load_with_semantic_contract(
1325            &dir,
1326            &required,
1327            &["pipeline/gpu".into()],
1328            &["pipeline/prepare".into()],
1329        )
1330        .unwrap();
1331
1332        assert!(!evidence.correlation.complete);
1333        assert_eq!(
1334            evidence.correlation.ledger.unexpected_cpu_only,
1335            vec!["pipeline/prepare".to_string()]
1336        );
1337        assert!(evidence.correlation.ledger.unexpected_observed.is_empty());
1338        assert!(evidence
1339            .correlation
1340            .reason
1341            .as_deref()
1342            .is_some_and(|reason| reason.contains("1 CPU-only projected")));
1343        let _ = fs::remove_dir_all(dir);
1344    }
1345
1346    #[test]
1347    fn correlation_reports_missing_expected_labels() {
1348        let dir = temp_dir("missing-label");
1349        fs::write(
1350            dir.join("sample_nvtx_gpu_proj_trace.csv"),
1351            "Name,Start (ns),Duration (ns),Projected Start (ns),Projected Duration (ns),Num GPU Ops\nrun/forward,1,10,2,8,3\n",
1352        )
1353        .unwrap();
1354        let evidence =
1355            NsightEvidence::load(&dir, &["run/forward".into(), "run/backward".into()]).unwrap();
1356        assert!(!evidence.correlation.complete);
1357        assert_eq!(
1358            evidence.correlation.ledger.missing_expected,
1359            vec!["run/backward"]
1360        );
1361        assert_eq!(evidence.correlation.ledger.matched, vec!["run/forward"]);
1362        let _ = fs::remove_dir_all(dir);
1363    }
1364
1365    #[test]
1366    fn correlation_completeness_is_computed_before_display_truncation() {
1367        let dir = temp_dir("pre-truncation");
1368        let mut csv = String::from(
1369            "Name,Start (ns),Duration (ns),Projected Start (ns),Projected Duration (ns),Num GPU Ops,CorrId\n",
1370        );
1371        let mut gpu_csv = String::from("Name,Start (ns),Duration (ns),CorrId\n");
1372        let mut expected = Vec::new();
1373        for index in 0..=TIMELINE_DISPLAY_LIMIT {
1374            let label = format!("phase/{index}");
1375            expected.push(label.clone());
1376            csv.push_str(&format!("{label},{index},1,{index},1,1,{index}\n"));
1377            gpu_csv.push_str(&format!("kernel/{index},{index},1,{index}\n"));
1378        }
1379        fs::write(dir.join("many_nvtx_gpu_proj_trace.csv"), csv).unwrap();
1380        fs::write(dir.join("many_cuda_gpu_trace.csv"), gpu_csv).unwrap();
1381        let evidence = NsightEvidence::load(&dir, &expected).unwrap();
1382        assert_eq!(evidence.nvtx_ranges.len(), TIMELINE_DISPLAY_LIMIT);
1383        assert_eq!(evidence.nvtx_ranges.first().unwrap().name, "phase/0");
1384        assert_eq!(
1385            evidence.nvtx_ranges.last().unwrap().name,
1386            format!("phase/{}", TIMELINE_DISPLAY_LIMIT - 1)
1387        );
1388        let omitted_label = format!("phase/{TIMELINE_DISPLAY_LIMIT}");
1389        assert!(!evidence
1390            .nvtx_ranges
1391            .iter()
1392            .any(|row| row.name == omitted_label));
1393        assert_eq!(
1394            evidence.correlation.ledger.observed.len(),
1395            TIMELINE_DISPLAY_LIMIT + 1
1396        );
1397        assert!(evidence.correlation.complete);
1398        let _ = fs::remove_dir_all(dir);
1399    }
1400
1401    #[test]
1402    fn manifest_hash_mismatch_does_not_discard_gpu_evidence() {
1403        let dir = temp_dir("manifest-mismatch");
1404        let csv_path = dir.join("sample_cuda_gpu_kern_sum.csv");
1405        fs::write(&csv_path, "Total Time (ns),Instances,Name\n1200,3,gemm\n").unwrap();
1406        let manifest = CaptureManifest {
1407            schema: CAPTURE_MANIFEST_SCHEMA.into(),
1408            run: CaptureRun {
1409                id: "run-1".into(),
1410                started_at: None,
1411            },
1412            correlation: CaptureCorrelation {
1413                id: "update-1".into(),
1414            },
1415            tool: CaptureTool {
1416                name: "nsys".into(),
1417                version: "test".into(),
1418            },
1419            commands: vec!["nsys profile app".into()],
1420            hardware: CaptureHardware::default(),
1421            source_revisions: BTreeMap::from([("app".into(), "abc123".into())]),
1422            required_semantic_labels: Vec::new(),
1423            gpu_expected_semantic_labels: Vec::new(),
1424            cpu_only_semantic_labels: Vec::new(),
1425            artifacts: vec![ManifestArtifact {
1426                path: "sample_cuda_gpu_kern_sum.csv".into(),
1427                size_bytes: fs::metadata(&csv_path).unwrap().len(),
1428                sha256: "0".repeat(64),
1429            }],
1430        };
1431        fs::write(
1432            dir.join(CAPTURE_MANIFEST),
1433            serde_json::to_vec_pretty(&manifest).unwrap(),
1434        )
1435        .unwrap();
1436        let evidence = NsightEvidence::load(&dir, &[]).unwrap();
1437        assert_eq!(evidence.status, GpuEvidenceStatus::Available);
1438        assert_eq!(
1439            evidence.provenance.binding,
1440            ProvenanceBindingState::Mismatch
1441        );
1442        assert!(evidence
1443            .provenance
1444            .diagnostics
1445            .iter()
1446            .any(|message| message.contains("SHA-256 mismatch")));
1447        let _ = fs::remove_dir_all(dir);
1448    }
1449
1450    #[test]
1451    fn valid_manifest_becomes_bound_only_after_trace_ids_match() {
1452        let dir = temp_dir("manifest-bound");
1453        let raw_path = dir.join("capture.nsys-rep");
1454        let csv_path = dir.join("sample_cuda_gpu_kern_sum.csv");
1455        fs::write(&raw_path, b"raw report").unwrap();
1456        fs::write(&csv_path, "Total Time (ns),Instances,Name\n1200,3,gemm\n").unwrap();
1457        let artifacts = [&raw_path, &csv_path]
1458            .into_iter()
1459            .map(|path| {
1460                let artifact = artifact_metadata(&dir, path).unwrap();
1461                ManifestArtifact {
1462                    path: artifact.path,
1463                    size_bytes: artifact.size_bytes,
1464                    sha256: artifact.sha256,
1465                }
1466            })
1467            .collect();
1468        let manifest = CaptureManifest {
1469            schema: CAPTURE_MANIFEST_SCHEMA.into(),
1470            run: CaptureRun {
1471                id: "run-1".into(),
1472                started_at: None,
1473            },
1474            correlation: CaptureCorrelation {
1475                id: "update-1".into(),
1476            },
1477            tool: CaptureTool {
1478                name: "nsys".into(),
1479                version: "test".into(),
1480            },
1481            commands: vec!["nsys profile app".into()],
1482            hardware: CaptureHardware::default(),
1483            source_revisions: BTreeMap::new(),
1484            required_semantic_labels: vec![],
1485            gpu_expected_semantic_labels: Vec::new(),
1486            cpu_only_semantic_labels: Vec::new(),
1487            artifacts,
1488        };
1489        fs::write(
1490            dir.join(CAPTURE_MANIFEST),
1491            serde_json::to_vec_pretty(&manifest).unwrap(),
1492        )
1493        .unwrap();
1494
1495        let mut evidence = NsightEvidence::load(&dir, &[]).unwrap();
1496        assert_eq!(evidence.provenance.binding, ProvenanceBindingState::Partial);
1497        evidence.bind_to_trace("run-1", "update-1");
1498        assert_eq!(evidence.provenance.binding, ProvenanceBindingState::Bound);
1499        let _ = fs::remove_dir_all(dir);
1500    }
1501
1502    #[test]
1503    fn overlapping_gpu_operations_are_union_attributed_to_each_phase() {
1504        let dir = temp_dir("phase-overlap");
1505        fs::write(
1506            dir.join("sample_nvtx_gpu_proj_trace.csv"),
1507            "Name,Start (ns),Duration (ns),Projected Start (ns),Projected Duration (ns),Num GPU Ops,CorrId\nphase/a,1,100,0,100,2,1\nphase/b,2,50,25,50,2,2\n",
1508        )
1509        .unwrap();
1510        fs::write(
1511            dir.join("sample_cuda_gpu_trace.csv"),
1512            "Name,Start (ns),Duration (ns),Device,Stream,CorrId\nkernel/a1,0,70,0,1,1\nkernel/a2,40,60,0,2,1\nkernel/b1,0,70,0,1,2\nkernel/b2,40,60,0,2,2\nunrelated,30,20,0,3,3\n",
1513        )
1514        .unwrap();
1515        let evidence = NsightEvidence::load(&dir, &["phase/a".into(), "phase/b".into()]).unwrap();
1516        assert!(!evidence.correlation.clock_aligned);
1517        assert!(!evidence.phase_attribution[0].clock_aligned);
1518        assert_eq!(evidence.phase_attribution[0].gpu_busy_ns, 100);
1519        assert_eq!(evidence.phase_attribution[0].gpu_operation_count, 2);
1520        assert_eq!(evidence.phase_attribution[1].gpu_busy_ns, 50);
1521        assert_eq!(evidence.phase_attribution[1].gpu_operation_count, 2);
1522        assert!(evidence
1523            .phase_attribution
1524            .iter()
1525            .all(|phase| { phase.join_keys == vec!["correlation_id"] }));
1526        let _ = fs::remove_dir_all(dir);
1527    }
1528}