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}