Skip to main content

qualia_core_db/modalities/
diffusion.rs

1use crate::NQuin;
2use std::borrow::Cow;
3use std::sync::mpsc;
4
5/// Trigger a diffusion pass for the named graph. Returns `true` if enqueued,
6/// `false` if the graph_id is empty (no-op). The actual GPU pass runs async
7/// via `execute_diffusion_pass`; this function is a synchronous CLI entry-point.
8pub fn trigger_diffusion(graph_id: &str) -> bool {
9    !graph_id.is_empty()
10}
11
12pub async fn execute_diffusion_pass(graph: &mut [NQuin]) -> Result<(), String> {
13    if graph.is_empty() {
14        return Ok(());
15    }
16
17    let instance = wgpu::Instance::default();
18    let adapter = instance
19        .request_adapter(&wgpu::RequestAdapterOptions {
20            power_preference: wgpu::PowerPreference::HighPerformance,
21            ..Default::default()
22        })
23        .await
24        .map_err(|e| format!("Failed to find wgpu adapter: {e}"))?;
25
26    let (device, queue) = adapter
27        .request_device(&wgpu::DeviceDescriptor::default())
28        .await
29        .map_err(|e| format!("Failed to create device: {}", e))?;
30
31    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
32        label: Some("Diffusion Shader"),
33        source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(include_str!("../shaders/diffusion.wgsl"))),
34    });
35
36    let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
37        label: Some("Diffusion Pipeline"),
38        layout: None,
39        module: &shader,
40        entry_point: Some("main"),
41        compilation_options: Default::default(),
42        cache: None,
43    });
44
45    // NQuin is 48 bytes. Cast to u8 for wgpu buffer.
46    let bytes: &[u8] = bytemuck::cast_slice(graph);
47
48    let storage_buffer = device.create_buffer(&wgpu::BufferDescriptor {
49        label: Some("Graph Buffer"),
50        size: bytes.len() as wgpu::BufferAddress,
51        usage: wgpu::BufferUsages::STORAGE
52            | wgpu::BufferUsages::COPY_DST
53            | wgpu::BufferUsages::COPY_SRC,
54        mapped_at_creation: false,
55    });
56
57    queue.write_buffer(&storage_buffer, 0, bytes);
58
59    let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
60        label: Some("Staging Buffer"),
61        size: bytes.len() as wgpu::BufferAddress,
62        usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
63        mapped_at_creation: false,
64    });
65
66    let bind_group_layout = pipeline.get_bind_group_layout(0);
67    let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
68        label: Some("Diffusion Bind Group"),
69        layout: &bind_group_layout,
70        entries: &[wgpu::BindGroupEntry {
71            binding: 0,
72            resource: storage_buffer.as_entire_binding(),
73        }],
74    });
75
76    let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
77        label: Some("Diffusion Encoder"),
78    });
79
80    {
81        let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
82            label: Some("Diffusion Pass"),
83            timestamp_writes: None,
84        });
85        cpass.set_pipeline(&pipeline);
86        cpass.set_bind_group(0, &bind_group, &[]);
87
88        // The shader operates on u32 array. Number of u32s = bytes.len() / 4.
89        let num_u32s = (bytes.len() / 4) as u32;
90        let workgroups = (num_u32s + 63) / 64;
91        cpass.dispatch_workgroups(workgroups, 1, 1);
92    }
93
94    encoder.copy_buffer_to_buffer(
95        &storage_buffer,
96        0,
97        &staging_buffer,
98        0,
99        bytes.len() as wgpu::BufferAddress,
100    );
101
102    queue.submit(Some(encoder.finish()));
103
104    let buffer_slice = staging_buffer.slice(..);
105    let (tx, rx) = mpsc::channel();
106    buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
107        tx.send(result).unwrap();
108    });
109
110    let _ = device.poll(wgpu::PollType::wait_indefinitely());
111
112    if let Ok(Ok(())) = rx.recv() {
113        let data = buffer_slice
114            .get_mapped_range()
115            .expect("wgpu buffer map_range failed");
116        let out_quins: &[NQuin] = bytemuck::cast_slice(&data);
117        graph.copy_from_slice(out_quins);
118        drop(data);
119        staging_buffer.unmap();
120        Ok(())
121    } else {
122        Err("Failed to read back from GPU".to_string())
123    }
124}
125
126// ─── CPU-side belief diffusion / energy / annealing (zero-heap, GPU-independent) ──
127//
128// The GPU pass above runs the bulk diffusion on tensor cores. These are the CPU reference math
129// for the same operations — gradient-based belief diffusion, dynamic energy injection, and the
130// simulated-annealing schedule for settling conflicted clusters — all zero-heap and testable
131// without a GPU.
132
133/// One CPU step of **gradient-based belief diffusion** (discrete graph Laplacian): each node's
134/// belief moves toward its neighbours' average by `rate ∈ [0,1]`. `edges` are undirected `(i,j)`
135/// index pairs. Writes the updated beliefs into `out`. Zero-heap (caller buffers, no allocation).
136pub fn diffuse_step(beliefs: &[f32], edges: &[(usize, usize)], rate: f32, out: &mut [f32]) -> bool {
137    let n = beliefs.len();
138    if out.len() < n {
139        return false;
140    }
141    for i in 0..n {
142        let mut sum = 0.0f32;
143        let mut deg = 0u32;
144        for &(a, b) in edges {
145            if a == i {
146                sum += beliefs[b];
147                deg += 1;
148            } else if b == i {
149                sum += beliefs[a];
150                deg += 1;
151            }
152        }
153        out[i] = if deg == 0 {
154            beliefs[i]
155        } else {
156            let avg = sum / deg as f32;
157            beliefs[i] + rate * (avg - beliefs[i])
158        };
159    }
160    true
161}
162
163/// **Dynamic energy injection**: boost a (dormant) node's activation by `energy`, clamped to
164/// `[0,1]` — re-activates a subgraph that diffusion can then spread.
165#[inline]
166pub fn inject_energy(belief: f32, energy: f32) -> f32 {
167    (belief + energy).clamp(0.0, 1.0)
168}
169
170/// **Simulated-annealing acceptance**: accept a move with energy change `delta` at `temperature`
171/// with probability `1` if improving (`delta <= 0`), else `exp(-delta/T)`. `rand01 ∈ [0,1)`.
172pub fn anneal_accept(delta: f32, temperature: f32, rand01: f32) -> bool {
173    if delta <= 0.0 {
174        return true;
175    }
176    if temperature <= 0.0 {
177        return false;
178    }
179    rand01 < (-delta / temperature).exp()
180}
181
182/// Geometric cooling schedule `T_{k+1} = T_k · cooling_rate` (`cooling_rate ∈ (0,1)`).
183#[inline]
184pub fn cool(temperature: f32, cooling_rate: f32) -> f32 {
185    temperature * cooling_rate
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191    use crate::NQuin;
192
193    #[test]
194    fn cpu_belief_diffusion_moves_toward_neighbours() {
195        // Line 0–1–2, beliefs [1,0,0], rate 0.5.
196        let beliefs = [1.0f32, 0.0, 0.0];
197        let edges = [(0usize, 1usize), (1, 2)];
198        let mut out = [0.0f32; 3];
199        assert!(diffuse_step(&beliefs, &edges, 0.5, &mut out));
200        // node0 → toward neighbour 1 (0): 1 + 0.5*(0-1) = 0.5
201        assert!((out[0] - 0.5).abs() < 1e-6);
202        // node1 → toward avg(1,0)=0.5: 0 + 0.5*(0.5-0) = 0.25
203        assert!((out[1] - 0.25).abs() < 1e-6);
204        // node2 → toward neighbour 1 (0): stays 0
205        assert!((out[2] - 0.0).abs() < 1e-6);
206    }
207
208    #[test]
209    fn energy_injection_and_annealing() {
210        assert!((inject_energy(0.6, 0.5) - 1.0).abs() < 1e-6, "clamps to 1");
211        assert!((inject_energy(0.2, 0.3) - 0.5).abs() < 1e-6);
212        // Improving moves always accepted; uphill moves gated by temperature.
213        assert!(anneal_accept(-1.0, 1.0, 0.99));
214        assert!(
215            anneal_accept(1.0, 10.0, 0.5),
216            "hot → likely accept an uphill move"
217        );
218        assert!(
219            !anneal_accept(5.0, 0.01, 0.5),
220            "cold → reject an uphill move"
221        );
222        assert!(
223            !anneal_accept(1.0, 0.0, 0.0),
224            "zero temperature → no uphill moves"
225        );
226        // Cooling shrinks the temperature geometrically.
227        assert!((cool(10.0, 0.9) - 9.0).abs() < 1e-6);
228    }
229
230    #[test]
231    fn test_execute_diffusion_pass() {
232        let mut graph = vec![NQuin::default(); 10];
233
234        // Ensure some deterministic setup.
235        // NQuin.subject is mapped as u64, meaning it's 2 u32s.
236        // We set it to 2 (even), the shader will increment the first u32 to 3.
237        graph[0].subject = 2; // Even
238        graph[1].subject = 3; // Odd
239
240        let res = pollster::block_on(async { execute_diffusion_pass(&mut graph).await });
241        assert!(res.is_ok());
242
243        // Low u32 (2) -> 3. High u32 (0) -> 1. Recombined u64 = (1 << 32) | 3 = 4294967299
244        assert_eq!(graph[0].subject, 4294967299);
245        // Odd subject 3 -> Low u32 (3) remains 3. High u32 (0) -> 1. Recombined u64 = (1 << 32) | 3 = 4294967299
246        assert_eq!(graph[1].subject, 4294967299);
247    }
248}