code wiki / (root) / nx_content_guard.nx

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}