Skip to main content

qualia_core_db/modalities/
probabilistic.rs

1use crate::NQuin;
2
3pub fn evaluate_threshold(weight: f32, threshold: f32) -> bool {
4    weight >= threshold
5}
6
7pub const MAX_BAYESIAN_NODES: usize = 32;
8
9#[derive(Debug, Clone, Copy, PartialEq)]
10pub struct BayesianNode {
11    pub id: u64,              // Variable hash
12    pub parent_ids: [u64; 4], // Max 4 parents to keep it fixed size
13    pub num_parents: usize,
14    pub probabilities: [f32; 16], // 2^4 = 16 conditional probabilities
15    pub evidence: Option<bool>,   // Observed state if any
16}
17
18impl Default for BayesianNode {
19    fn default() -> Self {
20        Self {
21            id: 0,
22            parent_ids: [0; 4],
23            num_parents: 0,
24            probabilities: [0.0; 16],
25            evidence: None,
26        }
27    }
28}
29
30pub struct BayesianNetwork {
31    pub nodes: [BayesianNode; MAX_BAYESIAN_NODES],
32    pub num_nodes: usize,
33}
34
35impl BayesianNetwork {
36    pub fn new() -> Self {
37        Self {
38            nodes: [BayesianNode::default(); MAX_BAYESIAN_NODES],
39            num_nodes: 0,
40        }
41    }
42
43    pub fn add_node(&mut self, node: BayesianNode) -> Result<(), &'static str> {
44        if self.num_nodes >= MAX_BAYESIAN_NODES {
45            return Err("Max nodes exceeded");
46        }
47        self.nodes[self.num_nodes] = node;
48        self.num_nodes += 1;
49        Ok(())
50    }
51
52    /// Extract probability encoded in the Quin's metadata field (canonical
53    /// truth-degree via the FrameLayout ABI — shared with fuzzy/stochastic).
54    pub fn extract_weight(quin: &NQuin) -> f32 {
55        crate::frame_layout::truth_degree(quin.metadata)
56    }
57
58    /// Exact inference via variable enumeration.
59    /// Fully zero-allocation, iteratively evaluates joint probability table on the stack.
60    /// Max unobserved variables allowed is 16 to prevent excessive CPU loop blocking (O(2^N)).
61    pub fn update_beliefs(&self, target_id: u64) -> Option<f32> {
62        let mut target_idx = None;
63        for i in 0..self.num_nodes {
64            if self.nodes[i].id == target_id {
65                target_idx = Some(i);
66                break;
67            }
68        }
69        let target_idx = target_idx?;
70
71        let mut num_unobserved = 0;
72        let mut unobserved_indices = [0; MAX_BAYESIAN_NODES];
73        for i in 0..self.num_nodes {
74            if self.nodes[i].evidence.is_none() && i != target_idx {
75                unobserved_indices[num_unobserved] = i;
76                num_unobserved += 1;
77            }
78        }
79
80        if num_unobserved > 16 {
81            return None; // Too complex for zero-heap full enumeration
82        }
83
84        let num_assignments = 1 << num_unobserved;
85
86        let mut prob_target_true = 0.0;
87        let mut prob_target_false = 0.0;
88
89        for assignment in 0..num_assignments {
90            for target_state in [true, false] {
91                let mut joint_prob = 1.0;
92
93                for i in 0..self.num_nodes {
94                    let node = &self.nodes[i];
95
96                    let state = if i == target_idx {
97                        target_state
98                    } else if let Some(e) = node.evidence {
99                        e
100                    } else {
101                        let mut bit_idx = 0;
102                        for j in 0..num_unobserved {
103                            if unobserved_indices[j] == i {
104                                bit_idx = j;
105                                break;
106                            }
107                        }
108                        ((assignment >> bit_idx) & 1) == 1
109                    };
110
111                    let mut parent_idx = 0;
112                    for p in 0..node.num_parents {
113                        let pid = node.parent_ids[p];
114                        let mut p_state = false;
115                        for j in 0..self.num_nodes {
116                            if self.nodes[j].id == pid {
117                                p_state = if j == target_idx {
118                                    target_state
119                                } else if let Some(e) = self.nodes[j].evidence {
120                                    e
121                                } else {
122                                    let mut bit_idx = 0;
123                                    for k in 0..num_unobserved {
124                                        if unobserved_indices[k] == j {
125                                            bit_idx = k;
126                                            break;
127                                        }
128                                    }
129                                    ((assignment >> bit_idx) & 1) == 1
130                                };
131                                break;
132                            }
133                        }
134                        if p_state {
135                            parent_idx |= 1 << p;
136                        }
137                    }
138
139                    let p_node = if state {
140                        node.probabilities[parent_idx]
141                    } else {
142                        1.0 - node.probabilities[parent_idx]
143                    };
144
145                    joint_prob *= p_node;
146                }
147
148                if target_state {
149                    prob_target_true += joint_prob;
150                } else {
151                    prob_target_false += joint_prob;
152                }
153            }
154        }
155
156        let total = prob_target_true + prob_target_false;
157        if total > 0.0 {
158            Some(prob_target_true / total)
159        } else {
160            None
161        }
162    }
163
164    /// The **Markov blanket** of `node_id`: its parents, its children, and its children's OTHER
165    /// parents (co-parents). Conditioned on its blanket a node is independent of all others —
166    /// the locality used for rapid conditional-independence testing and Gibbs sampling. Writes the
167    /// member ids into `out`, returns the count. Zero-heap.
168    pub fn markov_blanket(&self, node_id: u64, out: &mut [u64]) -> usize {
169        let mut n = 0usize;
170        let add = |id: u64, out: &mut [u64], n: &mut usize| {
171            if id != node_id && id != 0 && !out[..*n].contains(&id) && *n < out.len() {
172                out[*n] = id;
173                *n += 1;
174            }
175        };
176        for i in 0..self.num_nodes {
177            if self.nodes[i].id == node_id {
178                for p in 0..self.nodes[i].num_parents {
179                    add(self.nodes[i].parent_ids[p], out, &mut n);
180                }
181            }
182        }
183        for i in 0..self.num_nodes {
184            let child = self.nodes[i];
185            if child.parent_ids[..child.num_parents].contains(&node_id) {
186                add(child.id, out, &mut n); // a child
187                for p in 0..child.num_parents {
188                    add(child.parent_ids[p], out, &mut n); // its co-parents
189                }
190            }
191        }
192        n
193    }
194
195    /// `P(node = state | parents)` from the node's CPT, reading parents' states out of `assign`
196    /// (indexed by node order).
197    fn node_cpt(&self, node_idx: usize, state: bool, assign: &[bool]) -> f32 {
198        let node = &self.nodes[node_idx];
199        let mut parent_idx = 0usize;
200        for p in 0..node.num_parents {
201            let pid = node.parent_ids[p];
202            for j in 0..self.num_nodes {
203                if self.nodes[j].id == pid {
204                    if assign[j] {
205                        parent_idx |= 1 << p;
206                    }
207                    break;
208                }
209            }
210        }
211        let pt = node.probabilities[parent_idx];
212        if state {
213            pt
214        } else {
215            1.0 - pt
216        }
217    }
218
219    /// Unnormalised `P(node_idx = state | rest)` ∝ `P(node|parents) · Π_children P(child|parents)`
220    /// — the Gibbs full-conditional over the Markov blanket.
221    fn gibbs_conditional(&self, node_idx: usize, state: bool, assign: &[bool]) -> f32 {
222        let mut a = [false; MAX_BAYESIAN_NODES];
223        a[..self.num_nodes].copy_from_slice(&assign[..self.num_nodes]);
224        a[node_idx] = state;
225        let mut prob = self.node_cpt(node_idx, state, &a);
226        let this_id = self.nodes[node_idx].id;
227        for c in 0..self.num_nodes {
228            let child = self.nodes[c];
229            if child.parent_ids[..child.num_parents].contains(&this_id) {
230                prob *= self.node_cpt(c, a[c], &a);
231            }
232        }
233        prob
234    }
235
236    /// **Gibbs sampling** (MCMC) estimate of `P(target = true | evidence)` — approximate inference
237    /// for networks too large for exact enumeration. `samples` sweeps, `seed` for the PRNG. Each
238    /// non-evidence variable is resampled from its Markov-blanket conditional. Zero-heap (bounded
239    /// stack arrays). `None` if `target_id` is not in the network.
240    pub fn gibbs_estimate(&self, target_id: u64, samples: u32, seed: u64) -> Option<f32> {
241        let mut target_idx = None;
242        for i in 0..self.num_nodes {
243            if self.nodes[i].id == target_id {
244                target_idx = Some(i);
245            }
246        }
247        let target_idx = target_idx?;
248        let mut rng = seed | 1;
249        let mut assign = [false; MAX_BAYESIAN_NODES];
250        for i in 0..self.num_nodes {
251            assign[i] = match self.nodes[i].evidence {
252                Some(e) => e,
253                None => next_unit(&mut rng) < 0.5,
254            };
255        }
256        let mut count_true = 0u32;
257        for _ in 0..samples {
258            for i in 0..self.num_nodes {
259                if self.nodes[i].evidence.is_some() {
260                    continue;
261                }
262                let pt = self.gibbs_conditional(i, true, &assign);
263                let pf = self.gibbs_conditional(i, false, &assign);
264                let denom = pt + pf;
265                let prob = if denom > 0.0 { pt / denom } else { 0.5 };
266                assign[i] = next_unit(&mut rng) < prob;
267            }
268            if assign[target_idx] {
269                count_true += 1;
270            }
271        }
272        if samples == 0 {
273            None
274        } else {
275            Some(count_true as f32 / samples as f32)
276        }
277    }
278}
279
280/// Deterministic xorshift PRNG → a uniform `f32` in `[0,1)`. Zero-heap.
281fn next_unit(state: &mut u64) -> f32 {
282    let mut x = *state;
283    x ^= x << 13;
284    x ^= x >> 7;
285    x ^= x << 17;
286    *state = x;
287    ((x >> 40) as f32) / ((1u32 << 24) as f32)
288}
289
290/// **PC-algorithm skeleton** (constraint-based structure learning): two variables are adjacent
291/// (share an edge) iff their `correlation` is at/above `threshold` in absolute value — i.e. they
292/// are NOT marginally independent. This is the order-0 skeleton; the full PC additionally removes
293/// edges via conditional-independence tests over separating sets.
294#[inline]
295pub fn pc_adjacent(correlation: f32, threshold: f32) -> bool {
296    correlation.abs() >= threshold
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn test_bayesian_network() {
305        let mut network = BayesianNetwork::new();
306
307        // Node 0: Rain (no parents, P(Rain)=0.2)
308        let mut node0 = BayesianNode::default();
309        node0.id = 100;
310        node0.probabilities[0] = 0.2;
311        network.add_node(node0).unwrap();
312
313        // Node 1: Sprinkler (parent: Rain)
314        // P(Sprinkler|Rain) = 0.01
315        // P(Sprinkler|NoRain) = 0.40
316        let mut node1 = BayesianNode::default();
317        node1.id = 200;
318        node1.parent_ids[0] = 100;
319        node1.num_parents = 1;
320        node1.probabilities[1] = 0.01; // Rain=True -> bit 0 is 1
321        node1.probabilities[0] = 0.40; // Rain=False -> bit 0 is 0
322        network.add_node(node1).unwrap();
323
324        // Node 2: Grass Wet (parents: Rain, Sprinkler)
325        // Rain is bit 0, Sprinkler is bit 1
326        // F, F (idx 0) = 0.0
327        // T, F (idx 1) = 0.8
328        // F, T (idx 2) = 0.9
329        // T, T (idx 3) = 0.99
330        let mut node2 = BayesianNode::default();
331        node2.id = 300;
332        node2.parent_ids[0] = 100;
333        node2.parent_ids[1] = 200;
334        node2.num_parents = 2;
335        node2.probabilities[0] = 0.0;
336        node2.probabilities[1] = 0.8;
337        node2.probabilities[2] = 0.9;
338        node2.probabilities[3] = 0.99;
339
340        // Let's observe the grass is wet
341        node2.evidence = Some(true);
342        network.add_node(node2).unwrap();
343
344        // Query: What is probability it rained, given grass is wet?
345        let p_rain = network.update_beliefs(100).unwrap();
346
347        // Approximate expected result: P(Rain|Wet) ≈ 0.3577
348        assert!((p_rain - 0.3577).abs() < 0.001);
349    }
350
351    fn sprinkler(with_wet_evidence: bool) -> BayesianNetwork {
352        let mut net = BayesianNetwork::new();
353        let mut n0 = BayesianNode::default();
354        n0.id = 100;
355        n0.probabilities[0] = 0.2;
356        net.add_node(n0).unwrap();
357        let mut n1 = BayesianNode::default();
358        n1.id = 200;
359        n1.parent_ids[0] = 100;
360        n1.num_parents = 1;
361        n1.probabilities[1] = 0.01;
362        n1.probabilities[0] = 0.40;
363        net.add_node(n1).unwrap();
364        let mut n2 = BayesianNode::default();
365        n2.id = 300;
366        n2.parent_ids[0] = 100;
367        n2.parent_ids[1] = 200;
368        n2.num_parents = 2;
369        n2.probabilities = [
370            0.0, 0.8, 0.9, 0.99, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
371        ];
372        if with_wet_evidence {
373            n2.evidence = Some(true);
374        }
375        net.add_node(n2).unwrap();
376        net
377    }
378
379    #[test]
380    fn markov_blanket_of_a_node() {
381        let net = sprinkler(false);
382        // Blanket(Rain) = children {Sprinkler, GrassWet} + co-parents (Sprinkler already in).
383        let mut out = [0u64; 8];
384        let n = net.markov_blanket(100, &mut out);
385        assert_eq!(n, 2);
386        assert!(out[..n].contains(&200) && out[..n].contains(&300));
387    }
388
389    #[test]
390    fn gibbs_approximates_the_exact_posterior() {
391        let net = sprinkler(true);
392        let exact = net.update_beliefs(100).unwrap(); // ≈ 0.3577
393        let approx = net.gibbs_estimate(100, 40_000, 0x9E3779B97F4A7C15).unwrap();
394        assert!(
395            (approx - exact).abs() < 0.08,
396            "Gibbs {approx} should approximate exact {exact}"
397        );
398    }
399
400    #[test]
401    fn pc_skeleton_uses_absolute_correlation() {
402        assert!(pc_adjacent(0.6, 0.3));
403        assert!(!pc_adjacent(0.1, 0.3));
404        assert!(pc_adjacent(-0.5, 0.3), "structure depends on |correlation|");
405    }
406}