Skip to main content

qualia_core_db/modalities/
causal.rs

1//! Causal & counterfactual logic (§16, legal_logic.md) — liability & dependency.
2//!
3//! Standard implication (`p → q`) is insufficient for legal liability. Adjudicating a
4//! human-rights violation or a structural failure needs **but-for causation** (was the cause
5//! *necessary* for the harm?), **root-node dependency** (removing a foundational support
6//! voids everything that depends on it — the "deepest absence"), and **overdetermination**
7//! (several independent sufficient causes → joint liability, where no single one is but-for).
8//!
9//! Causation is a DAG of `(cause, q42:causeOf, effect)` edges over a set of `roots` (the
10//! base facts that actually occurred). All evaluation is bounded BFS reachability — zero-heap
11//! (fixed frontier/visited arrays), the same shape as `dl::check_subsumption_quin`.
12
13use crate::{q_hash, NQuin};
14
15/// Bound on distinct nodes in one causal query.
16pub const MAX_CAUSAL_NODES: usize = 256;
17
18/// Sentinel meaning "remove nothing" — `q_hash` is 60-bit, so `u64::MAX` is never a real node.
19const NO_REMOVAL: u64 = u64::MAX;
20
21/// The causal-edge predicate `(cause, q42:causeOf, effect)`.
22#[inline]
23pub fn cause_predicate() -> u64 {
24    q_hash("q42:causeOf")
25}
26
27/// Internal: is `target` reachable from any `root` along `causeOf` edges, with `removed`
28/// node excised from the graph (both as a root and as any edge endpoint)? Bounded, zero-heap.
29fn caused_internal(edges: &[NQuin], roots: &[u64], target: u64, removed: u64) -> bool {
30    if target == removed {
31        return false;
32    }
33    let p = cause_predicate();
34    let mut frontier = [0u64; MAX_CAUSAL_NODES];
35    let mut visited = [0u64; MAX_CAUSAL_NODES];
36    let mut fl = 0usize;
37    let mut vl = 0usize;
38    for &r in roots {
39        if r == removed {
40            continue;
41        }
42        if r == target {
43            return true;
44        }
45        if fl < MAX_CAUSAL_NODES {
46            frontier[fl] = r;
47            fl += 1;
48        }
49    }
50    while fl > 0 {
51        fl -= 1;
52        let cur = frontier[fl];
53        if visited[..vl].contains(&cur) {
54            continue;
55        }
56        if vl < MAX_CAUSAL_NODES {
57            visited[vl] = cur;
58            vl += 1;
59        } else {
60            break; // closure exceeds the bound — refuse rather than mis-answer
61        }
62        for e in edges {
63            if e.predicate == p && e.subject == cur && e.subject != removed && e.object != removed {
64                let nxt = e.object;
65                if nxt == target {
66                    return true;
67                }
68                if fl < MAX_CAUSAL_NODES && !visited[..vl].contains(&nxt) {
69                    frontier[fl] = nxt;
70                    fl += 1;
71                }
72            }
73        }
74    }
75    false
76}
77
78/// Did `effect` occur — i.e. is it reachable from the occurred `roots` along causeOf edges?
79#[inline]
80pub fn caused(edges: &[NQuin], roots: &[u64], effect: u64) -> bool {
81    caused_internal(edges, roots, effect, NO_REMOVAL)
82}
83
84/// **But-for causation**: `effect` occurred, and *but for* `cause` it would NOT have — i.e.
85/// `cause` is a *necessary* condition (removing it makes `effect` unreachable). This is the
86/// legal "but-for" / sine-qua-non test.
87pub fn but_for_cause(edges: &[NQuin], roots: &[u64], cause: u64, effect: u64) -> bool {
88    caused(edges, roots, effect) && !caused_internal(edges, roots, effect, cause)
89}
90
91/// **Root-node dependency**: is `node` voided by removing the foundational support `removed`?
92/// True iff `node` occurs normally but becomes unreachable once `removed` is gone — "if food/
93/// shelter is removed, all dependent rights and capacities are voided" (the deepest-absence rule).
94pub fn is_voided_by(edges: &[NQuin], roots: &[u64], removed: u64, node: u64) -> bool {
95    caused(edges, roots, node) && !caused_internal(edges, roots, node, removed)
96}
97
98/// Collect, into `out`, the `candidates` that are voided by removing `removed`. Returns the
99/// count written. Zero-heap (caller-supplied `out`).
100pub fn dependents_voided(
101    edges: &[NQuin],
102    roots: &[u64],
103    removed: u64,
104    candidates: &[u64],
105    out: &mut [u64],
106) -> usize {
107    let mut n = 0usize;
108    for &c in candidates {
109        if is_voided_by(edges, roots, removed, c) {
110            if n >= out.len() {
111                break;
112            }
113            out[n] = c;
114            n += 1;
115        }
116    }
117    n
118}
119
120/// **Causal overdetermination** (joint liability): `effect` occurred, there are ≥2 candidate
121/// `causes`, and **no single one is but-for** — removing any one alone still yields the effect
122/// (another sufficient cause remains). Liability is then shared across all of them.
123pub fn is_overdetermined(edges: &[NQuin], roots: &[u64], causes: &[u64], effect: u64) -> bool {
124    if causes.len() < 2 || !caused(edges, roots, effect) {
125        return false;
126    }
127    // No cause is necessary: for each, the effect still occurs without it.
128    causes
129        .iter()
130        .all(|&c| caused_internal(edges, roots, effect, c))
131}
132
133// ─── Pearl's do-operator (formal intervention) ────────────────────────────────────
134
135/// **Intervention** `do(...)`: force the variables in `set_present` to occur and `set_absent` to
136/// NOT occur — severing the absent nodes from the graph and treating the present ones as
137/// exogenous roots — then compute whether `effect` results (`P(effect | do(X))` as boolean
138/// reachability). Zero-heap.
139pub fn do_intervene(
140    edges: &[NQuin],
141    roots: &[u64],
142    set_present: &[u64],
143    set_absent: &[u64],
144    effect: u64,
145) -> bool {
146    if set_absent.contains(&effect) {
147        return false;
148    }
149    let p = cause_predicate();
150    let mut frontier = [0u64; MAX_CAUSAL_NODES];
151    let mut visited = [0u64; MAX_CAUSAL_NODES];
152    let mut fl = 0usize;
153    let mut vl = 0usize;
154    let push = |n: u64, frontier: &mut [u64; MAX_CAUSAL_NODES], fl: &mut usize| {
155        if !set_absent.contains(&n) && *fl < MAX_CAUSAL_NODES {
156            frontier[*fl] = n;
157            *fl += 1;
158        }
159    };
160    for &r in roots {
161        if r == effect {
162            return true;
163        }
164        push(r, &mut frontier, &mut fl);
165    }
166    for &x in set_present {
167        if x == effect {
168            return true;
169        }
170        push(x, &mut frontier, &mut fl);
171    }
172    while fl > 0 {
173        fl -= 1;
174        let cur = frontier[fl];
175        if visited[..vl].contains(&cur) {
176            continue;
177        }
178        if vl < MAX_CAUSAL_NODES {
179            visited[vl] = cur;
180            vl += 1;
181        } else {
182            break;
183        }
184        for e in edges {
185            if e.predicate == p
186                && e.subject == cur
187                && !set_absent.contains(&e.subject)
188                && !set_absent.contains(&e.object)
189            {
190                let nxt = e.object;
191                if nxt == effect {
192                    return true;
193                }
194                if fl < MAX_CAUSAL_NODES && !visited[..vl].contains(&nxt) {
195                    frontier[fl] = nxt;
196                    fl += 1;
197                }
198            }
199        }
200    }
201    false
202}
203
204// ─── Structural Causal Model: exogenous vs endogenous ─────────────────────────────
205
206/// **Exogenous** variable (SCM): a node with NO incoming causal edge — its value enters from
207/// outside the model (a root cause / external factor).
208pub fn is_exogenous(edges: &[NQuin], node: u64) -> bool {
209    let p = cause_predicate();
210    !edges.iter().any(|e| e.predicate == p && e.object == node)
211}
212
213/// **Endogenous** variable: determined within the model (≥1 incoming causal edge).
214#[inline]
215pub fn is_endogenous(edges: &[NQuin], node: u64) -> bool {
216    !is_exogenous(edges, node)
217}
218
219// ─── Counterfactual (twin-network) query ──────────────────────────────────────────
220
221/// **Counterfactual** "had `intervene` been absent, would `effect` still have occurred?" — the
222/// twin-network comparison of the factual world against the counterfactual `do(intervene absent)`
223/// world. Returns `(factual, counterfactual)`; if they differ, `intervene` was counterfactually
224/// necessary for `effect`.
225pub fn counterfactual_absent(
226    edges: &[NQuin],
227    roots: &[u64],
228    intervene: u64,
229    effect: u64,
230) -> (bool, bool) {
231    let factual = caused(edges, roots, effect);
232    let counterfactual = do_intervene(edges, roots, &[], &[intervene], effect);
233    (factual, counterfactual)
234}
235
236// ─── Backdoor criterion (confounder adjustment) ───────────────────────────────────
237
238/// **Backdoor criterion**: a set `z` is admissible for estimating the effect of `x` on `y` iff
239/// (a) no node in `z` is a descendant of `x`, and (b) `z` blocks every backdoor (confounding)
240/// path — here, every common ancestor (confounder) of `x` and `y` is in `z`. `nodes` enumerates
241/// the model's variables. Bounded, zero-heap (composes `caused` reachability).
242pub fn backdoor_satisfied(edges: &[NQuin], x: u64, y: u64, z: &[u64], nodes: &[u64]) -> bool {
243    // (a) no z node may be a descendant of x.
244    for &zn in z {
245        if zn != x && caused(edges, &[x], zn) {
246            return false;
247        }
248    }
249    // (b) every confounder (common ancestor of x and y) must be in z.
250    for &c in nodes {
251        if c != x && c != y && caused(edges, &[c], x) && caused(edges, &[c], y) && !z.contains(&c) {
252            return false;
253        }
254    }
255    true
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn edge(cause: u64, effect: u64) -> NQuin {
263        let mut q = NQuin {
264            subject: cause,
265            predicate: cause_predicate(),
266            object: effect,
267            context: 0,
268            metadata: 0,
269            parity: 0,
270        };
271        q.parity = q.subject ^ q.predicate ^ q.object ^ q.context;
272        q
273    }
274
275    #[test]
276    fn but_for_along_a_chain() {
277        // missing-funding → no-staff → service-failure (the harm). Roots: missing-funding occurred.
278        let fund = q_hash("cause:missingFunding");
279        let staff = q_hash("cause:noStaff");
280        let harm = q_hash("harm:serviceFailure");
281        let edges = [edge(fund, staff), edge(staff, harm)];
282        let roots = [fund];
283        assert!(caused(&edges, &roots, harm));
284        // Every node on the only path is but-for necessary.
285        assert!(but_for_cause(&edges, &roots, fund, harm));
286        assert!(but_for_cause(&edges, &roots, staff, harm));
287        // An unrelated node is not a but-for cause.
288        assert!(!but_for_cause(
289            &edges,
290            &roots,
291            q_hash("cause:weather"),
292            harm
293        ));
294    }
295
296    #[test]
297    fn overdetermination_is_joint_not_but_for() {
298        // Two independent sufficient causes of the same harm.
299        let c1 = q_hash("cause:fireA");
300        let c2 = q_hash("cause:fireB");
301        let harm = q_hash("harm:houseDestroyed");
302        let edges = [edge(c1, harm), edge(c2, harm)];
303        let roots = [c1, c2];
304        assert!(caused(&edges, &roots, harm));
305        // Neither alone is but-for (the other still destroys the house).
306        assert!(!but_for_cause(&edges, &roots, c1, harm));
307        assert!(!but_for_cause(&edges, &roots, c2, harm));
308        // → overdetermined → joint liability.
309        assert!(is_overdetermined(&edges, &roots, &[c1, c2], harm));
310    }
311
312    #[test]
313    fn root_removal_voids_dependents() {
314        // food → health → work ; shelter → health (diamond on health).
315        let food = q_hash("support:food");
316        let shelter = q_hash("support:shelter");
317        let health = q_hash("capacity:health");
318        let work = q_hash("capacity:work");
319        let edges = [
320            edge(food, health),
321            edge(shelter, health),
322            edge(health, work),
323        ];
324        let roots = [food, shelter];
325        // Removing food alone does NOT void health/work (shelter still supports health).
326        assert!(!is_voided_by(&edges, &roots, food, work));
327        // But a single-support chain: education → literacy. Remove education → literacy voided.
328        let edu = q_hash("support:education");
329        let lit = q_hash("capacity:literacy");
330        let edges2 = [edge(edu, lit)];
331        let roots2 = [edu];
332        let mut out = [0u64; 4];
333        let n = dependents_voided(&edges2, &roots2, edu, &[lit], &mut out);
334        assert_eq!(n, 1);
335        assert_eq!(out[0], lit);
336    }
337
338    #[test]
339    fn do_operator_and_scm_classification() {
340        // smoking → tar → cancer.
341        let (smoke, tar, cancer) = (q_hash("v:smoke"), q_hash("v:tar"), q_hash("v:cancer"));
342        let edges = [edge(smoke, tar), edge(tar, cancer)];
343        // do(smoke present): cancer results.
344        assert!(do_intervene(&edges, &[], &[smoke], &[], cancer));
345        // do(tar absent): severs the chain → no cancer even if smoke present.
346        assert!(!do_intervene(&edges, &[], &[smoke], &[tar], cancer));
347        // SCM: smoke is exogenous (no incoming edge); tar/cancer are endogenous.
348        assert!(is_exogenous(&edges, smoke));
349        assert!(is_endogenous(&edges, tar) && is_endogenous(&edges, cancer));
350    }
351
352    #[test]
353    fn counterfactual_and_backdoor() {
354        let (smoke, tar, cancer) = (q_hash("v:smoke"), q_hash("v:tar"), q_hash("v:cancer"));
355        let edges = [edge(smoke, tar), edge(tar, cancer)];
356        // Counterfactual: had tar been absent, cancer would NOT have occurred (tar was necessary).
357        let (factual, cf) = counterfactual_absent(&edges, &[smoke], tar, cancer);
358        assert!(
359            factual && !cf,
360            "tar is counterfactually necessary for cancer"
361        );
362
363        // Backdoor: confounder genes → smoke and genes → cancer (a common cause).
364        let genes = q_hash("v:genes");
365        let confounded = [edge(genes, smoke), edge(smoke, cancer), edge(genes, cancer)];
366        let nodes = [genes, smoke, cancer];
367        // {} does NOT block the genes confounder; {genes} does.
368        assert!(!backdoor_satisfied(&confounded, smoke, cancer, &[], &nodes));
369        assert!(backdoor_satisfied(
370            &confounded,
371            smoke,
372            cancer,
373            &[genes],
374            &nodes
375        ));
376        // Conditioning on a descendant of x (cancer) is NOT admissible.
377        assert!(!backdoor_satisfied(
378            &confounded,
379            smoke,
380            cancer,
381            &[genes, cancer],
382            &nodes
383        ));
384    }
385}