code wiki / (root) / nx_ingest_scene.nx

nx_ingest_scene.nx source

↩ module page · 161 lines · 5956 B

1// nx_ingest.nx -- WRITING arc, rung W-ING-1 (X-WRITE-ING-001): the content-agnostic 2// SCENE / ENTITY EXTRACTOR -- the ingestion point that turns generated prose into 3// reusable structured DATA for the companion (elder-ai) + image-gen. Per the 4// 2026-06-12 fiction-ingestion seam, this is engine PLUMBING: register-blind, so 5// the SFW lane and the operator's local-LLM explicit lane run the SAME organ. 6// 7// ROSTER-BASED + DETERMINISTIC (no LLM in the loop): the caller supplies the 8// character bible (a packed list of names); the extractor finds, per scene, who 9// is present and how strongly. That is exactly what image-gen needs (render the 10// characters present; the focus character is the subject) and what the companion 11// needs (who is in which scene, dialogue density). 12// 13// REAL-TIME == PRIOR: the extractor is a pure function of (text, roster). Running 14// it on each scene as the LLM finishes it (streaming) yields byte-identical scene 15// records to running it once on the whole work (batch). The gate proves this. 16// 17// Scene break = a blank line (>=2 consecutive '\n'). Character match = WHOLE-WORD, 18// case-insensitive ("Yuki" does NOT match inside "Yukiko"); a trailing apostrophe 19// is a boundary so possessives ("Kenji's") count as a mention. Marker breaks 20// (***, # heading) and a descriptor/outfit/setting lexicon are the next layers 21// (DATA-driven, rule 25) and ride on this same per-scene structure. 22// 23// out[] layout, per scene s (stride = 5 + nnames; caller allocates 24// >= maxsc*(5+nnames) i64): 25// out[base+0] = char start (absolute in the buffer passed) 26// out[base+1] = char end (exclusive) 27// out[base+2] = dialogue segments (count of double-quote pairs) 28// out[base+3] = focus character index (most mentions; -1 if none present) 29// out[base+4] = total mentions across all roster names 30// out[base+5+k] = mentions of roster name k 31// ig_extract returns the scene count (capped at maxsc). 32// 33// Pure integer, NO syscalls. Reuses nx_writecraft for char classification (DRY). 34// 35// license_tier: ORIGINAL 36// module: nishi-core.write.ingest 37// depends: nishi-core.write.craft 38// capability: WRITE_SCENE_EXTRACT 39import "nx_writecraft.nx" 40 41// byte offset of roster name k within the packed null-terminated names buffer 42func ig_name_off(names: *u8, k: i64) -> i64 { 43 var off: i64 = 0 44 var idx: i64 = 0 45 while idx < k { 46 while names[off] != (0 as u8) { off = off + 1 } 47 off = off + 1 // skip the null separator 48 idx = idx + 1 49 } 50 return off 51} 52 53// 1 if the name at names[off..null] matches t[pos..] as a WHOLE WORD within [.,nlim) 54func ig_match_at(t: *u8, pos: i64, nlim: i64, names: *u8, off: i64) -> i64 { 55 if pos > 0 { 56 if wc_is_alpha(t[pos - 1] as i64) == 1 { return 0 } // not at a word start 57 } 58 var j: i64 = 0 59 while names[off + j] != (0 as u8) { 60 if pos + j >= nlim { return 0 } 61 if wc_lower(t[pos + j] as i64) != wc_lower(names[off + j] as i64) { return 0 } 62 j = j + 1 63 } 64 let e: i64 = pos + j 65 if e < nlim { 66 if wc_is_alpha(t[e] as i64) == 1 { return 0 } // not at a word end 67 } 68 return 1 69} 70 71// whole-word mention count of roster name `off` inside t[a..b) 72func ig_count_name_in(t: *u8, a: i64, b: i64, names: *u8, off: i64) -> i64 { 73 var c: i64 = 0 74 var pos: i64 = a 75 while pos < b { 76 if ig_match_at(t, pos, b, names, off) == 1 { 77 c = c + 1 78 var l: i64 = 0 79 while names[off + l] != (0 as u8) { l = l + 1 } // skip past the matched name 80 pos = pos + l 81 } else { 82 pos = pos + 1 83 } 84 } 85 return c 86} 87 88// write one scene record for scene index sc covering t[a..b) 89func ig_close_scene(t: *u8, a: i64, b: i64, names: *u8, nnames: i64, out: *i64, sc: i64) -> i64 { 90 let stride: i64 = 5 + nnames 91 let base: i64 = sc * stride 92 out[base + 0] = a 93 out[base + 1] = b 94 // dialogue segments: count opening double-quotes 95 var dcount: i64 = 0 96 var inside: i64 = 0 97 var p: i64 = a 98 while p < b { 99 if t[p] == (34 as u8) { 100 if inside == 0 { dcount = dcount + 1 } 101 inside = 1 - inside 102 } 103 p = p + 1 104 } 105 out[base + 2] = dcount 106 // per-name mentions, total, focus 107 var total: i64 = 0 108 var focus: i64 = 0 - 1 109 var focusval: i64 = 0 110 var k: i64 = 0 111 while k < nnames { 112 let off: i64 = ig_name_off(names, k) 113 let cnt: i64 = ig_count_name_in(t, a, b, names, off) 114 out[base + 5 + k] = cnt 115 total = total + cnt 116 if cnt > focusval { focusval = cnt; focus = k } 117 k = k + 1 118 } 119 out[base + 3] = focus 120 out[base + 4] = total 121 return 0 122} 123 124// segment t[0..n) into scenes; fill out; return scene count (<= maxsc) 125func ig_extract(t: *u8, n: i64, names: *u8, nnames: i64, out: *i64, maxsc: i64) -> i64 { 126 var nsc: i64 = 0 127 var scene_start: i64 = 0 128 var i: i64 = 0 129 while i < n { 130 if t[i] == (10 as u8) { 131 var j: i64 = i 132 var go: i64 = 1 133 while go == 1 { 134 if j < n { 135 if t[j] == (10 as u8) { j = j + 1 } else { go = 0 } 136 } else { 137 go = 0 138 } 139 } 140 if j - i >= 2 { // blank line -> scene break 141 if i > scene_start { 142 if nsc < maxsc { 143 ig_close_scene(t, scene_start, i, names, nnames, out, nsc) 144 nsc = nsc + 1 145 } 146 } 147 scene_start = j 148 } 149 i = j 150 } else { 151 i = i + 1 152 } 153 } 154 if n > scene_start { // trailing scene 155 if nsc < maxsc { 156 ig_close_scene(t, scene_start, n, names, nnames, out, nsc) 157 nsc = nsc + 1 158 } 159 } 160 return nsc 161}