nx_patch_attribution.nx source
↩ module page · 332 lines · 12177 B
1// nx_patch_attribution.nx -- the COMPOST primitive.
2//
3// User: "did this patch improve things if so then lets find out why
4// and test and integrate like how we are using a lora right now
5// that improves our elder ai realism image gen but i couldnt tell
6// you why and neither can the system".
7//
8// THE answer. Given a population of "before-patch" samples and a
9// population of "after-patch" samples, each with per-axis
10// measurements, this primitive reports:
11//
12// - WHICH measurement axes the patch significantly moved
13// - HOW MUCH each axis moved (Cohen's d effect size)
14// - WHETHER the movement is statistically significant after
15// family-wise alpha correction (Bonferroni)
16//
17// The substrate's bits-up answer to the user's concrete pain:
18// don't ship LoRAs nobody understands; ship a decomposition that
19// turns the LoRA's effect into measurable named-axis changes that
20// can then be COMPOSTED into substrate architecture per the
21// patches-as-manure cardinal.
22//
23// ===== Workflow ===================================================
24//
25// 1. Run N generations WITHOUT the patch; collect per-axis
26// measurements via caller's measurement_fn. Call this
27// sample population B (before).
28// 2. Run N generations WITH the patch; collect same measurements.
29// Call this population A (after).
30// 3. nx_patch_attribute(B_data, A_data, n_axes, n_samples)
31// 4. Result lists per-axis: mean_before, mean_after, effect_size,
32// significant? Tells caller EXACTLY what the patch is doing.
33// 5. Caller then chooses: is this a useful axis movement? If
34// yes: ship a substrate-side primitive that achieves the
35// same axis movement without the LoRA. COMPOSTED.
36//
37// ===== Statistical methods =======================================
38//
39// Cohen's d (Cohen 1988): standardised mean difference.
40// d = (mean_A - mean_B) / pooled_std
41// pooled_std = sqrt((var_A + var_B) / 2)
42// Interpretation (Cohen 1988 rules of thumb):
43// |d| < 0.2: negligible effect
44// 0.2-0.5: small effect
45// 0.5-0.8: medium effect
46// |d| > 0.8: large effect
47//
48// Welch t-test (Welch 1947): unequal-variance two-sample test.
49// t = (mean_A - mean_B) / sqrt(var_A/n_A + var_B/n_B)
50// Significance: |t| > critical_t for given alpha + dof.
51//
52// Bonferroni correction: divide alpha by n_axes for family-wise
53// alpha control. Critical t table from nx_multivariate.
54//
55// Per the bits-up + bounded-loop cardinals.
56//
57// genealogy_id: cohen_1988_effect_size + welch_1947_t_test +
58// bonferroni_1936_inequality +
59// hinton_2015_distillation_attribution
60// lineage_id: substrate_patch_attribution_v1
61
62// nx_safety_envelope:
63// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
64// sil_target: SIL1
65// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
66// verdict: NOT_YET_EVALUATED
67
68import "nx_syscalls.nx"
69import "nx_tier.nx"
70import "nx_loop.nx"
71import "nx_isqrt.nx"
72import "nx_multivariate.nx"
73
74const NX_PA_Q10: nx_int = 1024
75
76// ===== Effect-size sealed enum ====================================
77//
78// Cohen's d magnitude classification. Cleaner-API than passing raw
79// d back; caller can branch on this directly.
80
81const NX_PA_EFFECT_NEGLIGIBLE: nx_int = 0 // |d| < 0.2
82const NX_PA_EFFECT_SMALL: nx_int = 1 // 0.2 <= |d| < 0.5
83const NX_PA_EFFECT_MEDIUM: nx_int = 2 // 0.5 <= |d| < 0.8
84const NX_PA_EFFECT_LARGE: nx_int = 3 // |d| >= 0.8
85const NX_PA_EFFECT_N: nx_int = 4
86
87func nx_pa_effect_is_valid(e: nx_int) -> nx_int {
88 if e < 0 { return 0 }
89 if e >= NX_PA_EFFECT_N { return 0 }
90 return 1
91}
92
93// ===== Patch-attribution verdict ==================================
94
95const NX_PA_OK: nx_int = 0
96const NX_PA_ERR_BAD_DIMS: nx_int = 1
97const NX_PA_ERR_INSUFFICIENT: nx_int = 2
98const NX_PA_N_VERDICTS: nx_int = 3
99
100func nx_pa_verdict_is_valid(v: nx_int) -> nx_int {
101 if v < 0 { return 0 }
102 if v >= NX_PA_N_VERDICTS { return 0 }
103 return 1
104}
105
106// ===== Result envelope ============================================
107
108struct NxPatchAttributionResult {
109 overall_verdict: nx_int, // NX_PA_OK / ERR
110 n_axes: nx_int,
111 n_samples_b: nx_int,
112 n_samples_a: nx_int,
113 means_b: *i64, // [n_axes]
114 means_a: *i64,
115 cohens_d_q10: *i64, // [n_axes] effect size Q10 (signed)
116 effect_class: *i64, // [n_axes] NX_PA_EFFECT_*
117 t_stat_q10: *i64, // [n_axes] Welch t-statistic
118 significant: *i64, // [n_axes] 1 = significant after Bonferroni, 0 else
119 n_significant: nx_int // count of significant axes
120}
121
122const NX_PA_RESULT_BYTES: nx_int = 88
123
124// ===== Helper: classify Cohen's d effect size =====================
125
126func _pa_classify_d(d_q10: i64) -> nx_int {
127 var abs_d: i64 = d_q10
128 if abs_d < 0 { abs_d = 0 - abs_d }
129 // Thresholds in Q10:
130 // 0.2 Q10 = 205
131 // 0.5 Q10 = 512
132 // 0.8 Q10 = 819
133 if abs_d < 205 { return NX_PA_EFFECT_NEGLIGIBLE }
134 if abs_d < 512 { return NX_PA_EFFECT_SMALL }
135 if abs_d < 819 { return NX_PA_EFFECT_MEDIUM }
136 return NX_PA_EFFECT_LARGE
137}
138
139// ===== Main attribute entrypoint ==================================
140//
141// samples_b: [n_samples_b * n_axes] per-axis measurements pre-patch
142// samples_a: [n_samples_a * n_axes] per-axis measurements with patch
143// n_axes: measurement dimensions
144// n_samples_b / n_samples_a: sample counts per population
145//
146// Returns *NxPatchAttributionResult populated with per-axis stats.
147
148func nx_patch_attribute(
149 samples_b: *i64, n_samples_b: nx_int,
150 samples_a: *i64, n_samples_a: nx_int,
151 n_axes: nx_int) -> *NxPatchAttributionResult {
152
153 let r: *NxPatchAttributionResult = sys_mmap(NX_PA_RESULT_BYTES) as *NxPatchAttributionResult
154 r.overall_verdict = NX_PA_ERR_BAD_DIMS
155 r.n_axes = n_axes
156 r.n_samples_b = n_samples_b
157 r.n_samples_a = n_samples_a
158 r.n_significant = 0
159
160 if n_axes <= 0 { return r }
161 if n_samples_b < NX_MC_MIN_N { r.overall_verdict = NX_PA_ERR_INSUFFICIENT; return r }
162 if n_samples_a < NX_MC_MIN_N { r.overall_verdict = NX_PA_ERR_INSUFFICIENT; return r }
163
164 r.means_b = sys_mmap(n_axes * 8) as *i64
165 r.means_a = sys_mmap(n_axes * 8) as *i64
166 r.cohens_d_q10 = sys_mmap(n_axes * 8) as *i64
167 r.effect_class = sys_mmap(n_axes * 8) as *i64
168 r.t_stat_q10 = sys_mmap(n_axes * 8) as *i64
169 r.significant = sys_mmap(n_axes * 8) as *i64
170
171 let crit_t: nx_int = _mv_bonferroni_critical_t_q10(n_axes)
172
173 var ax: nx_int = 0
174 var ax_iter: nx_int = 0
175 var ax_verdict: nx_int = NX_LOOP_RUNNING
176 let AX_BUDGET: nx_int = n_axes
177 var n_sig: nx_int = 0
178 while ax_verdict == NX_LOOP_RUNNING && ax_iter < AX_BUDGET {
179 // Per-axis stats: mean + sample variance for B and A.
180 var sum_b: i64 = 0
181 var i: nx_int = 0
182 while i < n_samples_b { sum_b = sum_b + samples_b[i * n_axes + ax]; i = i + 1 }
183 let mean_b: i64 = sum_b / n_samples_b
184 var sumsq_b: i64 = 0
185 var j: nx_int = 0
186 while j < n_samples_b {
187 let d: i64 = samples_b[j * n_axes + ax] - mean_b
188 sumsq_b = sumsq_b + d * d
189 j = j + 1
190 }
191 let var_b: i64 = sumsq_b / (n_samples_b - 1)
192
193 var sum_a: i64 = 0
194 var k: nx_int = 0
195 while k < n_samples_a { sum_a = sum_a + samples_a[k * n_axes + ax]; k = k + 1 }
196 let mean_a: i64 = sum_a / n_samples_a
197 var sumsq_a: i64 = 0
198 var m: nx_int = 0
199 while m < n_samples_a {
200 let d: i64 = samples_a[m * n_axes + ax] - mean_a
201 sumsq_a = sumsq_a + d * d
202 m = m + 1
203 }
204 let var_a: i64 = sumsq_a / (n_samples_a - 1)
205
206 r.means_b[ax] = mean_b
207 r.means_a[ax] = mean_a
208
209 // Cohen's d: (mean_a - mean_b) / pooled_std
210 // pooled_std = sqrt((var_b + var_a) / 2)
211 let pooled_var: i64 = (var_b + var_a) / 2
212 let pooled_std: i64 = nx_isqrt_q10(pooled_var + 1)
213 var d_q10: i64 = 0
214 if pooled_std > 0 {
215 d_q10 = ((mean_a - mean_b) * NX_PA_Q10) / pooled_std
216 }
217 r.cohens_d_q10[ax] = d_q10
218 r.effect_class[ax] = _pa_classify_d(d_q10)
219
220 // Welch t-test: t = (mean_a - mean_b) / sqrt(var_b/n_b + var_a/n_a)
221 // se^2 in raw units; sqrt -> Q10 via nx_isqrt_q10(... * Q10).
222 let se_sq: i64 = var_b / n_samples_b + var_a / n_samples_a
223 let se: i64 = nx_isqrt_q10(se_sq * NX_PA_Q10 + 1)
224 var diff: i64 = mean_a - mean_b
225 var abs_diff: i64 = diff
226 if abs_diff < 0 { abs_diff = 0 - abs_diff }
227 var t_stat: i64 = 0
228 if se > 0 {
229 t_stat = (abs_diff * NX_PA_Q10) / se
230 }
231 r.t_stat_q10[ax] = t_stat
232
233 // Significance (Bonferroni-corrected).
234 if t_stat >= crit_t {
235 r.significant[ax] = 1
236 n_sig = n_sig + 1
237 }
238 if t_stat < crit_t {
239 r.significant[ax] = 0
240 }
241
242 ax = ax + 1
243 ax_iter = ax_iter + 1
244 }
245 r.n_significant = n_sig
246 r.overall_verdict = NX_PA_OK
247 return r
248}
249
250// ===== Self-test ==================================================
251//
252// 3 measurement axes, 100 samples each population.
253// Axis 0: B mean ~100, A mean ~100 (NO effect; patch didn't move)
254// Axis 1: B mean ~100, A mean ~150 (LARGE effect)
255// Axis 2: B mean ~100, A mean ~110 (SMALL effect)
256//
257// Expected:
258// axis 0: NEGLIGIBLE, not significant
259// axis 1: LARGE, significant
260// axis 2: SMALL, may or may not be significant depending on
261// variance (low variance -> significant)
262
263func main() -> i64 {
264 let N: nx_int = 100
265 let A: nx_int = 3
266 let buf_b: *i64 = sys_mmap(N * A * 8) as *i64
267 let buf_a: *i64 = sys_mmap(N * A * 8) as *i64
268
269 let prng: *i64 = sys_mmap(8) as *i64
270 nx_prng_init(prng, 0xdeadbeef)
271
272 // Fill samples.
273 var i: nx_int = 0
274 while i < N {
275 // B population.
276 let r0_b: i64 = nx_prng_range(prng, 20) + 90 // uniform [90, 109]
277 let r1_b: i64 = nx_prng_range(prng, 20) + 90
278 let r2_b: i64 = nx_prng_range(prng, 20) + 90
279 buf_b[i * A + 0] = r0_b
280 buf_b[i * A + 1] = r1_b
281 buf_b[i * A + 2] = r2_b
282
283 // A population.
284 let r0_a: i64 = nx_prng_range(prng, 20) + 90 // same as B (no effect)
285 let r1_a: i64 = nx_prng_range(prng, 20) + 140 // shifted up by 50 (large effect)
286 let r2_a: i64 = nx_prng_range(prng, 20) + 100 // shifted up by 10 (small effect)
287 buf_a[i * A + 0] = r0_a
288 buf_a[i * A + 1] = r1_a
289 buf_a[i * A + 2] = r2_a
290
291 i = i + 1
292 }
293
294 let r: *NxPatchAttributionResult = nx_patch_attribute(
295 buf_b, N, buf_a, N, A)
296 if r.overall_verdict != NX_PA_OK { return 10 }
297
298 // Axis 0: no movement, NEGLIGIBLE.
299 if r.effect_class[0] != NX_PA_EFFECT_NEGLIGIBLE { return 20 }
300 if r.significant[0] != 0 { return 21 }
301
302 // Axis 1: large movement.
303 if r.effect_class[1] != NX_PA_EFFECT_LARGE { return 30 }
304 if r.significant[1] != 1 { return 31 }
305 // d should be ~ +50 / ~5 std ~ 10.0 -> Q10 = ~10000.
306 // Actual will depend on uniform-distribution variance ~ (range^2/12).
307 if r.cohens_d_q10[1] < 5000 { return 32 } // certainly large
308
309 // Axis 2: small movement (~10 mean shift on similar variance);
310 // may or may not be significant depending on n; with N=100 likely
311 // significant. Just check effect_class is SMALL or MEDIUM.
312 if r.effect_class[2] != NX_PA_EFFECT_SMALL {
313 if r.effect_class[2] != NX_PA_EFFECT_MEDIUM { return 40 }
314 }
315
316 // At least 1 significant axis (axis 1).
317 if r.n_significant < 1 { return 50 }
318
319 // Verdict gates.
320 var vi: nx_int = 0
321 while vi < NX_PA_N_VERDICTS {
322 if nx_pa_verdict_is_valid(vi) != 1 { return 60 + vi }
323 vi = vi + 1
324 }
325 var ei: nx_int = 0
326 while ei < NX_PA_EFFECT_N {
327 if nx_pa_effect_is_valid(ei) != 1 { return 70 + ei }
328 ei = ei + 1
329 }
330
331 return 0
332}