code wiki / (root) / nx_analyst_causal.nx

nx_analyst_causal.nx source

↩ module page · 99 lines · 6236 B

1// nx_analyst_causal.nx -- F1005: the CAUSAL layer, and mostly the honesty about not having one. 2// 3// WHY THIS EXISTS. F1004 gave the analyst partial correlation and wired ai_confound_scan into the driver 4// claim -- it controls for whichever candidate WEAKENS the relationship most. That is a defect, and this 5// organ is the fix. "Control for everything, especially whatever moves the number" is a known statistical 6// error, because a third variable's ROLE decides whether controlling for it is right, useless, or actively 7// harmful, and no amount of correlation arithmetic can recover that role from the numbers: 8// CONFOUNDER (z -> x, z -> y) : you MUST adjust. Not adjusting leaves a spurious association. 9// MEDIATOR (x -> m -> y) : you must NOT adjust. Adjusting removes the very effect being measured. 10// COLLIDER (x -> c <- y) : you must NOT adjust. Adjusting MANUFACTURES an association from nothing 11// -- the gate demonstrates two independent columns acquiring a strong 12// negative partial correlation purely by conditioning on their sum. 13// All three look identical to a correlation scan. The only sound source of a role is a DECLARATION from 14// whoever knows the domain, so that is what this organ takes -- and with no declaration it returns 15// ASSOCIATION_ONLY and refuses to dress an argmax as a cause. 16// 17// SCOPE, STATED PLAINLY: this implements the backdoor rule for a DECLARED graph -- adjust for declared 18// confounders, refuse declared colliders and mediators, refuse when nothing valid exists. It does NOT do 19// causal discovery (learning the graph from data), instrumental variables, or sensitivity analysis to 20// unmeasured confounding. Those are still GAP. 21// license_tier: ORIGINAL No hardware writes (Rule 26). 22import "nx_syscalls.nx" 23import "nx_analyst_infer.nx" 24 25// declared roles for a candidate column, relative to the exposure/outcome pair under test 26const AC_ROLE_UNKNOWN: i64 = 0 27const AC_ROLE_CONFOUNDER: i64 = 1 28const AC_ROLE_MEDIATOR: i64 = 2 29const AC_ROLE_COLLIDER: i64 = 3 30 31// what the analyst is entitled to say 32const AC_ASSOCIATION_ONLY: i64 = 0 // nothing declared -> no causal claim is available, full stop 33const AC_ADJUSTED: i64 = 1 // adjusted for a declared confounder: a backdoor path is blocked 34const AC_REFUSED_COLLIDER: i64 = 2 // asked to adjust for a collider -> would INDUCE association 35const AC_REFUSED_MEDIATOR: i64 = 3 // asked to adjust for a mediator -> would REMOVE the real effect 36const AC_REFUSED_NOSET: i64 = 4 // roles declared, but no confounder available to adjust for 37 38func ac_cat(d: *u8, o: i64, s: *u8) -> i64 { var i: i64 = 0; while s[i] != (0 as u8) { d[o + i] = s[i]; i = i + 1 } return o + i } 39 40// Is adjusting for a column with this role legitimate? ONLY a declared confounder is. 41func ac_is_valid_control(role: i64) -> i64 { if role == AC_ROLE_CONFOUNDER { return 1 } return 0 } 42 43// Any role declared at all? With none, the honest ceiling is association. 44func ac_any_declared(roles: *i64, ncand: i64) -> i64 { 45 var i: i64 = 0 46 while i < ncand { if roles[i] != AC_ROLE_UNKNOWN { return 1 } i = i + 1 } 47 return 0 48} 49 50// The backdoor ADJUSTMENT SET: declared confounders only, never mediators or colliders. Writes indices 51// into out and returns the count. `skip` (the exposure itself) is excluded. 52func ac_adjustment_set(roles: *i64, ncand: i64, skip: i64, out: *i64) -> i64 { 53 var cnt: i64 = 0 54 var i: i64 = 0 55 while i < ncand { 56 if i != skip { if roles[i] == AC_ROLE_CONFOUNDER { out[cnt] = i; cnt = cnt + 1 } } 57 i = i + 1 58 } 59 return cnt 60} 61 62// What may be claimed if we control for `ctrl` when testing exposure `expo` against the outcome? 63// This is the gate between "a number moved" and "a causal claim is licensed". 64func ac_verdict(roles: *i64, ncand: i64, expo: i64, ctrl: i64) -> i64 { 65 if ac_any_declared(roles, ncand) == 0 { return AC_ASSOCIATION_ONLY } 66 if ctrl < 0 { return AC_REFUSED_NOSET } 67 if ctrl >= ncand { return AC_REFUSED_NOSET } 68 let r: i64 = roles[ctrl] 69 if r == AC_ROLE_COLLIDER { return AC_REFUSED_COLLIDER } 70 if r == AC_ROLE_MEDIATOR { return AC_REFUSED_MEDIATOR } 71 if r == AC_ROLE_CONFOUNDER { return AC_ADJUSTED } 72 // declared graph, but this particular control has no declared role -> not licensed 73 let tmp: *i64 = sys_mmap(8 * 64) as *i64 74 if ac_adjustment_set(roles, ncand, expo, tmp) == 0 { return AC_REFUSED_NOSET } 75 return AC_ASSOCIATION_ONLY 76} 77 78// The sentence the analyst is allowed to print. Never contains the word "causes" unless a declared 79// confounder was actually adjusted for. 80func ac_explain(verdict: i64, out: *u8) -> i64 { 81 var o: i64 = 0 82 if verdict == AC_ADJUSTED { 83 o = ac_cat(out, o, "CAUSAL (conditional): adjusted for a DECLARED confounder, so a backdoor path is blocked. This licenses a causal reading ONLY under the declared graph -- unmeasured confounding is not testable from these columns" as *u8) 84 } 85 if verdict == AC_REFUSED_COLLIDER { 86 o = ac_cat(out, o, "REFUSED: that control is a declared COLLIDER. Conditioning on a common effect INDUCES an association between causes that are actually independent -- adjusting here would manufacture the finding, not clean it" as *u8) 87 } 88 if verdict == AC_REFUSED_MEDIATOR { 89 o = ac_cat(out, o, "REFUSED: that control is a declared MEDIATOR. The effect travels THROUGH it, so adjusting removes the very thing being measured and drives a real effect toward zero" as *u8) 90 } 91 if verdict == AC_REFUSED_NOSET { 92 o = ac_cat(out, o, "REFUSED: roles are declared but no valid adjustment set exists -- there is no confounder here to block a backdoor path with" as *u8) 93 } 94 if verdict == AC_ASSOCIATION_ONLY { 95 o = ac_cat(out, o, "ASSOCIATION ONLY: no causal roles were declared, so nothing here licenses a causal claim. Controlling for a column chosen because it moves the number is a heuristic, not an identification -- the same operation is required for a confounder, forbidden for a mediator, and actively harmful for a collider, and correlation cannot tell those apart" as *u8) 96 } 97 out[o] = 0 as u8 98 return o 99}