nx_scene_index.nx source
↩ module page · 242 lines · 9264 B
1// nx_scene_index.nx -- companion-queryable surface over discovered anchors.
2//
3// Top of the ingestion stack. Turns each ArcAnchor (an abstract
4// "(role + role) recurs in this corpus") into a concrete
5// SceneCandidate the companion's autonomous-trigger layer can
6// query and surface as a surprise scene.
7//
8// Each SceneCandidate carries:
9// - anchor_kind (e.g. NX_ANCHOR_OUTFIT_LOCATION)
10// - registry slot ids for the two terms
11// - the BEST-FIT representative beat (the one that most strongly
12// instances this anchor in the corpus)
13// - the byte span (start, end) within the source media so the
14// companion can quote / re-render the scene verbatim
15// - first / last sighting beat ids (recency for ranking)
16// - sighting count (frequency for ranking)
17// - stage (STAGE1 SUPERVISED today; STAGE3 once user pins)
18//
19// Per cardinals:
20// feedback-self-surfacing-intelligence-staged-autonomy:
21// SceneCandidates default STAGE1 SUPERVISED -- the companion
22// PROPOSES to the user before surfacing. STAGE2 PROMPTED at
23// anchor count >= NX_ARC_ANCHOR_PROMOTE_N. STAGE3 once user
24// explicitly pins.
25// feedback-launching-content-must-be-one-command-easy:
26// SceneIndex is the substrate primitive a single one-command
27// surfacer (nx_companion_surprise.nx, future) wires through.
28//
29// nx_safety_envelope:
30// intended_use: "Companion-side queryable index of scene
31// candidates derived from discovered
32// ArcAnchors + source-beat back-references."
33// sil_target: SIL2
34// asil_target: QM
35// dal_target: DAL C
36// iec_62304_class: NONE
37// evidence: [no_floating_point,
38// bounded_loops_per_jpl_rule_2,
39// anchor_to_candidate_one_to_one,
40// source_span_preserved_for_audit]
41// hazard_register: [bug-tape-best-beat-tie-breaker-undefined,
42// bug-tape-stale-source-span-after-corpus-purge]
43// residual_risk: "Best-beat selection is highest matching-
44// role-id sum today; ties broken by lowest
45// beat_id. V2 incorporates recency + affect
46// + user-pin weights."
47// verdict: NOT_YET_EVALUATED
48
49import "nx_syscalls.nx"
50import "nx_storybeat.nx"
51import "nx_storydb.nx"
52import "nx_media_pool.nx"
53import "nx_term_registry.nx"
54import "nx_arc_anchor.nx"
55
56// ===== SceneCandidate record ======================================
57//
58// Fixed 96 bytes (12 * i64). Stable schema; append-only.
59
60struct SceneCandidate {
61 anchor_kind: i64, // NX_ANCHOR_*
62 term_a: i64, // registry slot id
63 term_b: i64, // registry slot id
64 role_a: i64, // NX_TERM_ROLE_*
65 role_b: i64,
66 best_beat_id: i64, // index into StoryDb.beats
67 source_media: i64, // media_id pointing into StoryDb.media_pool
68 source_start: i64, // byte offset within source bytes
69 source_end: i64, // exclusive
70 sighting_count: i64, // anchor.cooccur_count (frequency rank)
71 stage: i64, // NX_TERM_STAGE_*
72 flags: i64, // bit 0 = user_pinned
73}
74
75const NX_SCENE_CAND_BYTES: i64 = 96
76
77const NX_SCENE_FLAG_USER_PINNED: i64 = 1
78
79// ===== SceneIndex container =======================================
80
81const NX_SCENE_INDEX_DEFAULT_CAP: i64 = 256
82
83struct SceneIndex {
84 candidates: *SceneCandidate,
85 n_candidates: i64,
86 cap: i64,
87}
88
89const NX_SCENE_INDEX_BYTES: i64 = 24
90
91// ===== allocation =================================================
92
93func nx_scene_index_alloc_sized(cap: i64) -> *SceneIndex {
94 var c: i64 = cap
95 if c <= 0 { c = NX_SCENE_INDEX_DEFAULT_CAP }
96 let raw: *u8 = sys_mmap(NX_SCENE_INDEX_BYTES)
97 let s: *SceneIndex = raw as *SceneIndex
98 s.candidates = (sys_mmap(c * NX_SCENE_CAND_BYTES)) as *SceneCandidate
99 s.n_candidates = 0
100 s.cap = c
101 return s
102}
103
104func nx_scene_index_alloc() -> *SceneIndex {
105 return nx_scene_index_alloc_sized(0)
106}
107
108func nx_scene_candidate_at(s: *SceneIndex, i: i64) -> *SceneCandidate {
109 return (((s.candidates as i64) + i * NX_SCENE_CAND_BYTES)
110 as *SceneCandidate)
111}
112
113// ===== find best beat for an anchor ==============================
114//
115// Scan db.beats and return the index of the beat whose (outfit_id,
116// location_id, character_id) most strongly matches the anchor's
117// (term_a, term_b). Score = number of matched slots. Ties broken
118// by lowest beat_id (earliest in corpus).
119
120func nx_scene_index_match_beat(db: *StoryDb,
121 anchor_term_a: i64,
122 anchor_term_b: i64) -> i64 {
123 var best: i64 = -1
124 var best_score: i64 = 0
125 let BUDGET: i64 = db.n_beats + 1
126 var iter: i64 = 0
127 var i: i64 = 0
128 while i < db.n_beats {
129 if iter >= BUDGET { return best }
130 let b: *StoryBeat = nx_storydb_beat_at(db, i)
131 var score: i64 = 0
132 if b.outfit_id == anchor_term_a { score = score + 1 }
133 if b.outfit_id == anchor_term_b { score = score + 1 }
134 if b.location_id == anchor_term_a { score = score + 1 }
135 if b.location_id == anchor_term_b { score = score + 1 }
136 if b.character_id == anchor_term_a { score = score + 1 }
137 if b.character_id == anchor_term_b { score = score + 1 }
138 if score > best_score {
139 best = i
140 best_score = score
141 }
142 i = i + 1
143 iter = iter + 1
144 }
145 return best
146}
147
148// ===== build index from anchors ===================================
149//
150// For each ArcAnchor, find the best representative beat and emit
151// a SceneCandidate. Skips anchors whose best beat resolves to -1
152// (anchor doesn't actually appear in this StoryDb -- shouldn't
153// happen but defensive).
154
155func nx_scene_index_build(idx: *SceneIndex, db: *StoryDb,
156 anchors: *ArcAnchor, n_anchors: i64) -> i64 {
157 if n_anchors <= 0 { return 0 }
158 var emitted: i64 = 0
159 let BUDGET: i64 = n_anchors + 1
160 var iter: i64 = 0
161 var i: i64 = 0
162 while i < n_anchors {
163 if iter >= BUDGET { return emitted }
164 if idx.n_candidates >= idx.cap { return emitted }
165 let a: *ArcAnchor =
166 (((anchors as i64) + i * NX_ARC_ANCHOR_BYTES) as *ArcAnchor)
167
168 let best_beat: i64 = nx_scene_index_match_beat(db, a.term_a, a.term_b)
169 if best_beat >= 0 {
170 let b: *StoryBeat = nx_storydb_beat_at(db, best_beat)
171 let dst: *SceneCandidate = nx_scene_candidate_at(idx,
172 idx.n_candidates)
173 dst.anchor_kind = a.anchor_kind
174 dst.term_a = a.term_a
175 dst.term_b = a.term_b
176 dst.role_a = a.role_a
177 dst.role_b = a.role_b
178 dst.best_beat_id = best_beat
179 dst.source_media = b.media_id
180 dst.source_start = b.span_start
181 dst.source_end = b.span_end
182 dst.sighting_count = a.cooccur_count
183 dst.stage = a.stage
184 dst.flags = 0
185 if a.user_pinned != 0 { dst.flags = NX_SCENE_FLAG_USER_PINNED }
186 idx.n_candidates = idx.n_candidates + 1
187 emitted = emitted + 1
188 }
189 i = i + 1
190 iter = iter + 1
191 }
192 return emitted
193}
194
195// ===== query helpers ==============================================
196//
197// Two query patterns matter for the companion-side selector:
198//
199// (1) "Give me a candidate matching anchor_kind K." Used when the
200// companion wants a specific scene archetype (e.g. autonomous
201// surprise pulls from OUTFIT_LOCATION).
202//
203// (2) "Give me the top-N candidates by sighting_count." Used when
204// the companion is browsing what the corpus has yielded.
205
206func nx_scene_index_first_of_kind(idx: *SceneIndex, kind: i64) -> i64 {
207 let BUDGET: i64 = idx.n_candidates + 1
208 var iter: i64 = 0
209 var i: i64 = 0
210 while i < idx.n_candidates {
211 if iter >= BUDGET { return -1 }
212 let c: *SceneCandidate = nx_scene_candidate_at(idx, i)
213 if c.anchor_kind == kind { return i }
214 i = i + 1
215 iter = iter + 1
216 }
217 return -1
218}
219
220// Returns the byte span of the source for a candidate, via outparams.
221// Caller looks up MediaPool to get the actual bytes pointer.
222func nx_scene_index_source_span(idx: *SceneIndex, cand_id: i64,
223 out_media: *i64,
224 out_start: *i64, out_end: *i64) -> i64 {
225 if cand_id < 0 { return -1 }
226 if cand_id >= idx.n_candidates { return -1 }
227 let c: *SceneCandidate = nx_scene_candidate_at(idx, cand_id)
228 *out_media = c.source_media
229 *out_start = c.source_start
230 *out_end = c.source_end
231 return 0
232}
233
234// User-pin a candidate (promotes its stage to STAGE3 UNSUPERVISED).
235func nx_scene_index_pin(idx: *SceneIndex, cand_id: i64) -> i64 {
236 if cand_id < 0 { return -1 }
237 if cand_id >= idx.n_candidates { return -1 }
238 let c: *SceneCandidate = nx_scene_candidate_at(idx, cand_id)
239 c.flags = c.flags | NX_SCENE_FLAG_USER_PINNED
240 c.stage = 3 // NX_TERM_STAGE_UNSUPERVISED
241 return 0
242}