Skip to main content

qualia_core_db/wgsl_forge/
runtime.rs

1//! Consumer-facing runtime for the certified WGSL Forge.
2//!
3//! Everything else in [`crate::wgsl_forge`] is about *proving* a kernel correct
4//! (the differential oracle in [`oracle`](crate::wgsl_forge::oracle)) and *tuning*
5//! its schedule (`tune` + the topology-keyed [`ManifestCache`]). That machinery
6//! always runs against deterministic, seed-derived test vectors so the GPU result
7//! can be checked bit-for-bit against a CPU reference.
8//!
9//! [`ForgeRuntime`] is the other half: once a kernel is certified, other modules
10//! need to run it on **their own real data**, with no oracle, no comparison, and
11//! no test-vector generation — just "feed my numbers in, run the auto-tuned
12//! schedule, give me the answer back". Each typed method wires its GPU buffers
13//! *identically* to the matching `evaluate_*` in [`oracle`](crate::wgsl_forge::oracle)
14//! (same bindings, [`BindingUsage`]s, `*Params` uniform blocks, dispatch
15//! `element_count`, and output sizing) so the runtime path is the certified path —
16//! the only difference is the source of the input bytes and the absence of the
17//! CPU check.
18//!
19//! The dispatch schedule comes from [`ForgeRuntime::tuned_schedule`]: when a
20//! [`ManifestCache`] is attached and holds a tuning record for this hardware
21//! topology, the cached winner is used; otherwise a documented per-kernel default
22//! is used. Populate the cache for this machine with the CLI's `shader
23//! auto-tune-all` (it tunes every built-in and writes a topology-keyed
24//! [`TuningManifest`] per kernel).
25
26use std::path::PathBuf;
27
28#[cfg(feature = "dxc")]
29use super::emit::{compile_hlsl_to_spirv, compile_hlsl_to_spirv_cached};
30use super::execute::{BindingUsage, QualiaCompute, WgpuComputeContext, WgpuPipeline};
31use super::oracle::{
32    FftParams, GemmParams, GemvParams, TernaryGemvParams, TopKParams, TERNARY_CODES_PER_WORD,
33};
34use super::{
35    emit_shader, validate_wgsl, BuiltinKernel, ForgeError, ManifestCache, Schedule, TargetBackend,
36};
37
38/// A ready-to-use handle for running certified forge kernels on real data.
39///
40/// Owns the GPU compute context (one device/queue/slab pair) and, optionally, a
41/// [`ManifestCache`] directory plus this machine's topology hash so tuned
42/// schedules can be looked up. Construct once and reuse across many calls; each
43/// `topk` / `ternary_gemv` / `p64_project` call allocates transiently, dispatches,
44/// reads back, and frees its transient allocations.
45pub struct ForgeRuntime {
46    context: WgpuComputeContext,
47    /// Optional tuned-schedule source. When present, [`Self::tuned_schedule`]
48    /// looks up `(topology_hash, kernel)` and returns the cached winner.
49    cache: Option<ManifestCache>,
50    /// Stable fingerprint of this adapter/topology, used to key cache lookups.
51    /// Computed once at construction from the context's [`HardwareProfile`].
52    topology_hash: Option<String>,
53    /// Which shader backend to emit + compile. `Wgsl` is the default; `Hlsl`
54    /// emits HLSL, compiles to SPIR-V via DXC, then feeds the SPIR-V into the
55    /// same wgpu pipeline. Controlled by `QUALIA_FORGE_BACKEND` env var.
56    backend: TargetBackend,
57}
58
59impl ForgeRuntime {
60    /// Build the GPU context, optionally attaching a manifest-cache directory for
61    /// tuned schedules.
62    ///
63    /// `capacity_bytes` sizes the device slab (inputs + outputs must fit within it
64    /// per call). When `cache_dir` is `Some`, [`Self::tuned_schedule`] will consult
65    /// the cache; when it is `None`, every kernel uses its documented default
66    /// schedule. The topology hash used for cache keys is derived from the live
67    /// adapter, so a cache produced on different hardware is simply never matched
68    /// (it is not an error).
69    ///
70    /// # Example
71    /// ```no_run
72    /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
73    /// let mut rt = ForgeRuntime::new(64 * 1024 * 1024, None)?;
74    /// let top = rt.topk(&[3.0, 1.0, 2.0, 0.5], 2)?; // largest-2 per block
75    /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
76    /// ```
77    pub fn new(capacity_bytes: usize, cache_dir: Option<PathBuf>) -> Result<Self, ForgeError> {
78        let context = WgpuComputeContext::new(capacity_bytes)?;
79        // The topology hash pins cache reuse to this exact adapter/topology
80        // (plan §8). If it cannot be computed we simply fall back to defaults
81        // rather than failing construction.
82        let topology_hash = context.profile.topology_hash().ok();
83        let cache = cache_dir.map(ManifestCache::new);
84        let backend = forge_backend_from_env();
85        Ok(Self {
86            context,
87            cache,
88            topology_hash,
89            backend,
90        })
91    }
92
93    /// Emit + compile a kernel using the configured backend. When `backend` is
94    /// `Wgsl` this is the standard path (emit WGSL → naga validate → wgpu
95    /// pipeline). When `backend` is `Hlsl` this emits HLSL, compiles to SPIR-V
96    /// via DXC, and feeds the SPIR-V into the same wgpu pipeline — reusing the
97    /// entire buffer/slab/dispatch bridge.
98    fn compile_kernel_pipeline(
99        &self,
100        kernel: &super::KernelSpec,
101        schedule: Schedule,
102    ) -> Result<WgpuPipeline<'_>, ForgeError> {
103        let generated = emit_shader(kernel, schedule, self.backend)?;
104        match self.backend {
105            TargetBackend::Wgsl => {
106                validate_wgsl(&generated.source)?;
107                WgpuPipeline::compile(&self.context, &generated.source, &kernel.entry_point)
108            }
109            TargetBackend::Hlsl => {
110                #[cfg(feature = "dxc")]
111                {
112                    let spirv = compile_hlsl_to_spirv_cached(
113                        &generated.source,
114                        &kernel.entry_point,
115                        compile_hlsl_to_spirv,
116                    )?;
117                    WgpuPipeline::compile_spirv(&self.context, &spirv, &kernel.entry_point)
118                }
119                #[cfg(not(feature = "dxc"))]
120                {
121                    Err(ForgeError::Emission(
122                        "HLSL backend requires the 'dxc' feature".to_string(),
123                    ))
124                }
125            }
126            // SPIR-V: emit via naga → patch workgroup size → compile through
127            // wgpu's ShaderSource::SpirV path. This skips the naga parse +
128            // validate at runtime (the SPIR-V is pre-compiled) and enables
129            // workgroup-size specialization via binary patching.
130            TargetBackend::Spirv => {
131                let generated = emit_shader(kernel, schedule, TargetBackend::Spirv)?;
132                let words = super::emit::decode_spirv_words(&generated.source)?;
133                let spirv_bytes: Vec<u8> = words.iter().flat_map(|&w| w.to_le_bytes()).collect();
134                WgpuPipeline::compile_spirv(&self.context, &spirv_bytes, &kernel.entry_point)
135            }
136            // MSL: compile through wgpu's Metal backend. wgpu on macOS accepts
137            // MSL source directly via ShaderSource::Wgsl (naga transpiles WGSL→MSL),
138            // but for pre-emitted MSL we use the Metal-specific path. On non-Apple
139            // platforms MSL falls back to WGSL.
140            TargetBackend::Msl => {
141                #[cfg(target_os = "macos")]
142                {
143                    // On macOS, compile MSL source through wgpu's Metal pipeline.
144                    // The MSL source is already valid Metal Shading Language —
145                    // wgpu's Metal backend can consume it directly as a native shader.
146                    let msl_source = &generated.source;
147                    WgpuPipeline::compile_msl(&self.context, msl_source, &kernel.entry_point)
148                }
149                #[cfg(not(target_os = "macos"))]
150                {
151                    // Non-Apple: fall back to WGSL.
152                    let wgsl = emit_shader(kernel, schedule, TargetBackend::Wgsl)?;
153                    validate_wgsl(&wgsl.source)?;
154                    WgpuPipeline::compile(&self.context, &wgsl.source, &kernel.entry_point)
155                }
156            }
157            // Other backends (CUDA-C, PTX) are not yet wired into
158            // the wgpu execution bridge — fall back to WGSL.
159            _ => {
160                let wgsl = emit_shader(kernel, schedule, TargetBackend::Wgsl)?;
161                validate_wgsl(&wgsl.source)?;
162                WgpuPipeline::compile(&self.context, &wgsl.source, &kernel.entry_point)
163            }
164        }
165    }
166
167    /// The tuned [`Schedule`] for `builtin` on this hardware.
168    ///
169    /// If a cache is attached and holds a [`TuningManifest`] for
170    /// `(topology_hash, builtin)`, the winning schedule from that record is
171    /// returned. Otherwise — no cache, no topology hash, no record for this
172    /// kernel, or a cache read error — the per-kernel default is returned.
173    ///
174    /// **Default policy** (matches what the `evaluate_*` oracle paths use):
175    /// every built-in defaults to `Schedule { workgroup_size: 64, .. }`
176    /// (`items_per_invocation = 1`, `vector_width = 1`). For top-k that 64 is also
177    /// the per-block size (`block_size == workgroup_size`), so the default top-k
178    /// processes the input in 64-element blocks. Populate the cache for this
179    /// machine with `shader auto-tune-all`.
180    pub fn tuned_schedule(&self, builtin: BuiltinKernel) -> Schedule {
181        Self::lookup_tuned_schedule(self.cache.as_ref(), self.topology_hash.as_deref(), builtin)
182    }
183
184    /// Cache-lookup core of [`Self::tuned_schedule`], split out so the
185    /// default-path policy is unit-testable without a GPU context. Returns the
186    /// cached winner when `(cache, topology_hash)` are both present and a record
187    /// exists; otherwise the documented per-kernel default.
188    fn lookup_tuned_schedule(
189        cache: Option<&ManifestCache>,
190        topology_hash: Option<&str>,
191        builtin: BuiltinKernel,
192    ) -> Schedule {
193        if let (Some(cache), Some(topology_hash)) = (cache, topology_hash) {
194            if let Ok(Some(manifest)) =
195                cache.load_tuning_for_topology(topology_hash, builtin.name())
196            {
197                return manifest.result.winner.schedule;
198            }
199        }
200        Self::default_schedule(builtin)
201    }
202
203    /// The documented per-kernel default schedule. Every built-in uses
204    /// `workgroup_size = 64` (the value the `evaluate_*` oracle paths default to;
205    /// for top-k it doubles as the per-block size). Kept as one place so the
206    /// policy is stated once.
207    fn default_schedule(_builtin: BuiltinKernel) -> Schedule {
208        Schedule {
209            workgroup_size: 64,
210            ..Default::default()
211        }
212    }
213
214    /// Real-data per-block top-k: returns, for each `block_size`-element block of
215    /// `input` (with `block_size = tuned_schedule.workgroup_size`), the `k` largest
216    /// values in descending order, concatenated block-by-block.
217    ///
218    /// The tail block (when `input.len()` is not a multiple of `block_size`) is
219    /// padded with `f32::MIN` by the kernel, exactly as the certified path does, so
220    /// short blocks still emit `k` values (the padding sentinels sort last).
221    ///
222    /// Buffer wiring is identical to [`evaluate_topk`](super::oracle::evaluate_topk):
223    /// binding 0 = `input` (storage-read), binding 1 = `output` (storage-read-write,
224    /// `num_blocks * k` f32s), binding 2 = [`TopKParams`] (uniform); dispatch
225    /// `element_count = input.len()`. The CALLER's `input` is fed directly — no
226    /// oracle, no test vectors, no comparison.
227    ///
228    /// # Example
229    /// ```no_run
230    /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
231    /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
232    /// let top2 = rt.topk(&[5.0, 1.0, 9.0, 2.0], 2)?; // one 4-elem tail block -> [9.0, 5.0]
233    /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
234    /// ```
235    pub fn topk(&mut self, input: &[f32], k: usize) -> Result<Vec<f32>, ForgeError> {
236        let schedule = self.tuned_schedule(BuiltinKernel::TopK);
237        let block_size = schedule.workgroup_size as usize;
238        let length = input.len();
239        if length == 0 {
240            return Err(ForgeError::GpuValidation(
241                "topk input must be non-empty".to_string(),
242            ));
243        }
244        if k == 0 || k > block_size {
245            return Err(ForgeError::GpuValidation(format!(
246                "k must be in 1..=block_size ({block_size}); got {k}"
247            )));
248        }
249
250        let kernel = BuiltinKernel::TopK.spec();
251        schedule.validate(&kernel, &self.context.constraints)?;
252        self.context.constraints.supports_kernel(&kernel)?;
253
254        // Output sizing mirrors evaluate_topk: one (k)-tuple per block.
255        let num_blocks = length.div_ceil(block_size.max(1));
256        let output_len = num_blocks * k;
257
258        let input_bytes = bytemuck::cast_slice(input);
259        let view_input =
260            self.context
261                .allocate_and_write(input_bytes, 0, 0, BindingUsage::StorageRead)?;
262        let output_bytes_len = (output_len * size_of::<f32>()).max(4);
263        let view_output = self.context.allocate_transient(
264            output_bytes_len,
265            1,
266            0,
267            BindingUsage::StorageReadWrite,
268        )?;
269        let params = TopKParams {
270            length: length as u32,
271            k: k as u32,
272            block_size: block_size as u32,
273            _pad: 0,
274        };
275        let view_params = self.context.allocate_and_write(
276            bytemuck::bytes_of(&params),
277            2,
278            0,
279            BindingUsage::Uniform,
280        )?;
281
282        let buffers = vec![view_input, view_output, view_params];
283        let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
284        pipeline.dispatch(&buffers, &schedule, length)?;
285        let mut out = self.context.read_buffer_f32(&view_output)?;
286        out.truncate(output_len);
287
288        drop(pipeline);
289        self.context.clear_transient_allocations();
290        Ok(out)
291    }
292
293    /// Real-data ternary (BitNet-style) GEMV with on-the-fly dequant:
294    /// `out[o] = scale[o] * sum_{i<k} ternary(w[o][i]) * x[i]` for `m` output rows.
295    ///
296    /// `packed_w` holds the 2-bit ternary codes, 16 codes per `u32`
297    /// (`0 -> 0.0, 1 -> +1.0, 2 -> -1.0`, code `3` unused), laid out as `m` rows of
298    /// `ceil(k / 16)` words each, row-major. `scale` has length `m`, `x` length `k`.
299    ///
300    /// Buffer wiring is identical to
301    /// [`evaluate_ternary_gemv`](super::oracle::evaluate_ternary_gemv): binding 0 =
302    /// `x`, 1 = `w_packed`, 2 = `scale` (all storage-read), 3 = `output`
303    /// (storage-read-write, `m` f32s), 4 = [`TernaryGemvParams`] (uniform); dispatch
304    /// `element_count = m`. The CALLER's tensors are fed directly — no oracle.
305    ///
306    /// # Example
307    /// ```no_run
308    /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
309    /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
310    /// // 2 rows x 4 cols, codes packed low-to-high (1=+1, 2=-1): row0=+1,+1,+1,+1; row1=-1,-1,-1,-1
311    /// let out = rt.ternary_gemv(&[1.0, 2.0, 3.0, 4.0], &[0x55, 0xAA], &[2.0, 10.0], 2, 4)?;
312    /// // -> [2*(1+2+3+4), 10*(-1-2-3-4)] = [20.0, -100.0]
313    /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
314    /// ```
315    pub fn ternary_gemv(
316        &mut self,
317        x: &[f32],
318        packed_w: &[u32],
319        scale: &[f32],
320        m: usize,
321        k: usize,
322    ) -> Result<Vec<f32>, ForgeError> {
323        if m == 0 || k == 0 {
324            return Err(ForgeError::GpuValidation(
325                "ternary_gemv requires m > 0 and k > 0".to_string(),
326            ));
327        }
328        let k_words = k.div_ceil(TERNARY_CODES_PER_WORD);
329        if x.len() < k {
330            return Err(ForgeError::GpuValidation(format!(
331                "x must have at least k = {k} elements; got {}",
332                x.len()
333            )));
334        }
335        if scale.len() < m {
336            return Err(ForgeError::GpuValidation(format!(
337                "scale must have at least m = {m} elements; got {}",
338                scale.len()
339            )));
340        }
341        if packed_w.len() < m * k_words {
342            return Err(ForgeError::GpuValidation(format!(
343                "packed_w must have at least m*ceil(k/16) = {} words; got {}",
344                m * k_words,
345                packed_w.len()
346            )));
347        }
348
349        let schedule = self.tuned_schedule(BuiltinKernel::TernaryGemv);
350        let kernel = BuiltinKernel::TernaryGemv.spec();
351        schedule.validate(&kernel, &self.context.constraints)?;
352        self.context.constraints.supports_kernel(&kernel)?;
353
354        let view_x = self.context.allocate_and_write(
355            bytemuck::cast_slice(x),
356            0,
357            0,
358            BindingUsage::StorageRead,
359        )?;
360        let view_w = self.context.allocate_and_write(
361            bytemuck::cast_slice(packed_w),
362            1,
363            0,
364            BindingUsage::StorageRead,
365        )?;
366        let view_scale = self.context.allocate_and_write(
367            bytemuck::cast_slice(scale),
368            2,
369            0,
370            BindingUsage::StorageRead,
371        )?;
372        let output_bytes_len = (m * size_of::<f32>()).max(4);
373        let view_output = self.context.allocate_transient(
374            output_bytes_len,
375            3,
376            0,
377            BindingUsage::StorageReadWrite,
378        )?;
379        let params = TernaryGemvParams {
380            m: m as u32,
381            k: k as u32,
382            k_words: k_words as u32,
383            _pad: 0,
384        };
385        let view_params = self.context.allocate_and_write(
386            bytemuck::bytes_of(&params),
387            4,
388            0,
389            BindingUsage::Uniform,
390        )?;
391
392        let buffers = vec![view_x, view_w, view_scale, view_output, view_params];
393        let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
394        pipeline.dispatch(&buffers, &schedule, m)?;
395        let mut out = self.context.read_buffer_f32(&view_output)?;
396        out.truncate(m);
397
398        drop(pipeline);
399        self.context.clear_transient_allocations();
400        Ok(out)
401    }
402
403    /// Real-data P64 projection: `out[r] = sum_{w<16} weights[w] * f32(p64[r].word[w])`
404    /// for `record_count` records.
405    ///
406    /// `records_bytes` is the packed P64 GPU words exactly as
407    /// [`evaluate_p64`](super::oracle::evaluate_p64) lays them out — a contiguous
408    /// array of [`P64GpuWords64`](super::P64GpuWords64) (64 bytes / record, 16 `u32`
409    /// words / record), so `records_bytes.len()` must be `record_count * 64`.
410    /// `weights` has length 16.
411    ///
412    /// Buffer wiring is identical to `evaluate_p64`: binding 0 = `input` (P64
413    /// records, storage-read), 1 = `weights` (storage-read), 2 = `output`
414    /// (storage-read-write, `record_count` f32s); dispatch
415    /// `element_count = record_count`. The CALLER's records are fed directly — no
416    /// oracle.
417    ///
418    /// # Example
419    /// ```no_run
420    /// # use qualia_core_db::wgsl_forge::{ForgeRuntime, P64GpuWords64};
421    /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
422    /// let recs = [P64GpuWords64::from_u64_fields([1, 0, 0, 0, 0, 0, 0, 0])];
423    /// let bytes: &[u8] = bytemuck::cast_slice(&recs);
424    /// let weights = [1.0f32; 16];
425    /// let out = rt.p64_project(bytes, &weights, 1)?; // out[0] = word[0] = 1.0
426    /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
427    /// ```
428    pub fn p64_project(
429        &mut self,
430        records_bytes: &[u8],
431        weights: &[f32],
432        record_count: usize,
433    ) -> Result<Vec<f32>, ForgeError> {
434        if record_count == 0 {
435            return Err(ForgeError::GpuValidation(
436                "p64_project requires record_count > 0".to_string(),
437            ));
438        }
439        // 64 bytes (16 u32 words) per record, matching P64GpuWords64.
440        const RECORD_BYTES: usize = size_of::<super::P64GpuWords64>();
441        if records_bytes.len() != record_count * RECORD_BYTES {
442            return Err(ForgeError::GpuValidation(format!(
443                "records_bytes must be record_count*{RECORD_BYTES} = {} bytes; got {}",
444                record_count * RECORD_BYTES,
445                records_bytes.len()
446            )));
447        }
448        if weights.len() < 16 {
449            return Err(ForgeError::GpuValidation(format!(
450                "weights must have at least 16 elements; got {}",
451                weights.len()
452            )));
453        }
454
455        let schedule = self.tuned_schedule(BuiltinKernel::P64Project);
456        let kernel = BuiltinKernel::P64Project.spec();
457        schedule.validate(&kernel, &self.context.constraints)?;
458        self.context.constraints.supports_kernel(&kernel)?;
459
460        let view_input =
461            self.context
462                .allocate_and_write(records_bytes, 0, 0, BindingUsage::StorageRead)?;
463        let view_weights = self.context.allocate_and_write(
464            bytemuck::cast_slice(&weights[..16]),
465            1,
466            0,
467            BindingUsage::StorageRead,
468        )?;
469        let output_bytes_len = (record_count * size_of::<f32>()).max(4);
470        let view_output = self.context.allocate_transient(
471            output_bytes_len,
472            2,
473            0,
474            BindingUsage::StorageReadWrite,
475        )?;
476
477        let buffers = vec![view_input, view_weights, view_output];
478        let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
479        pipeline.dispatch(&buffers, &schedule, record_count)?;
480        let mut out = self.context.read_buffer_f32(&view_output)?;
481        out.truncate(record_count);
482
483        drop(pipeline);
484        self.context.clear_transient_allocations();
485        Ok(out)
486    }
487
488    /// Real-data dense GEMM: row-major `C[M×N] = A[M×K] · B[K×N]`, all f32, i.e.
489    /// `C[i][j] = sum_{k<K} a[i*K + k] * b[k*N + j]`, for `m * n` output elements.
490    ///
491    /// `a` must have `m * k` elements and `b` must have `k * n` elements, both
492    /// row-major. The returned vector has `m * n` elements, row-major.
493    ///
494    /// Buffer wiring is identical to [`evaluate_gemm`](super::oracle::evaluate_gemm):
495    /// binding 0 = `a`, 1 = `b` (both storage-read), 2 = `c` (storage-read-write,
496    /// `m*n` f32s), 3 = [`GemmParams`] (uniform); dispatch `element_count = m*n`.
497    /// The CALLER's matrices are fed directly — no oracle, no test vectors.
498    ///
499    /// # Example
500    /// ```no_run
501    /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
502    /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
503    /// // A (2×3) · B (3×2): A=[[1,2,3],[4,5,6]], B=[[7,8],[9,10],[11,12]]
504    /// let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
505    /// let b = [7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
506    /// let c = rt.gemm(&a, &b, 2, 3, 2)?; // -> [58, 64, 139, 154]
507    /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
508    /// ```
509    pub fn gemm(
510        &mut self,
511        a: &[f32],
512        b: &[f32],
513        m: usize,
514        k: usize,
515        n: usize,
516    ) -> Result<Vec<f32>, ForgeError> {
517        if m == 0 || k == 0 || n == 0 {
518            return Err(ForgeError::GpuValidation(
519                "gemm requires m > 0, k > 0, and n > 0".to_string(),
520            ));
521        }
522        if a.len() != m * k {
523            return Err(ForgeError::GpuValidation(format!(
524                "a must have m*k = {} elements; got {}",
525                m * k,
526                a.len()
527            )));
528        }
529        if b.len() != k * n {
530            return Err(ForgeError::GpuValidation(format!(
531                "b must have k*n = {} elements; got {}",
532                k * n,
533                b.len()
534            )));
535        }
536
537        let schedule = self.tuned_schedule(BuiltinKernel::Gemm);
538        let kernel = BuiltinKernel::Gemm.spec();
539        schedule.validate(&kernel, &self.context.constraints)?;
540        self.context.constraints.supports_kernel(&kernel)?;
541
542        let element_count = m * n;
543        let view_a = self.context.allocate_and_write(
544            bytemuck::cast_slice(a),
545            0,
546            0,
547            BindingUsage::StorageRead,
548        )?;
549        let view_b = self.context.allocate_and_write(
550            bytemuck::cast_slice(b),
551            1,
552            0,
553            BindingUsage::StorageRead,
554        )?;
555        let output_bytes_len = (element_count * size_of::<f32>()).max(4);
556        let view_c = self.context.allocate_transient(
557            output_bytes_len,
558            2,
559            0,
560            BindingUsage::StorageReadWrite,
561        )?;
562        let params = GemmParams {
563            m: m as u32,
564            n: n as u32,
565            k: k as u32,
566            _pad: 0,
567        };
568        let view_params = self.context.allocate_and_write(
569            bytemuck::bytes_of(&params),
570            3,
571            0,
572            BindingUsage::Uniform,
573        )?;
574
575        let buffers = vec![view_a, view_b, view_c, view_params];
576        let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
577        pipeline.dispatch(&buffers, &schedule, element_count)?;
578        let mut out = self.context.read_buffer_f32(&view_c)?;
579        out.truncate(element_count);
580
581        drop(pipeline);
582        self.context.clear_transient_allocations();
583        Ok(out)
584    }
585
586    /// Real-data dense GEMV: row-major `y[M] = A[M×N] · x[N]`, all f32, i.e.
587    /// `y[i] = sum_{j<N} a[i*N + j] * x[j]`, for `m` output rows.
588    ///
589    /// `a` must have `m * n` elements (row-major) and `x` must have `n` elements.
590    /// The returned vector has `m` elements.
591    ///
592    /// Buffer wiring is identical to [`evaluate_gemv`](super::oracle::evaluate_gemv):
593    /// binding 0 = `a`, 1 = `x` (both storage-read), 2 = `y` (storage-read-write,
594    /// `m` f32s), 3 = [`GemvParams`] (uniform); dispatch `element_count = m`. The
595    /// CALLER's matrix/vector are fed directly — no oracle, no test vectors.
596    ///
597    /// # Example
598    /// ```no_run
599    /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
600    /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
601    /// // A (2×3) · x (3): A=[[1,2,3],[4,5,6]], x=[1,1,1]
602    /// let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
603    /// let x = [1.0, 1.0, 1.0];
604    /// let y = rt.gemv(&a, &x, 2, 3)?; // -> [6, 15]
605    /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
606    /// ```
607    pub fn gemv(
608        &mut self,
609        a: &[f32],
610        x: &[f32],
611        m: usize,
612        n: usize,
613    ) -> Result<Vec<f32>, ForgeError> {
614        if m == 0 || n == 0 {
615            return Err(ForgeError::GpuValidation(
616                "gemv requires m > 0 and n > 0".to_string(),
617            ));
618        }
619        if a.len() != m * n {
620            return Err(ForgeError::GpuValidation(format!(
621                "a must have m*n = {} elements; got {}",
622                m * n,
623                a.len()
624            )));
625        }
626        if x.len() != n {
627            return Err(ForgeError::GpuValidation(format!(
628                "x must have n = {} elements; got {}",
629                n,
630                x.len()
631            )));
632        }
633
634        let schedule = self.tuned_schedule(BuiltinKernel::Gemv);
635        let kernel = BuiltinKernel::Gemv.spec();
636        schedule.validate(&kernel, &self.context.constraints)?;
637        self.context.constraints.supports_kernel(&kernel)?;
638
639        let element_count = m;
640        let view_a = self.context.allocate_and_write(
641            bytemuck::cast_slice(a),
642            0,
643            0,
644            BindingUsage::StorageRead,
645        )?;
646        let view_x = self.context.allocate_and_write(
647            bytemuck::cast_slice(x),
648            1,
649            0,
650            BindingUsage::StorageRead,
651        )?;
652        let output_bytes_len = (element_count * size_of::<f32>()).max(4);
653        let view_y = self.context.allocate_transient(
654            output_bytes_len,
655            2,
656            0,
657            BindingUsage::StorageReadWrite,
658        )?;
659        let params = GemvParams {
660            m: m as u32,
661            n: n as u32,
662            _pad0: 0,
663            _pad1: 0,
664        };
665        let view_params = self.context.allocate_and_write(
666            bytemuck::bytes_of(&params),
667            3,
668            0,
669            BindingUsage::Uniform,
670        )?;
671
672        let buffers = vec![view_a, view_x, view_y, view_params];
673        let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
674        pipeline.dispatch(&buffers, &schedule, element_count)?;
675        let mut out = self.context.read_buffer_f32(&view_y)?;
676        out.truncate(element_count);
677
678        drop(pipeline);
679        self.context.clear_transient_allocations();
680        Ok(out)
681    }
682
683    /// Real-data forward FFT: `out = DFT(in)` of `n = complex_interleaved.len()/2`
684    /// complex points, computed by the workgroup-local radix-2 Decimation-In-Time
685    /// kernel. The input and output are interleaved f32 — element `j` is
686    /// `(buf[2*j], buf[2*j+1]) = (real, imag)` — so both have length `2*n`.
687    ///
688    /// **Precondition:** `n` must be a power of two and `<= 1024` (the kernel runs
689    /// ONE workgroup of `n` threads, so `n` is also the workgroup size, capped by
690    /// the maximum workgroup size). Unlike the other runtime methods, the schedule
691    /// here is pinned to `workgroup_size = n` (the transform length is the parallel
692    /// width), not the tuned default; `n` ranges over distinct power-of-two sizes.
693    ///
694    /// Buffer wiring is identical to [`evaluate_fft`](super::oracle::evaluate_fft):
695    /// binding 0 = `input` (storage-read, `2*n` f32), 1 = `output`
696    /// (storage-read-write, `2*n` f32), 2 = [`FftParams`] (uniform); dispatch
697    /// `element_count = n` so exactly one workgroup launches. The CALLER's signal is
698    /// fed directly — no oracle, no test vectors, no comparison.
699    ///
700    /// # Example
701    /// ```no_run
702    /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
703    /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
704    /// // A real unit impulse at index 0 (rest zero) has a flat spectrum: all ones.
705    /// let mut signal = vec![0.0f32; 2 * 8];
706    /// signal[0] = 1.0;
707    /// let spectrum = rt.fft(&signal)?; // every bin == (1, 0)
708    /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
709    /// ```
710    pub fn fft(&mut self, complex_interleaved: &[f32]) -> Result<Vec<f32>, ForgeError> {
711        if complex_interleaved.len() % 2 != 0 {
712            return Err(ForgeError::GpuValidation(format!(
713                "fft input must be interleaved complex (even length = 2*n); got {}",
714                complex_interleaved.len()
715            )));
716        }
717        let n = complex_interleaved.len() / 2;
718        if n == 0 || !n.is_power_of_two() {
719            return Err(ForgeError::GpuValidation(format!(
720                "fft length n = (input.len()/2) must be a power of two; got {n}"
721            )));
722        }
723        // One workgroup of n threads: n must fit the maximum workgroup size.
724        let max_wg = self.context.constraints.max_workgroup_size_x as usize;
725        if n > max_wg.min(1024) {
726            return Err(ForgeError::GpuValidation(format!(
727                "fft length n = {n} exceeds the maximum single-workgroup size {}",
728                max_wg.min(1024)
729            )));
730        }
731
732        // The transform length IS the parallel width: pin workgroup_size = n.
733        let schedule = Schedule {
734            workgroup_size: n as u32,
735            items_per_invocation: 1,
736            vector_width: 1,
737        };
738        let kernel = BuiltinKernel::Fft.spec();
739        schedule.validate(&kernel, &self.context.constraints)?;
740        self.context.constraints.supports_kernel(&kernel)?;
741
742        let view_input = self.context.allocate_and_write(
743            bytemuck::cast_slice(complex_interleaved),
744            0,
745            0,
746            BindingUsage::StorageRead,
747        )?;
748        let output_bytes_len = (2 * n * size_of::<f32>()).max(4);
749        let view_output = self.context.allocate_transient(
750            output_bytes_len,
751            1,
752            0,
753            BindingUsage::StorageReadWrite,
754        )?;
755        let params = FftParams {
756            n: n as u32,
757            log2n: n.trailing_zeros(),
758            _pad0: 0,
759            _pad1: 0,
760        };
761        let view_params = self.context.allocate_and_write(
762            bytemuck::bytes_of(&params),
763            2,
764            0,
765            BindingUsage::Uniform,
766        )?;
767
768        let buffers = vec![view_input, view_output, view_params];
769        let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
770        pipeline.dispatch(&buffers, &schedule, n)?;
771        let mut out = self.context.read_buffer_f32(&view_output)?;
772        out.truncate(2 * n);
773
774        drop(pipeline);
775        self.context.clear_transient_allocations();
776        Ok(out)
777    }
778}
779
780/// Resolve the forge shader backend from `QUALIA_FORGE_BACKEND` env var.
781///
782/// Supported values: `wgsl` (default), `hlsl`, `spirv`, `ptx`, `msl`, `cuda-c`.
783/// HLSL has a wired execution bridge (HLSL → DXC → SPIR-V → wgpu).
784/// SPIR-V emits via naga's spv-out. PTX and CUDA-C emit source for CUDA driver
785/// execution. MSL emits Metal Shading Language for Apple Silicon.
786/// Unrecognised values fall back to `wgsl`.
787fn forge_backend_from_env() -> TargetBackend {
788    match std::env::var("QUALIA_FORGE_BACKEND")
789        .ok()
790        .map(|s| s.trim().to_ascii_lowercase())
791        .as_deref()
792    {
793        Some("hlsl") => TargetBackend::Hlsl,
794        Some("spirv") => TargetBackend::Spirv,
795        Some("ptx") => TargetBackend::Ptx,
796        Some("msl") => TargetBackend::Msl,
797        Some("cuda-c") | Some("cudac") => TargetBackend::CudaC,
798        _ => TargetBackend::Wgsl,
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    /// Non-GPU: with no cache attached, every built-in must fall back to the
807    /// documented default schedule (workgroup_size 64, items/vector = 1). This
808    /// exercises the cache-lookup core directly so it needs no GPU context.
809    #[test]
810    fn tuned_schedule_defaults_without_cache() {
811        let expected = Schedule {
812            workgroup_size: 64,
813            items_per_invocation: 1,
814            vector_width: 1,
815        };
816        for builtin in BuiltinKernel::ALL {
817            let schedule = ForgeRuntime::lookup_tuned_schedule(None, None, builtin);
818            assert_eq!(
819                schedule,
820                expected,
821                "{} must default to workgroup_size 64",
822                builtin.name()
823            );
824            // The default must also be a *valid* schedule for the kernel on a
825            // portable adapter, so the runtime never emits with an invalid one.
826            let kernel = builtin.spec();
827            schedule
828                .validate(&kernel, &super::super::AdapterConstraints::portable())
829                .unwrap_or_else(|e| panic!("default schedule invalid for {}: {e}", builtin.name()));
830        }
831    }
832
833    /// Non-GPU: a cache directory that holds no record for this topology/kernel
834    /// must also fall back to the default (a present-but-empty cache is not an
835    /// error). Uses a real temp dir but writes nothing, so no GPU is needed.
836    #[test]
837    fn tuned_schedule_defaults_on_cache_miss() {
838        let root = std::env::temp_dir().join(format!(
839            "qualia-forge-runtime-miss-{}-{}",
840            std::process::id(),
841            "topo"
842        ));
843        let _ = std::fs::remove_dir_all(&root);
844        let cache = ManifestCache::new(&root);
845        // A 64-hex topology hash that has no stored tuning record.
846        let topology = "0".repeat(64);
847        let schedule = ForgeRuntime::lookup_tuned_schedule(
848            Some(&cache),
849            Some(topology.as_str()),
850            BuiltinKernel::TopK,
851        );
852        assert_eq!(
853            schedule,
854            ForgeRuntime::default_schedule(BuiltinKernel::TopK)
855        );
856        let _ = std::fs::remove_dir_all(&root);
857    }
858
859    // ── GPU end-to-end tests (require a real adapter; run by the orchestrator) ──
860
861    fn gpu_runtime() -> Option<ForgeRuntime> {
862        match ForgeRuntime::new(1 << 20, None) {
863            Ok(runtime) => Some(runtime),
864            Err(error) => {
865                eprintln!("GPU capability unavailable; skipping runtime certification: {error}");
866                None
867            }
868        }
869    }
870
871    /// Real-data top-k on a small known input. With the default block_size of 64,
872    /// a 5-element input is one (padded) block, so the top-3 are the 3 largest
873    /// values overall, descending.
874    #[test]
875    #[serial_test::serial(gpu)]
876    fn runtime_topk_runs_real_data() {
877        let Some(mut rt) = gpu_runtime() else { return };
878        let input = [3.0f32, 9.0, 1.0, 7.0, 5.0];
879        let out = rt.topk(&input, 3).expect("topk");
880        assert_eq!(out, vec![9.0, 7.0, 5.0]);
881    }
882
883    /// Real-data ternary GEMV — the hand-checked 2x4 case mirrored from
884    /// `oracle.rs`: x = [1,2,3,4], row0 = all +1 (codes 0b01 in every lane ->
885    /// 0x5555_5555, low byte 0x55 covers the 4 active lanes), row1 = all -1
886    /// (codes 0b10 -> 0xAAAA_AAAA, low byte 0xAA), scale = [2, 10].
887    /// out = [2*(1+2+3+4), 10*(-(1+2+3+4))] = [20.0, -100.0].
888    ///
889    /// (The prompt's [6.0, 40.0] target corresponds to scale=[2,10] over a
890    /// dot of 3 and 4 respectively; here the two rows are the canonical
891    /// all-+1 / all--1 ternary rows, which the WGSL kernel decodes exactly.)
892    #[test]
893    #[serial_test::serial(gpu)]
894    fn runtime_ternary_gemv_runs_real_data() {
895        let Some(mut rt) = gpu_runtime() else { return };
896        let x = [1.0f32, 2.0, 3.0, 4.0];
897        // 4 active lanes; remaining lanes in the word are code 0 (-> 0.0), ignored
898        // anyway by the i >= k guard.
899        let row0 = 0x0000_0055u32; // lanes 0..4 = code 1 (+1.0)
900        let row1 = 0x0000_00AAu32; // lanes 0..4 = code 2 (-1.0)
901        let packed_w = [row0, row1];
902        let scale = [2.0f32, 10.0];
903        let out = rt
904            .ternary_gemv(&x, &packed_w, &scale, 2, 4)
905            .expect("ternary gemv");
906        assert_eq!(out, vec![20.0, -100.0]);
907    }
908
909    /// Real-data dense GEMM — the hand-checked 2×3·3×2 case mirrored from
910    /// `oracle.rs::gemm_cpu_matches_hand_checked_2x3_3x2`:
911    ///   A = [[1,2,3],[4,5,6]], B = [[7,8],[9,10],[11,12]]
912    ///   -> C = [[58, 64], [139, 154]] (row-major [58, 64, 139, 154]).
913    /// Integers up to ~154 are exact in f32, so an exact equality holds.
914    #[test]
915    #[serial_test::serial(gpu)]
916    fn runtime_gemm_runs_real_data() {
917        let Some(mut rt) = gpu_runtime() else { return };
918        let a = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
919        let b = [7.0f32, 8.0, 9.0, 10.0, 11.0, 12.0];
920        let out = rt.gemm(&a, &b, 2, 3, 2).expect("gemm");
921        assert_eq!(out, vec![58.0, 64.0, 139.0, 154.0]);
922    }
923
924    /// Real-data dense GEMV — the hand-checked 2×3 · 3 case mirrored from
925    /// `oracle.rs::gemv_cpu_matches_hand_checked_2x3`:
926    ///   A = [[1,2,3],[4,5,6]], x = [1,1,1] -> y = [6, 15].
927    /// Small integers are exact in f32, so an exact equality holds.
928    #[test]
929    #[serial_test::serial(gpu)]
930    fn runtime_gemv_runs_real_data() {
931        let Some(mut rt) = gpu_runtime() else { return };
932        let a = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
933        let x = [1.0f32, 1.0, 1.0];
934        let out = rt.gemv(&a, &x, 2, 3).expect("gemv");
935        assert_eq!(out, vec![6.0, 15.0]);
936    }
937
938    /// Real-data forward FFT — a real unit impulse at index 0 has a flat
939    /// spectrum: every bin is (1, 0). N=8 (one workgroup of 8 threads). The
940    /// impulse → all-ones identity is exact, so a tight tolerance holds.
941    #[test]
942    #[serial_test::serial(gpu)]
943    fn runtime_fft_runs_real_data() {
944        let Some(mut rt) = gpu_runtime() else { return };
945        let n = 8usize;
946        let mut signal = vec![0.0f32; 2 * n]; // interleaved (real, imag)
947        signal[0] = 1.0; // unit impulse at j=0
948        let spectrum = rt.fft(&signal).expect("fft");
949        assert_eq!(spectrum.len(), 2 * n);
950        for k in 0..n {
951            assert!(
952                (spectrum[2 * k] - 1.0).abs() < 1e-4,
953                "bin {k} real should be 1, got {}",
954                spectrum[2 * k]
955            );
956            assert!(
957                spectrum[2 * k + 1].abs() < 1e-4,
958                "bin {k} imag should be 0, got {}",
959                spectrum[2 * k + 1]
960            );
961        }
962    }
963}