nx_world_state.nx source
↩ module page · 328 lines · 13697 B
1// nx_world_state.nx -- CANVAS-style carry-forward visual memory +
2// per-scene continuity verifier.
3//
4// Direct port of the world-class architecture from:
5//
6// * CANVAS (arxiv 2604.13452) -- Continuity-Aware Narratives via
7// Visual Agentic Storyboarding. Continuity plan P = (C, L, O)
8// and visual memory M = (Mc, Ml, Mf).
9// * Narrative Graph Prompting (ICCV 2025 AISTORY workshop) --
10// typed hyper-nodes with unique consistency seeds per entity.
11// * OpenAI Agents DungeonMaster pattern -- engine owns the world
12// state, deterministic rules live in code (here in NishiLang).
13//
14// The substrate's existing nx_arc treated each stage as an
15// independent draw from pools. That's WRONG for the D&D-campaign-
16// in-a-multiverse arc model (see feedback-arcs-as-dnd-campaign-
17// multiverse-continuity): renders are scenes in a chained narrative,
18// not random rolls. This module owns the carry-forward.
19//
20// Scope of v1: characters + locations + monotonic day_clock +
21// universe_id (for multiverse branching). Objects/inventory queued
22// for v2 (CANVAS's O mapping) once we see how it's used in
23// nx_batch_audit_sidecar.
24//
25// genealogy_id: canvas_2026 + narrative_graph_prompting_iccv2025 +
26// openai_agents_dungeonmaster_pattern
27// lineage_id: world_state_v1
28//
29// nx_safety_envelope:
30// intended_use: "World-state container -- terrain + biome +
31// event-trace registers consumed by per-kind
32// generators"
33// sil_target: SIL1
34// asil_target: QM
35// dal_target: NONE
36// evidence: [sealed_state_kind_enum,
37// fixed_capacity_pools,
38// no_FP_in_state_management]
39// hazard_register: [bug-tape-state-overflow-on-cap-exhaustion,
40// bug-tape-event-trace-truncation]
41// residual_risk: "Pool capacities are static; large world
42// partitioning queued via nx_world_shard."
43// verdict: NOT_YET_EVALUATED
44
45import "nx_syscalls.nx"
46import "nx_tier.nx"
47import "nx_directors_note.nx"
48
49// ===== Sealed-enum: ContinuityVerdict =============================
50//
51// What kind of break the candidate next-scene would cause if
52// committed. CONSISTENT is the only ship-without-flag verdict.
53
54const NX_CONT_CONSISTENT: nx_int = 0
55const NX_CONT_VIOLATES_OUTFIT: nx_int = 1 // outfit changed without story beat
56const NX_CONT_VIOLATES_LOCATION_TELEPORT: nx_int = 2 // location changed without transit
57const NX_CONT_VIOLATES_TIME_REVERSAL: nx_int = 3 // clock went backwards
58const NX_CONT_VIOLATES_CHARACTER_ABSENT: nx_int = 4 // character not registered
59const NX_CONT_VIOLATES_UNIVERSE_MISMATCH: nx_int = 5 // wrong universe_id
60const NX_CONT_N_VERDICTS: nx_int = 6
61
62func nx_cont_verdict_is_valid(v: nx_int) -> nx_int {
63 if v < 0 { return 0 }
64 if v >= NX_CONT_N_VERDICTS { return 0 }
65 return 1
66}
67
68// ===== Character carry-forward fields =============================
69//
70// Flat-array per character. Matches the substrate's flat-buffer
71// convention (nx_canon_proportions, nx_arc).
72
73const NX_WS_CHAR_F_ID: nx_int = 0
74const NX_WS_CHAR_F_IDENTITY_SEED: nx_int = 1 // per-NGP consistency seed
75const NX_WS_CHAR_F_ARCHETYPE_ID: nx_int = 2
76const NX_WS_CHAR_F_OUTFIT_ID: nx_int = 3
77const NX_WS_CHAR_F_LOCATION_ID: nx_int = 4
78const NX_WS_CHAR_F_AFFECT_AXIS: nx_int = 5
79const NX_WS_CHAR_F_LAST_SEEN_MIN: nx_int = 6 // day_clock at last scene
80const NX_WS_CHAR_FIELDS: nx_int = 7
81
82// ===== Location carry-forward fields ==============================
83
84const NX_WS_LOC_F_ID: nx_int = 0
85const NX_WS_LOC_F_SETTING_SEED: nx_int = 1
86const NX_WS_LOC_F_TIME_FIRST_SEEN: nx_int = 2
87const NX_WS_LOC_FIELDS: nx_int = 3
88
89// ===== Story-beat permission flags ================================
90//
91// Outfit + location changes require an EXPLICIT story beat per the
92// D&D-continuity cardinal. The pending-beat slot lets the caller
93// authorise the next legitimate change before committing it.
94
95const NX_WS_BEAT_NONE: nx_int = 0
96const NX_WS_BEAT_OUTFIT_CHANGE_OK: nx_int = 1
97const NX_WS_BEAT_LOCATION_CHANGE_OK: nx_int = 2
98const NX_WS_BEAT_BOTH_OK: nx_int = 3
99const NX_WS_BEAT_N_KINDS: nx_int = 4
100
101// ===== WorldState struct ===========================================
102
103struct WorldState {
104 universe_id: nx_int,
105 parent_universe_id: nx_int, // -1 if root universe
106 day_clock_minutes: nx_int, // monotonic 0..1439 (or beyond for multi-day)
107 character_count: nx_int,
108 character_cap: nx_int,
109 characters: *i64, // character_cap * NX_WS_CHAR_FIELDS
110 location_count: nx_int,
111 location_cap: nx_int,
112 locations: *i64, // location_cap * NX_WS_LOC_FIELDS
113 pending_beat: nx_int, // NX_WS_BEAT_*
114 scene_count: nx_int // monotonic scene-number counter
115}
116
117const NX_WS_BYTES: nx_int = 88 // 11 fields * 8
118
119// ===== Builder ====================================================
120
121func nx_world_alloc(universe_id: nx_int, parent_universe_id: nx_int,
122 character_cap: nx_int, location_cap: nx_int) -> *WorldState {
123 let ws: *WorldState = (sys_mmap(NX_WS_BYTES)) as *WorldState
124 ws.universe_id = universe_id
125 ws.parent_universe_id = parent_universe_id
126 ws.day_clock_minutes = 0
127 ws.character_count = 0
128 ws.character_cap = character_cap
129 let char_bytes: nx_int = character_cap * NX_WS_CHAR_FIELDS * NX_SIZEOF_NX_INT
130 ws.characters = (sys_mmap(char_bytes)) as *i64
131 ws.location_count = 0
132 ws.location_cap = location_cap
133 let loc_bytes: nx_int = location_cap * NX_WS_LOC_FIELDS * NX_SIZEOF_NX_INT
134 ws.locations = (sys_mmap(loc_bytes)) as *i64
135 ws.pending_beat = NX_WS_BEAT_NONE
136 ws.scene_count = 0
137 return ws
138}
139
140// ===== Character registration ======================================
141//
142// Returns the slot index of the registered character, or -1 if the
143// table is full. Identity-seed and archetype-id are set at register;
144// they don't change across scenes (that's NGP's stable-seed rule).
145
146func nx_world_register_character(ws: *WorldState, id: nx_int,
147 identity_seed: nx_int,
148 archetype_id: nx_int) -> nx_int {
149 if ws.character_count >= ws.character_cap { return 0 - 1 }
150 let slot: nx_int = ws.character_count
151 let base: nx_int = slot * NX_WS_CHAR_FIELDS
152 ws.characters[base + NX_WS_CHAR_F_ID] = id
153 ws.characters[base + NX_WS_CHAR_F_IDENTITY_SEED] = identity_seed
154 ws.characters[base + NX_WS_CHAR_F_ARCHETYPE_ID] = archetype_id
155 ws.characters[base + NX_WS_CHAR_F_OUTFIT_ID] = 0 // unset
156 ws.characters[base + NX_WS_CHAR_F_LOCATION_ID] = 0 // unset
157 ws.characters[base + NX_WS_CHAR_F_AFFECT_AXIS] = NX_AF_NEUTRAL
158 ws.characters[base + NX_WS_CHAR_F_LAST_SEEN_MIN] = 0
159 ws.character_count = ws.character_count + 1
160 return slot
161}
162
163// ===== Location registration ======================================
164
165func nx_world_register_location(ws: *WorldState, id: nx_int,
166 setting_seed: nx_int) -> nx_int {
167 if ws.location_count >= ws.location_cap { return 0 - 1 }
168 let slot: nx_int = ws.location_count
169 let base: nx_int = slot * NX_WS_LOC_FIELDS
170 ws.locations[base + NX_WS_LOC_F_ID] = id
171 ws.locations[base + NX_WS_LOC_F_SETTING_SEED] = setting_seed
172 ws.locations[base + NX_WS_LOC_F_TIME_FIRST_SEEN] = ws.day_clock_minutes
173 ws.location_count = ws.location_count + 1
174 return slot
175}
176
177// ===== Lookup helpers =============================================
178
179func nx_world_find_character_slot(ws: *WorldState, char_id: nx_int) -> nx_int {
180 var i: nx_int = 0
181 while i < ws.character_count {
182 let base: nx_int = i * NX_WS_CHAR_FIELDS
183 if ws.characters[base + NX_WS_CHAR_F_ID] == char_id { return i }
184 i = i + 1
185 }
186 return 0 - 1
187}
188
189func nx_world_get_char_field(ws: *WorldState, char_id: nx_int,
190 field: nx_int) -> nx_int {
191 let slot: nx_int = nx_world_find_character_slot(ws, char_id)
192 if slot < 0 { return 0 - 1 }
193 return ws.characters[slot * NX_WS_CHAR_FIELDS + field]
194}
195
196// ===== Story-beat authorisation ===================================
197//
198// Caller signals the kind of legitimate change about to be committed.
199// Once consumed by nx_continuity_check the beat resets to NONE so
200// subsequent stages must re-authorise.
201
202func nx_world_arm_beat(ws: *WorldState, kind: nx_int) -> nx_int {
203 if kind < 0 { return 0 - 1 }
204 if kind >= NX_WS_BEAT_N_KINDS { return 0 - 1 }
205 ws.pending_beat = kind
206 return 0
207}
208
209// ===== Beat-permission helpers (hoisted above continuity_check
210// because nxc2's resolver is single-pass) ===========================
211
212func _ws_beat_permits_outfit(beat: nx_int) -> nx_int {
213 if beat == NX_WS_BEAT_OUTFIT_CHANGE_OK { return 1 }
214 if beat == NX_WS_BEAT_BOTH_OK { return 1 }
215 return 0
216}
217
218func _ws_beat_permits_location(beat: nx_int) -> nx_int {
219 if beat == NX_WS_BEAT_LOCATION_CHANGE_OK { return 1 }
220 if beat == NX_WS_BEAT_BOTH_OK { return 1 }
221 return 0
222}
223
224// ===== Continuity verifier ========================================
225//
226// Given the world's CURRENT state and a candidate DirectorsNote for
227// the NEXT scene, decide whether committing the note would cause a
228// continuity violation. Per CANVAS: the verifier sits between
229// "candidate generation" and "memory update".
230//
231// Sealed-enum return; CONSISTENT = ship.
232
233func nx_continuity_check(ws: *WorldState, dn: *DirectorsNote) -> nx_int {
234 // Character must be registered
235 let cslot: nx_int = nx_world_find_character_slot(ws, dn.character_id)
236 if cslot < 0 { return NX_CONT_VIOLATES_CHARACTER_ABSENT }
237 let cbase: nx_int = cslot * NX_WS_CHAR_FIELDS
238
239 // Outfit may change only if an OUTFIT or BOTH beat is armed.
240 let cur_outfit: nx_int = ws.characters[cbase + NX_WS_CHAR_F_OUTFIT_ID]
241 if cur_outfit != 0 {
242 if dn.outfit_key_id != cur_outfit {
243 let beat_outfit_ok: nx_int = _ws_beat_permits_outfit(ws.pending_beat)
244 if beat_outfit_ok == 0 { return NX_CONT_VIOLATES_OUTFIT }
245 }
246 }
247
248 // Location may change only if a LOCATION or BOTH beat is armed.
249 let cur_loc: nx_int = ws.characters[cbase + NX_WS_CHAR_F_LOCATION_ID]
250 if cur_loc != 0 {
251 if dn.location_id != cur_loc {
252 let beat_loc_ok: nx_int = _ws_beat_permits_location(ws.pending_beat)
253 if beat_loc_ok == 0 { return NX_CONT_VIOLATES_LOCATION_TELEPORT }
254 }
255 }
256
257 // Day clock must not reverse vs the character's last_seen_min.
258 let last_seen: nx_int = ws.characters[cbase + NX_WS_CHAR_F_LAST_SEEN_MIN]
259 if ws.day_clock_minutes < last_seen { return NX_CONT_VIOLATES_TIME_REVERSAL }
260
261 return NX_CONT_CONSISTENT
262}
263
264// ===== Commit the scene ===========================================
265//
266// AFTER a candidate passes nx_continuity_check, the caller commits
267// it: state advances, pending_beat consumed, scene_count++. This is
268// CANVAS's "update memory before proceeding to the next shot" step.
269
270func nx_world_commit_scene(ws: *WorldState, dn: *DirectorsNote,
271 new_day_clock: nx_int) -> nx_int {
272 if new_day_clock < ws.day_clock_minutes { return NX_CONT_VIOLATES_TIME_REVERSAL }
273
274 let cslot: nx_int = nx_world_find_character_slot(ws, dn.character_id)
275 if cslot < 0 { return NX_CONT_VIOLATES_CHARACTER_ABSENT }
276 let cbase: nx_int = cslot * NX_WS_CHAR_FIELDS
277
278 ws.characters[cbase + NX_WS_CHAR_F_OUTFIT_ID] = dn.outfit_key_id
279 ws.characters[cbase + NX_WS_CHAR_F_LOCATION_ID] = dn.location_id
280 ws.characters[cbase + NX_WS_CHAR_F_AFFECT_AXIS] = dn.affect_axis
281 ws.characters[cbase + NX_WS_CHAR_F_LAST_SEEN_MIN] = new_day_clock
282
283 ws.day_clock_minutes = new_day_clock
284 ws.pending_beat = NX_WS_BEAT_NONE
285 ws.scene_count = ws.scene_count + 1
286 return NX_CONT_CONSISTENT
287}
288
289// ===== Multiverse branching =======================================
290//
291// Spawn a NEW universe forked from the current one. Copies character
292// + location registry but resets pending_beat + scene_count so the
293// branch is internally independent. The parent universe_id chain
294// lets the caller (or batch sidecar) walk back to the divergence
295// point for explanation traces.
296
297func nx_world_branch(parent: *WorldState, new_universe_id: nx_int) -> *WorldState {
298 let child: *WorldState = nx_world_alloc(new_universe_id,
299 parent.universe_id,
300 parent.character_cap,
301 parent.location_cap)
302 child.day_clock_minutes = parent.day_clock_minutes
303 var i: nx_int = 0
304 while i < parent.character_count {
305 let pbase: nx_int = i * NX_WS_CHAR_FIELDS
306 let cbase: nx_int = i * NX_WS_CHAR_FIELDS
307 var f: nx_int = 0
308 while f < NX_WS_CHAR_FIELDS {
309 child.characters[cbase + f] = parent.characters[pbase + f]
310 f = f + 1
311 }
312 i = i + 1
313 }
314 child.character_count = parent.character_count
315 var j: nx_int = 0
316 while j < parent.location_count {
317 let pbase2: nx_int = j * NX_WS_LOC_FIELDS
318 let cbase2: nx_int = j * NX_WS_LOC_FIELDS
319 var ff: nx_int = 0
320 while ff < NX_WS_LOC_FIELDS {
321 child.locations[cbase2 + ff] = parent.locations[pbase2 + ff]
322 ff = ff + 1
323 }
324 j = j + 1
325 }
326 child.location_count = parent.location_count
327 return child
328}