nx_noun_phrase_scan.nx source
↩ module page · 360 lines · 14810 B
1// nx_noun_phrase_scan.nx -- bounded heuristic noun-phrase discovery.
2//
3// Walks a source byte region looking for STRUCTURAL English cues
4// (determiners + key verbs + prepositions) that precede a noun
5// phrase, then captures the next 1-4 token span as a candidate.
6//
7// Cues are GRAMMATICAL not TOPICAL: "wearing", "in the", "at the",
8// "her", "his" -- not "apron" / "kitchen" / "bedroom". The actual
9// vocabulary is discovered from what follows the cues.
10//
11// Output: caller-allocated array of PhraseCandidate records. The
12// term registry observes each candidate and grows the open
13// vocabulary. No closed-set dictionary anywhere in the pipeline.
14//
15// Per cardinals:
16// feedback-loras-and-negatives-are-patches:
17// structural cues, not topical lexicons -- the substrate measures
18// statistical co-occurrence rather than memorizing seed lists.
19// feedback-self-surfacing-intelligence:
20// scanner emits CANDIDATES with confidence; registry grades
21// them through STAGE1/2/3 over time.
22// feedback-bounded-loop-discipline-jpl-rule-2:
23// every loop carries explicit BUDGET ceiling.
24//
25// nx_safety_envelope:
26// intended_use: "Surface noun-phrase candidates from a beat's
27// source byte range using grammatical-cue
28// heuristics, with role-hint + confidence."
29// sil_target: SIL2
30// asil_target: QM
31// dal_target: DAL C
32// iec_62304_class: NONE
33// evidence: [no_floating_point,
34// no_closed_topical_dictionary,
35// bounded_outer_and_inner_loops,
36// out_array_capacity_respected]
37// hazard_register: [bug-tape-cue-pattern-false-positive,
38// bug-tape-phrase-span-overruns-sentence]
39// residual_risk: "Structural-only English cues today; non-
40// English text gets fewer candidates emitted
41// (matches expectation -- those go to language-
42// specific scanners as they ship)."
43// verdict: NOT_YET_EVALUATED
44
45import "nx_syscalls.nx"
46import "nx_term_registry.nx"
47import "nx_phrase_match.nx"
48
49// ===== PhraseCandidate record =====================================
50//
51// 32 bytes (4 * i64).
52
53struct PhraseCandidate {
54 span_start: i64, // byte offset within source buffer
55 span_end: i64, // exclusive
56 role_hint: i64, // NX_TERM_ROLE_* (defined in nx_term_registry)
57 confidence: i64, // 0..10000 basis points
58}
59
60const NX_NP_CAND_BYTES: i64 = 32
61
62// ===== cue table ==================================================
63//
64// Each cue: literal prefix string + role hint + base confidence.
65// Cues are scanned case-insensitively via nx_phrase_match utilities.
66// The cue MUST be followed by a SPACE in source (avoids "winnable"
67// matching cue "wi" or similar).
68//
69// Confidence policy:
70// - Strong context verb (wearing/dressed/wrapped) -> 7000-8500
71// - Locative preposition + the/a -> 6000-7000
72// - Possessive (her/his/my) -> 4500 (often modifies clothing or
73// body part; refined by co-occurrence)
74// - Plain determiner -> 3000 (weak)
75//
76// Adding a cue: append to the dispatch in nx_np_cue_at; bump
77// NX_NP_CUE_COUNT; structural-English only -- no topical terms.
78
79const NX_NP_CUE_COUNT: i64 = 18
80
81func nx_np_cue_at(idx: i64,
82 out_role: *i64,
83 out_conf: *i64) -> *u8 {
84 // OUTFIT cues -----------------------------------------------
85 if idx == 0 { *out_role = NX_TERM_ROLE_OUTFIT; *out_conf = 8500; return "wearing " as *u8 }
86 if idx == 1 { *out_role = NX_TERM_ROLE_OUTFIT; *out_conf = 8000; return "dressed in " as *u8 }
87 if idx == 2 { *out_role = NX_TERM_ROLE_OUTFIT; *out_conf = 7500; return "wrapped in " as *u8 }
88 if idx == 3 { *out_role = NX_TERM_ROLE_OUTFIT; *out_conf = 7000; return "put on " as *u8 }
89 if idx == 4 { *out_role = NX_TERM_ROLE_OUTFIT; *out_conf = 7000; return "stripped off " as *u8 }
90 if idx == 5 { *out_role = NX_TERM_ROLE_OUTFIT; *out_conf = 6500; return "tied " as *u8 }
91 // LOCATION cues ---------------------------------------------
92 if idx == 6 { *out_role = NX_TERM_ROLE_LOCATION; *out_conf = 7000; return "in the " as *u8 }
93 if idx == 7 { *out_role = NX_TERM_ROLE_LOCATION; *out_conf = 6500; return "at the " as *u8 }
94 if idx == 8 { *out_role = NX_TERM_ROLE_LOCATION; *out_conf = 6500; return "into the " as *u8 }
95 if idx == 9 { *out_role = NX_TERM_ROLE_LOCATION; *out_conf = 6000; return "from the " as *u8 }
96 if idx == 10 { *out_role = NX_TERM_ROLE_LOCATION; *out_conf = 6000; return "to the " as *u8 }
97 if idx == 11 { *out_role = NX_TERM_ROLE_LOCATION; *out_conf = 5500; return "on the " as *u8 }
98 // ACTION cues -----------------------------------------------
99 if idx == 12 { *out_role = NX_TERM_ROLE_ACTION; *out_conf = 5000; return "she " as *u8 }
100 if idx == 13 { *out_role = NX_TERM_ROLE_ACTION; *out_conf = 5000; return "he " as *u8 }
101 // POSSESSIVE / UNKNOWN-ROLE cues ----------------------------
102 if idx == 14 { *out_role = NX_TERM_ROLE_UNKNOWN; *out_conf = 4500; return "her " as *u8 }
103 if idx == 15 { *out_role = NX_TERM_ROLE_UNKNOWN; *out_conf = 4500; return "his " as *u8 }
104 if idx == 16 { *out_role = NX_TERM_ROLE_UNKNOWN; *out_conf = 4000; return "my " as *u8 }
105 if idx == 17 { *out_role = NX_TERM_ROLE_UNKNOWN; *out_conf = 3000; return "the " as *u8 }
106 *out_role = NX_TERM_ROLE_UNKNOWN; *out_conf = 0
107 return 0 as *u8
108}
109
110// ===== terminator detection =======================================
111//
112// A noun phrase span ends at the next sentence-terminator,
113// conjunction, or strong punctuation.
114
115func nx_np_is_terminator(c: i64) -> i64 {
116 if c == 0x2E { return 1 } // .
117 if c == 0x2C { return 1 } // ,
118 if c == 0x3B { return 1 } // ;
119 if c == 0x3A { return 1 } // :
120 if c == 0x21 { return 1 } // !
121 if c == 0x3F { return 1 } // ?
122 if c == 0x0A { return 1 } // newline
123 if c == 0x0D { return 1 } // CR
124 if c == 0x22 { return 1 } // "
125 if c == 0x29 { return 1 } // )
126 return 0
127}
128
129func nx_np_load_u8(p: *u8, i: i64) -> i64 {
130 let q: *u8 = ((p as i64) + i) as *u8
131 return *q
132}
133
134// ===== span capture ===============================================
135//
136// Starting just past a cue, capture exactly ONE content token,
137// skipping leading stop-tokens (a / an / the / of / to). V1
138// limitation: multi-word phrases like "silk robe" capture only
139// "robe"; adjectival pre-modifier support is V2 (POS-aware).
140//
141// Returns the (start, end) of the captured span via out params.
142// If no valid span found before terminator, returns -1 start.
143
144const NX_NP_MAX_TOKEN_LEN: i64 = 48
145
146func nx_np_is_stop_at_start(src: *u8, s: i64, e: i64) -> i64 {
147 let n: i64 = e - s
148 if n == 1 {
149 let c: i64 = nx_pm_to_lower(nx_np_load_u8(src, s))
150 if c == 0x61 { return 1 } // "a"
151 }
152 if n == 2 {
153 let c0: i64 = nx_pm_to_lower(nx_np_load_u8(src, s))
154 let c1: i64 = nx_pm_to_lower(nx_np_load_u8(src, s + 1))
155 if c0 == 0x61 {
156 if c1 == 0x6E { return 1 } // "an"
157 }
158 if c0 == 0x74 {
159 if c1 == 0x6F { return 1 } // "to"
160 }
161 if c0 == 0x6F {
162 if c1 == 0x66 { return 1 } // "of"
163 }
164 }
165 if n == 3 {
166 let d0: i64 = nx_pm_to_lower(nx_np_load_u8(src, s))
167 let d1: i64 = nx_pm_to_lower(nx_np_load_u8(src, s + 1))
168 let d2: i64 = nx_pm_to_lower(nx_np_load_u8(src, s + 2))
169 if d0 == 0x74 {
170 if d1 == 0x68 {
171 if d2 == 0x65 { return 1 } // "the"
172 }
173 }
174 }
175 return 0
176}
177
178func nx_np_skip_ws(src: *u8, p: i64, region_end: i64) -> i64 {
179 var q: i64 = p
180 let BUDGET: i64 = (region_end - p) + 2
181 var iter: i64 = 0
182 var scanning: i64 = 1
183 while scanning == 1 {
184 if iter >= BUDGET { scanning = 0 }
185 if scanning == 1 {
186 if q >= region_end { scanning = 0 }
187 if scanning == 1 {
188 let c: i64 = nx_np_load_u8(src, q)
189 if c == 0x20 { q = q + 1 }
190 if c == 0x09 { q = q + 1 }
191 if c != 0x20 {
192 if c != 0x09 { scanning = 0 }
193 }
194 }
195 }
196 iter = iter + 1
197 }
198 return q
199}
200
201// Read one token starting at p; returns end offset (= p if no token).
202func nx_np_read_token(src: *u8, p: i64, region_end: i64) -> i64 {
203 var q: i64 = p
204 let BUDGET: i64 = NX_NP_MAX_TOKEN_LEN + 2
205 var iter: i64 = 0
206 var scanning: i64 = 1
207 while scanning == 1 {
208 if iter >= BUDGET { scanning = 0 }
209 if scanning == 1 {
210 if q >= region_end { scanning = 0 }
211 if scanning == 1 {
212 let c: i64 = nx_np_load_u8(src, q)
213 if c == 0x20 { scanning = 0 }
214 if c == 0x09 { scanning = 0 }
215 if scanning == 1 {
216 if nx_np_is_terminator(c) == 1 { scanning = 0 }
217 if scanning == 1 { q = q + 1 }
218 }
219 }
220 }
221 iter = iter + 1
222 }
223 return q
224}
225
226func nx_np_capture_span(src: *u8, after_cue: i64, region_end: i64,
227 out_start: *i64, out_end: *i64) -> i64 {
228 var p: i64 = nx_np_skip_ws(src, after_cue, region_end)
229 if p >= region_end { *out_start = -1; *out_end = -1; return -1 }
230 if nx_np_is_terminator(nx_np_load_u8(src, p)) == 1 {
231 *out_start = -1; *out_end = -1; return -1
232 }
233
234 // Skip up to 2 leading stop tokens.
235 var stop_skipped: i64 = 0
236 while stop_skipped < 2 {
237 let tk_end: i64 = nx_np_read_token(src, p, region_end)
238 if tk_end <= p { stop_skipped = 2 }
239 if tk_end > p {
240 if nx_np_is_stop_at_start(src, p, tk_end) == 1 {
241 p = nx_np_skip_ws(src, tk_end, region_end)
242 if p >= region_end { stop_skipped = 2 }
243 stop_skipped = stop_skipped + 1
244 }
245 if nx_np_is_stop_at_start(src, p, tk_end) == 0 {
246 stop_skipped = 2
247 }
248 }
249 }
250
251 if p >= region_end { *out_start = -1; *out_end = -1; return -1 }
252 if nx_np_is_terminator(nx_np_load_u8(src, p)) == 1 {
253 *out_start = -1; *out_end = -1; return -1
254 }
255
256 // Capture one content token.
257 let span_start: i64 = p
258 let span_end: i64 = nx_np_read_token(src, p, region_end)
259 if span_end <= span_start { *out_start = -1; *out_end = -1; return -1 }
260 *out_start = span_start
261 *out_end = span_end
262 return 0
263}
264
265// ===== top-level scanner ==========================================
266//
267// Walks [region_start, region_end) looking for cue matches. On
268// each match, captures span via nx_np_capture_span and emits a
269// PhraseCandidate. Caller pre-allocates `out` with `out_cap`
270// records; returns count emitted (may be 0).
271
272func nx_noun_phrase_scan(src: *u8, region_start: i64, region_end: i64,
273 out: *PhraseCandidate, out_cap: i64) -> i64 {
274 if out_cap <= 0 { return 0 }
275 if region_end <= region_start { return 0 }
276 var emitted: i64 = 0
277 var p: i64 = region_start
278 let OUTER_BUDGET: i64 = (region_end - region_start) + 2
279 var outer: i64 = 0
280 while p < region_end {
281 if outer >= OUTER_BUDGET { return emitted }
282 if emitted >= out_cap { return emitted }
283
284 // Try each cue at position p (cues require word boundary at
285 // p: either start-of-region or preceded by whitespace/punct).
286 var ok_boundary: i64 = 1
287 if p > region_start {
288 let prev: i64 = nx_np_load_u8(src, p - 1)
289 if nx_pm_is_alnum(prev) == 1 { ok_boundary = 0 }
290 }
291 if ok_boundary == 1 {
292 var cue_idx: i64 = 0
293 var matched_idx: i64 = -1
294 var matched_role: i64 = NX_TERM_ROLE_UNKNOWN
295 var matched_conf: i64 = 0
296 var matched_len: i64 = 0
297 while cue_idx < NX_NP_CUE_COUNT {
298 var role: i64 = NX_TERM_ROLE_UNKNOWN
299 var conf: i64 = 0
300 let cue: *u8 = nx_np_cue_at(cue_idx, &role, &conf)
301 let cue_len: i64 = nx_phrase_strlen(cue, 64)
302 if cue_len > 0 {
303 if p + cue_len <= region_end {
304 var cue_match: i64 = 1
305 var k: i64 = 0
306 while k < cue_len {
307 let h: i64 = nx_pm_to_lower(nx_np_load_u8(src, p + k))
308 let n: i64 = nx_pm_to_lower(nx_pm_load_u8(cue, k))
309 if h != n { cue_match = 0; k = cue_len }
310 k = k + 1
311 }
312 if cue_match == 1 {
313 if matched_idx < 0 {
314 matched_idx = cue_idx
315 matched_role = role
316 matched_conf = conf
317 matched_len = cue_len
318 }
319 // Prefer longer cues -- they're more specific.
320 if cue_len > matched_len {
321 matched_idx = cue_idx
322 matched_role = role
323 matched_conf = conf
324 matched_len = cue_len
325 }
326 }
327 }
328 }
329 cue_idx = cue_idx + 1
330 }
331
332 if matched_idx >= 0 {
333 var s_start: i64 = -1
334 var s_end: i64 = -1
335 if nx_np_capture_span(src, p + matched_len, region_end,
336 &s_start, &s_end) == 0 {
337 if s_start >= 0 {
338 let slot_ptr: *PhraseCandidate =
339 (((out as i64) + emitted * NX_NP_CAND_BYTES)
340 as *PhraseCandidate)
341 slot_ptr.span_start = s_start
342 slot_ptr.span_end = s_end
343 slot_ptr.role_hint = matched_role
344 slot_ptr.confidence = matched_conf
345 emitted = emitted + 1
346 p = s_end
347 }
348 if s_start < 0 { p = p + 1 }
349 }
350 if matched_len > 0 {
351 if p == region_start { p = p + matched_len }
352 }
353 }
354 if matched_idx < 0 { p = p + 1 }
355 }
356 if ok_boundary == 0 { p = p + 1 }
357 outer = outer + 1
358 }
359 return emitted
360}