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}