sketch_zscore.nx source
↩ module page · 128 lines · 4232 B
1// sketch_zscore.nx -- point z-score anomaly detector.
2//
3// Composes against sketch_stream_stats (running mean + stddev) to flag
4// individual samples deviating from the streaming baseline:
5//
6// z = (x - mean) / stddev
7// alarm if |z| > threshold (typical 2.0 ~ 95% conf, 3.0 ~ 99.7%)
8//
9// CAPABILITY:
10// - O(1) state (mean + variance from StreamStats)
11// - online: baseline updates with every sample
12// - Returns Z in PPM ([-1e10, +1e10] practical range)
13// - Sealed verdict: NORMAL / HIGH / LOW
14//
15// COMPLEMENTS THE ANOMALY FAMILY:
16// - sketch_cusum cumulative shift over a window
17// - sketch_mann_kendall monotonic trend test
18// - sketch_zscore (this) point outlier vs running baseline
19//
20// CONFIGURATION:
21// "warm-up" period: do not raise alarms until count >= min_count.
22// Default: min_count = 30 (statistician's rule-of-thumb for CLT).
23//
24// LOSSLESS-LANGUAGE DISCIPLINE: returns z in PPM with NX_ENV_ABS,
25// param_a = 1 (PPM quantization). Production tier; exact integer
26// arithmetic over the running statistics.
27
28import "syscalls.nx"
29import "sketch_stream_stats.nx"
30import "sketch_types.nx"
31
32const NX_ZS_VERDICT_NORMAL: i64 = 0
33const NX_ZS_VERDICT_HIGH: i64 = 1
34const NX_ZS_VERDICT_LOW: i64 = 2
35
36const NX_ZS_MIN_COUNT_DEFAULT: i64 = 30
37
38struct ZScore {
39 stats: *StreamStats,
40 threshold_ppm: i64, // |z| > threshold_ppm/1e6 triggers alarm
41 min_count: i64,
42 n_alarms: i64,
43}
44
45// === construction =================================================
46//
47// threshold_ppm: e.g. 2_000_000 = z >= 2.0
48// min_count: warm-up samples before alarms fire (default 30 if 0).
49
50func nx_zs_alloc(threshold_ppm: i64, min_count: i64) -> *ZScore {
51 if threshold_ppm <= 0 { return 0 as *ZScore }
52 let stats: *StreamStats = nx_stats_alloc()
53 if stats == (0 as *StreamStats) { return 0 as *ZScore }
54 let raw: *u8 = sys_mmap(40)
55 let z: *ZScore = raw as *ZScore
56 z.stats = stats
57 z.threshold_ppm = threshold_ppm
58 if min_count <= 0 {
59 z.min_count = NX_ZS_MIN_COUNT_DEFAULT
60 }
61 if min_count > 0 {
62 z.min_count = min_count
63 }
64 z.n_alarms = 0
65 return z
66}
67
68// === observe =====================================================
69//
70// Compute z BEFORE updating stats (so the score reflects deviation
71// from the prior baseline rather than including the sample itself).
72// Caller can choose to call nx_zs_score_then_update vs separate
73// observe/score patterns; this primitive does observe-after-score.
74
75func nx_zs_score_ppm(z: *ZScore, x: i64) -> i64 {
76 if nx_stats_count(z.stats) < z.min_count { return 0 }
77 let mean: i64 = nx_stats_mean(z.stats)
78 let var_val: i64 = nx_stats_variance(z.stats)
79 if var_val <= 0 { return 0 }
80 let stddev: i64 = nx_stats_isqrt(var_val)
81 if stddev == 0 { return 0 }
82 return ((x - mean) * 1000000) / stddev
83}
84
85func nx_zs_verdict(z: *ZScore, x: i64) -> i64 {
86 let zppm: i64 = nx_zs_score_ppm(z, x)
87 if zppm >= z.threshold_ppm { return NX_ZS_VERDICT_HIGH }
88 if zppm <= -z.threshold_ppm { return NX_ZS_VERDICT_LOW }
89 return NX_ZS_VERDICT_NORMAL
90}
91
92// Full pipeline: score, classify, then incorporate sample into baseline.
93// Returns the verdict.
94func nx_zs_step(z: *ZScore, x: i64) -> i64 {
95 let v: i64 = nx_zs_verdict(z, x)
96 if v != NX_ZS_VERDICT_NORMAL { z.n_alarms = z.n_alarms + 1 }
97 nx_stats_add(z.stats, x)
98 return v
99}
100
101// === introspection ================================================
102
103func nx_zs_count(z: *ZScore) -> i64 {
104 return nx_stats_count(z.stats)
105}
106
107func nx_zs_mean(z: *ZScore) -> i64 {
108 return nx_stats_mean(z.stats)
109}
110
111func nx_zs_stddev(z: *ZScore) -> i64 {
112 return nx_stats_stddev(z.stats)
113}
114
115func nx_zs_n_alarms(z: *ZScore) -> i64 {
116 return z.n_alarms
117}
118
119func nx_zs_query(z: *ZScore, x: i64) -> *ApproxI64 {
120 let v: i64 = nx_zs_verdict(z, x)
121 return nx_approx_new(v, NX_ENV_ABS, 0, 1000000000,
122 NX_MATURITY_PRODUCTION,
123 NX_ADV_HONEST)
124}
125
126func nx_zs_memory_bytes(z: *ZScore) -> i64 {
127 return 40 + nx_stats_memory_bytes(z.stats)
128}