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}