Skip to main content

softgpu_core/
profile.rs

1//! Device profile schema with per-field provenance.
2//!
3//! A profile is a versioned compatibility contract, not a bag of marketing
4//! strings. Unknown is valid. SoftGPU must never invent numeric device facts
5//! merely because an API requests them.
6
7use crate::error::{Error, ErrorCategory, Result};
8use crate::fidelity::FidelityLevel;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::Path;
12
13/// How a profile field was established.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum CapabilityProvenance {
17    /// Confirmed against a primary source or reproducible measurement.
18    Verified,
19    /// Observed in a pinned toolchain/runtime without full normative citation.
20    Observed,
21    /// Derived from related evidence; not directly measured.
22    Inferred,
23    /// Explicitly unknown; callers must not treat as a concrete capability.
24    Unknown,
25    /// Temporary stand-in required for scaffolding; never used for conformance.
26    Provisional,
27}
28
29impl CapabilityProvenance {
30    pub fn as_str(self) -> &'static str {
31        match self {
32            Self::Verified => "verified",
33            Self::Observed => "observed",
34            Self::Inferred => "inferred",
35            Self::Unknown => "unknown",
36            Self::Provisional => "provisional",
37        }
38    }
39
40    /// Whether this provenance may participate in conformance claims.
41    pub fn allows_conformance(self) -> bool {
42        matches!(self, Self::Verified | Self::Observed)
43    }
44}
45
46/// Machine-readable support verification state (support matrix cells).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "kebab-case")]
49pub enum SupportState {
50    ImplementedUnverified,
51    VerifiedUnit,
52    VerifiedIntegration,
53    HardwareDifferential,
54    Experimental,
55    Unsupported,
56}
57
58impl SupportState {
59    pub fn as_str(self) -> &'static str {
60        match self {
61            Self::ImplementedUnverified => "implemented-unverified",
62            Self::VerifiedUnit => "verified-unit",
63            Self::VerifiedIntegration => "verified-integration",
64            Self::HardwareDifferential => "hardware-differential",
65            Self::Experimental => "experimental",
66            Self::Unsupported => "unsupported",
67        }
68    }
69}
70
71/// A typed profile field carrying value + provenance + optional notes.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct ProfileField<T> {
74    pub value: Option<T>,
75    pub provenance: CapabilityProvenance,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub source_ref: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub notes: Option<String>,
80}
81
82impl<T> ProfileField<T> {
83    pub fn unknown() -> Self {
84        Self {
85            value: None,
86            provenance: CapabilityProvenance::Unknown,
87            source_ref: None,
88            notes: Some("not established; do not invent".into()),
89        }
90    }
91
92    pub fn verified(value: T, source_ref: impl Into<String>) -> Self {
93        Self {
94            value: Some(value),
95            provenance: CapabilityProvenance::Verified,
96            source_ref: Some(source_ref.into()),
97            notes: None,
98        }
99    }
100
101    pub fn provisional(value: T, reason: impl Into<String>) -> Self {
102        Self {
103            value: Some(value),
104            provenance: CapabilityProvenance::Provisional,
105            source_ref: None,
106            notes: Some(reason.into()),
107        }
108    }
109}
110
111/// Identity advertised through a runtime adapter (Phase 2+).
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct ProfileIdentity {
114    pub vendor: String,
115    pub product_name: String,
116    pub architecture_family: String,
117    pub llvm_target: ProfileField<String>,
118}
119
120/// Versioned device profile document.
121#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct DeviceProfile {
123    pub schema_version: u32,
124    pub profile_id: String,
125    pub profile_revision: String,
126    pub identity: ProfileIdentity,
127    /// Maximum advertised fidelity SoftGPU may claim with this profile today.
128    pub max_fidelity: FidelityLevel,
129    /// Whether any conformance claim is permitted for this profile revision.
130    pub conformance_allowed: bool,
131    /// Resource limits used to reject impossible launches. Unknown is valid.
132    pub resource_limits: ResourceLimits,
133    /// Optional analytical performance parameters (never used as correctness).
134    #[serde(default)]
135    pub analytical_performance: AnalyticalPerformanceParams,
136    #[serde(default)]
137    pub quirks: Vec<String>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
141pub struct ResourceLimits {
142    pub max_workgroup_size_x: ProfileField<u32>,
143    pub max_workgroup_size_y: ProfileField<u32>,
144    pub max_workgroup_size_z: ProfileField<u32>,
145    pub wavefront_size: ProfileField<u32>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
149pub struct AnalyticalPerformanceParams {
150    /// Explicitly empty in Phase 0; present so schema shape is stable.
151    #[serde(default)]
152    pub notes: Vec<String>,
153}
154
155impl Default for ProfileField<u32> {
156    fn default() -> Self {
157        Self::unknown()
158    }
159}
160
161impl DeviceProfile {
162    pub const CURRENT_SCHEMA_VERSION: u32 = 1;
163
164    /// Load and validate a profile JSON document from disk.
165    pub fn load_path(path: impl AsRef<Path>) -> Result<Self> {
166        let bytes = fs::read(path.as_ref())?;
167        Self::parse_bytes(&bytes)
168    }
169
170    /// Parse and validate profile bytes.
171    pub fn parse_bytes(bytes: &[u8]) -> Result<Self> {
172        let profile: DeviceProfile = serde_json::from_slice(bytes)?;
173        profile.validate()?;
174        Ok(profile)
175    }
176
177    /// Validate schema invariants without trusting serde alone.
178    pub fn validate(&self) -> Result<()> {
179        if self.schema_version != Self::CURRENT_SCHEMA_VERSION {
180            return Err(Error::new(
181                ErrorCategory::Profile,
182                format!(
183                    "unsupported profile schema_version {}; expected {}",
184                    self.schema_version,
185                    Self::CURRENT_SCHEMA_VERSION
186                ),
187            )
188            .with_remediation("migrate the profile or use a SoftGPU build that supports it"));
189        }
190
191        if self.profile_id.trim().is_empty() {
192            return Err(Error::new(
193                ErrorCategory::Profile,
194                "profile_id must be non-empty",
195            ));
196        }
197
198        if self.identity.vendor.trim().is_empty()
199            || self.identity.product_name.trim().is_empty()
200            || self.identity.architecture_family.trim().is_empty()
201        {
202            return Err(Error::new(
203                ErrorCategory::Profile,
204                "identity.vendor/product_name/architecture_family must be non-empty",
205            ));
206        }
207
208        if self.conformance_allowed {
209            if self.max_fidelity == FidelityLevel::HardwareConformant {
210                return Err(Error::new(
211                    ErrorCategory::Profile,
212                    "conformance_allowed cannot be true for hardware-conformant max_fidelity until Phase 12 evidence exists",
213                )
214                .with_remediation("set conformance_allowed=false or lower max_fidelity"));
215            }
216            // Any provisional/unknown resource limit forbids conformance.
217            for (name, field) in [
218                (
219                    "max_workgroup_size_x",
220                    &self.resource_limits.max_workgroup_size_x,
221                ),
222                (
223                    "max_workgroup_size_y",
224                    &self.resource_limits.max_workgroup_size_y,
225                ),
226                (
227                    "max_workgroup_size_z",
228                    &self.resource_limits.max_workgroup_size_z,
229                ),
230                ("wavefront_size", &self.resource_limits.wavefront_size),
231            ] {
232                if !field.provenance.allows_conformance() {
233                    return Err(Error::new(
234                        ErrorCategory::Profile,
235                        format!(
236                            "conformance_allowed requires verified/observed provenance for {name}; found {}",
237                            field.provenance.as_str()
238                        ),
239                    ));
240                }
241            }
242            if !self.identity.llvm_target.provenance.allows_conformance() {
243                return Err(Error::new(
244                    ErrorCategory::Profile,
245                    format!(
246                        "conformance_allowed requires verified/observed llvm_target; found {}",
247                        self.identity.llvm_target.provenance.as_str()
248                    ),
249                ));
250            }
251        }
252
253        // llvm_target may be unknown, but if a value is present it must be non-empty.
254        if let Some(target) = &self.identity.llvm_target.value {
255            if target.trim().is_empty() {
256                return Err(Error::new(
257                    ErrorCategory::Profile,
258                    "identity.llvm_target.value must not be an empty string when present",
259                ));
260            }
261        }
262
263        Ok(())
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    fn generic_profile() -> DeviceProfile {
272        DeviceProfile {
273            schema_version: 1,
274            profile_id: "softgpu-generic".into(),
275            profile_revision: "0".into(),
276            identity: ProfileIdentity {
277                vendor: "SoftGPU".into(),
278                product_name: "Generic Test Device".into(),
279                architecture_family: "softgpu-abstract".into(),
280                llvm_target: ProfileField::unknown(),
281            },
282            max_fidelity: FidelityLevel::Abi,
283            conformance_allowed: false,
284            resource_limits: ResourceLimits::default(),
285            analytical_performance: AnalyticalPerformanceParams::default(),
286            quirks: vec![],
287        }
288    }
289
290    #[test]
291    fn valid_generic_profile_passes() {
292        generic_profile().validate().unwrap();
293    }
294
295    #[test]
296    fn wrong_schema_version_fails() {
297        let mut p = generic_profile();
298        p.schema_version = 99;
299        let err = p.validate().unwrap_err();
300        assert_eq!(err.category(), ErrorCategory::Profile);
301        assert!(err.message().contains("schema_version"));
302    }
303
304    #[test]
305    fn conformance_forbidden_with_unknown_limits() {
306        let mut p = generic_profile();
307        p.conformance_allowed = true;
308        p.identity.llvm_target =
309            ProfileField::verified("gfx1201".into(), "docs/sources.md#llvm-amdgpu");
310        let err = p.validate().unwrap_err();
311        assert!(err.message().contains("conformance_allowed"));
312    }
313
314    #[test]
315    fn empty_llvm_target_value_rejected() {
316        let mut p = generic_profile();
317        p.identity.llvm_target = ProfileField {
318            value: Some("".into()),
319            provenance: CapabilityProvenance::Provisional,
320            source_ref: None,
321            notes: Some("bad".into()),
322        };
323        let err = p.validate().unwrap_err();
324        assert!(err.message().contains("llvm_target"));
325    }
326}