Skip to main content

candle_graph/
capability.rs

1//! Typed coverage and capability states used across capture, analysis, and comparison.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use anyhow::{ensure, Result};
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9/// Domain separator for ordered gradient-manifest digests.
10pub const GRADIENT_MANIFEST_SCHEMA: &str = "candle-graph/gradient-manifest/1";
11
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum CoverageLevel {
15    #[default]
16    None,
17    Partial,
18    Complete,
19}
20
21impl CoverageLevel {
22    pub fn with_observations(self, count: usize) -> Self {
23        if count == 0 {
24            self
25        } else {
26            self.max(Self::Partial)
27        }
28    }
29}
30
31#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum MeasurementScope {
34    #[default]
35    Unknown,
36    ProfiledWork,
37    ProductionEquivalent,
38}
39
40/// One parameter expected in a complete gradient capture.
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct ExpectedGradient {
43    pub root: String,
44    pub key: String,
45    pub family: String,
46}
47
48impl ExpectedGradient {
49    pub fn new(root: impl Into<String>, key: impl Into<String>, family: impl Into<String>) -> Self {
50        Self {
51            root: root.into(),
52            key: key.into(),
53            family: family.into(),
54        }
55    }
56}
57
58/// Expected runtime state of one caller-defined parameter family.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "snake_case")]
61pub enum GradientFamilyExpectation {
62    Active,
63    Inactive,
64    /// The caller's data determines whether this family is missing, attached
65    /// with an exact zero, or present. If any members are present, at least
66    /// `min_present` must be present; zero alone is not a structural failure.
67    DataConditional,
68}
69
70/// Family-level expectation applied after exact parameter-manifest validation.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct GradientFamilyContract {
73    pub family: String,
74    pub expectation: GradientFamilyExpectation,
75    pub min_present: usize,
76}
77
78impl GradientFamilyContract {
79    pub fn active(family: impl Into<String>, min_present: usize) -> Self {
80        Self {
81            family: family.into(),
82            expectation: GradientFamilyExpectation::Active,
83            min_present,
84        }
85    }
86
87    pub fn inactive(family: impl Into<String>) -> Self {
88        Self {
89            family: family.into(),
90            expectation: GradientFamilyExpectation::Inactive,
91            min_present: 0,
92        }
93    }
94
95    pub fn data_conditional(family: impl Into<String>, min_present: usize) -> Self {
96        Self {
97            family: family.into(),
98            expectation: GradientFamilyExpectation::DataConditional,
99            min_present,
100        }
101    }
102}
103
104/// Exact, digest-bound gradient parameter manifest and family expectations.
105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
106pub struct GradientContract {
107    pub manifest_sha256: String,
108    pub expected: Vec<ExpectedGradient>,
109    pub families: Vec<GradientFamilyContract>,
110}
111
112impl GradientContract {
113    pub fn new(
114        expected: Vec<ExpectedGradient>,
115        families: Vec<GradientFamilyContract>,
116    ) -> Result<Self> {
117        let contract = Self {
118            manifest_sha256: gradient_manifest_sha256(&expected),
119            expected,
120            families,
121        };
122        contract.validate()?;
123        Ok(contract)
124    }
125
126    /// Validate a constructed or deserialized contract before trusting it.
127    pub fn validate(&self) -> Result<()> {
128        ensure!(
129            !self.expected.is_empty(),
130            "gradient manifest must not be empty"
131        );
132        ensure!(
133            !self.families.is_empty(),
134            "gradient families must not be empty"
135        );
136        ensure!(
137            self.manifest_sha256 == gradient_manifest_sha256(&self.expected),
138            "gradient manifest SHA-256 does not match its ordered parameter manifest"
139        );
140
141        let mut keys = BTreeSet::new();
142        let mut members = BTreeMap::<&str, usize>::new();
143        for parameter in &self.expected {
144            ensure!(
145                !parameter.root.trim().is_empty()
146                    && !parameter.key.trim().is_empty()
147                    && !parameter.family.trim().is_empty(),
148                "gradient manifest root, key, and family must not be empty"
149            );
150            ensure!(
151                keys.insert((parameter.root.as_str(), parameter.key.as_str())),
152                "gradient manifest declares ({:?}, {:?}) more than once",
153                parameter.root,
154                parameter.key
155            );
156            *members.entry(parameter.family.as_str()).or_default() += 1;
157        }
158
159        let mut family_names = BTreeSet::new();
160        for family in &self.families {
161            ensure!(
162                !family.family.trim().is_empty(),
163                "gradient family name must not be empty"
164            );
165            ensure!(
166                family_names.insert(family.family.as_str()),
167                "gradient family {:?} is declared more than once",
168                family.family
169            );
170            let member_count = members.get(family.family.as_str()).copied().unwrap_or(0);
171            ensure!(
172                member_count > 0,
173                "gradient family {:?} has no manifest members",
174                family.family
175            );
176            match family.expectation {
177                GradientFamilyExpectation::Inactive => ensure!(
178                    family.min_present == 0,
179                    "inactive gradient family {:?} must have min_present=0",
180                    family.family
181                ),
182                GradientFamilyExpectation::Active
183                | GradientFamilyExpectation::DataConditional => ensure!(
184                    family.min_present > 0 && family.min_present <= member_count,
185                    "active or data-conditional gradient family {:?} needs min_present in 1..={member_count}",
186                    family.family
187                ),
188            }
189        }
190        for family in members.keys() {
191            ensure!(
192                family_names.contains(family),
193                "gradient manifest family {family:?} has no family contract"
194            );
195        }
196        Ok(())
197    }
198}
199
200fn gradient_manifest_sha256(expected: &[ExpectedGradient]) -> String {
201    let mut digest = Sha256::new();
202    digest.update(GRADIENT_MANIFEST_SCHEMA.as_bytes());
203    digest.update([0]);
204    for parameter in expected {
205        for value in [&parameter.root, &parameter.key, &parameter.family] {
206            digest.update((value.len() as u64).to_le_bytes());
207            digest.update(value.as_bytes());
208        }
209    }
210    format!("sha256:{:x}", digest.finalize())
211}
212
213#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
214pub struct CaptureContract {
215    pub measurement_scope: MeasurementScope,
216    pub operations: CoverageLevel,
217    #[serde(default)]
218    pub tensors: CoverageLevel,
219    #[serde(default)]
220    pub gradients: CoverageLevel,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub gradient_contract: Option<GradientContract>,
223    pub logical_memory: CoverageLevel,
224    pub physical_memory: CoverageLevel,
225    pub device_timing: CoverageLevel,
226    #[serde(default)]
227    pub required_semantic_labels: Vec<String>,
228    /// Required application spans expected to project onto the GPU. When both semantic-class
229    /// lists are empty, all required semantic labels retain the legacy GPU-expected meaning.
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub gpu_expected_semantic_labels: Vec<String>,
232    /// Required application spans that must not appear in an Nsight GPU projection report.
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub cpu_only_semantic_labels: Vec<String>,
235}
236
237impl CaptureContract {
238    pub fn resolved_gpu_expected_semantic_labels(&self) -> Vec<String> {
239        if self.gpu_expected_semantic_labels.is_empty() && self.cpu_only_semantic_labels.is_empty()
240        {
241            self.required_semantic_labels.clone()
242        } else {
243            self.gpu_expected_semantic_labels.clone()
244        }
245    }
246
247    pub fn resolved_cpu_only_semantic_labels(&self) -> Vec<String> {
248        self.cpu_only_semantic_labels.clone()
249    }
250
251    /// Validate relationships that must hold before a producer starts capture.
252    pub fn validate(&self) -> Result<()> {
253        let mut semantic_labels = BTreeSet::new();
254        for label in &self.required_semantic_labels {
255            ensure!(
256                !label.trim().is_empty(),
257                "required semantic labels must not be empty"
258            );
259            ensure!(
260                semantic_labels.insert(label.as_str()),
261                "required semantic label `{label}` is declared more than once"
262            );
263        }
264        let explicitly_classified = !self.gpu_expected_semantic_labels.is_empty()
265            || !self.cpu_only_semantic_labels.is_empty();
266        if explicitly_classified {
267            let mut classified_labels = BTreeSet::new();
268            for (class, labels) in [
269                ("GPU-expected", &self.gpu_expected_semantic_labels),
270                ("CPU-only", &self.cpu_only_semantic_labels),
271            ] {
272                let mut class_labels = BTreeSet::new();
273                for label in labels {
274                    ensure!(
275                        !label.trim().is_empty(),
276                        "{class} semantic labels must not be empty"
277                    );
278                    ensure!(
279                        class_labels.insert(label.as_str()),
280                        "{class} semantic label `{label}` is declared more than once"
281                    );
282                    ensure!(
283                        semantic_labels.contains(label.as_str()),
284                        "{class} semantic label `{label}` is not a required application label"
285                    );
286                    ensure!(
287                        classified_labels.insert(label.as_str()),
288                        "semantic label `{label}` is classified as both GPU-expected and CPU-only"
289                    );
290                }
291            }
292            ensure!(
293                classified_labels == semantic_labels,
294                "GPU-expected and CPU-only semantic labels must partition all required application labels"
295            );
296        }
297        match (self.gradients, self.gradient_contract.as_ref()) {
298            (CoverageLevel::Complete, Some(contract)) => contract.validate(),
299            (CoverageLevel::Complete, None) => {
300                anyhow::bail!("complete gradient coverage requires an exact gradient contract")
301            }
302            (_, Some(_)) => anyhow::bail!(
303                "an exact gradient contract requires complete declared gradient coverage"
304            ),
305            (_, None) => Ok(()),
306        }
307    }
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312pub enum CapabilityLevel {
313    Unavailable,
314    Partial,
315    Complete,
316    Invalid,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320pub struct CapabilityState {
321    pub level: CapabilityLevel,
322    pub source: String,
323    pub reason: String,
324}
325
326impl Default for CapabilityState {
327    fn default() -> Self {
328        Self::unavailable("capability was not recorded by this evidence schema")
329    }
330}
331
332impl CapabilityState {
333    pub fn invalid(source: impl Into<String>, reason: impl Into<String>) -> Self {
334        Self {
335            level: CapabilityLevel::Invalid,
336            source: source.into(),
337            reason: reason.into(),
338        }
339    }
340
341    pub fn unavailable(reason: impl Into<String>) -> Self {
342        Self {
343            level: CapabilityLevel::Unavailable,
344            source: "none".into(),
345            reason: reason.into(),
346        }
347    }
348
349    pub fn from_coverage(
350        coverage: CoverageLevel,
351        source: impl Into<String>,
352        reason: impl Into<String>,
353    ) -> Self {
354        Self {
355            level: match coverage {
356                CoverageLevel::None => CapabilityLevel::Unavailable,
357                CoverageLevel::Partial => CapabilityLevel::Partial,
358                CoverageLevel::Complete => CapabilityLevel::Complete,
359            },
360            source: source.into(),
361            reason: reason.into(),
362        }
363    }
364
365    pub fn is_available(&self) -> bool {
366        matches!(
367            self.level,
368            CapabilityLevel::Partial | CapabilityLevel::Complete
369        )
370    }
371
372    pub fn is_complete(&self) -> bool {
373        self.level == CapabilityLevel::Complete
374    }
375}
376
377#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
378pub struct EvidenceCapabilities {
379    pub structural_trace: CapabilityState,
380    pub outer_wall_time: CapabilityState,
381    pub nested_host_time: CapabilityState,
382    pub nested_device_time: CapabilityState,
383    pub operation_coverage: CapabilityState,
384    #[serde(default)]
385    pub tensor_coverage: CapabilityState,
386    #[serde(default)]
387    pub gradient_coverage: CapabilityState,
388    pub logical_memory_coverage: CapabilityState,
389    pub physical_memory_coverage: CapabilityState,
390    pub gpu_correlation: CapabilityState,
391    pub provenance_binding: CapabilityState,
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
395#[serde(rename_all = "snake_case")]
396pub enum CapabilityKind {
397    StructuralTrace,
398    OuterWallTime,
399    NestedHostTime,
400    NestedDeviceTime,
401    Operations,
402    Tensors,
403    Gradients,
404    LogicalMemory,
405    PhysicalMemory,
406    GpuCorrelation,
407    ProvenanceBinding,
408}
409
410impl EvidenceCapabilities {
411    pub fn get(&self, kind: CapabilityKind) -> &CapabilityState {
412        match kind {
413            CapabilityKind::StructuralTrace => &self.structural_trace,
414            CapabilityKind::OuterWallTime => &self.outer_wall_time,
415            CapabilityKind::NestedHostTime => &self.nested_host_time,
416            CapabilityKind::NestedDeviceTime => &self.nested_device_time,
417            CapabilityKind::Operations => &self.operation_coverage,
418            CapabilityKind::Tensors => &self.tensor_coverage,
419            CapabilityKind::Gradients => &self.gradient_coverage,
420            CapabilityKind::LogicalMemory => &self.logical_memory_coverage,
421            CapabilityKind::PhysicalMemory => &self.physical_memory_coverage,
422            CapabilityKind::GpuCorrelation => &self.gpu_correlation,
423            CapabilityKind::ProvenanceBinding => &self.provenance_binding,
424        }
425    }
426}