Skip to main content

qualia_core_db/modalities/
dialectical.rs

1use crate::NQuin;
2
3// Canonical bit positions live in the FrameLayout ABI (single source of truth).
4pub use crate::frame_layout::{COUNTERFACTUAL_BIT, DO_INTERVENTION_BIT, SYNTHESIZED_BIT};
5
6/// Causal intervention operator for do-calculus
7/// Implements P(Y | do(X = x)) by intervening on the causal graph.
8///
9/// The do-calculus intervention do(X = x) severs X from its parents (removes
10/// incoming edges to X) while preserving X's outgoing causal edges so that
11/// X can still influence downstream variables.  We mark the intervention on
12/// the relevant quins with DO_INTERVENTION_BIT but do NOT overwrite the
13/// structural `object` field (which encodes the causal successor, not X's
14/// value).  The intervention_value is recorded in metadata so callers can
15/// inspect it; path existence is used as the evidence of causal effect.
16pub fn do_intervention(
17    graph: &[NQuin],
18    intervention_var: u64,
19    intervention_value: u64,
20    target_var: u64,
21) -> Option<f64> {
22    let mut causal_paths = Vec::new();
23    let mut intervened_graph = graph.to_vec();
24
25    // Apply intervention: mark outgoing edges from X and drop incoming edges
26    // to X (cut X from its parents) but preserve the causal structure X → …
27    // Store intervention_value in the upper bits of metadata so it is
28    // visible without corrupting the `object` (causal target) field.
29    intervened_graph.retain(|q| q.object != intervention_var); // remove parents of X
30    for quin in &mut intervened_graph {
31        if quin.subject == intervention_var {
32            quin.metadata = DO_INTERVENTION_BIT | (intervention_value << 32);
33        }
34    }
35
36    // Find causal paths from intervention variable to target
37    find_causal_paths(
38        &intervened_graph,
39        intervention_var,
40        target_var,
41        &mut causal_paths,
42    );
43
44    if causal_paths.is_empty() {
45        return None;
46    }
47
48    // P(Y = target_var reached) = fraction of discovered paths
49    // Each discovered path represents one causal route; all routes count as
50    // evidence that the intervention influences the target.
51    let total_count = causal_paths.len() as f64;
52    Some(total_count / total_count) // = 1.0 when any path exists
53}
54
55/// Counterfactual query: "What would happen if X were x?"
56pub fn counterfactual_query(
57    actual_graph: &[NQuin],
58    factual_outcome: u64,
59    counterfactual_intervention: u64,
60    intervention_value: u64,
61    target_var: u64,
62) -> Option<NQuin> {
63    // Step 1: Abduction - update beliefs based on actual outcome
64    let mut updated_graph = actual_graph.to_vec();
65    for quin in &mut updated_graph {
66        if quin.subject == target_var {
67            quin.object = factual_outcome;
68            quin.metadata |= COUNTERFACTUAL_BIT;
69        }
70    }
71
72    // Step 2: Action - apply counterfactual intervention.
73    // Mark the intervention in metadata (upper bits hold the intervention value)
74    // but preserve the structural `object` field (causal successor) so that
75    // do_intervention() can still traverse the causal graph.
76    for quin in &mut updated_graph {
77        if quin.subject == counterfactual_intervention {
78            quin.metadata |= DO_INTERVENTION_BIT | (intervention_value << 32);
79        }
80    }
81
82    // Step 3: Prediction - compute counterfactual outcome
83    if let Some(counterfactual_prob) = do_intervention(
84        &updated_graph,
85        counterfactual_intervention,
86        intervention_value,
87        target_var,
88    ) {
89        let mut result = NQuin::default();
90        result.subject = target_var;
91        result.predicate = crate::q_hash("has_counterfactual_probability");
92        result.object = (counterfactual_prob * 1000.0) as u64; // Store as scaled integer
93        result.metadata = COUNTERFACTUAL_BIT;
94        result.parity = result.subject ^ result.predicate ^ result.object ^ result.context;
95
96        Some(result)
97    } else {
98        None
99    }
100}
101
102/// Find all causal paths from source to target in the causal graph
103fn find_causal_paths(graph: &[NQuin], source: u64, target: u64, paths: &mut Vec<Vec<NQuin>>) {
104    // Simple depth-first search for causal paths
105    let mut visited = std::collections::HashSet::new();
106    let mut current_path = Vec::new();
107
108    dfs_find_paths(
109        graph,
110        source,
111        target,
112        &mut visited,
113        &mut current_path,
114        paths,
115    );
116}
117
118/// Depth-first search helper for finding causal paths
119fn dfs_find_paths(
120    graph: &[NQuin],
121    current: u64,
122    target: u64,
123    visited: &mut std::collections::HashSet<u64>,
124    current_path: &mut Vec<NQuin>,
125    all_paths: &mut Vec<Vec<NQuin>>,
126) {
127    if visited.contains(&current) {
128        return;
129    }
130
131    visited.insert(current);
132
133    // Find all outgoing edges from current node
134    for quin in graph {
135        if quin.subject == current {
136            current_path.push(*quin);
137
138            if quin.object == target {
139                // Found a path to target
140                all_paths.push(current_path.clone());
141            } else {
142                // Continue searching
143                dfs_find_paths(graph, quin.object, target, visited, current_path, all_paths);
144            }
145
146            current_path.pop();
147        }
148    }
149
150    visited.remove(&current);
151}
152
153/// Check if two variables are confounded (share a common cause)
154pub fn are_confounded(graph: &[NQuin], var1: u64, var2: u64) -> bool {
155    // Find common causes by looking for nodes that point to both var1 and var2
156    let mut parents1 = std::collections::HashSet::new();
157    let mut parents2 = std::collections::HashSet::new();
158
159    for quin in graph {
160        if quin.object == var1 {
161            parents1.insert(quin.subject);
162        }
163        if quin.object == var2 {
164            parents2.insert(quin.subject);
165        }
166    }
167
168    // Check for intersection (common causes)
169    !parents1.is_disjoint(&parents2)
170}
171
172/// Compute do-calculus adjustment for confounding
173pub fn adjust_for_confounding(
174    graph: &[NQuin],
175    treatment: u64,
176    outcome: u64,
177    confounder: u64,
178) -> Option<f64> {
179    // Simplified adjustment: P(Y|do(X)) = Σ_z P(Y|X,Z=z) * P(Z=z)
180    // This is a basic implementation - full do-calculus would be more sophisticated
181
182    let mut adjusted_prob = 0.0;
183    let mut confounder_values = std::collections::HashSet::new();
184
185    // Collect all possible values of confounder
186    for quin in graph {
187        if quin.subject == confounder {
188            confounder_values.insert(quin.object);
189        }
190    }
191
192    // Compute adjustment
193    for &confounder_val in &confounder_values {
194        // P(Y|X,Z=z)
195        let mut filtered_graph = graph.to_vec();
196        for quin in &mut filtered_graph {
197            if quin.subject == treatment {
198                quin.metadata |= DO_INTERVENTION_BIT;
199            }
200            if quin.subject == confounder {
201                quin.object = confounder_val;
202            }
203        }
204
205        if let Some(p_y_given_x_z) =
206            compute_conditional_probability(&filtered_graph, outcome, treatment)
207        {
208            // P(Z=z) - simplified as uniform distribution
209            let p_z = 1.0 / confounder_values.len() as f64;
210            adjusted_prob += p_y_given_x_z * p_z;
211        }
212    }
213
214    if adjusted_prob > 0.0 {
215        Some(adjusted_prob)
216    } else {
217        None
218    }
219}
220
221/// Compute conditional probability P(Y|X) from graph.
222///
223/// In a causal graph whose edges represent causal arrows (not observations),
224/// P(Y|X) is estimated as 1.0 if Y is causally reachable from X via a
225/// directed path, and None if X has no outgoing edges at all (X is
226/// unobserved / disconnected in this context).
227fn compute_conditional_probability(graph: &[NQuin], y_var: u64, x_var: u64) -> Option<f64> {
228    // Check that X participates as a cause in this graph
229    let x_has_edges = graph.iter().any(|q| q.subject == x_var);
230    if !x_has_edges {
231        return None;
232    }
233
234    // BFS / DFS reachability from x_var to y_var
235    let mut visited: std::collections::HashSet<u64> = std::collections::HashSet::new();
236    let mut frontier: Vec<u64> = vec![x_var];
237    while let Some(current) = frontier.pop() {
238        if current == y_var {
239            return Some(1.0);
240        }
241        if visited.insert(current) {
242            for quin in graph {
243                if quin.subject == current && !visited.contains(&quin.object) {
244                    frontier.push(quin.object);
245                }
246            }
247        }
248    }
249
250    // Y not reachable from X in this graph — no causal effect
251    Some(0.0)
252}
253
254pub fn synthesize_dialectical(thesis: &NQuin, antithesis: &NQuin) -> Option<NQuin> {
255    // A contradiction requires the same subject and predicate but different object
256    if thesis.subject == antithesis.subject
257        && thesis.predicate == antithesis.predicate
258        && thesis.object != antithesis.object
259    {
260        let mut synthesized = *thesis;
261        synthesized.context = thesis.context ^ antithesis.context;
262        synthesized.metadata |= SYNTHESIZED_BIT;
263        // The object becomes a combination, maybe just bitwise XOR for now?
264        synthesized.object = thesis.object ^ antithesis.object;
265
266        // Update parity to maintain structural integrity
267        synthesized.parity =
268            synthesized.subject ^ synthesized.predicate ^ synthesized.object ^ synthesized.context;
269
270        return Some(synthesized);
271    }
272    None
273}
274
275// ─── Causal necessity (but-for) — zero-heap reachability ─────────────────────────
276
277/// Max nodes for the bounded zero-heap causal reachability search.
278pub const MAX_CAUSAL_NODES: usize = 256;
279
280/// Zero-heap reachability over causal edges (`subject → object`): is `target`
281/// reachable from `source` WITHOUT ever passing through `avoid`? Bounded BFS over
282/// fixed stack buffers (no allocation). Pass `avoid == u64::MAX` to avoid nothing.
283/// (The heap variant `find_causal_paths` enumerates *all* paths for analysis;
284/// this answers the yes/no reachability the but-for test needs, allocation-free.)
285pub fn reachable_avoiding(graph: &[NQuin], source: u64, target: u64, avoid: u64) -> bool {
286    if source == avoid {
287        return false;
288    }
289    if source == target {
290        return true;
291    }
292    let mut stack = [0u64; MAX_CAUSAL_NODES];
293    let mut slen = 1usize;
294    stack[0] = source;
295    let mut visited = [0u64; MAX_CAUSAL_NODES];
296    let mut vlen = 1usize;
297    visited[0] = source;
298
299    while slen > 0 {
300        slen -= 1;
301        let node = stack[slen];
302        for q in graph {
303            if q.subject != node || q.object == avoid {
304                continue;
305            }
306            if q.object == target {
307                return true;
308            }
309            let mut seen = false;
310            for &v in visited.iter().take(vlen) {
311                if v == q.object {
312                    seen = true;
313                    break;
314                }
315            }
316            if !seen && vlen < MAX_CAUSAL_NODES && slen < MAX_CAUSAL_NODES {
317                visited[vlen] = q.object;
318                vlen += 1;
319                stack[slen] = q.object;
320                slen += 1;
321            }
322        }
323    }
324    false
325}
326
327/// But-for causal necessity: `candidate` is a NECESSARY cause of `effect` (from
328/// origin `root`) iff `effect` is reachable from `root`, but is NOT reachable once
329/// `candidate` is removed from the causal graph. The attribution/liability test
330/// ("would the harm have occurred but for this agent's act?"). Zero-heap.
331pub fn is_necessary_cause(graph: &[NQuin], root: u64, candidate: u64, effect: u64) -> bool {
332    reachable_avoiding(graph, root, effect, u64::MAX)
333        && !reachable_avoiding(graph, root, effect, candidate)
334}
335
336// ─── Paraconsistent conflict isolation ────────────────────────────────────────────
337
338/// A **dialectical contradiction**: thesis and antithesis assert the same `(subject, predicate)`
339/// with different objects — the conflict that is either SYNTHESIZED ([`synthesize_dialectical`])
340/// or, when no synthesis is wanted, ISOLATED into a paraconsistent sub-context
341/// (`paraconsistent::route_paraconsistent`) so it does not explode the rest of the graph.
342pub fn is_dialectical_contradiction(thesis: &NQuin, antithesis: &NQuin) -> bool {
343    thesis.subject == antithesis.subject
344        && thesis.predicate == antithesis.predicate
345        && thesis.object != antithesis.object
346}
347
348// ─── IBIS discourse model (Issue-Based Information System) ─────────────────────────
349
350/// An IBIS discourse node — the multi-agent argumentation structure: an `Issue` raises a question,
351/// `Position`s answer it, and `Argument`s support or object to positions.
352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
353pub enum IbisNode {
354    Issue,
355    Position,
356    /// An argument that supports (`true`) or objects to (`false`) a position.
357    Argument(bool),
358}
359
360/// A position in an IBIS discourse is **favoured** iff its net support (supporting − objecting
361/// arguments) is positive. The multi-agent dialectical resolution of an issue.
362#[inline]
363pub fn ibis_position_favoured(supporting: u32, objecting: u32) -> bool {
364    supporting > objecting
365}
366
367// ─── Synthesis-coherence scoring ──────────────────────────────────────────────────
368
369/// **Synthesis-quality / coherence** score in `[0,1]`: a good Hegelian synthesis PRESERVES the
370/// shared ground (same subject + predicate as both thesis and antithesis) and genuinely INTEGRATES
371/// the two objects (rather than echoing one side). `1.0` for a well-formed synthesis; lower when it
372/// drifts from the common ground or fails to combine both sides.
373pub fn synthesis_coherence(thesis: &NQuin, antithesis: &NQuin, synthesis: &NQuin) -> f32 {
374    let mut score = 0.0f32;
375    if synthesis.subject == thesis.subject && synthesis.subject == antithesis.subject {
376        score += 0.4;
377    }
378    if synthesis.predicate == thesis.predicate && synthesis.predicate == antithesis.predicate {
379        score += 0.3;
380    }
381    if synthesis.object != thesis.object && synthesis.object != antithesis.object {
382        score += 0.3;
383    }
384    score
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    fn edge(cause: u64, effect: u64) -> NQuin {
392        let mut q = NQuin {
393            subject: cause,
394            predicate: crate::q_hash("causal:causes"),
395            object: effect,
396            context: 0,
397            metadata: 0,
398            parity: 0,
399        };
400        q.parity = q.subject ^ q.predicate ^ q.object ^ q.context;
401        q
402    }
403
404    #[test]
405    fn but_for_causal_necessity() {
406        // Chain: root → C → effect. C is necessary (removing it disconnects effect).
407        let chain = [edge(1, 2), edge(2, 3)];
408        assert!(
409            is_necessary_cause(&chain, 1, 2, 3),
410            "C is a necessary cause in a chain"
411        );
412        // Diamond: root → C → effect AND root → D → effect. C is NOT necessary.
413        let diamond = [edge(1, 2), edge(2, 4), edge(1, 3), edge(3, 4)];
414        assert!(
415            !is_necessary_cause(&diamond, 1, 2, 4),
416            "C is not necessary when an alternative path exists"
417        );
418        assert!(
419            reachable_avoiding(&diamond, 1, 4, u64::MAX),
420            "effect is reachable normally"
421        );
422    }
423
424    #[test]
425    fn dialectical_contradiction_ibis_and_coherence() {
426        let mk = |s: u64, p: u64, o: u64| {
427            let mut q = NQuin {
428                subject: s,
429                predicate: p,
430                object: o,
431                context: 0,
432                metadata: 0,
433                parity: 0,
434            };
435            q.parity = q.subject ^ q.predicate ^ q.object ^ q.context;
436            q
437        };
438        let (subj, pred) = (crate::q_hash("policy:borders"), crate::q_hash("stance"));
439        let thesis = mk(subj, pred, crate::q_hash("open"));
440        let antithesis = mk(subj, pred, crate::q_hash("closed"));
441        // Same subject+predicate, different object → a dialectical contradiction.
442        assert!(is_dialectical_contradiction(&thesis, &antithesis));
443        let agree = mk(subj, pred, crate::q_hash("open"));
444        assert!(!is_dialectical_contradiction(&thesis, &agree));
445
446        // The synthesis (XOR-combined object) is highly coherent (preserves ground, integrates both).
447        let synthesis = synthesize_dialectical(&thesis, &antithesis).unwrap();
448        assert!((synthesis_coherence(&thesis, &antithesis, &synthesis) - 1.0).abs() < 1e-6);
449        // A degenerate "synthesis" that just echoes the thesis scores lower (no integration).
450        assert!(synthesis_coherence(&thesis, &antithesis, &thesis) < 1.0);
451
452        // IBIS: a position with more support than objection is favoured.
453        assert!(ibis_position_favoured(3, 1));
454        assert!(!ibis_position_favoured(1, 1));
455        assert_eq!(IbisNode::Argument(true), IbisNode::Argument(true));
456    }
457
458    #[test]
459    fn test_synthesize_dialectical() {
460        let thesis = NQuin {
461            subject: 1,
462            predicate: 2,
463            object: 3,
464            context: 10,
465            metadata: 0,
466            parity: 0,
467        };
468        let antithesis = NQuin {
469            subject: 1,
470            predicate: 2,
471            object: 4,
472            context: 20,
473            metadata: 0,
474            parity: 0,
475        };
476
477        let syn = synthesize_dialectical(&thesis, &antithesis).unwrap();
478        assert_eq!(syn.context, 10 ^ 20);
479        assert!(syn.metadata & SYNTHESIZED_BIT != 0);
480    }
481
482    #[test]
483    fn test_do_intervention() {
484        // Create a simple causal graph: X -> Y
485        let mut graph = Vec::new();
486
487        // X = 1 causes Y = 1
488        let mut x_to_y = NQuin::default();
489        x_to_y.subject = 1; // X
490        x_to_y.predicate = crate::q_hash("causes");
491        x_to_y.object = 2; // Y
492        x_to_y.context = 100;
493        x_to_y.parity = x_to_y.subject ^ x_to_y.predicate ^ x_to_y.object ^ x_to_y.context;
494        graph.push(x_to_y);
495
496        // Test intervention: do(X = 1) should affect Y
497        let result = do_intervention(&graph, 1, 1, 2);
498        assert!(result.is_some());
499        assert!(result.unwrap() > 0.0);
500    }
501
502    #[test]
503    fn test_counterfactual_query() {
504        // Create causal graph: Treatment -> Outcome
505        let mut graph = Vec::new();
506
507        let mut treatment_to_outcome = NQuin::default();
508        treatment_to_outcome.subject = 10; // Treatment
509        treatment_to_outcome.predicate = crate::q_hash("causes");
510        treatment_to_outcome.object = 20; // Outcome
511        treatment_to_outcome.context = 200;
512        treatment_to_outcome.parity = treatment_to_outcome.subject
513            ^ treatment_to_outcome.predicate
514            ^ treatment_to_outcome.object
515            ^ treatment_to_outcome.context;
516        graph.push(treatment_to_outcome);
517
518        // Test counterfactual: "What if Treatment were 0?"
519        let result = counterfactual_query(&graph, 1, 10, 0, 20);
520        assert!(result.is_some());
521
522        let counterfactual = result.unwrap();
523        assert_eq!(counterfactual.subject, 20); // Target is outcome
524        assert!(counterfactual.metadata & COUNTERFACTUAL_BIT != 0);
525    }
526
527    #[test]
528    fn test_confounding_detection() {
529        // Create graph with confounding: Confounder -> Treatment, Confounder -> Outcome
530        let mut graph = Vec::new();
531
532        // Confounder -> Treatment
533        let mut conf_to_treat = NQuin::default();
534        conf_to_treat.subject = 100; // Confounder
535        conf_to_treat.predicate = crate::q_hash("causes");
536        conf_to_treat.object = 10; // Treatment
537        conf_to_treat.context = 300;
538        conf_to_treat.parity = conf_to_treat.subject
539            ^ conf_to_treat.predicate
540            ^ conf_to_treat.object
541            ^ conf_to_treat.context;
542        graph.push(conf_to_treat);
543
544        // Confounder -> Outcome
545        let mut conf_to_outcome = NQuin::default();
546        conf_to_outcome.subject = 100; // Confounder
547        conf_to_outcome.predicate = crate::q_hash("causes");
548        conf_to_outcome.object = 20; // Outcome
549        conf_to_outcome.context = 301;
550        conf_to_outcome.parity = conf_to_outcome.subject
551            ^ conf_to_outcome.predicate
552            ^ conf_to_outcome.object
553            ^ conf_to_outcome.context;
554        graph.push(conf_to_outcome);
555
556        // Test confounding detection
557        let confounded = are_confounded(&graph, 10, 20);
558        assert!(confounded);
559    }
560
561    #[test]
562    fn test_adjust_for_confounding() {
563        // Create graph with confounding
564        let mut graph = Vec::new();
565
566        // Confounder -> Treatment
567        let mut conf_to_treat = NQuin::default();
568        conf_to_treat.subject = 100; // Confounder
569        conf_to_treat.predicate = crate::q_hash("causes");
570        conf_to_treat.object = 10; // Treatment
571        conf_to_treat.context = 400;
572        conf_to_treat.parity = conf_to_treat.subject
573            ^ conf_to_treat.predicate
574            ^ conf_to_treat.object
575            ^ conf_to_treat.context;
576        graph.push(conf_to_treat);
577
578        // Treatment -> Outcome
579        let mut treat_to_outcome = NQuin::default();
580        treat_to_outcome.subject = 10; // Treatment
581        treat_to_outcome.predicate = crate::q_hash("causes");
582        treat_to_outcome.object = 20; // Outcome
583        treat_to_outcome.context = 401;
584        treat_to_outcome.parity = treat_to_outcome.subject
585            ^ treat_to_outcome.predicate
586            ^ treat_to_outcome.object
587            ^ treat_to_outcome.context;
588        graph.push(treat_to_outcome);
589
590        // Test adjustment
591        let adjusted = adjust_for_confounding(&graph, 10, 20, 100);
592        assert!(adjusted.is_some());
593        assert!(adjusted.unwrap() >= 0.0);
594    }
595
596    #[test]
597    fn test_no_contradiction() {
598        let thesis = NQuin {
599            subject: 1,
600            predicate: 2,
601            object: 3,
602            context: 10,
603            metadata: 0,
604            parity: 0,
605        };
606
607        let no_contradiction = NQuin {
608            subject: 1,
609            predicate: 3, // Different predicate
610            object: 4,
611            context: 20,
612            metadata: 0,
613            parity: 0,
614        };
615
616        assert!(synthesize_dialectical(&thesis, &no_contradiction).is_none());
617    }
618}