code wiki / (root) / nx_sketch_mann_kendall.nx

nx_sketch_mann_kendall.nx source

↩ module page · 178 lines · 5841 B

1// sketch_mann_kendall.nx -- streaming Mann-Kendall trend test. 2// 3// Non-parametric trend detection over the last W samples. No distribution 4// assumption -- works on any ordinal data including heavy-tailed, 5// skewed, or with outliers (vs Pearson correlation which assumes 6// linearity + normality). 7// 8// MANN-KENDALL STATISTIC: 9// For each pair (i, j) with i < j in the window: 10// S += sign(x_j - x_i) where sign in {-1, 0, +1} 11// S > 0 = upward trend 12// S < 0 = downward trend 13// |S| > threshold = significant 14// 15// THEORETICAL VARIANCE (no ties): 16// Var(S) = n(n-1)(2n+5)/18 17// Z = S / sqrt(Var(S)) — standardized, approximately N(0,1) for large n. 18// 19// FOR THE STREAMING VARIANT: maintain a sliding window of W samples. 20// On each new sample: compare against all W-1 previous samples in O(W). 21// Total state: O(W). S incrementally updated. 22// 23// USE CASES: 24// - hydrology: long-term streamflow trends 25// - climatology: warming trend detection 26// - SRE: monotonic latency trend 27// - finance: directional momentum (vs mean-reverting) 28// 29// Production tier; exact integer arithmetic over the window. 30 31// nx_safety_envelope: 32// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 33// sil_target: SIL1 34// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 35// verdict: NOT_YET_EVALUATED 36 37import "nx_syscalls.nx" 38import "nx_sketch_types.nx" 39 40const NX_MK_MIN_W: i64 = 3 41const NX_MK_MAX_W: i64 = 100000 42 43const NX_MK_TREND_NONE: i64 = 0 44const NX_MK_TREND_UP: i64 = 1 45const NX_MK_TREND_DOWN: i64 = 2 46 47struct MannKendall { 48 buffer: *i64, // circular ring of last W samples 49 window: i64, 50 count: i64, 51 head: i64, // next write position 52 s: i64, // running Mann-Kendall S over current window 53} 54 55// === construction ================================================= 56 57func nx_mk_alloc(window: i64) -> *MannKendall { 58 if window < NX_MK_MIN_W { return 0 as *MannKendall } 59 if window > NX_MK_MAX_W { return 0 as *MannKendall } 60 let raw: *u8 = sys_mmap(48) 61 let m: *MannKendall = raw as *MannKendall 62 m.buffer = sys_mmap(window * 8) as *i64 63 var i: i64 = 0 64 while i < window { 65 m.buffer[i] = 0 66 i = i + 1 67 } 68 m.window = window 69 m.count = 0 70 m.head = 0 71 m.s = 0 72 return m 73} 74 75// === sign ========================================================= 76 77func nx_mk_sign(x: i64) -> i64 { 78 if x > 0 { return 1 } 79 if x < 0 { return -1 } 80 return 0 81} 82 83// === recompute S over current window (used after eviction) ======== 84// 85// O(W²) -- called only when an eviction invalidates the running S 86// incrementally. v1 always recomputes; v2 can incrementally update 87// by subtracting all pairs involving the evicted sample. 88 89func nx_mk_recompute_s(m: *MannKendall) -> i64 { 90 var new_s: i64 = 0 91 // Buffer order: oldest at (head - count) mod window; newest at head-1. 92 var i: i64 = 0 93 while i < m.count { 94 var j: i64 = i + 1 95 while j < m.count { 96 // Physical positions in ring. 97 let pi: i64 = (m.head - m.count + i + m.window) % m.window 98 let pj: i64 = (m.head - m.count + j + m.window) % m.window 99 new_s = new_s + nx_mk_sign(m.buffer[pj] - m.buffer[pi]) 100 j = j + 1 101 } 102 i = i + 1 103 } 104 m.s = new_s 105 return 0 106} 107 108// === push ========================================================== 109// 110// O(W): compare new sample against all current samples (O(W)) -- add 111// each comparison to S. If buffer was full, evicted sample's 112// contributions must be subtracted (O(W) recompute). v1 just 113// recomputes on overflow for simplicity. 114 115func nx_mk_push(m: *MannKendall, value: i64) -> i64 { 116 if m.count >= m.window { 117 // Evict oldest and recompute (simpler than incremental subtract). 118 m.buffer[m.head] = value 119 m.head = (m.head + 1) % m.window 120 nx_mk_recompute_s(m) 121 return 0 122 } 123 // Under-cap: append, update S incrementally. 124 // For each EXISTING sample (oldest..newest), S += sign(value - sample). 125 var i: i64 = 0 126 while i < m.count { 127 let pos: i64 = (m.head - m.count + i + m.window) % m.window 128 m.s = m.s + nx_mk_sign(value - m.buffer[pos]) 129 i = i + 1 130 } 131 m.buffer[m.head] = value 132 m.head = (m.head + 1) % m.window 133 m.count = m.count + 1 134 return 0 135} 136 137// === variance bound ============================================== 138// 139// Var(S) = n(n-1)(2n+5)/18 under null (no trend, no ties). 140 141func nx_mk_var_s(m: *MannKendall) -> i64 { 142 let n: i64 = m.count 143 if n < 2 { return 0 } 144 return (n * (n - 1) * (2 * n + 5)) / 18 145} 146 147// === verdict ====================================================== 148// 149// Use a critical-value-style cutoff: |S| > 1.96 * sqrt(Var(S)) for 150// 95% confidence (two-sided). We approximate this as |S|² > 4 * Var(S). 151// Returns sealed enum: NONE / UP / DOWN. 152 153func nx_mk_verdict(m: *MannKendall) -> i64 { 154 if m.count < 3 { return NX_MK_TREND_NONE } 155 let vs: i64 = nx_mk_var_s(m) 156 if vs == 0 { return NX_MK_TREND_NONE } 157 let s_sq: i64 = m.s * m.s 158 // |S|² > 4 * Var(S) approximates |Z| > 2 (~95% confidence two-sided). 159 if s_sq <= 4 * vs { return NX_MK_TREND_NONE } 160 if m.s > 0 { return NX_MK_TREND_UP } 161 return NX_MK_TREND_DOWN 162} 163 164// === introspection ================================================ 165 166func nx_mk_s(m: *MannKendall) -> i64 { return m.s } 167func nx_mk_count(m: *MannKendall) -> i64 { return m.count } 168 169func nx_mk_query(m: *MannKendall) -> *ApproxI64 { 170 let v: i64 = nx_mk_verdict(m) 171 return nx_approx_new(v, NX_ENV_ABS, 0, 950000000, 172 NX_MATURITY_PRODUCTION, 173 NX_ADV_HONEST) 174} 175 176func nx_mk_memory_bytes(m: *MannKendall) -> i64 { 177 return 48 + m.window * 8 178}