code wiki / hub / nx_search_handler_flow.nx

nx_search_handler_flow.nx source

↩ module page · 359 lines · 13934 B

1// nx_search_handler_flow.nx -- HUB primitive; site-agnostic search 2// HTTP handler flow. Operator cardinal 2026-05-27: "make sure you 3// make the onsite and offsite search reusable and not just tied to the 4// wiki but with capabilities to be used on other sites". 5// 6// COMPOSES (HUB / runtime-level primitives only -- ZERO site imports): 7// hub/nx_search_query_parser (query string -> NxSearchQuery) 8// hub/nx_search_render_html (HTML render with per-site theme) 9// nx_search_onsite_engine (NxSearchResults) 10// nx_search_inverted (NxInvIndex) 11// 12// COMPOSED BY: 13// wiki/nx_wiki_search_wiring (next commit) 14// (future) obd-config search wiring 15// (future) sprinkler-config search wiring 16// (future) Nishi-hosted third-party site search per charter §3.5 V4 17// 18// SEPARATION OF CONCERNS (per operator cardinal): 19// This module owns the FLOW: parse -> execute -> render -> HTTP wrap. 20// The SITE owns: 21// 1. Building the index (when content changes) 22// 2. The doc-store callback: given rowid, return title/url/body 23// 3. The theme literals (per-site CSS class prefixes) 24// 4. The HTTP route prefix matching ("/wiki/search" vs "/obd/search") 25// The site never re-implements query parsing, AND-intersection, 26// snippet extraction, or HTML emission -- those are HUB primitives. 27// 28// HOW SITES INTEGRATE (~50-line per-site wiring): 29// 1. Allocate one NxSearchFlow at startup (large buffers reused) 30// 2. Define site doc-store walker: given results.rowids[], fill 31// the inputs.title_ptrs/url_ptrs/body_ptrs arrays 32// 3. Build NxSearchRenderTheme with site CSS class prefixes 33// 4. On HTTP request: extract query string, call 34// nx_search_flow_run(flow, query_str, q_n, walker_fn, theme, 35// resp_buf, resp_cap, resp_n) 36// 37// V1 SCOPE per NISHI_SEARCH_CHARTER.md §6: 38// - End-to-end onsite query -> HTML response bytes 39// - Site doc-store integration via pre-resolved inputs 40// - QoS elapsed_us observable 41// 42// V2/V3 SCOPE (TODO): 43// - V2: offsite scope dispatch to nx_search_offsite_engine (not yet) 44// - V2: BM25F ranker integration 45// - V3: hybrid onsite+offsite federated merge 46// 47// Status: V1. 2026-05-27. 48 49import "nx_syscalls.nx" 50import "nx_search_query_parser.nx" 51import "nx_search_onsite_engine.nx" 52import "nx_search_render_html.nx" 53import "nx_search_inverted.nx" 54 55// ===== Sealed verdict surface (codes 2100-2109) ================================================= 56const NX_SHF_OK: i64 = 0 57const NX_SHF_BAD_INPUT: i64 = 2100 58const NX_SHF_PARSE_FAIL: i64 = 2101 59const NX_SHF_ENGINE_FAIL: i64 = 2102 60const NX_SHF_RESOLVER_FAIL: i64 = 2103 61const NX_SHF_RENDER_FAIL: i64 = 2104 62const NX_SHF_HTTP_OVERFLOW: i64 = 2105 63const NX_SHF_NOT_IMPLEMENTED: i64 = 2106 64 65// ===== Named sizing constants (M7) ================================================= 66const NX_SHF_DEFAULT_RESP_CAP: i64 = 65536 // 64 KB response cap 67const NX_SHF_DEFAULT_BODY_CAP: i64 = 49152 // 48 KB body cap (header ~16 KB headroom) 68const NX_SHF_DEFAULT_RESULTS: i64 = 100 69const NX_SHF_DEFAULT_TERM_BUF: i64 = 128 70const NX_SHF_DEFAULT_POSTINGS: i64 = 100000 71const NX_SHF_DEFAULT_COUNTER: i64 = 4000000 // 4 MB; ~4M docs 72const NX_SHF_QUERY_STR_CAP: i64 = 2048 73const NX_SHF_TERMS_BUF_CAP: i64 = 1024 74const NX_SHF_HEADER_BUF_CAP: i64 = 512 75const NX_SHF_INT_BUF_CAP: i64 = 32 76 77// ===== NxSearchFlow: persistent context with all working buffers ================================================= 78// 79// Allocated once at site startup; reused across many queries. 80// Site owns the underlying buffer memory (caller pattern); this 81// struct is just a typed view. 82 83struct NxSearchFlow { 84 // Query parser working buffers 85 q: *NxSearchQuery 86 terms_buf: *u8 87 88 // Onsite engine working buffers 89 engine_ctx: *NxSearchOnsiteCtx 90 counter_buf: *u8 91 term_scratch: *u8 92 postings_buf: *i64 93 94 // Results buffers 95 results: *NxSearchResults 96 rowids_buf: *i64 97 scores_buf: *i64 98 99 // Render inputs (parallel arrays; site fills before render) 100 inputs: *NxSearchRenderInputs 101 title_ptrs: *i64 102 title_lens: *i64 103 url_ptrs: *i64 104 url_lens: *i64 105 body_ptrs: *i64 106 body_lens: *i64 107 108 // Render output buffer (body bytes before HTTP-wrap) 109 body_buf: *u8 110 body_cap: i64 111 112 // Sealed 113 docs_cap: i64 114 valid: i64 115} 116 117func nx_search_flow_init(flow: *NxSearchFlow, 118 counter_cap: i64, 119 docs_cap: i64, 120 postings_cap: i64) -> i64 { 121 if (flow as i64) == 0 { return 0 - NX_SHF_BAD_INPUT } 122 if counter_cap < 1 { return 0 - NX_SHF_BAD_INPUT } 123 if counter_cap > NX_SHF_DEFAULT_COUNTER { return 0 - NX_SHF_BAD_INPUT } 124 if docs_cap < 1 { return 0 - NX_SHF_BAD_INPUT } 125 if docs_cap > NX_SHF_DEFAULT_RESULTS * 10 { return 0 - NX_SHF_BAD_INPUT } 126 if postings_cap < 1 { return 0 - NX_SHF_BAD_INPUT } 127 if postings_cap > NX_SHF_DEFAULT_POSTINGS { return 0 - NX_SHF_BAD_INPUT } 128 129 // Query parser 130 flow.q = (sys_mmap(128)) as *NxSearchQuery 131 flow.terms_buf = (sys_mmap(NX_SHF_TERMS_BUF_CAP)) as *u8 132 let rc_q: i64 = nx_search_query_init(flow.q, flow.terms_buf, NX_SHF_TERMS_BUF_CAP) 133 if rc_q != NX_SQP_OK { return 0 - NX_SHF_BAD_INPUT } 134 135 // Onsite engine 136 flow.engine_ctx = (sys_mmap(128)) as *NxSearchOnsiteCtx 137 flow.counter_buf = (sys_mmap(counter_cap)) as *u8 138 flow.term_scratch = (sys_mmap(NX_SHF_DEFAULT_TERM_BUF)) as *u8 139 flow.postings_buf = (sys_mmap(postings_cap * 8)) as *i64 140 let rc_e: i64 = nx_search_onsite_init(flow.engine_ctx, 141 flow.counter_buf, counter_cap, 142 flow.term_scratch, NX_SHF_DEFAULT_TERM_BUF, 143 flow.postings_buf, postings_cap) 144 if rc_e != NX_SOE_OK { return 0 - NX_SHF_BAD_INPUT } 145 146 // Results 147 flow.results = (sys_mmap(128)) as *NxSearchResults 148 flow.rowids_buf = (sys_mmap(docs_cap * 8)) as *i64 149 flow.scores_buf = (sys_mmap(docs_cap * 8)) as *i64 150 let rc_r: i64 = nx_search_results_init(flow.results, 151 flow.rowids_buf, docs_cap, 152 flow.scores_buf, docs_cap) 153 if rc_r != NX_SOE_OK { return 0 - NX_SHF_BAD_INPUT } 154 155 // Render inputs (parallel arrays) 156 flow.inputs = (sys_mmap(128)) as *NxSearchRenderInputs 157 flow.title_ptrs = (sys_mmap(docs_cap * 8)) as *i64 158 flow.title_lens = (sys_mmap(docs_cap * 8)) as *i64 159 flow.url_ptrs = (sys_mmap(docs_cap * 8)) as *i64 160 flow.url_lens = (sys_mmap(docs_cap * 8)) as *i64 161 flow.body_ptrs = (sys_mmap(docs_cap * 8)) as *i64 162 flow.body_lens = (sys_mmap(docs_cap * 8)) as *i64 163 164 // Body buffer 165 flow.body_buf = (sys_mmap(NX_SHF_DEFAULT_BODY_CAP)) as *u8 166 flow.body_cap = NX_SHF_DEFAULT_BODY_CAP 167 168 flow.docs_cap = docs_cap 169 flow.valid = 1 170 return NX_SHF_OK 171} 172 173// ===== Sealed enum: site-resolver hook kind ================================================= 174// 175// To avoid a circular hub<->site import while keeping the site 176// pluggable, the SITE invokes the flow steps individually and inserts 177// its own resolver between execute and render. See API surface below. 178 179// ===== Step 1: parse query string ================================================= 180 181func nx_search_flow_parse(flow: *NxSearchFlow, 182 query_str: *u8, query_str_n: i64) -> i64 { 183 if flow.valid != 1 { return 0 - NX_SHF_BAD_INPUT } 184 185 // Reset query state for this run (re-init). 186 let rc_q: i64 = nx_search_query_init(flow.q, flow.terms_buf, NX_SHF_TERMS_BUF_CAP) 187 if rc_q != NX_SQP_OK { return 0 - NX_SHF_PARSE_FAIL } 188 189 let rc: i64 = nx_search_query_parse(flow.q, query_str, query_str_n) 190 if rc != NX_SQP_OK { return 0 - NX_SHF_PARSE_FAIL } 191 return NX_SHF_OK 192} 193 194// ===== Step 2: execute onsite engine ================================================= 195// 196// Site provides the index (built/persisted by site code; hub 197// doesn't own indexing lifecycle). 198 199func nx_search_flow_execute_onsite(flow: *NxSearchFlow, 200 idx: *NxInvIndex) -> i64 { 201 if flow.valid != 1 { return 0 - NX_SHF_BAD_INPUT } 202 if (idx as i64) == 0 { return 0 - NX_SHF_BAD_INPUT } 203 204 // Reset results. 205 flow.results.count = 0 206 flow.results.elapsed_us = 0 207 208 let rc: i64 = nx_search_onsite_run(flow.engine_ctx, idx, flow.q, flow.results) 209 if rc != NX_SOE_OK { return 0 - NX_SHF_ENGINE_FAIL } 210 return NX_SHF_OK 211} 212 213// ===== Step 3: SITE RESOLVES DOCS ================================================= 214// 215// AFTER nx_search_flow_execute_onsite returns, the SITE is expected 216// to walk flow.results.rowids[0..count] and populate the parallel 217// arrays in flow.inputs (title_ptrs/lens, url_ptrs/lens, body_ptrs/lens). 218// 219// Then the site calls nx_search_flow_finalize_inputs(flow) to 220// stamp NxSearchRenderInputs (sealed). 221// 222// This boundary is where the hub<->site coupling minimizes: 223// the SITE owns its doc store; the HUB owns parse/execute/render. 224 225func nx_search_flow_finalize_inputs(flow: *NxSearchFlow) -> i64 { 226 if flow.valid != 1 { return 0 - NX_SHF_BAD_INPUT } 227 let rc: i64 = nx_srh_inputs_init(flow.inputs, 228 flow.title_ptrs, flow.title_lens, 229 flow.url_ptrs, flow.url_lens, 230 flow.body_ptrs, flow.body_lens, 231 flow.results.count) 232 if rc != NX_SRH_OK { return 0 - NX_SHF_RESOLVER_FAIL } 233 return NX_SHF_OK 234} 235 236// ===== Step 4: render HTML ================================================= 237// 238// Caller supplies theme (site-specific CSS class prefixes). 239 240func nx_search_flow_render(flow: *NxSearchFlow, 241 theme: *NxSearchRenderTheme, 242 out_body_n: *i64) -> i64 { 243 if flow.valid != 1 { return 0 - NX_SHF_BAD_INPUT } 244 if theme.valid != 1 { return 0 - NX_SHF_BAD_INPUT } 245 if (out_body_n as i64) == 0 { return 0 - NX_SHF_BAD_INPUT } 246 247 let off: *i64 = (sys_mmap(8)) as *i64 248 off[0] = 0 249 let rc: i64 = nx_search_render_html(flow.body_buf, flow.body_cap, 0, 250 flow.q, flow.results, flow.inputs, 251 theme, off) 252 if rc != NX_SRH_OK { return 0 - NX_SHF_RENDER_FAIL } 253 out_body_n[0] = off[0] 254 return NX_SHF_OK 255} 256 257// ===== Decimal int -> bytes (M7 + M10) ================================================= 258 259func nx_shf_int_to_dec(v: i64, out: *u8, cap: i64) -> i64 { 260 if cap < 2 { return 0 } 261 if v == 0 { out[0] = 0x30 as u8; return 1 } 262 var n: i64 = v 263 var neg: i64 = 0 264 if n < 0 { neg = 1; n = 0 - n } 265 let tmp: *u8 = (sys_mmap(NX_SHF_INT_BUF_CAP)) as *u8 266 var k: i64 = 0 267 while n > 0 { 268 if k >= NX_SHF_INT_BUF_CAP - 2 { return 0 } 269 tmp[k] = (0x30 + (n % 10)) as u8 270 n = n / 10; k = k + 1 271 } 272 var off_local: i64 = 0 273 if neg == 1 { 274 if off_local >= cap { return 0 } 275 out[off_local] = 0x2D as u8; off_local = off_local + 1 276 } 277 var j: i64 = k - 1 278 while j >= 0 { 279 if off_local >= cap { return 0 } 280 out[off_local] = tmp[j] 281 off_local = off_local + 1; j = j - 1 282 } 283 return off_local 284} 285 286// ===== Step 5: HTTP-wrap body into response ================================================= 287// 288// Prepends HTTP/1.1 200 OK + Content-Type + Content-Length headers. 289 290func nx_search_flow_wrap_http(flow: *NxSearchFlow, 291 body_n: i64, 292 resp_buf: *u8, resp_cap: i64, 293 out_resp_n: *i64) -> i64 { 294 if flow.valid != 1 { return 0 - NX_SHF_BAD_INPUT } 295 if (resp_buf as i64) == 0 { return 0 - NX_SHF_BAD_INPUT } 296 if (out_resp_n as i64) == 0 { return 0 - NX_SHF_BAD_INPUT } 297 if body_n < 0 { return 0 - NX_SHF_BAD_INPUT } 298 if body_n > flow.body_cap { return 0 - NX_SHF_HTTP_OVERFLOW } 299 300 let header_prefix: *u8 = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: " as *u8 301 let header_prefix_n: i64 = 71 302 303 let crlf2: *u8 = "\r\n\r\n" as *u8 304 let crlf2_n: i64 = 4 305 306 let len_buf: *u8 = (sys_mmap(NX_SHF_INT_BUF_CAP)) as *u8 307 let len_n: i64 = nx_shf_int_to_dec(body_n, len_buf, NX_SHF_INT_BUF_CAP) 308 if len_n < 1 { return 0 - NX_SHF_HTTP_OVERFLOW } 309 310 let need: i64 = header_prefix_n + len_n + crlf2_n + body_n 311 if need > resp_cap { return 0 - NX_SHF_HTTP_OVERFLOW } 312 313 var off: i64 = 0 314 // Header prefix 315 var i: i64 = 0 316 while i < header_prefix_n { 317 resp_buf[off + i] = header_prefix[i] 318 i = i + 1 319 } 320 off = off + header_prefix_n 321 // Content-Length value 322 i = 0 323 while i < len_n { 324 resp_buf[off + i] = len_buf[i] 325 i = i + 1 326 } 327 off = off + len_n 328 // \r\n\r\n 329 i = 0 330 while i < crlf2_n { 331 resp_buf[off + i] = crlf2[i] 332 i = i + 1 333 } 334 off = off + crlf2_n 335 // Body 336 i = 0 337 while i < body_n { 338 resp_buf[off + i] = flow.body_buf[i] 339 i = i + 1 340 } 341 off = off + body_n 342 343 out_resp_n[0] = off 344 return NX_SHF_OK 345} 346 347// ===== Accessor: peek at NxSearchResults for site-resolver step ================================================= 348// 349// Site code reads from this between steps 2 and 3 to populate inputs. 350 351func nx_search_flow_results(flow: *NxSearchFlow) -> *NxSearchResults { 352 if flow.valid != 1 { return 0 as *NxSearchResults } 353 return flow.results 354} 355 356func nx_search_flow_query(flow: *NxSearchFlow) -> *NxSearchQuery { 357 if flow.valid != 1 { return 0 as *NxSearchQuery } 358 return flow.q 359}