nx_storybeat.nx source
↩ module page · 237 lines · 9196 B
1// nx_storybeat.nx -- media-agnostic narrative beat.
2//
3// A StoryBeat is the atomic unit of a parsed narrative across ANY
4// source medium. The downstream director pipeline (nx_arc /
5// nx_moment_resolve / nx_directors_note) consumes a stream of
6// StoryBeats and does not care whether they came from text prose,
7// a video transcript, a comic-panel image, or a GIF sequence.
8//
9// Each per-medium ingester (nx_text_ingest, nx_image_ingest,
10// nx_gif_ingest, nx_video_ingest, nx_audio_ingest) emits beats
11// into a StoryDb. A beat always points back into the MediaPool
12// for full-resolution playback / re-extraction.
13//
14// Schema is FROZEN below this line per cardinal feedback-bug-class-
15// prevention-additive-not-restrictive: append-only fields, never
16// renumber or reorder existing ones. Downstream consumers index
17// by field offset.
18//
19// See: docs/NISHI_MEDIA_INGESTION_ROADMAP.md
20//
21// nx_safety_envelope:
22// intended_use: "Schema and helpers for the atomic narrative
23// beat unit consumed by the director pipeline."
24// sil_target: SIL2
25// asil_target: QM
26// dal_target: DAL C
27// iec_62304_class: NONE
28// evidence: [no_floating_point,
29// fixed_struct_size_128_bytes,
30// sealed_enum_beat_kind,
31// sealed_enum_domain_code,
32// sealed_enum_consent_tier,
33// media_id_references_pool_no_aliasing]
34// hazard_register: [bug-tape-beat-kind-collision,
35// bug-tape-affect-pack-overflow]
36// residual_risk: "Affect axes packed i16 each; values outside
37// [-32768,32767] silently saturate. Caller
38// uses Q10 fixed-point so saturation is safe."
39// verdict: NOT_YET_EVALUATED
40
41import "nx_syscalls.nx"
42
43// ===== sealed beat kinds ===========================================
44//
45// What this beat REPRESENTS. Stays stable forever. Append new
46// values at the end; never renumber.
47
48const NX_BEAT_UNKNOWN: i64 = 0
49const NX_BEAT_NARRATION: i64 = 1 // 3rd-person prose narration
50const NX_BEAT_DIALOGUE: i64 = 2 // quoted speech
51const NX_BEAT_ACTION: i64 = 3 // verb-led action sentence
52const NX_BEAT_DESCRIPTION: i64 = 4 // setting / appearance description
53const NX_BEAT_TRANSITION: i64 = 5 // time / scene break ("Hours later")
54const NX_BEAT_HEADING: i64 = 6 // chapter / scene header line
55const NX_BEAT_MEDIA_FRAME: i64 = 7 // single frame from video / gif / image
56const NX_BEAT_MEDIA_CAPTION: i64 = 8 // ASR / OCR caption from audio / image
57const NX_BEAT_INTERIORITY: i64 = 9 // 1st-person inner thought (italics)
58const NX_BEAT_GESTURE: i64 = 10 // non-verbal body / face expression
59
60// ===== sealed domain codes =========================================
61//
62// Coarse content domain. Used by nx_consent_boundary and the
63// director-initiative gating logic.
64
65const NX_DOMAIN_UNKNOWN: i64 = 0
66const NX_DOMAIN_MUNDANE: i64 = 1 // breakfast, work, errands
67const NX_DOMAIN_INTIMATE: i64 = 2 // kiss / hug / cuddle, no explicit
68const NX_DOMAIN_EROTIC: i64 = 3 // sexual content, consenting adults
69const NX_DOMAIN_FANTASY: i64 = 4 // fantasy / sci-fi setting tag
70const NX_DOMAIN_VIOLENCE: i64 = 5 // physical violence (combat / injury)
71
72// ===== sealed consent tiers ========================================
73//
74// What did the source consent to being used for? The user's own
75// inputs are at the most-permissive tier; third-party ingestions
76// are constrained.
77
78const NX_CONSENT_UNKNOWN: i64 = 0
79const NX_CONSENT_PUBLIC: i64 = 1 // public-domain / CC0
80const NX_CONSENT_PRIVATE_OK: i64 = 2 // user-owned, private use only
81const NX_CONSENT_EROTIC_OK: i64 = 3 // user explicitly tagged for erotic
82const NX_CONSENT_EXPLICIT: i64 = 4 // explicit user authorization granted
83
84// ===== sealed arc stage hints ======================================
85//
86// Mirrors nx_arc.nx's 16-stage data model. This is a HINT only
87// (the arc fitter can override). Matched by ordinal to keep the
88// two enums in lock-step; if you add a stage to nx_arc, mirror here.
89
90const NX_ARCSTAGE_UNKNOWN: i64 = 0
91const NX_ARCSTAGE_SETUP: i64 = 1
92const NX_ARCSTAGE_HOOK: i64 = 2
93const NX_ARCSTAGE_TRIGGER: i64 = 3
94const NX_ARCSTAGE_ENGAGE: i64 = 4
95const NX_ARCSTAGE_RISING: i64 = 5
96const NX_ARCSTAGE_OBSTACLE: i64 = 6
97const NX_ARCSTAGE_TURN: i64 = 7
98const NX_ARCSTAGE_DEEPEN: i64 = 8
99const NX_ARCSTAGE_BUILD: i64 = 9
100const NX_ARCSTAGE_PEAK: i64 = 10
101const NX_ARCSTAGE_CLIMAX: i64 = 11
102const NX_ARCSTAGE_BREATH: i64 = 12
103const NX_ARCSTAGE_AFTER: i64 = 13
104const NX_ARCSTAGE_RESOLVE: i64 = 14
105const NX_ARCSTAGE_COOL: i64 = 15
106
107// ===== bit flags ===================================================
108
109const NX_BEAT_FLAG_IS_QUOTED: i64 = 1 // text was inside "..."
110const NX_BEAT_FLAG_IS_ITALIC: i64 = 2 // interiority styling
111const NX_BEAT_FLAG_HAS_SUMMARY: i64 = 4 // summary_off/len valid
112const NX_BEAT_FLAG_USER_PINNED: i64 = 8 // user starred this beat
113const NX_BEAT_FLAG_DIRECTOR_CUE: i64 = 16 // marked for director init
114
115// ===== StoryBeat struct ============================================
116//
117// Fixed 128 bytes (16 * i64). Schema FROZEN; append-only.
118
119struct StoryBeat {
120 beat_kind: i64, // NX_BEAT_*
121 media_type: i64, // NX_MEDIA_TYPE_* (mirror nx_media_pool)
122 media_id: i64, // index into MediaPool
123
124 // Span within source media. Semantics depend on media_type:
125 // TEXT/HTML : byte offset into UTF-8 bytes
126 // IMAGE : pixel (y * width + x), span_end - span_start = pixel count
127 // GIF : frame index
128 // VIDEO : video PTS in 90 kHz ticks (MPEG standard)
129 // AUDIO : sample index at source sample rate
130 span_start: i64,
131 span_end: i64,
132
133 location_id: i64, // 0 = unknown / not extracted
134 outfit_id: i64,
135 character_id: i64,
136 intent_id: i64,
137
138 domain_code: i64, // NX_DOMAIN_*
139 consent_tier: i64, // NX_CONSENT_*
140 arc_stage_hint: i64, // NX_ARCSTAGE_*
141
142 affect_packed: i64, // 4x i16 packed: arousal, tenderness,
143 // urgency, intimacy (Q10)
144 flags: i64, // NX_BEAT_FLAG_*
145
146 summary_off: i64, // offset into StoryDb summary arena
147 summary_len: i64, // 0 if no synopsis attached
148}
149
150const NX_STORYBEAT_BYTES: i64 = 128
151
152// ===== affect packing ==============================================
153//
154// Pack 4 i16 lanes (Q10 fixed-point) into one i64. Layout:
155// bits 0..15 : arousal
156// bits 16..31 : tenderness
157// bits 32..47 : urgency
158// bits 48..63 : intimacy
159//
160// Values outside i16 range silently saturate. Caller uses Q10
161// so the safe input range is [-32, 32] which fits well.
162
163func nx_beat_pack_affect(arousal: i64, tenderness: i64,
164 urgency: i64, intimacy: i64) -> i64 {
165 let a: i64 = arousal & 0xFFFF
166 let b: i64 = tenderness & 0xFFFF
167 let c: i64 = urgency & 0xFFFF
168 let d: i64 = intimacy & 0xFFFF
169 return a | (b << 16) | (c << 32) | (d << 48)
170}
171
172func nx_beat_affect_arousal(packed: i64) -> i64 {
173 let v: i64 = packed & 0xFFFF
174 if v >= 0x8000 { return v - 0x10000 }
175 return v
176}
177
178func nx_beat_affect_tenderness(packed: i64) -> i64 {
179 let v: i64 = (packed >> 16) & 0xFFFF
180 if v >= 0x8000 { return v - 0x10000 }
181 return v
182}
183
184func nx_beat_affect_urgency(packed: i64) -> i64 {
185 let v: i64 = (packed >> 32) & 0xFFFF
186 if v >= 0x8000 { return v - 0x10000 }
187 return v
188}
189
190func nx_beat_affect_intimacy(packed: i64) -> i64 {
191 let v: i64 = (packed >> 48) & 0xFFFF
192 if v >= 0x8000 { return v - 0x10000 }
193 return v
194}
195
196// ===== beat constructor / mutator helpers ==========================
197
198func nx_beat_init(b: *StoryBeat, beat_kind: i64, media_type: i64,
199 media_id: i64, span_start: i64, span_end: i64) {
200 b.beat_kind = beat_kind
201 b.media_type = media_type
202 b.media_id = media_id
203 b.span_start = span_start
204 b.span_end = span_end
205 b.location_id = 0
206 b.outfit_id = 0
207 b.character_id = 0
208 b.intent_id = 0
209 b.domain_code = NX_DOMAIN_UNKNOWN
210 b.consent_tier = NX_CONSENT_UNKNOWN
211 b.arc_stage_hint = NX_ARCSTAGE_UNKNOWN
212 b.affect_packed = 0
213 b.flags = 0
214 b.summary_off = 0
215 b.summary_len = 0
216}
217
218func nx_beat_set_affect(b: *StoryBeat, arousal: i64, tenderness: i64,
219 urgency: i64, intimacy: i64) {
220 b.affect_packed = nx_beat_pack_affect(arousal, tenderness,
221 urgency, intimacy)
222}
223
224func nx_beat_set_summary(b: *StoryBeat, off: i64, len: i64) {
225 b.summary_off = off
226 b.summary_len = len
227 b.flags = b.flags | NX_BEAT_FLAG_HAS_SUMMARY
228}
229
230func nx_beat_mark_director_cue(b: *StoryBeat) {
231 b.flags = b.flags | NX_BEAT_FLAG_DIRECTOR_CUE
232}
233
234func nx_beat_has_director_cue(b: *StoryBeat) -> i64 {
235 if (b.flags & NX_BEAT_FLAG_DIRECTOR_CUE) != 0 { return 1 }
236 return 0
237}