nx_batch_audit_sidecar.nx source
↩ module page · 265 lines · 9725 B
1// nx_batch_audit_sidecar.nx -- READ-ONLY observability sidecar for
2// live-fire batch monitoring.
3//
4// Architecture: This is "Step C" of the surgical 3-step wedge.
5// Steps A (nx_world_state) and B (nx_trace_emit) are the foundation;
6// this sidecar threads them. It does NOT change picks, prompts, or
7// renders -- it observes the existing system's decisions and emits
8// trace spans + continuity verdicts so the user can SEE what's
9// happening instead of staring at a black box.
10//
11// Assumed input schema (one event per JSONL line; production host
12// converts to the in-memory AuditEvent struct via a thin reader,
13// can be NishiLang nx_json parser or Python boundary code):
14//
15// {"batch_id":42,"scene_seq":3,"trace_id":100,
16// "character_id":7,"arc_id":10,"stage_idx":1,
17// "outfit_id":200,"location_id":101,"pose_id":300,
18// "affect_axis":8,"camera_id":500,
19// "day_clock_min":420,"beat_armed":3}
20//
21// "beat_armed" maps to NX_WS_BEAT_* and tells the sidecar whether
22// the existing picker already authorised an outfit/location change
23// at this step. Omitting it -> NX_WS_BEAT_NONE -> any change flags
24// a continuity violation.
25//
26// Pool-utilization tracking: the sidecar maintains parallel
27// histograms (outfit_pool, pose_pool, location_pool, arc_pool) so
28// at batch close it can name the under-sampled IDs.
29//
30// genealogy_id: canvas_qa_verification + opentelemetry_traces +
31// helicone_2025_proxy_observability
32// lineage_id: batch_audit_sidecar_v1
33
34// nx_safety_envelope:
35// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
36// sil_target: SIL1
37// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
38// verdict: NOT_YET_EVALUATED
39
40import "nx_syscalls.nx"
41import "nx_runtime.nx"
42import "nx_tier.nx"
43import "nx_directors_note.nx"
44import "nx_world_state.nx"
45import "nx_trace_emit.nx"
46const NX_MAGIC_1024: i64 = 1024
47
48// ===== AuditEvent struct ==========================================
49
50struct AuditEvent {
51 batch_id: nx_int,
52 scene_seq: nx_int,
53 trace_id: nx_int,
54 character_id: nx_int,
55 arc_id: nx_int,
56 stage_idx: nx_int,
57 outfit_id: nx_int,
58 location_id: nx_int,
59 pose_id: nx_int,
60 affect_axis: nx_int,
61 camera_id: nx_int,
62 day_clock_min: nx_int,
63 beat_armed: nx_int
64}
65
66const NX_AE_BYTES: nx_int = 104 // 13 fields * 8
67
68func nx_audit_event_alloc() -> *AuditEvent {
69 let e: *AuditEvent = (sys_mmap(NX_AE_BYTES)) as *AuditEvent
70 e.batch_id = 0
71 e.scene_seq = 0
72 e.trace_id = 0
73 e.character_id = 0
74 e.arc_id = 0
75 e.stage_idx = 0
76 e.outfit_id = 0
77 e.location_id = 0
78 e.pose_id = 0
79 e.affect_axis = NX_AF_NEUTRAL
80 e.camera_id = 0
81 e.day_clock_min = 0
82 e.beat_armed = NX_WS_BEAT_NONE
83 return e
84}
85
86// ===== Pool-utilization histogram =================================
87//
88// Open-addressed [id -> count] table per axis. Caller supplies one
89// table per pool kind (outfit / pose / location / arc / camera /
90// affect); we treat all uniformly. Cap must be power of two.
91
92const NX_POOL_EMPTY_KEY: nx_int = 0
93
94func nx_pool_hist_init(keys: *i64, counts: *i64, cap: nx_int) -> nx_int {
95 var i: nx_int = 0
96 while i < cap {
97 keys[i] = NX_POOL_EMPTY_KEY
98 counts[i] = 0
99 i = i + 1
100 }
101 return 0
102}
103
104func nx_pool_hist_bump(keys: *i64, counts: *i64, cap: nx_int,
105 id: nx_int) -> nx_int {
106 if id == NX_POOL_EMPTY_KEY { return 0 }
107 let mask: nx_int = cap - 1
108 var slot: nx_int = id & mask
109 if slot < 0 { slot = slot + cap }
110 var placed: nx_int = 0
111 while placed == 0 {
112 if keys[slot] == NX_POOL_EMPTY_KEY {
113 keys[slot] = id
114 counts[slot] = 1
115 placed = 1
116 }
117 if placed == 0 {
118 if keys[slot] == id {
119 counts[slot] = counts[slot] + 1
120 placed = 1
121 }
122 }
123 if placed == 0 {
124 slot = (slot + 1) & mask
125 if slot < 0 { slot = slot + cap }
126 }
127 }
128 return 0
129}
130
131func nx_pool_hist_distinct(keys: *i64, cap: nx_int) -> nx_int {
132 var n: nx_int = 0
133 var i: nx_int = 0
134 while i < cap {
135 if keys[i] != NX_POOL_EMPTY_KEY { n = n + 1 }
136 i = i + 1
137 }
138 return n
139}
140
141// ===== Sealed-enum: utilization band ==============================
142
143const NX_UTIL_EMPTY: nx_int = 0 // nothing picked
144const NX_UTIL_SEVERE_BIAS: nx_int = 1 // one id dominates >75%
145const NX_UTIL_MILD_BIAS: nx_int = 2 // 50%-75% from one id
146const NX_UTIL_UNIFORM: nx_int = 3 // close to uniform
147const NX_UTIL_N_BANDS: nx_int = 4
148
149// Classify based on max-share. Q10.
150func nx_util_classify_q10(max_share_q10: nx_int, total: nx_int) -> nx_int {
151 if total <= 0 { return NX_UTIL_EMPTY }
152 if max_share_q10 >= 768 { return NX_UTIL_SEVERE_BIAS } // >= 75%
153 if max_share_q10 >= 512 { return NX_UTIL_MILD_BIAS } // >= 50%
154 return NX_UTIL_UNIFORM
155}
156
157func nx_pool_hist_max_share_q10(counts: *i64, cap: nx_int,
158 total_out: *i64) -> nx_int {
159 var total: nx_int = 0
160 var max_c: nx_int = 0
161 var i: nx_int = 0
162 while i < cap {
163 let c: nx_int = counts[i]
164 total = total + c
165 if c > max_c { max_c = c }
166 i = i + 1
167 }
168 total_out[0] = total
169 if total <= 0 { return 0 }
170 return (max_c * NX_MAGIC_1024) / total
171}
172
173// ===== Event processing ===========================================
174//
175// One event -> apply to world_state -> run continuity_check -> emit
176// trace span(s) -> bump pool histograms. No prompt or pick changes.
177
178func nx_audit_process_event(cfg: *TraceCfg, ws: *WorldState,
179 evt: *AuditEvent,
180 outfit_keys: *i64, outfit_counts: *i64, outfit_cap: nx_int,
181 pose_keys: *i64, pose_counts: *i64, pose_cap: nx_int) -> nx_int {
182 // Materialise the event as a DirectorsNote so continuity_check
183 // works without a separate code path.
184 let dn: *DirectorsNote = nx_dn_alloc()
185 dn.character_id = evt.character_id
186 dn.body_archetype_id = 1 // unknown from event; sidecar doesn't model archetype
187 dn.outfit_key_id = evt.outfit_id
188 dn.location_id = evt.location_id
189 dn.pose_id = evt.pose_id
190 dn.affect_axis = evt.affect_axis
191 dn.camera_motivation_id = evt.camera_id
192 dn.activity_id = evt.pose_id // proxy until activity_id lands on event schema
193
194 // Apply armed beat (whatever the live picker said it authorised)
195 nx_world_arm_beat(ws, evt.beat_armed)
196
197 // Continuity check
198 let verdict: nx_int = nx_continuity_check(ws, dn)
199
200 // Emit per-event span: continuity verdict as the attribute.
201 let evt_span: nx_int = nx_trace_mint_span_id(cfg)
202 nx_trace_emit_one_attr(cfg, evt.trace_id, evt_span,
203 0 - 1, NX_SPAN_CONTINUITY_CHECK,
204 evt.scene_seq * 100,
205 evt.scene_seq * 100 + 1,
206 0xCC07, verdict)
207
208 // Commit ONLY if verdict was consistent -- otherwise the world
209 // state would chase a known-bad transition and propagate the
210 // breakage downstream. Pool bumps still fire either way because
211 // the live system DID pick those IDs.
212 if verdict == NX_CONT_CONSISTENT {
213 nx_world_commit_scene(ws, dn, evt.day_clock_min)
214 }
215
216 // Pool histograms
217 nx_pool_hist_bump(outfit_keys, outfit_counts, outfit_cap, evt.outfit_id)
218 nx_pool_hist_bump(pose_keys, pose_counts, pose_cap, evt.pose_id)
219
220 return verdict
221}
222
223// ===== Batch close: emit summary span =============================
224//
225// Aggregates pool histograms into utilization bands and emits a
226// single summary span the user (or downstream dashboard) can read.
227//
228// outfit_attr_key = FNV-style key hash for the "outfit_util_band"
229// attribute (caller-supplied so names are reproducible across runs).
230
231func nx_audit_emit_batch_summary(cfg: *TraceCfg, trace_id: nx_int,
232 parent: nx_int,
233 n_scenes: nx_int, n_violations: nx_int,
234 outfit_keys: *i64, outfit_counts: *i64, outfit_cap: nx_int,
235 pose_keys: *i64, pose_counts: *i64, pose_cap: nx_int) -> nx_int {
236 let outfit_total: *i64 = (sys_mmap(8)) as *i64
237 let pose_total: *i64 = (sys_mmap(8)) as *i64
238 let outfit_share: nx_int = nx_pool_hist_max_share_q10(outfit_counts, outfit_cap, outfit_total)
239 let pose_share: nx_int = nx_pool_hist_max_share_q10(pose_counts, pose_cap, pose_total)
240
241 let outfit_band: nx_int = nx_util_classify_q10(outfit_share, outfit_total[0])
242 let pose_band: nx_int = nx_util_classify_q10(pose_share, pose_total[0])
243
244 let outfit_distinct: nx_int = nx_pool_hist_distinct(outfit_keys, outfit_cap)
245 let pose_distinct: nx_int = nx_pool_hist_distinct(pose_keys, pose_cap)
246
247 let attrs: *i64 = (sys_mmap(96)) as *i64
248 attrs[0] = 0x5C01 // "n_scenes"
249 attrs[1] = n_scenes
250 attrs[2] = 0xCC07 // "n_violations"
251 attrs[3] = n_violations
252 attrs[4] = 0x0FBA // "outfit_band"
253 attrs[5] = outfit_band
254 attrs[6] = 0xFBD1 // "outfit_distinct"
255 attrs[7] = outfit_distinct
256 attrs[8] = 0xB05E // "pose_band"
257 attrs[9] = pose_band
258 attrs[10] = 0xBD15 // "pose_distinct"
259 attrs[11] = pose_distinct
260
261 let close_span: nx_int = nx_trace_mint_span_id(cfg)
262 return nx_trace_emit_span(cfg, trace_id, close_span, parent,
263 NX_SPAN_BATCH_CLOSE, 0, n_scenes * 100,
264 attrs, 6)
265}