nx_analyst_causal_gate.nx source
↩ module page · 149 lines · 7827 B
1// nx_analyst_causal_gate.nx -- F1005. The role logic is trivial to assert; what this gate really has to
2// prove is WHY it must exist. So the centre of it is two EMPIRICAL demonstrations on real generated data,
3// where the identical operation -- "control for a third column" -- is right once and catastrophic twice:
4// T6 COLLIDER: two INDEPENDENT columns acquire a strong NEGATIVE partial correlation purely by
5// conditioning on their sum. The association is manufactured out of nothing.
6// T7 MEDIATOR: a real, strong x->m->y effect is driven toward ZERO by conditioning on m.
7// T8 CONFOUNDER: the same operation is CORRECT, collapsing a spurious link.
8// F1004's ai_confound_scan picks whichever control weakens the relationship most -- which in T6 and T7 is
9// exactly the wrong column. That is the defect this organ exists to gate.
10// D001-compliant verdict via nx_gate_verdict. license_tier: ORIGINAL expect_exit: 0
11import "nx_gate_verdict.nx"
12import "nx_analyst_causal.nx"
13
14const CG_N: i64 = 60
15
16func cg_eq(a: i64, b: i64) -> i64 { if a == b { return 1 } return 0 }
17func cg_has(hay: *u8, needle: *u8) -> i64 {
18 var hn: i64 = 0
19 while hay[hn] != (0 as u8) { hn = hn + 1 }
20 var nl: i64 = 0
21 while needle[nl] != (0 as u8) { nl = nl + 1 }
22 var i: i64 = 0
23 while i + nl <= hn {
24 var j: i64 = 0
25 var eq: i64 = 1
26 while j < nl { if hay[i+j] != needle[j] { eq = 0 } j = j + 1 }
27 if eq == 1 { return 1 }
28 i = i + 1
29 }
30 return 0
31}
32
33func main() -> i64 {
34 let ctr: *i64 = gv_ctr()
35 gv_head("nx_analyst_causal_gate -- when is controlling for a third column right, and when does it lie?" as *u8)
36
37 // ---- role logic --------------------------------------------------------------------------------
38 let roles: *i64 = sys_mmap(8 * 4) as *i64
39 roles[0] = AC_ROLE_UNKNOWN
40 roles[1] = AC_ROLE_UNKNOWN
41 roles[2] = AC_ROLE_UNKNOWN
42 roles[3] = AC_ROLE_UNKNOWN
43 gv_check("T1 nothing declared -> ASSOCIATION ONLY (the default is never a causal claim)" as *u8, cg_eq(ac_verdict(roles, 4, 0, 1), AC_ASSOCIATION_ONLY), ctr)
44 roles[1] = AC_ROLE_CONFOUNDER
45 gv_check("T2 adjusting for a declared CONFOUNDER is licensed" as *u8, cg_eq(ac_verdict(roles, 4, 0, 1), AC_ADJUSTED), ctr)
46 roles[2] = AC_ROLE_COLLIDER
47 gv_check("T3 adjusting for a declared COLLIDER is REFUSED (it would induce the association)" as *u8, cg_eq(ac_verdict(roles, 4, 0, 2), AC_REFUSED_COLLIDER), ctr)
48 roles[3] = AC_ROLE_MEDIATOR
49 gv_check("T4 adjusting for a declared MEDIATOR is REFUSED (it would erase the real effect)" as *u8, cg_eq(ac_verdict(roles, 4, 0, 3), AC_REFUSED_MEDIATOR), ctr)
50 let aset: *i64 = sys_mmap(8 * 8) as *i64
51 var t5: i64 = 1
52 if ac_adjustment_set(roles, 4, 0, aset) != 1 { t5 = 0 }
53 if aset[0] != 1 { t5 = 0 }
54 gv_check("T5 the adjustment set holds ONLY the confounder -- collider and mediator excluded" as *u8, t5, ctr)
55 let ex: *u8 = sys_mmap(1024)
56 ac_explain(AC_ASSOCIATION_ONLY, ex)
57 gv_check("T5b the undeclared wording refuses a causal reading in words, not just in a code" as *u8, cg_has(ex, "ASSOCIATION ONLY" as *u8), ctr)
58
59 // ---- T6 COLLIDER: an association MANUFACTURED from two independent columns --------------------
60 // x and y are independent draws; c = x + y is a common EFFECT of both. Conditioning on c must create
61 // a strong NEGATIVE partial correlation between causes that have no relationship whatsoever.
62 let x: *i64 = sys_mmap(8 * CG_N) as *i64
63 let y: *i64 = sys_mmap(8 * CG_N) as *i64
64 let c: *i64 = sys_mmap(8 * CG_N) as *i64
65 var s: i64 = 12345
66 var i: i64 = 0
67 while i < CG_N {
68 s = (s * 1103515245 + 12345) & 0x7fffffff
69 x[i] = s % 1000
70 s = (s * 1103515245 + 12345) & 0x7fffffff
71 y[i] = s % 1000
72 c[i] = x[i] + y[i]
73 i = i + 1
74 }
75 let r_xy: i64 = am_pearson_milli(x, y, CG_N)
76 let r_xc: i64 = am_pearson_milli(x, c, CG_N)
77 let r_yc: i64 = am_pearson_milli(y, c, CG_N)
78 let p_xy_c: i64 = ai_partial_milli(r_xy, r_xc, r_yc)
79 var arxy: i64 = r_xy
80 if arxy < 0 { arxy = 0 - arxy }
81 var t6: i64 = 1
82 if arxy > 300 { t6 = 0 } // x and y really are ~independent
83 if p_xy_c > (0 - 400) { t6 = 0 } // conditioning on their sum creates a strong NEGATIVE link
84 gv_check("T6 COLLIDER: independent x,y (|r|<300) acquire partial r < -400 by conditioning on x+y -- the association is MANUFACTURED" as *u8, t6, ctr)
85
86 // ---- T7 MEDIATOR: a real effect ERASED --------------------------------------------------------
87 // x -> m -> y. The x->y effect is real and travels entirely through m, so conditioning on m must
88 // drive it to ~0. An analyst that "controls for whatever weakens it most" would report no effect.
89 let mx: *i64 = sys_mmap(8 * CG_N) as *i64
90 let mm: *i64 = sys_mmap(8 * CG_N) as *i64
91 let my: *i64 = sys_mmap(8 * CG_N) as *i64
92 i = 0
93 while i < CG_N {
94 let v: i64 = i + 1
95 // noise must be a real fraction of the signal. The first version used (i%7) and (i%5), which made
96 // the chain almost deterministic: r(y,m) rounded to exactly 1000, the denominator went to zero and
97 // ai_partial_milli correctly answered UNDEFINED. The organ was right and the FIXTURE was degenerate
98 // -- a reminder that a "too clean" test dataset can fail a gate for reasons that are not the code.
99 mx[i] = v
100 mm[i] = 4 * v + (i % 13) * 5
101 my[i] = 3 * mm[i] + (i % 17) * 12
102 i = i + 1
103 }
104 let r_xy2: i64 = am_pearson_milli(mx, my, CG_N)
105 let r_xm: i64 = am_pearson_milli(mx, mm, CG_N)
106 let r_ym: i64 = am_pearson_milli(my, mm, CG_N)
107 let p_xy_m: i64 = ai_partial_milli(r_xy2, r_xm, r_ym)
108 var ap: i64 = p_xy_m
109 if ap < 0 { ap = 0 - ap }
110 var t7: i64 = 1
111 if r_xy2 < 900 { t7 = 0 } // the total effect is genuinely strong
112 if ap > 400 { t7 = 0 } // conditioning on the mediator collapses it
113 gv_check("T7 MEDIATOR: a real r>900 effect collapses to |partial|<400 through its own mediator -- controlling ERASES it" as *u8, t7, ctr)
114
115 // ---- T8 CONFOUNDER: the SAME operation, and here it is correct --------------------------------
116 let zz: *i64 = sys_mmap(8 * CG_N) as *i64
117 let zx: *i64 = sys_mmap(8 * CG_N) as *i64
118 let zy: *i64 = sys_mmap(8 * CG_N) as *i64
119 i = 0
120 while i < CG_N {
121 let v: i64 = i + 1
122 zz[i] = v
123 zx[i] = 3 * v + (i % 11) * 2
124 zy[i] = 5 * v + (i % 13) * 3
125 i = i + 1
126 }
127 let r_ab: i64 = am_pearson_milli(zx, zy, CG_N)
128 let r_az: i64 = am_pearson_milli(zx, zz, CG_N)
129 let r_bz: i64 = am_pearson_milli(zy, zz, CG_N)
130 let p_ab_z: i64 = ai_partial_milli(r_ab, r_az, r_bz)
131 var ap2: i64 = p_ab_z
132 if ap2 < 0 { ap2 = 0 - ap2 }
133 var t8: i64 = 1
134 if r_ab < 900 { t8 = 0 }
135 if ap2 > 300 { t8 = 0 }
136 gv_check("T8 CONFOUNDER: the identical operation correctly collapses a SPURIOUS r>900 to |partial|<300" as *u8, t8, ctr)
137
138 // ---- the point of the whole organ -------------------------------------------------------------
139 // T6, T7 and T8 all ran "control for a third column" and got a big drop. Only in T8 was that right.
140 // Nothing in the numbers distinguishes them -- which is exactly why an undeclared graph caps the
141 // claim at ASSOCIATION and why picking the most-shrinking control is unsound.
142 var t9: i64 = 0
143 if p_xy_c < 0 { if ap < 400 { if ap2 < 300 { t9 = 1 } } }
144 gv_check("T9 all three cases show a large drop under control, and ONLY the confounder case earned it" as *u8, t9, ctr)
145
146 let rc: i64 = gv_verdict("ANALYST-CAUSAL-GATE" as *u8, ctr, "roles are declared, never inferred from correlation; colliders and mediators are refused" as *u8)
147 sys_exit(rc)
148 return rc
149}