Skip to main content

qualia_core_db/modalities/
asp.rs

1use crate::NQuin;
2
3pub const MAX_STABLE_MODELS: usize = 8;
4
5/// Returns number of stable models found (max MAX_STABLE_MODELS = 8)
6/// Worlds are encoded as context-hash variants: world_i_context = base_context ^ (i as u64)
7pub fn enumerate_stable_models(
8    base: &NQuin,
9    rules: &[NQuin],
10    out_worlds: &mut [u64; MAX_STABLE_MODELS],
11) -> usize {
12    if rules.is_empty() {
13        out_worlds[0] = base.context;
14        return 1;
15    }
16
17    let mut num_worlds = 1;
18    out_worlds[0] = base.context;
19
20    // For each rule, we bifurcate the context simulating applying vs not applying the rule,
21    // up to the maximum number of supported stable models.
22    for rule in rules.iter().take(3) {
23        // 2^3 = 8
24        let current_worlds = num_worlds;
25        for w in 0..current_worlds {
26            if num_worlds < MAX_STABLE_MODELS {
27                // Bifurcate by XORing the rule's hash components into the context
28                out_worlds[num_worlds] = out_worlds[w] ^ rule.subject ^ rule.object;
29                num_worlds += 1;
30            }
31        }
32    }
33
34    num_worlds
35}
36
37// ─── True stable-model (answer-set) semantics — Gelfond-Lifschitz ───────────────
38//
39// The function above is a legacy context-bifurcation heuristic kept for its callers.
40// `compute_answer_sets` is the REAL thing: stable models of a normal logic program
41// (`head :- p1..pk, not n1..nm`, plus integrity constraints `:- body`) under the
42// Gelfond-Lifschitz reduct. Bounded + zero-heap: atoms are indexed into a u64 bitmask,
43// candidate models are brute-forced over 2^|atoms|, each reduced + least-fixpoint'd +
44// checked for stability. Correct (not heuristic): an under-determined norm
45// ("permitted :- not forbidden; forbidden :- not permitted") yields its TWO consistent
46// answer sets; a constraint prunes them.
47
48pub const ASP_MAX_ATOMS: usize = 12; // 2^12 = 4096 candidate interpretations
49pub const ASP_MAX_BODY: usize = 6;
50
51/// A normal ASP rule `head :- pos.., not neg..`. `head == 0` encodes an integrity
52/// constraint `:- pos.., not neg..` (admits no atom; prunes models satisfying the body).
53#[derive(Clone, Copy)]
54pub struct AspRule {
55    pub head: u64,
56    pub pos: [u64; ASP_MAX_BODY],
57    pub pos_len: usize,
58    pub neg: [u64; ASP_MAX_BODY],
59    pub neg_len: usize,
60}
61
62impl AspRule {
63    pub fn new(head: u64, pos: &[u64], neg: &[u64]) -> Self {
64        let mut r = AspRule {
65            head,
66            pos: [0; ASP_MAX_BODY],
67            pos_len: 0,
68            neg: [0; ASP_MAX_BODY],
69            neg_len: 0,
70        };
71        for &a in pos.iter().take(ASP_MAX_BODY) {
72            r.pos[r.pos_len] = a;
73            r.pos_len += 1;
74        }
75        for &a in neg.iter().take(ASP_MAX_BODY) {
76            r.neg[r.neg_len] = a;
77            r.neg_len += 1;
78        }
79        r
80    }
81    pub fn fact(head: u64) -> Self {
82        Self::new(head, &[], &[])
83    }
84    pub fn constraint(pos: &[u64], neg: &[u64]) -> Self {
85        Self::new(0, pos, neg)
86    }
87}
88
89/// Compute the stable models (answer sets) of `rules` over `atoms`. Each answer set is
90/// written to `out` as a bitmask over atom indices (bit i ⇔ `atoms[i]` is in the set).
91/// Returns the number found. Zero-heap; bounded to `ASP_MAX_ATOMS` atoms.
92pub fn compute_answer_sets(atoms: &[u64], rules: &[AspRule], out: &mut [u64]) -> usize {
93    let n = atoms.len().min(ASP_MAX_ATOMS);
94    let idx = |a: u64| -> Option<usize> { atoms[..n].iter().position(|&x| x == a) };
95    let removed_by_reduct = |r: &AspRule, cand: u64| -> bool {
96        // GL reduct: a rule is removed iff some negative-body atom is IN the candidate.
97        for &na in &r.neg[..r.neg_len] {
98            if let Some(ni) = idx(na) {
99                if cand & (1u64 << ni) != 0 {
100                    return true;
101                }
102            }
103        }
104        false
105    };
106    let body_pos_in = |r: &AspRule, m: u64| -> bool {
107        for &pa in &r.pos[..r.pos_len] {
108            match idx(pa) {
109                Some(pi) if m & (1u64 << pi) != 0 => {}
110                _ => return false,
111            }
112        }
113        true
114    };
115
116    let mut found = 0usize;
117    let total: u64 = 1u64 << n;
118    for cand in 0..total {
119        // Least model of the reduct (positive Horn) by fixpoint iteration.
120        let mut m: u64 = 0;
121        loop {
122            let mut changed = false;
123            for r in rules {
124                if r.head == 0 || removed_by_reduct(r, cand) {
125                    continue;
126                }
127                if body_pos_in(r, m) {
128                    if let Some(hi) = idx(r.head) {
129                        if m & (1u64 << hi) == 0 {
130                            m |= 1u64 << hi;
131                            changed = true;
132                        }
133                    }
134                }
135            }
136            if !changed {
137                break;
138            }
139        }
140        // Stability: the least model of the reduct must equal the candidate …
141        if m != cand {
142            continue;
143        }
144        // … and no integrity constraint may be violated by it.
145        let mut ok = true;
146        for r in rules {
147            if r.head != 0 || removed_by_reduct(r, cand) {
148                continue;
149            }
150            if body_pos_in(r, m) {
151                ok = false;
152                break;
153            }
154        }
155        if ok {
156            if found >= out.len() {
157                break;
158            }
159            out[found] = m;
160            found += 1;
161        }
162    }
163    found
164}
165
166// ─── Index helper ───────────────────────────────────────────────────────────────────
167
168/// Index of atom `a` in `atoms` (bit position), if present.
169#[inline]
170pub fn atom_index(atoms: &[u64], a: u64) -> Option<usize> {
171    atoms.iter().take(ASP_MAX_ATOMS).position(|&x| x == a)
172}
173
174#[inline]
175fn body_holds(atoms: &[u64], model: u64, pos: &[u64], neg: &[u64]) -> bool {
176    for &p in pos {
177        match atom_index(atoms, p) {
178            Some(i) if model & (1u64 << i) != 0 => {}
179            _ => return false,
180        }
181    }
182    for &nn in neg {
183        if let Some(i) = atom_index(atoms, nn) {
184            if model & (1u64 << i) != 0 {
185                return false;
186            }
187        }
188    }
189    true
190}
191
192// ─── Grounding: zero-heap instantiation of a non-ground rule template ───────────────
193
194/// Ground a rule TEMPLATE by substituting variable `var` with each element of `domain`, writing
195/// the ground instances into `out`. Returns the count. Apply repeatedly (over the partially-ground
196/// output) for multiple variables. Zero-heap — bounded by `out.len()` (the "millions of
197/// constraints" ceiling is the caller's buffer, not a heap allocation here).
198pub fn ground_rule(template: &AspRule, var: u64, domain: &[u64], out: &mut [AspRule]) -> usize {
199    let subst = |a: u64, d: u64| if a == var { d } else { a };
200    let mut n = 0usize;
201    for &d in domain {
202        if n >= out.len() {
203            break;
204        }
205        let mut g = *template;
206        g.head = subst(g.head, d);
207        for i in 0..g.pos_len {
208            g.pos[i] = subst(g.pos[i], d);
209        }
210        for i in 0..g.neg_len {
211            g.neg[i] = subst(g.neg[i], d);
212        }
213        out[n] = g;
214        n += 1;
215    }
216    n
217}
218
219// ─── Weak constraints & optimization (the "best" stable model) ──────────────────────
220
221/// A weak constraint `:~ pos.., not neg.. [weight]` — incurs `weight` when its body holds in a
222/// model. Optimal answer sets MINIMISE total incurred weight.
223#[derive(Clone, Copy)]
224pub struct WeakConstraint {
225    pub pos: [u64; ASP_MAX_BODY],
226    pub pos_len: usize,
227    pub neg: [u64; ASP_MAX_BODY],
228    pub neg_len: usize,
229    pub weight: i64,
230}
231
232impl WeakConstraint {
233    pub fn new(pos: &[u64], neg: &[u64], weight: i64) -> Self {
234        let mut w = WeakConstraint {
235            pos: [0; ASP_MAX_BODY],
236            pos_len: 0,
237            neg: [0; ASP_MAX_BODY],
238            neg_len: 0,
239            weight,
240        };
241        for &a in pos.iter().take(ASP_MAX_BODY) {
242            w.pos[w.pos_len] = a;
243            w.pos_len += 1;
244        }
245        for &a in neg.iter().take(ASP_MAX_BODY) {
246            w.neg[w.neg_len] = a;
247            w.neg_len += 1;
248        }
249        w
250    }
251}
252
253/// Total penalty of `model` under `weak`: the sum of weights of the weak constraints whose body
254/// holds in the model.
255pub fn model_penalty(atoms: &[u64], model: u64, weak: &[WeakConstraint]) -> i64 {
256    let mut total = 0i64;
257    for w in weak {
258        if body_holds(atoms, model, &w.pos[..w.pos_len], &w.neg[..w.neg_len]) {
259            total += w.weight;
260        }
261    }
262    total
263}
264
265/// The **optimal** answer set: the stable model minimising total weak-constraint penalty. Returns
266/// `(model_bitmask, penalty)`, or `None` if the program has no stable model. `buf` is scratch for
267/// the enumerated answer sets.
268pub fn optimal_answer_set(
269    atoms: &[u64],
270    rules: &[AspRule],
271    weak: &[WeakConstraint],
272    buf: &mut [u64],
273) -> Option<(u64, i64)> {
274    let k = compute_answer_sets(atoms, rules, buf);
275    if k == 0 {
276        return None;
277    }
278    let mut best = (buf[0], model_penalty(atoms, buf[0], weak));
279    for &m in &buf[1..k] {
280        let p = model_penalty(atoms, m, weak);
281        if p < best.1 {
282            best = (m, p);
283        }
284    }
285    Some(best)
286}
287
288// ─── Cautious / brave reasoning ─────────────────────────────────────────────────────
289
290/// **Cautious** (skeptical) consequences: the atoms in EVERY answer set (bit-AND of all models).
291/// `0` if there are no models.
292pub fn cautious_consequences(models: &[u64]) -> u64 {
293    match models.split_first() {
294        Some((&first, rest)) => rest.iter().fold(first, |acc, &m| acc & m),
295        None => 0,
296    }
297}
298
299/// **Brave** (credulous) consequences: the atoms in SOME answer set (bit-OR of all models).
300pub fn brave_consequences(models: &[u64]) -> u64 {
301    models.iter().fold(0u64, |acc, &m| acc | m)
302}
303
304// ─── Paraconsistent routing: no-stable-model handling ───────────────────────────────
305
306/// Outcome of an answer-set computation — distinguishes "no model" (an over-constrained /
307/// inconsistent program) from genuine results, so the caller can route the former to
308/// paraconsistent reasoning instead of treating absence-of-model as plain falsity.
309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
310pub enum AspOutcome {
311    /// `n` stable models were written to the output buffer.
312    Stable(usize),
313    /// No stable model exists — the program is inconsistent; route to `paraconsistent`.
314    NoStableModel,
315}
316
317/// Compute answer sets; if NONE exist, return [`AspOutcome::NoStableModel`] so the caller routes
318/// the (inconsistent) program to `modalities::paraconsistent::route_paraconsistent` rather than
319/// silently concluding falsity. This is the tight integration with paraconsistent routing.
320pub fn answer_sets_or_paraconsistent(
321    atoms: &[u64],
322    rules: &[AspRule],
323    out: &mut [u64],
324) -> AspOutcome {
325    let k = compute_answer_sets(atoms, rules, out);
326    if k == 0 {
327        AspOutcome::NoStableModel
328    } else {
329        AspOutcome::Stable(k)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    /// Real stable-model semantics: an even loop has exactly TWO answer sets; a constraint prunes.
338    #[test]
339    fn answer_sets_even_loop_and_constraint() {
340        let (p, q) = (101u64, 202u64);
341        let atoms = [p, q];
342        // p :- not q.   q :- not p.   → answer sets {p} and {q}.
343        let prog = [AspRule::new(p, &[], &[q]), AspRule::new(q, &[], &[p])];
344        let mut out = [0u64; 8];
345        let k = compute_answer_sets(&atoms, &prog, &mut out);
346        assert_eq!(k, 2, "even loop has exactly two stable models");
347        let bp = 1u64 << 0; // p is atoms[0]
348        let bq = 1u64 << 1; // q is atoms[1]
349        assert!(
350            out[..k].contains(&bp) && out[..k].contains(&bq),
351            "the two answer sets are {{p}} and {{q}}"
352        );
353
354        // Add `:- q` (forbid q) → only {p} survives.
355        let prog2 = [
356            AspRule::new(p, &[], &[q]),
357            AspRule::new(q, &[], &[p]),
358            AspRule::constraint(&[q], &[]),
359        ];
360        let mut out2 = [0u64; 8];
361        let k2 = compute_answer_sets(&atoms, &prog2, &mut out2);
362        assert_eq!(k2, 1, "the constraint prunes {{q}}");
363        assert_eq!(out2[0], bp, "only {{p}} remains");
364    }
365
366    #[test]
367    fn test_enumerate_stable_models() {
368        let base = NQuin {
369            subject: 0,
370            predicate: 0,
371            object: 0,
372            context: 42,
373            metadata: 0,
374            parity: 0,
375        };
376        let mut out_worlds = [0; MAX_STABLE_MODELS];
377
378        // Empty rules -> 1 world
379        let count = enumerate_stable_models(&base, &[], &mut out_worlds);
380        assert_eq!(count, 1);
381        assert_eq!(out_worlds[0], 42);
382
383        // One rule -> 2 worlds
384        let rule = NQuin {
385            subject: 10,
386            predicate: 0,
387            object: 20,
388            context: 0,
389            metadata: 0,
390            parity: 0,
391        };
392        let count2 = enumerate_stable_models(&base, &[rule], &mut out_worlds);
393        assert_eq!(count2, 2);
394        assert_eq!(out_worlds[0], 42);
395        assert_eq!(out_worlds[1], 42 ^ 10 ^ 20);
396    }
397
398    #[test]
399    fn grounder_instantiates_a_template_over_a_domain() {
400        // Template:  node(X).   with X a variable, domain {a,b,c} → three ground facts.
401        let var = crate::q_hash("var:X");
402        let node = |x: u64| x; // identity: the head IS the (variable) atom node(X)≡X here
403        let template = AspRule::fact(var);
404        let (a, b, c) = (node(11), node(22), node(33));
405        let mut out = [AspRule::fact(0); 8];
406        let n = ground_rule(&template, var, &[a, b, c], &mut out);
407        assert_eq!(n, 3);
408        assert_eq!(out[0].head, a);
409        assert_eq!(out[1].head, b);
410        assert_eq!(out[2].head, c);
411    }
412
413    #[test]
414    fn weak_constraints_select_the_optimal_model() {
415        let (p, q) = (101u64, 202u64);
416        let atoms = [p, q];
417        // Even loop → {p} and {q}. Weak constraint `:~ q [1]` penalises q.
418        let prog = [AspRule::new(p, &[], &[q]), AspRule::new(q, &[], &[p])];
419        let weak = [WeakConstraint::new(&[q], &[], 1)];
420        let mut buf = [0u64; 8];
421        let (best, penalty) = optimal_answer_set(&atoms, &prog, &weak, &mut buf).unwrap();
422        assert_eq!(best, 1u64 << 0, "optimal model is {{p}} (no penalty)");
423        assert_eq!(penalty, 0);
424        // {q} would have incurred penalty 1.
425        assert_eq!(model_penalty(&atoms, 1u64 << 1, &weak), 1);
426    }
427
428    #[test]
429    fn cautious_and_brave_consequences() {
430        let (p, q) = (101u64, 202u64);
431        let atoms = [p, q];
432        let prog = [AspRule::new(p, &[], &[q]), AspRule::new(q, &[], &[p])];
433        let mut buf = [0u64; 8];
434        let k = compute_answer_sets(&atoms, &prog, &mut buf);
435        assert_eq!(k, 2);
436        // Cautious: in BOTH {p} and {q} → neither p nor q → 0. Brave: in SOME → both bits set.
437        assert_eq!(cautious_consequences(&buf[..k]), 0);
438        assert_eq!(brave_consequences(&buf[..k]), (1u64 << 0) | (1u64 << 1));
439    }
440
441    #[test]
442    fn no_stable_model_routes_to_paraconsistent() {
443        let p = 101u64;
444        let atoms = [p];
445        // `p :- not p` has NO stable model — an inconsistent program.
446        let prog = [AspRule::new(p, &[], &[p])];
447        let mut out = [0u64; 8];
448        assert_eq!(
449            answer_sets_or_paraconsistent(&atoms, &prog, &mut out),
450            AspOutcome::NoStableModel
451        );
452        // A consistent program reports its model count.
453        let prog2 = [AspRule::fact(p)];
454        assert_eq!(
455            answer_sets_or_paraconsistent(&atoms, &prog2, &mut out),
456            AspOutcome::Stable(1)
457        );
458    }
459}