nx_content_guard.nx source
↩ module page · 476 lines · 17247 B
1// nx_content_guard.nx -- extensible content-policy detector.
2//
3// Runs between scene-index build and caption emit. For each
4// SceneCandidate, scans the source span for content-policy
5// signals and writes a flags bitset back into the candidate.
6// caption_emit checks those flags and refuses CONFIRMED categories,
7// emits SUSPECT categories with a warning header.
8//
9// Default categories (V1):
10// NTR_SUSPECT -- multiple male active referents present
11// NTR_CONFIRMED -- NTR-coded phrasing detected ("her husband",
12// "cuckold", "watched him with", etc.)
13// MULTI_MALE_ACTIVE -- >= 2 distinct male pronoun groups doing verbs
14// OBSERVER_PASSIVE -- the user/observer pronouns are predominantly
15// passive verbs (watching, observing); the
16// user spec is DOMINANT ACTOR not watcher
17//
18// Future categories (sealed enum extensible append-only):
19// MINOR_INDICATOR -- age signals below threshold
20// NONCON_INDICATOR -- non-consent phrasing without resolution
21// GORE_INDICATOR -- graphic violence
22//
23// Per cardinal feedback-build-intelligence-never-strip-features:
24// the guard SURFACES + ATTRIBUTES; it does not silently drop
25// content. The user reviews flagged candidates and pins / rejects.
26// Caption emit only refuses CONFIRMED -- everything else is opt-in.
27//
28// Per cardinal feedback-user-owns-every-bit: thresholds + cue
29// lists are caller-configurable. No hard-coded magic that the
30// user can't audit.
31//
32// nx_safety_envelope:
33// intended_use: "Scan each SceneCandidate's source span for
34// content-policy signals (NTR / multi-male-
35// active / observer-passive / etc.) and
36// attach flag bits to the candidate."
37// sil_target: SIL2
38// asil_target: QM
39// dal_target: DAL C
40// iec_62304_class: NONE
41// evidence: [no_floating_point,
42// sealed_content_category_enum,
43// per_category_cue_lists_static_but_caller_extendable,
44// flag_bits_additive_never_renumbered,
45// refusal_only_on_confirmed_per_caption_emit]
46// hazard_register: [bug-tape-cue-pattern-false-positive,
47// bug-tape-cue-list-incomplete-misses-class,
48// bug-tape-flag-bit-collision-across-versions]
49// residual_risk: "Heuristic detection has both false positives
50// and false negatives. Per the user-supervised
51// cardinal, every CONFIRMED refusal logs the
52// cue that matched so the user can validate
53// and override if needed."
54// verdict: NOT_YET_EVALUATED
55
56import "nx_syscalls.nx"
57import "nx_storybeat.nx"
58import "nx_storydb.nx"
59import "nx_media_pool.nx"
60import "nx_scene_index.nx"
61import "nx_phrase_match.nx"
62
63// ===== sealed content categories ==================================
64//
65// One-bit-per-category in SceneCandidate.flags. Bit 0 is
66// USER_PINNED (existing). Append-only.
67
68const NX_CG_FLAG_USER_PINNED: i64 = 1 // bit 0 (existing)
69const NX_CG_FLAG_NTR_SUSPECT: i64 = 2 // bit 1
70const NX_CG_FLAG_NTR_CONFIRMED: i64 = 4 // bit 2
71const NX_CG_FLAG_MULTI_MALE_ACTIVE: i64 = 8 // bit 3
72const NX_CG_FLAG_OBSERVER_PASSIVE: i64 = 16 // bit 4
73
74// ===== sealed verdicts ============================================
75
76const NX_CG_VERDICT_NONE: i64 = 0
77const NX_CG_VERDICT_SUSPECT: i64 = 1
78const NX_CG_VERDICT_CONFIRMED: i64 = 2
79
80// ===== byte helpers ===============================================
81
82func nx_cg_load_u8(p: *u8, i: i64) -> i64 {
83 let q: *u8 = ((p as i64) + i) as *u8
84 return *q
85}
86
87// ===== NTR-coded phrase table =====================================
88//
89// Hard-confirmed NTR signals. Hit any of these in the source span
90// and the candidate gets NTR_CONFIRMED.
91//
92// All case-insensitive via nx_phrase_match.
93
94const NX_CG_NTR_CUE_COUNT: i64 = 14
95
96func nx_cg_ntr_cue_at(idx: i64) -> *u8 {
97 if idx == 0 { return "her husband" as *u8 }
98 if idx == 1 { return "her boyfriend" as *u8 }
99 if idx == 2 { return "her lover" as *u8 }
100 if idx == 3 { return "her partner" as *u8 }
101 if idx == 4 { return "her fiance" as *u8 }
102 if idx == 5 { return "her ex" as *u8 }
103 if idx == 6 { return "cuckold" as *u8 }
104 if idx == 7 { return "cucked" as *u8 }
105 if idx == 8 { return "another man" as *u8 }
106 if idx == 9 { return "the other man" as *u8 }
107 if idx == 10 { return "watched him with" as *u8 }
108 if idx == 11 { return "watched them" as *u8 }
109 if idx == 12 { return "ntr" as *u8 }
110 if idx == 13 { return "netorare" as *u8 }
111 return 0 as *u8
112}
113
114func nx_cg_ntr_cue_len(idx: i64) -> i64 {
115 if idx == 0 { return 11 } // "her husband"
116 if idx == 1 { return 13 } // "her boyfriend"
117 if idx == 2 { return 9 } // "her lover"
118 if idx == 3 { return 11 } // "her partner"
119 if idx == 4 { return 9 } // "her fiance"
120 if idx == 5 { return 6 } // "her ex"
121 if idx == 6 { return 7 } // "cuckold"
122 if idx == 7 { return 6 } // "cucked"
123 if idx == 8 { return 11 } // "another man"
124 if idx == 9 { return 13 } // "the other man"
125 if idx == 10 { return 15 } // "watched him with"
126 if idx == 11 { return 12 } // "watched them"
127 if idx == 12 { return 3 } // "ntr"
128 if idx == 13 { return 8 } // "netorare"
129 return 0
130}
131
132// ===== passive-observer cue table =================================
133//
134// Phrases where the user/observer is in a SPECTATING role rather
135// than acting. Suspect, not confirmed.
136
137const NX_CG_OBS_PASSIVE_CUE_COUNT: i64 = 6
138
139func nx_cg_obs_passive_cue_at(idx: i64) -> *u8 {
140 if idx == 0 { return "i watched" as *u8 }
141 if idx == 1 { return "i observed" as *u8 }
142 if idx == 2 { return "i stood there" as *u8 }
143 if idx == 3 { return "i stared as" as *u8 }
144 if idx == 4 { return "i couldn't" as *u8 }
145 if idx == 5 { return "from behind" as *u8 }
146 return 0 as *u8
147}
148
149func nx_cg_obs_passive_cue_len(idx: i64) -> i64 {
150 if idx == 0 { return 9 } // "i watched"
151 if idx == 1 { return 10 } // "i observed"
152 if idx == 2 { return 13 } // "i stood there"
153 if idx == 3 { return 11 } // "i stared as"
154 if idx == 4 { return 9 } // "i couldn't"
155 if idx == 5 { return 11 } // "from behind"
156 return 0
157}
158
159// ===== male-active-pronoun count ==================================
160//
161// Count distinct male-actor pronoun GROUPS doing active verbs. V1
162// approximation: count occurrences of "he" with a word boundary
163// followed by an active verb form. Real co-reference resolution
164// is V2.
165//
166// If count >= 2 within a single span, flag MULTI_MALE_ACTIVE.
167
168const NX_CG_MALE_VERB_PAIR_COUNT: i64 = 8
169
170func nx_cg_male_verb_pair_at(idx: i64) -> *u8 {
171 if idx == 0 { return "he walked" as *u8 }
172 if idx == 1 { return "he stepped" as *u8 }
173 if idx == 2 { return "he stood" as *u8 }
174 if idx == 3 { return "he took" as *u8 }
175 if idx == 4 { return "he grabbed" as *u8 }
176 if idx == 5 { return "he pulled" as *u8 }
177 if idx == 6 { return "he pushed" as *u8 }
178 if idx == 7 { return "he kissed" as *u8 }
179 return 0 as *u8
180}
181
182func nx_cg_male_verb_pair_len(idx: i64) -> i64 {
183 if idx == 0 { return 9 } // "he walked"
184 if idx == 1 { return 10 } // "he stepped"
185 if idx == 2 { return 8 } // "he stood"
186 if idx == 3 { return 7 } // "he took"
187 if idx == 4 { return 10 } // "he grabbed"
188 if idx == 5 { return 9 } // "he pulled"
189 if idx == 6 { return 9 } // "he pushed"
190 if idx == 7 { return 9 } // "he kissed"
191 return 0
192}
193
194// ===== span scanners ==============================================
195
196func nx_cg_scan_ntr(src: *u8, start: i64, end: i64) -> i64 {
197 let nh: i64 = end - start
198 let hay: *u8 = ((src as i64) + start) as *u8
199 var hits: i64 = 0
200 let BUDGET: i64 = NX_CG_NTR_CUE_COUNT + 1
201 var iter: i64 = 0
202 var i: i64 = 0
203 while i < NX_CG_NTR_CUE_COUNT {
204 if iter >= BUDGET { i = NX_CG_NTR_CUE_COUNT }
205 if i < NX_CG_NTR_CUE_COUNT {
206 let cue: *u8 = nx_cg_ntr_cue_at(i)
207 let clen: i64 = nx_cg_ntr_cue_len(i)
208 if nx_phrase_find_ci(hay, nh, cue, clen, 1) >= 0 {
209 hits = hits + 1
210 }
211 i = i + 1
212 }
213 iter = iter + 1
214 }
215 return hits
216}
217
218func nx_cg_scan_male_active(src: *u8, start: i64, end: i64) -> i64 {
219 let nh: i64 = end - start
220 let hay: *u8 = ((src as i64) + start) as *u8
221 var hits: i64 = 0
222 let BUDGET: i64 = NX_CG_MALE_VERB_PAIR_COUNT + 1
223 var iter: i64 = 0
224 var i: i64 = 0
225 while i < NX_CG_MALE_VERB_PAIR_COUNT {
226 if iter >= BUDGET { i = NX_CG_MALE_VERB_PAIR_COUNT }
227 if i < NX_CG_MALE_VERB_PAIR_COUNT {
228 let cue: *u8 = nx_cg_male_verb_pair_at(i)
229 let clen: i64 = nx_cg_male_verb_pair_len(i)
230 if nx_phrase_find_ci(hay, nh, cue, clen, 1) >= 0 {
231 hits = hits + 1
232 }
233 i = i + 1
234 }
235 iter = iter + 1
236 }
237 return hits
238}
239
240func nx_cg_scan_observer_passive(src: *u8, start: i64, end: i64) -> i64 {
241 let nh: i64 = end - start
242 let hay: *u8 = ((src as i64) + start) as *u8
243 var hits: i64 = 0
244 let BUDGET: i64 = NX_CG_OBS_PASSIVE_CUE_COUNT + 1
245 var iter: i64 = 0
246 var i: i64 = 0
247 while i < NX_CG_OBS_PASSIVE_CUE_COUNT {
248 if iter >= BUDGET { i = NX_CG_OBS_PASSIVE_CUE_COUNT }
249 if i < NX_CG_OBS_PASSIVE_CUE_COUNT {
250 let cue: *u8 = nx_cg_obs_passive_cue_at(i)
251 let clen: i64 = nx_cg_obs_passive_cue_len(i)
252 if nx_phrase_find_ci(hay, nh, cue, clen, 1) >= 0 {
253 hits = hits + 1
254 }
255 i = i + 1
256 }
257 iter = iter + 1
258 }
259 return hits
260}
261
262// ===== per-candidate scan =========================================
263//
264// Returns the flag-bits that should be OR-ed into the candidate's
265// flags field.
266
267func nx_cg_scan_candidate(db: *StoryDb, cand: *SceneCandidate) -> i64 {
268 var src: *u8 = 0 as *u8
269 var nbytes: i64 = 0
270 if nx_media_pool_get(db.media_pool, cand.source_media,
271 &src, &nbytes) != 0 { return 0 }
272 if cand.source_start < 0 { return 0 }
273 if cand.source_end > nbytes { return 0 }
274 if cand.source_end <= cand.source_start { return 0 }
275
276 var flags: i64 = 0
277
278 // NTR detection.
279 let ntr_hits: i64 = nx_cg_scan_ntr(src, cand.source_start, cand.source_end)
280 if ntr_hits >= 1 {
281 flags = flags | NX_CG_FLAG_NTR_CONFIRMED
282 }
283
284 // Multi-male-active.
285 let male_hits: i64 = nx_cg_scan_male_active(src, cand.source_start,
286 cand.source_end)
287 if male_hits >= 2 {
288 flags = flags | NX_CG_FLAG_MULTI_MALE_ACTIVE
289 // Two distinct male actors raise NTR_SUSPECT even without an
290 // explicit "her husband"-class confirmed cue.
291 flags = flags | NX_CG_FLAG_NTR_SUSPECT
292 }
293
294 // Observer-passive.
295 let passive_hits: i64 = nx_cg_scan_observer_passive(src,
296 cand.source_start,
297 cand.source_end)
298 if passive_hits >= 1 {
299 flags = flags | NX_CG_FLAG_OBSERVER_PASSIVE
300 }
301
302 return flags
303}
304
305// ===== whole-buffer guard scan ====================================
306//
307// Runs the same per-category detectors against an arbitrary byte
308// range (not a SceneCandidate). Returns the flag bitset. Used by
309// CLI drivers that want a top-level refusal before even building
310// the scene index.
311
312func nx_content_guard_scan_bytes(src: *u8, n: i64) -> i64 {
313 if n <= 0 { return 0 }
314 var flags: i64 = 0
315 let ntr_hits: i64 = nx_cg_scan_ntr(src, 0, n)
316 if ntr_hits >= 1 { flags = flags | NX_CG_FLAG_NTR_CONFIRMED }
317 let male_hits: i64 = nx_cg_scan_male_active(src, 0, n)
318 if male_hits >= 2 {
319 flags = flags | NX_CG_FLAG_MULTI_MALE_ACTIVE
320 flags = flags | NX_CG_FLAG_NTR_SUSPECT
321 }
322 let passive_hits: i64 = nx_cg_scan_observer_passive(src, 0, n)
323 if passive_hits >= 1 { flags = flags | NX_CG_FLAG_OBSERVER_PASSIVE }
324 return flags
325}
326
327// ===== batch: scan every candidate in an index ====================
328//
329// Walks idx.candidates, scans each, OR-s discovered flags into the
330// candidate's existing flags. Returns count of candidates that
331// gained at least one new flag bit.
332
333func nx_content_guard_scan_index(idx: *SceneIndex, db: *StoryDb) -> i64 {
334 var changed: i64 = 0
335 let BUDGET: i64 = idx.n_candidates + 1
336 var iter: i64 = 0
337 var i: i64 = 0
338 while i < idx.n_candidates {
339 if iter >= BUDGET { i = idx.n_candidates }
340 if i < idx.n_candidates {
341 let c: *SceneCandidate = nx_scene_candidate_at(idx, i)
342 let before: i64 = c.flags
343 let add: i64 = nx_cg_scan_candidate(db, c)
344 if add != 0 {
345 c.flags = c.flags | add
346 if c.flags != before { changed = changed + 1 }
347 }
348 i = i + 1
349 }
350 iter = iter + 1
351 }
352 return changed
353}
354
355// ===== verdict helper =============================================
356//
357// Maps a candidate's flags to a coarse VERDICT for caption_emit.
358
359func nx_content_guard_verdict(flags: i64) -> i64 {
360 if (flags & NX_CG_FLAG_NTR_CONFIRMED) != 0 {
361 return NX_CG_VERDICT_CONFIRMED
362 }
363 if (flags & NX_CG_FLAG_NTR_SUSPECT) != 0 {
364 return NX_CG_VERDICT_SUSPECT
365 }
366 if (flags & NX_CG_FLAG_MULTI_MALE_ACTIVE) != 0 {
367 return NX_CG_VERDICT_SUSPECT
368 }
369 if (flags & NX_CG_FLAG_OBSERVER_PASSIVE) != 0 {
370 return NX_CG_VERDICT_SUSPECT
371 }
372 return NX_CG_VERDICT_NONE
373}
374
375// ===== guard wrapper =============================================
376//
377// Returns explanation bytes the caller emits IN PLACE OF a caption
378// when verdict == CONFIRMED, or AS A HEADER prepended to the caption
379// when verdict == SUSPECT. When verdict == NONE returns empty.
380//
381// Used by caption-emit callers to render policy decisions inline
382// without modifying the caption_emit primitive itself.
383
384func nx_content_guard_emit_explain(flags: i64,
385 dst: *u8, dst_cap: i64) -> i64 {
386 let verdict: i64 = nx_content_guard_verdict(flags)
387 if verdict == NX_CG_VERDICT_NONE { return 0 }
388
389 var pos: i64 = 0
390 let prefix: *u8 = "# nx_content_guard:" as *u8
391
392 var i: i64 = 0
393 while i < 19 {
394 if pos < dst_cap {
395 let bp: *u8 = ((prefix as i64) + i) as *u8
396 let dp: *u8 = ((dst as i64) + pos) as *u8
397 *dp = *bp
398 pos = pos + 1
399 }
400 i = i + 1
401 }
402
403 if verdict == NX_CG_VERDICT_CONFIRMED {
404 let s1: *u8 = " REFUSED: NTR_CONFIRMED" as *u8
405 var k: i64 = 0
406 while k < 23 {
407 if pos < dst_cap {
408 let bp: *u8 = ((s1 as i64) + k) as *u8
409 let dp: *u8 = ((dst as i64) + pos) as *u8
410 *dp = *bp
411 pos = pos + 1
412 }
413 k = k + 1
414 }
415 }
416
417 if verdict == NX_CG_VERDICT_SUSPECT {
418 let s2: *u8 = " WARNING: " as *u8
419 var k2: i64 = 0
420 while k2 < 10 {
421 if pos < dst_cap {
422 let bp: *u8 = ((s2 as i64) + k2) as *u8
423 let dp: *u8 = ((dst as i64) + pos) as *u8
424 *dp = *bp
425 pos = pos + 1
426 }
427 k2 = k2 + 1
428 }
429 if (flags & NX_CG_FLAG_NTR_SUSPECT) != 0 {
430 let l: *u8 = "NTR_SUSPECT " as *u8
431 var m: i64 = 0
432 while m < 12 {
433 if pos < dst_cap {
434 let bp: *u8 = ((l as i64) + m) as *u8
435 let dp: *u8 = ((dst as i64) + pos) as *u8
436 *dp = *bp
437 pos = pos + 1
438 }
439 m = m + 1
440 }
441 }
442 if (flags & NX_CG_FLAG_MULTI_MALE_ACTIVE) != 0 {
443 let l: *u8 = "MULTI_MALE_ACTIVE " as *u8
444 var m: i64 = 0
445 while m < 18 {
446 if pos < dst_cap {
447 let bp: *u8 = ((l as i64) + m) as *u8
448 let dp: *u8 = ((dst as i64) + pos) as *u8
449 *dp = *bp
450 pos = pos + 1
451 }
452 m = m + 1
453 }
454 }
455 if (flags & NX_CG_FLAG_OBSERVER_PASSIVE) != 0 {
456 let l: *u8 = "OBSERVER_PASSIVE " as *u8
457 var m: i64 = 0
458 while m < 17 {
459 if pos < dst_cap {
460 let bp: *u8 = ((l as i64) + m) as *u8
461 let dp: *u8 = ((dst as i64) + pos) as *u8
462 *dp = *bp
463 pos = pos + 1
464 }
465 m = m + 1
466 }
467 }
468 }
469
470 if pos < dst_cap {
471 let dp: *u8 = ((dst as i64) + pos) as *u8
472 *dp = 0x0A as u8
473 pos = pos + 1
474 }
475 return pos
476}