code wiki / (root) / nx_noun_phrase_scan.nx

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}