nx_sketch_zscore.nx source
↩ module page · 134 lines · 4364 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
28// nx_safety_envelope:
29// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
30// sil_target: SIL1
31// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
32// verdict: NOT_YET_EVALUATED
33
34import "nx_syscalls.nx"
35import "nx_sketch_stream_stats.nx"
36import "nx_sketch_types.nx"
37
38const NX_ZS_VERDICT_NORMAL: i64 = 0
39const NX_ZS_VERDICT_HIGH: i64 = 1
40const NX_ZS_VERDICT_LOW: i64 = 2
41
42const NX_ZS_MIN_COUNT_DEFAULT: i64 = 30
43
44struct ZScore {
45 stats: *StreamStats,
46 threshold_ppm: i64, // |z| > threshold_ppm/1e6 triggers alarm
47 min_count: i64,
48 n_alarms: i64,
49}
50
51// === construction =================================================
52//
53// threshold_ppm: e.g. 2_000_000 = z >= 2.0
54// min_count: warm-up samples before alarms fire (default 30 if 0).
55
56func nx_zs_alloc(threshold_ppm: i64, min_count: i64) -> *ZScore {
57 if threshold_ppm <= 0 { return 0 as *ZScore }
58 let stats: *StreamStats = nx_stats_alloc()
59 if stats == (0 as *StreamStats) { return 0 as *ZScore }
60 let raw: *u8 = sys_mmap(40)
61 let z: *ZScore = raw as *ZScore
62 z.stats = stats
63 z.threshold_ppm = threshold_ppm
64 if min_count <= 0 {
65 z.min_count = NX_ZS_MIN_COUNT_DEFAULT
66 }
67 if min_count > 0 {
68 z.min_count = min_count
69 }
70 z.n_alarms = 0
71 return z
72}
73
74// === observe =====================================================
75//
76// Compute z BEFORE updating stats (so the score reflects deviation
77// from the prior baseline rather than including the sample itself).
78// Caller can choose to call nx_zs_score_then_update vs separate
79// observe/score patterns; this primitive does observe-after-score.
80
81func nx_zs_score_ppm(z: *ZScore, x: i64) -> i64 {
82 if nx_stats_count(z.stats) < z.min_count { return 0 }
83 let mean: i64 = nx_stats_mean(z.stats)
84 let var_val: i64 = nx_stats_variance(z.stats)
85 if var_val <= 0 { return 0 }
86 let stddev: i64 = nx_stats_isqrt(var_val)
87 if stddev == 0 { return 0 }
88 return ((x - mean) * 1000000) / stddev
89}
90
91func nx_zs_verdict(z: *ZScore, x: i64) -> i64 {
92 let zppm: i64 = nx_zs_score_ppm(z, x)
93 if zppm >= z.threshold_ppm { return NX_ZS_VERDICT_HIGH }
94 if zppm <= -z.threshold_ppm { return NX_ZS_VERDICT_LOW }
95 return NX_ZS_VERDICT_NORMAL
96}
97
98// Full pipeline: score, classify, then incorporate sample into baseline.
99// Returns the verdict.
100func nx_zs_step(z: *ZScore, x: i64) -> i64 {
101 let v: i64 = nx_zs_verdict(z, x)
102 if v != NX_ZS_VERDICT_NORMAL { z.n_alarms = z.n_alarms + 1 }
103 nx_stats_add(z.stats, x)
104 return v
105}
106
107// === introspection ================================================
108
109func nx_zs_count(z: *ZScore) -> i64 {
110 return nx_stats_count(z.stats)
111}
112
113func nx_zs_mean(z: *ZScore) -> i64 {
114 return nx_stats_mean(z.stats)
115}
116
117func nx_zs_stddev(z: *ZScore) -> i64 {
118 return nx_stats_stddev(z.stats)
119}
120
121func nx_zs_n_alarms(z: *ZScore) -> i64 {
122 return z.n_alarms
123}
124
125func nx_zs_query(z: *ZScore, x: i64) -> *ApproxI64 {
126 let v: i64 = nx_zs_verdict(z, x)
127 return nx_approx_new(v, NX_ENV_ABS, 0, 1000000000,
128 NX_MATURITY_PRODUCTION,
129 NX_ADV_HONEST)
130}
131
132func nx_zs_memory_bytes(z: *ZScore) -> i64 {
133 return 40 + nx_stats_memory_bytes(z.stats)
134}