Skip to main content

qualia_core_db/wgsl_forge/
manifest.rs

1use serde::{Deserialize, Serialize};
2
3use super::{
4    AdapterConstraints, ComparisonReport, ForgeError, GeneratedShader, Schedule, TuningResult,
5    ValidationReport, CUDARC_API_VERSION, FORGE_SCHEMA_VERSION, NAGA_API_VERSION, WGPU_API_VERSION,
6};
7
8/// Rich, queryable description of the local compute hardware (plan §9
9/// `profile-hardware`). Acts as the topology fingerprint that keys the manifest
10/// cache (plan §8): tuning records are only reused when the topology hash matches.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct HardwareProfile {
13    pub adapter: AdapterIdentity,
14    pub constraints: AdapterConstraints,
15    /// "unified" (zero-copy host/device) or "discrete" (PCIe, staged copies).
16    pub memory_class: String,
17    pub supports_timestamp_query: bool,
18    pub max_compute_workgroup_storage_size: u32,
19    pub max_storage_buffer_binding_size: u64,
20    pub min_storage_buffer_offset_alignment: u32,
21    pub min_uniform_buffer_offset_alignment: u32,
22}
23
24impl HardwareProfile {
25    /// Stable fingerprint over the topology-defining fields (omits volatile
26    /// driver strings' influence by hashing the structured fields directly).
27    pub fn topology_hash(&self) -> Result<String, ForgeError> {
28        let bytes = serde_json::to_vec(&(
29            FORGE_SCHEMA_VERSION,
30            &self.adapter,
31            &self.constraints,
32            &self.memory_class,
33            self.max_compute_workgroup_storage_size,
34            self.min_storage_buffer_offset_alignment,
35            self.min_uniform_buffer_offset_alignment,
36            WGPU_API_VERSION,
37        ))?;
38        Ok(blake3::hash(&bytes).to_hex().to_string())
39    }
40
41    pub fn to_pretty_json(&self) -> Result<String, ForgeError> {
42        Ok(serde_json::to_string_pretty(self)?)
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ValidationLevel {
49    Generated,
50    NagaValidated,
51    PipelineCreated,
52    OracleVerified,
53    Profiled,
54    Certified,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum TimingSource {
60    GpuTimestamp,
61    CompletionClock,
62    Synthetic,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct TimingSummary {
67    pub source: TimingSource,
68    pub sample_count: usize,
69    pub minimum_ns: u64,
70    pub median_ns: u64,
71    pub p95_ns: u64,
72}
73
74impl TimingSummary {
75    pub fn from_samples(source: TimingSource, samples: &[u64]) -> Option<Self> {
76        if samples.is_empty() {
77            return None;
78        }
79        let mut sorted = samples.to_vec();
80        sorted.sort_unstable();
81        let median_ns = sorted[(sorted.len() - 1) / 2];
82        let p95_index = ((sorted.len() - 1) * 95).div_ceil(100);
83        Some(Self {
84            source,
85            sample_count: sorted.len(),
86            minimum_ns: sorted[0],
87            median_ns,
88            p95_ns: sorted[p95_index],
89        })
90    }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct AdapterIdentity {
95    pub name: String,
96    pub vendor: u32,
97    pub device: u32,
98    pub device_type: String,
99    pub backend: String,
100    pub driver: String,
101    pub driver_info: String,
102}
103
104impl AdapterIdentity {
105    /// Reuse fingerprint (plan §8): tuning/certification evidence is only reused
106    /// when this key matches. Folds in everything that must invalidate reuse:
107    /// the forge schema version, the full adapter identity, the kernel's semantic
108    /// and source hashes, the schedule, the crate version, the wgpu / naga / cudarc
109    /// API versions, and the correctness tolerance the certification used (absolute,
110    /// relative). A coarser tolerance or a different CUDA toolchain surface yields a
111    /// different key, so cached evidence certified under looser tolerances is not
112    /// silently reused. `tolerance` is the (absolute, relative) f32 pair from the
113    /// `OracleTolerance` the run was verified against.
114    pub fn cache_key(
115        &self,
116        semantic_hash: &str,
117        source_hash: &str,
118        schedule: Schedule,
119        tolerance: (f32, f32),
120    ) -> Result<String, ForgeError> {
121        let bytes = serde_json::to_vec(&(
122            FORGE_SCHEMA_VERSION,
123            self,
124            semantic_hash,
125            source_hash,
126            schedule,
127            env!("CARGO_PKG_VERSION"),
128            WGPU_API_VERSION,
129            NAGA_API_VERSION,
130            CUDARC_API_VERSION,
131            tolerance.0.to_bits(),
132            tolerance.1.to_bits(),
133        ))?;
134        Ok(blake3::hash(&bytes).to_hex().to_string())
135    }
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
139pub struct CertificationManifest {
140    pub forge_schema_version: u32,
141    pub crate_version: String,
142    pub wgpu_api_version: String,
143    pub naga_api_version: String,
144    pub kernel_id: String,
145    pub semantic_hash: String,
146    pub source_hash: String,
147    pub schedule: Schedule,
148    pub validation_level: ValidationLevel,
149    pub validation: ValidationReport,
150    pub adapter: Option<AdapterIdentity>,
151    pub oracle: Option<ComparisonReport>,
152    pub timing: Option<TimingSummary>,
153    pub cache_key: Option<String>,
154    /// Deterministic test-vector seed actually used for this kernel's oracle run
155    /// (plan §8 evidence). `None` for kernels whose vectors are not seed-derived
156    /// (e.g. the ray-probe fixed scene) or for non-certified manifests.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub vector_seed: Option<u64>,
159    /// blake3 hex of the expected CPU-reference output bytes the GPU result was
160    /// checked against — pins exactly which vector certified this manifest.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub vector_hash: Option<String>,
163    /// Wall-clock certification time (Unix seconds). Provenance only; not folded
164    /// into the cache key. Source-commit provenance would also belong here, but
165    /// capturing the git commit needs build-time plumbing we don't have, so it is
166    /// out of scope for now.
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub certified_at_unix: Option<u64>,
169}
170
171impl CertificationManifest {
172    pub fn naga_only(generated: &GeneratedShader, validation: ValidationReport) -> Self {
173        Self {
174            forge_schema_version: FORGE_SCHEMA_VERSION,
175            crate_version: env!("CARGO_PKG_VERSION").to_string(),
176            wgpu_api_version: WGPU_API_VERSION.to_string(),
177            naga_api_version: NAGA_API_VERSION.to_string(),
178            kernel_id: generated.kernel_id.clone(),
179            semantic_hash: generated.semantic_hash.clone(),
180            source_hash: generated.source_hash.clone(),
181            schedule: generated.schedule,
182            validation_level: ValidationLevel::NagaValidated,
183            validation,
184            adapter: None,
185            oracle: None,
186            timing: None,
187            cache_key: None,
188            vector_seed: None,
189            vector_hash: None,
190            certified_at_unix: None,
191        }
192    }
193
194    pub fn to_pretty_json(&self) -> Result<String, ForgeError> {
195        Ok(serde_json::to_string_pretty(self)?)
196    }
197}
198
199#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
200pub struct TuningManifest {
201    pub forge_schema_version: u32,
202    pub crate_version: String,
203    pub wgpu_api_version: String,
204    pub naga_api_version: String,
205    pub kernel_id: String,
206    pub semantic_hash: String,
207    pub winning_source_hash: String,
208    pub adapter: AdapterIdentity,
209    pub cache_key: String,
210    pub result: TuningResult,
211}
212
213impl TuningManifest {
214    pub fn new(
215        generated_winner: &GeneratedShader,
216        adapter: AdapterIdentity,
217        result: TuningResult,
218    ) -> Result<Self, ForgeError> {
219        // Tuning selects the winner via the per-kernel evaluate path; the manifest
220        // itself records no scalar tolerance, so the key folds in the default
221        // OracleTolerance (1e-6 absolute / 1e-5 relative — see oracle::OracleTolerance).
222        let cache_key = adapter.cache_key(
223            &generated_winner.semantic_hash,
224            &generated_winner.source_hash,
225            generated_winner.schedule,
226            (1.0e-6, 1.0e-5),
227        )?;
228        Ok(Self {
229            forge_schema_version: FORGE_SCHEMA_VERSION,
230            crate_version: env!("CARGO_PKG_VERSION").to_string(),
231            wgpu_api_version: WGPU_API_VERSION.to_string(),
232            naga_api_version: NAGA_API_VERSION.to_string(),
233            kernel_id: generated_winner.kernel_id.clone(),
234            semantic_hash: generated_winner.semantic_hash.clone(),
235            winning_source_hash: generated_winner.source_hash.clone(),
236            adapter,
237            cache_key,
238            result,
239        })
240    }
241
242    pub fn to_pretty_json(&self) -> Result<String, ForgeError> {
243        Ok(serde_json::to_string_pretty(self)?)
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    fn sample_profile() -> HardwareProfile {
252        HardwareProfile {
253            adapter: AdapterIdentity {
254                name: "Test GPU".to_string(),
255                vendor: 4318,
256                device: 1,
257                device_type: "DiscreteGpu".to_string(),
258                backend: "Vulkan".to_string(),
259                driver: "test".to_string(),
260                driver_info: "1.0".to_string(),
261            },
262            constraints: AdapterConstraints::portable(),
263            memory_class: "discrete".to_string(),
264            supports_timestamp_query: true,
265            max_compute_workgroup_storage_size: 32768,
266            max_storage_buffer_binding_size: 1 << 30,
267            min_storage_buffer_offset_alignment: 256,
268            min_uniform_buffer_offset_alignment: 256,
269        }
270    }
271
272    #[test]
273    fn topology_hash_is_stable_and_sensitive() {
274        let profile = sample_profile();
275        assert_eq!(
276            profile.topology_hash().unwrap(),
277            profile.topology_hash().unwrap()
278        );
279        let mut other = sample_profile();
280        other.memory_class = "unified".to_string();
281        assert_ne!(
282            profile.topology_hash().unwrap(),
283            other.topology_hash().unwrap()
284        );
285    }
286
287    #[test]
288    fn cache_key_changes_when_tolerance_changes() {
289        // Plan §10: the reuse signature must change when a reuse-invalidating input
290        // changes. Same adapter/kernel/schedule, different correctness tolerance ->
291        // different key, so evidence certified under a looser tolerance is not
292        // silently reused. (The cudarc API version is also folded in; it is a compile
293        // -time const so it can't vary at runtime to be asserted here.)
294        let adapter = sample_profile().adapter;
295        let strict = adapter
296            .cache_key("sem", "src", Schedule::default(), (1.0e-6, 1.0e-5))
297            .unwrap();
298        let strict_again = adapter
299            .cache_key("sem", "src", Schedule::default(), (1.0e-6, 1.0e-5))
300            .unwrap();
301        let loose = adapter
302            .cache_key("sem", "src", Schedule::default(), (1.0e-2, 1.0e-2))
303            .unwrap();
304        assert_eq!(strict, strict_again, "same inputs must yield the same key");
305        assert_ne!(strict, loose, "a coarser tolerance must change the key");
306    }
307
308    #[test]
309    fn timing_summary_is_robust_and_deterministic() {
310        let timing =
311            TimingSummary::from_samples(TimingSource::Synthetic, &[100, 50, 10_000, 75, 80])
312                .unwrap();
313        assert_eq!(timing.minimum_ns, 50);
314        assert_eq!(timing.median_ns, 80);
315        assert_eq!(timing.p95_ns, 10_000);
316    }
317}