code wiki / _hdl_build / nx_web_crawl_step.nx

nx_web_crawl_step.nx source

↩ module page · 2799 lines · 173838 B

1// nx_web_crawl_step.nx -- CRAWL AT SCALE: the frontier-PERSISTED successor to nx_web_ingest's one-shot BFS. 2// The frontier lives IN the web shard itself (seg_store-native, no side files): key fr:<cid(url)> whose 3// value is "P<url>" (pending) or "D<url>" (done) -- append-only, ss_hget last-wins flips state. Each run: 4// 1. enumerate pending frontier rows (keys-index walk, dedup, latest state wins) 5// 2. fetch up to <max_pages> of them (sovereign TLS; seq628 pool: WC_POOL_N forked workers on 6// DISTINCT hosts, parent-enforced WC_FETCH_BUDGET_MS wall clock, dead-host deferral) 7// 3. ingest kept pages (doc:<cid> + url:<cid>, the nx_web_ingest mechanics) 8// 4. bank every NEWLY-DISCOVERED content link as a pending fr: row (filtered: no queries/fragments/ 9// binaries/wiki-meta) and mark fetched urls done (even failures -- a dead url never wedges the loop) 10// So the crawl RESUMES where it stopped: run it any number of times and the index only grows. A seed url 11// is only needed to BOOTSTRAP an empty frontier (or inject a new start). 12// usage: nx_web_crawl_step <max_pages> [seed-url] 13// license_tier: ORIGINAL 14import "nx_heavyio_lib.nx" // the estate-wide heavy-I/O concurrency bound (2026-09-03): a crawl step fans out fetch children and seg-store writes, so it asks BEFORE the certdata load 15import "nx_corpus_ingest.nx" // ci_hash / ci_mkurlkey / dss_prefix / dss_mkkey / seg_store / nx_html_to_text 16import "nx_x509_trust_store.nx" 17import "nx_trust_store_load_from_certdata.nx" 18import "nx_https_fetch_follow.nx" 19import "nx_simhash.nx" 20import "nx_itoa_lib.nx" // nxi_out -- THE canonical integer emitter (runtime/ layer, importable from here) 21import "nx_crawl_pace.nx" 22import "nx_robots.nx" // R11: RFC 9309 allow/disallow + Crawl-delay (was imported by ONE organ) // adaptive per-host pacing (replaces the fixed 350ms; 429 backoff at scale) 23import "nx_feed_extract.nx" // nx_feed_discover + nx_feed_item_at -- seed the frontier with clean article URLs 24import "nx_js_eval.nx" // R-JS-HEADLESS: js_render_page + js_has_inline_script (hydrate JS pages for indexing) 25import "nx_outlink_harvest.nx" 26import "nx_store_seed_lib.nx" // sts_load -> the urlpolicy- plane (R10: the denylist is DATA, not code) // olh_urlok / olh_outkey / olh_resolve_root / olh_scan -- CANONICAL url filter + 27import "nx_block_density.nx" // R6 (2026-08-24, /compare/webscraping bd_fit_text): the index stores the ARTICLE, not the chrome around it 28import "nx_crawl_sufficiency.nx" // R5 (cs_confidence): query-scoped crawl stops when coverage+consistency+saturation clear the bar 29import "nx_antibot.nx" // R4 (abt_classify): bot walls classified from marker ROWS and named by vendor, never solved 30const WC_MAGIC_1024: i64 = 1024 // NX-UNNAMED: mechanical, collision-free placeholder -- this name RESTATES the value and so carries no meaning. Replace it with a domain term that says WHY, or move the number to a config row. 31const WC_MAGIC_1089: i64 = 1089 32const WC_MAGIC_4000: i64 = 4000 33// seq1002: JS hydration is an ENHANCEMENT gated behind an UNBUDGETED interpreter. 0 = off (safe default, 34// crawler always terminates); 1 = on (only once nx_js_eval enforces an execution budget). Named const so 35// re-enabling is a deliberate one-line decision with this comment attached, not a silent edit. 36const WC_JS_HYDRATE: i64 = 0 37const WC_MAGIC_2000: i64 = 2000 38const WC_MAGIC_2048: i64 = 2048 39const WC_MAGIC_8192: i64 = 8192 40const WC_MAGIC_4194304: i64 = 4194304 41const WC_MAGIC_4096: i64 = 4096 42const WC_MAGIC_8388608: i64 = 8388608 43const WC_MAGIC_1048576: i64 = 1048576 44const WC_MAGIC_5381: i64 = 5381 45 // link-graph edge semantics, SHARED with nx_cc_ingest (one node-identity policy) 46 47const WC_MAXPEND: i64 = 2048 // pending urls pulled per run. MEASURED 2026-08-04: at 256 the pull 48 // window sat 93% full of skipped-dead-host rows (~240 recur every run 49 // in stable walk order, deliberately left pending) -> fetched=13-48 of a 50 // 400-page budget, ~300-600 pages/day vs the ~7-8K the beat intends. 2048 51 // leaves ~1800 live-host candidates after the rot blockade; all buffers 52 // (urls mmap, seenk, seed shift) scale off this const. The rot itself 53 // still needs a persistent host-health retire policy (debt filed). 54 // STALE AS OF 2026-08-25 -- READ WC_HD_RETIRE IMMEDIATELY BELOW BEFORE 55 // ACTING ON THE LINE ABOVE. That persistent host-health retire policy WAS 56 // IMPLEMENTED: wc_hd_streak gates it at :1268 and nx_web_crawl_step_gate 57 // T8 proves the P->D retire actually fires. The sentence is kept, not 58 // deleted, because it records WHY 2048 was chosen -- but a growth-analysis 59 // lane read it as a LIVE gap and ranked "implement dead-host retire" as the 60 // number-one blocker, for a fix that already ships. A HEADER IS NOT A 61 // MEASUREMENT, and a stale one costs the next reader the whole investigation. 62 // The measured blocker is WC_MAXDISC below, pinned at its 300 ceiling in 63 // 58 of 60 recent runs while the frontier sits flat at ~1900 -- discovery 64 // exactly offsets consumption, so the frontier cannot build a backlog. 65const WC_HD_RETIRE: i64 = 3 // R3 host-health: retire (P->D) a skipped row once its host's PERSISTED 66 // dead-run streak reaches this (streak increments once per dead run, 67 // resets on any completed response; retire fires on run streak+1). 68const WC_MAXSEEDS: i64 = 256 // seed-file urls held for the retire EXEMPTION + the R4 canonical lane + 69 // the R5 re-queue. Was 96 while the seed file sat at 95 rows -- ONE row 70 // from a SILENT cliff: the 97th line would have been dropped from the 71 // retire exemption with no log line, and a retired seed is PERMANENT 72 // host loss (wc_seed_add refuses ANY existing fr: row). Raised 73 // 2026-08-05 (debt 1785936869); the drop is now LOUD (seed parse below). 74const WC_SEED_REFRESH: i64 = 8 // R5: already-done seed urls re-marked pending per run. The persisted 75 // sr:cursor rotates the whole seed list, so every hub is re-fetched 76 // roughly every nseeds/8 runs and re-harvests its outlinks -- the 77 // frontier REPLENISHES instead of burning down. 78const WC_MAXDISC: i64 = 300 // FLOOR for the per-run discovery budget -- see wc_disc_budget below. 79// R17 SPLIT (2026-08-25). WC_MAXDISC was serving TWO UNRELATED PURPOSES: the per-run POLICY budget, and 80// the DIMENSION of the runseen dedup buffer (sys_mmap(32 * (WC_MAXDISC + 8)), and nrs[0] < WC_MAXDISC+7). 81// ONE CONSTANT SERVING TWO PURPOSES CAN NEVER BE TUNED FOR EITHER: raising the budget silently overruns 82// the buffer, so the discovery policy was frozen in place by a memory bound nobody was thinking about 83// when they chose 300. Splitting them is what makes the policy adjustable at all. 84// WC_DISC_CEIL is now the buffer dimension AND the hard ceiling. Derived from WC_MAXPEND because banking 85// more rows than one pull window can hold buys nothing -- they cannot be pulled next run either way. 86// No new guessed number: the ceiling IS the window. 87const WC_DISC_CEIL: i64 = WC_MAXPEND 88const WC_CANON_RESERVE: i64 = 384 // R4 CANONICAL LANE (2026-08-05): head slots of each run's work list 89 // reserved for canonical-seed hosts. MEASURED that day: an 89,000-doc 90 // corpus held 64 en.wikipedia.org / 0 who.int / 1 python.org / 2 irs.gov 91 // docs -- every one of them seeded and answering HTTP 200, none blocked 92 // and none retired. They were STARVED, not refused. 93const WC_MAXCANH: i64 = 96 // distinct canonical HOSTS parsed from the seed file (mirrors WC_MAXSEEDS) 94const WC_MAXURL: i64 = 1500 // urls longer than this are skipped (junk/tracking monsters) 95const WC_MININDEX: i64 = 500 // min extracted chars to INDEX a page (not to harvest its links). Measured 96 // bimodal gap 2026-07-05: junk<=56 chars, real content>=1251, nothing between 97 // -> 500 filters thin junk (person/tag stubs) with huge margin. (>CI_MINDOC=20.) 98const WC_HOSTCAP_CANON: i64 = 40 // R4b DEPTH CAP FOR CURATED HOSTS (2026-08-05). WC_HOSTCAP below exists to 99 // stop ONE junk host exploding into N near-duplicate variants -- that is a 100 // defence against UNCURATED breadth, and it is exactly backwards for a 101 // canonical seed host, where DEPTH is the whole objective. MEASURED: at 6 102 // pages/host/run en.wikipedia.org gains at most 432 docs/day, so climbing 103 // out of 64 docs would take about three weeks; at 40 it is a few days. 104 // Still bounded, and still spread across ~60 canonical hosts. 105const WC_HOSTCAP: i64 = 6 // max pages INDEXED per host per crawl step (diversity). Measured 2026-07-06: 106 // one CC0 deed page exploded into 16 language variants (deed.fr/de/fi/...) that 107 // simhash misses (translated = different fingerprint). A per-host cap defends 108 // against that + faceted-nav/calendar/pagination traps. Harvest is NOT capped. 109 110// ---- seq628 PARALLEL FETCH POOL (2026-07-29) ---- 111// The crawl was SEQUENTIAL: one unbounded fetch at a time, so a frontier of mostly-dead hosts burned 112// minutes per url on connect timeouts (measured 2026-07-23: a 30-page crawl stalled 10+ min). Fix shape: 113// fetch in BATCHES of WC_POOL_N forked children (network wait overlaps), each child hard-bounded by a 114// PARENT-enforced wall clock (stronger than a connect timeout: it bounds connect+TLS+read+redirects), 115// one DISTINCT host per in-flight slot (politeness: never two concurrent fetches to one host), and a 116// host that times out or hard-fails is DEFERRED for the rest of the run (its urls stay PENDING, not 117// done -- recency/priority, retried next run at one-probe cost). Ingest/harvest stay single-process in 118// the parent: the seg-store writer keeps ONE writer by construction. 119const WC_POOL_N: i64 = 8 // in-flight fetch children per batch. Bounded by spool RAM (8 x 8MiB 120 // fetch caps = 64MiB peak) and by typical distinct-host diversity in 121 // a 256-url frontier pull; speedup ~ min(N, distinct hosts). 122const WC_FETCH_BUDGET_MS: i64 = 20000 // per-fetch wall clock. Healthy fetches measure <1s recv with 123 // worst-case ~5s TLS handshake flakes; 20s = ~4x the worst GOOD 124 // fetch observed, where a dead host used to cost minutes. 125const WC_POLL_MS: i64 = 100 // parent reap-poll quantum while children are in flight 126const WC_US_PER_MS: i64 = 1000 127const WC_WNOHANG: i64 = 1 128const WC_SIGKILL: i64 = 9 129// spool header: [i64 http-status][i64 body-bytes][i64 redirect-hops] 130// WIDENED 2026-08-16 to carry REDIRECT DEPTH across the fork. The fetch happens in a CHILD, so 131// nx_https_last_hops() read in the parent would report a static the child never touched -- a 132// constant-zero field with an authoritative name. The count has to ride the spool or it does not 133// exist downstream at all. 134// SAFE BY CONSTRUCTION, not by convention: the child is a fork() of this same binary, so producer and 135// consumer are the same struct in the same image and cannot disagree about layout. (The usual 136// wire-format hazard -- a reader that misparses a widened record -- needs two SEPARATELY deployed 137// programs; it does not apply to a process and its own fork.) 138// WHY IT MATTERS: a doorway that redirects onward and never serves the artifact is the most 139// goal-defeating result a search engine can return -- it looks like an answer and hands over nothing. 140// Captured at INGEST, so detecting it later never costs a re-fetch. 141const WC_SPOOL_HDRB: i64 = 24 142// fp: row scratch. Lazy-allocated ONCE per process, never per ingest -- an mmap inside the ingest 143// loop would burn a page for every page indexed (the estate has paid that bill before: 262KB x 2400 144// files = 640MB for one census). 145static wc_fpk_g: *u8 146static wc_fpv_g: *u8 147static wc_fek_g: *u8 // S12: the fe:<cid> key buffer (one, reused per page like wc_fpk_g) 148static wc_fev_g: *u8 // S12: the fetch epoch's decimal bytes 149// NEAR-DUP COMPARISON WINDOW. The dedup loop compared against at most this many of the run's kept 150// fingerprints and said NOTHING when it truncated -- past the window, duplicates pass unnoticed and 151// the run reports a clean ingest it never actually checked. A cap reached in silence becomes a 152// measurement nobody knows is partial. Named here and ANNOUNCED at the call site. 153const WC_DEDUP_WINDOW: i64 = 128 154// simhash Hamming distance at or below which two documents are the same page. 64-bit fingerprint, so 155// this is ~6% of the bit width -- the standard near-dup band, not a tuned knob. DECLARED IMPRECISION: 156// nx_simhash misses TRANSLATED duplicates (different tokens -> different fingerprint), which is why 157// the per-host cap exists as a second defence; see the note at WC_HOSTCAP. 158const WC_SIMHASH_HAM: i64 = 4 159static wc_dwarn_g: i64 // 1 once the window-truncation notice fired (announce ONCE per run, not per page) 160const WC_SPOOLPATH_B: i64 = 128 161const WC_MODE_RW: i64 = 420 // 0644 spool file mode 162const WC_DEADHOSTS: i64 = 128 // dead-host slots per run (frontier pull is 256 urls; hosts <= urls) 163const WC_HASH_MASK: i64 = 0x7fffffffffffffff 164 165// ==== R7 FRONTIER PRIORITY + AGING (2026-08-24, /compare/webscraping contract wc_priority) ================== 166// MEASURED 2026-08-04 (the WC_MAXPEND note above): the pull window sat 93% full of the SAME skipped rows in 167// stable walk order, so rows beyond the window never got a turn. Two mechanisms, both pure and gate-tested: 168// ROTATION: a persisted cursor (pc:cur row, the sr:cursor encoding) -- wc_pending skips the first `cursor` 169// pending rows (phase 0) and, if the window is not full, wraps to the head (phase 1); the cursor advances by 170// the rows taken and wraps at the pending population, so every pending row is offered in turn. 171// RANKING: the general window is ordered by (query-term hits in the url DESC, path depth ASC), stable. 172// Shallow-first is Cho, Garcia-Molina and Page 1998 (breadth-first order finds high-PageRank pages early); 173// the query bonus is the crawl4ai KeywordRelevanceScorer shape. No weight constants: one query hit outranks 174// any depth because a url is at most WC_MAXURL bytes and therefore at most WC_MAXURL levels deep. 175// The canonical lane (R4) is untouched: its own wc_pending pass runs with rotation off (skip=stop=0). 176static wc_qh_g: *i64 177static wc_qn_g: i64 178static wc_qtok_g: *i64 179static wc_pc_skip_g: i64 180static wc_pc_stop_g: i64 181static wc_pc_seen_g: i64 182static wc_pcc_key_g: *u8 183static wc_pcc_pp_g: *i64 184static wc_pcc_pl_g: *i64 185static wc_pcp_key_g: *u8 186static wc_pcp_val_g: *u8 187static wc_cs_g: *i64 188static wc_cs_stop_g: i64 189static wc_fit_pages_g: i64 190static wc_fit_saved_g: i64 191static wc_fit_fallback_g: i64 192const WC_FIT_TEXT: i64 = 1 // R6: index the density-fit text (nx_block_density); 0 = the pre-R6 full render 193func wc_query_set(q: *u8) -> i64 { 194 if (wc_qh_g as i64) == 0 { wc_qh_g = sys_mmap(CS_QMAX * 8) as *i64 } 195 if (wc_qtok_g as i64) == 0 { wc_qtok_g = sys_mmap(WC_MAXURL * 8) as *i64 } 196 var n: i64 = cs_tokens(q, wc_len(q), wc_qh_g, CS_QMAX) 197 if n > CS_QMAX { n = CS_QMAX } 198 wc_qn_g = n 199 return n 200} 201// path depth: levels below the host. https://a.org/ -> 0, /x -> 1, /x/y/z -> 3; a doubled or trailing slash opens no level 202func wc_url_depth(u: *u8, ul: i64) -> i64 { 203 var i: i64 = 0 204 var s: i64 = 0 - 1 205 var f: i64 = 0 206 while f == 0 { if i + 2 >= ul { f = 1 } else { if u[i] == (58 as u8) { if u[i + 1] == (47 as u8) { if u[i + 2] == (47 as u8) { s = i + 3; f = 1 } } } if f == 0 { i = i + 1 } } } 207 if s < 0 { s = 0 } 208 var g: i64 = 0 209 while g == 0 { if s >= ul { g = 1 } else { if u[s] == (47 as u8) { g = 1 } else { s = s + 1 } } } 210 var depth: i64 = 0 211 var p: i64 = s 212 while p < ul { 213 if u[p] == (47 as u8) { if p + 1 < ul { if u[p + 1] != (47 as u8) { depth = depth + 1 } } } 214 p = p + 1 215 } 216 return depth 217} 218// how many DISTINCT query terms the url names (0 without a query) 219func wc_url_qhits(u: *u8, ul: i64) -> i64 { 220 if wc_qn_g <= 0 { return 0 } 221 var n: i64 = cs_tokens(u, ul, wc_qtok_g, WC_MAXURL) 222 if n > WC_MAXURL { n = WC_MAXURL } 223 var hits: i64 = 0 224 var q: i64 = 0 225 while q < wc_qn_g { 226 var i: i64 = 0 227 var f: i64 = 0 228 while i < n { if wc_qtok_g[i] == wc_qh_g[q] { f = 1; i = n } else { i = i + 1 } } 229 hits = hits + f 230 q = q + 1 231 } 232 return hits 233} 234func wc_priority(u: *u8, ul: i64) -> i64 { return wc_url_qhits(u, ul) * WC_MAXURL - wc_url_depth(u, ul) } 235// order the window's 2KB slots by priority DESC, stable: rank an index array, then permute ONCE through a copy 236func wc_rank_window(urls: *u8, n: i64) -> i64 { 237 if n <= 1 { return 0 } 238 let pr: *i64 = sys_mmap(n * 8) as *i64 239 let ix: *i64 = sys_mmap(n * 8) as *i64 240 var i: i64 = 0 241 while i < n { let sp: *u8 = (urls as i64 + i * WC_MAGIC_2048) as *u8; pr[i] = wc_priority(sp, wc_len(sp)); ix[i] = i; i = i + 1 } 242 var a: i64 = 1 243 while a < n { 244 var j: i64 = a 245 while j > 0 { 246 if pr[ix[j - 1]] < pr[ix[j]] { let t: i64 = ix[j - 1]; ix[j - 1] = ix[j]; ix[j] = t; j = j - 1 } else { j = 0 } 247 } 248 a = a + 1 249 } 250 let cp: *u8 = sys_mmap(n * WC_MAGIC_2048) 251 var k: i64 = 0 252 while k < n { 253 let sp2: *u8 = (urls as i64 + ix[k] * WC_MAGIC_2048) as *u8 254 let dp2: *u8 = (cp as i64 + k * WC_MAGIC_2048) as *u8 255 var c: i64 = 0 256 var go: i64 = 1 257 while go == 1 { dp2[c] = sp2[c]; if sp2[c] == (0 as u8) { go = 0 } else { c = c + 1 } } 258 k = k + 1 259 } 260 k = 0 261 while k < n { 262 let sp3: *u8 = (cp as i64 + k * WC_MAGIC_2048) as *u8 263 let dp3: *u8 = (urls as i64 + k * WC_MAGIC_2048) as *u8 264 var c3: i64 = 0 265 var go3: i64 = 1 266 while go3 == 1 { dp3[c3] = sp3[c3]; if sp3[c3] == (0 as u8) { go3 = 0 } else { c3 = c3 + 1 } } 267 k = k + 1 268 } 269 sys_munmap(cp, n * WC_MAGIC_2048) 270 sys_munmap(pr as *u8, n * 8) 271 sys_munmap(ix as *u8, n * 8) 272 return n 273} 274// the persisted rotation cursor: pc:cur row, ASCII digits, last-wins (the sr:cursor pattern) 275func wc_pckey(out: *u8) -> i64 { 276 out[0] = 112 as u8; out[1] = 99 as u8; out[2] = 58 as u8 277 out[3] = 99 as u8; out[4] = 117 as u8; out[5] = 114 as u8 278 out[6] = 0 as u8 279 return 6 280} 281func wc_pc_cursor(h: *i64) -> i64 { 282 if (h as i64) == 0 { return 0 } 283 if (wc_pcc_key_g as i64) == 0 { wc_pcc_key_g = sys_mmap(64) } 284 if (wc_pcc_pp_g as i64) == 0 { wc_pcc_pp_g = sys_mmap(16) as *i64 } 285 if (wc_pcc_pl_g as i64) == 0 { wc_pcc_pl_g = sys_mmap(16) as *i64 } 286 wc_pckey(wc_pcc_key_g) 287 if ss_hget(h, wc_pcc_key_g, wc_pcc_pp_g, wc_pcc_pl_g) != 1 { return 0 } 288 let vp: *u8 = wc_pcc_pp_g[0] as *u8 289 var v: i64 = 0 290 var i: i64 = 0 291 while i < wc_pcc_pl_g[0] { let c: i64 = vp[i] as i64; if c >= 48 { if c <= 57 { v = v * 10 + (c - 48) } } i = i + 1 } 292 return v 293} 294func wc_pc_put(w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, v: i64) -> i64 { 295 if (wc_pcp_key_g as i64) == 0 { wc_pcp_key_g = sys_mmap(64) } 296 if (wc_pcp_val_g as i64) == 0 { wc_pcp_val_g = sys_mmap(28) } 297 wc_pckey(wc_pcp_key_g) 298 let vl: i64 = ccz_cat_num(wc_pcp_val_g, 0, v) 299 if ss_add(w, 1, wc_pcp_key_g, wc_pcp_val_g, vl) < 0 { 300 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 301 segidbox[0] = segidbox[0] + 1 302 w[1] = 0 303 ss_add(w, 1, wc_pcp_key_g, wc_pcp_val_g, vl) 304 } 305 return 0 306} 307// next cursor: advance by the rows taken; wrap to 0 at the pending population (or when the cursor is already past it) 308func wc_pc_next(cur: i64, took: i64, seen: i64) -> i64 { 309 if seen <= 0 { return 0 } 310 if cur >= seen { return 0 } 311 let nx: i64 = cur + took 312 if nx >= seen { return 0 } 313 return nx 314} 315// wc_pending's admission under rotation: counts every pending row it meets (the population, an upper bound -- 316// a key living in two segments is met twice), takes a row only when the window has room and the row is inside 317// R13 FRONTIER GAUGE, EXTRACTED 2026-08-25 so it can be gated. It was inline inside the done-line's 318// wc_num() call, which meant the one number every capacity decision is read off had no tooth at all. 319// A row pulled into this run's window leaves the frontier by exactly FOUR routes, and each one marks it 320// D: it was fetched, it was retired as dead, robots refused it, or the selection-time host cap dropped it. 321// Everything else pulled stays PENDING and must NOT be subtracted -- deadhost SKIPS (that did not reach 322// the retire bar) and R13 pace DEFERRALS both stay P on purpose, and rows the batch loop never reached 323// because the page budget ran out are still P as well. 324// A PARTITION IS A CLAIM: THESE PARTS SUM, and wc_frontier_left below is the other half of the claim. 325func wc_frontier_remaining(npend: i64, fetched: i64, retired: i64, rbskip: i64, hostcapped: i64, disc: i64) -> i64 { 326 return npend - wc_frontier_left(fetched, retired, rbskip, hostcapped) + disc 327} 328// The outflow alone: the rows that were pulled AND left the pending state this run. Named separately so a 329// tooth can assert the partition rather than re-deriving the same expression it is supposed to be checking 330// (a check that restates its subject proves only that addition is commutative). 331func wc_frontier_left(fetched: i64, retired: i64, rbskip: i64, hostcapped: i64) -> i64 { 332 return fetched + retired + rbskip + hostcapped 333} 334// the phase's band. With skip=stop=0 this is exactly the old `npend < maxout`. 335func wc_pc_take(npend: i64, maxout: i64) -> i64 { 336 wc_pc_seen_g = wc_pc_seen_g + 1 337 if npend >= maxout { return 0 } 338 if wc_pc_skip_g > 0 { if wc_pc_seen_g <= wc_pc_skip_g { return 0 } } 339 if wc_pc_stop_g > 0 { if wc_pc_seen_g > wc_pc_stop_g { return 0 } } 340 return 1 341} 342 343func wc_puts(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 } 344// HOISTED 2026-08-08: wc_num is the NUMBER PRINTER and it ran TWO sys_mmap(28) per call. sys_mmap is 345// page-granular, so EVERY NUMBER THIS ORGAN HAS EVER LOGGED COST 8 KB. Not per href, so it survived 346// three passes that were all looking at the crawl path -- a leak inside the INSTRUMENTATION is invisible 347// to anyone reading the instrumentation. Both buffers are fully consumed by sys_write before return. 348// DELEGATED 2026-08-08 -- SUPERSEDES MY OWN HOIST FROM THIS MORNING, and the reason is worth keeping. 349// wc_num was a hand-rolled number printer: two sys_mmap(28) per call, page-granular, so every number 350// this organ ever logged cost 8 KB. My first fix hoisted both buffers to lazy statics. That WORKED but 351// was still the wrong shape -- it left a SECOND implementation of a primitive the estate already owns. 352// nx_itoa_lib.nx (runtime/ layer) has been the canonical emitter since 2026-07-31 and names itself 353// 'the drop-in for every hand-rolled putn/gn/wn/pn clone'; this organ is simply one of the ~1792 clones. 354// Also drops 2 of the nx_mmapbal findings the hoist introduced (row 3795): a delegation has no 355// unmatched sys_mmap at all, so it stops triggering the scanner rather than arguing with it. 356func wc_num(v: i64) -> i64 { nxi_out(v); return 0 } 357func wc_len(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 358// resolve a feed href against the page url: absolute (http...) kept; "/path" -> scheme://host/path. 359func wc_resolve(base: *u8, href: *u8, out: *u8) -> i64 { 360 if href[0]==(104 as u8) { if href[1]==(116 as u8) { if href[2]==(116 as u8) { if href[3]==(112 as u8) { 361 var a: i64=0; while href[a]!=(0 as u8) { out[a]=href[a]; a=a+1 } out[a]=0 as u8; return 0 362 } } } } 363 var sp: i64 = 0 - 1; var i: i64 = 0; var df: i64 = 0 364 while df == 0 { if base[i]==(0 as u8) { df=1 } else { if base[i]==(58 as u8) { if base[i+1]==(47 as u8) { if base[i+2]==(47 as u8) { sp=i; df=1 } } } if df==0 { i=i+1 } } } 365 if sp < 0 { var k: i64=0; while href[k]!=(0 as u8) { out[k]=href[k]; k=k+1 } out[k]=0 as u8; return 0 } 366 var he: i64 = sp + 3; var dn: i64 = 0 367 while dn == 0 { if base[he]==(0 as u8) { dn=1 } else { if base[he]==(47 as u8) { dn=1 } else { he=he+1 } } } 368 var o: i64 = 0; while o < he { out[o]=base[o]; o=o+1 } 369 if href[0]==(47 as u8) { var j: i64=0; while href[j]!=(0 as u8) { out[o]=href[j]; o=o+1; j=j+1 } } 370 else { out[o]=47 as u8; o=o+1; var j: i64=0; while href[j]!=(0 as u8) { out[o]=href[j]; o=o+1; j=j+1 } } 371 out[o]=0 as u8; return 0 372} 373// host (between "://" and next '/' or ':') -> out; flag-based (never index past the string). returns len. 374// HOST SCRATCH BOUND (2026-08-15). Every wc_host call site allocates sys_mmap(64); the scan below 375// had NO cap on `o` and stopped only at NUL, '/' or ':'. MEASURED LIVE, twice, on consecutive runs: 376// ARENA-OVERRUN prev_alloc_size=64 own: www.asianude4u.net.k.du.-q4fz.googlevideo.com.2F 377// A url that percent-encodes its slashes (%2F) presents NO literal '/', so the host scan walks the 378// whole PATH into a 64-byte buffer. olh_urlok_q rejects '?' and '#' but not %2F, so such urls are 379// admitted -- i.e. this is reachable from crawled third-party page content, not just our own seeds. 380// A "host" longer than the buffer is never a long host; it is a parse that ran off the end of one. 381// REFUSE (return 0 = no host), never truncate: callers all guard `if hl > 0`, so a refusal skips the 382// row, which is the wrong-in-the-direction-of-doing-nothing failure a guard is required to have. 383const WC_HOSTBUF: i64 = 64 // == the sys_mmap(64) every caller passes 384const WC_HOSTMAX: i64 = WC_HOSTBUF - 1 // DERIVED, authored once: longest host + its NUL 385func wc_host(url: *u8, out: *u8) -> i64 { 386 var sp: i64 = 0 - 1; var i: i64 = 0; var d: i64 = 0 387 while d == 0 { if url[i]==(0 as u8) { d=1 } else { if url[i]==(58 as u8) { if url[i+1]==(47 as u8) { if url[i+2]==(47 as u8) { sp=i; d=1 } } } if d==0 { i=i+1 } } } 388 var s: i64 = 0; if sp >= 0 { s = sp + 3 } 389 var o: i64 = 0; var d2: i64 = 0 390 while d2 == 0 { let c: i64 = url[s] as i64; if c==0 { d2=1 } else { if c==47 { d2=1 } else { if c==58 { d2=1 } else { if o >= WC_HOSTMAX { out[0] = 0 as u8; return 0 } out[o]=url[s]; o=o+1; s=s+1 } } } } 391 out[o] = 0 as u8; return o 392} 393func wc_atoi(s: *u8) -> i64 { var v: i64 = 0; var i: i64 = 0; while s[i] != (0 as u8) { let c: i64 = s[i] as i64; if c >= 48 { if c <= 57 { v = v * 10 + (c - 48) } } i = i + 1 } return v } 394func wc_hassub(u: *u8, ul: i64, lit: *u8) -> i64 { 395 let ll: i64 = wc_len(lit) 396 if ll == 0 { return 0 } 397 var i: i64 = 0 398 while i + ll <= ul { 399 var m: i64 = 1 400 var k: i64 = 0 401 while k < ll { if u[i + k] != lit[k] { m = 0; k = ll } else { k = k + 1 } } 402 if m == 1 { return 1 } 403 i = i + 1 404 } 405 return 0 406} 407func wc_endswith(u: *u8, ul: i64, lit: *u8) -> i64 { 408 let ll: i64 = wc_len(lit) 409 if ul < ll { return 0 } 410 var k: i64 = 0 411 while k < ll { if u[ul - ll + k] != lit[k] { return 0 } k = k + 1 } 412 return 1 413} 414// CONTENT-URL FILTER: 1 = worth crawling. Delegates to the CANONICAL olh_urlok (verbatim extraction of the 415// rules that lived here; shared with nx_cc_ingest) so frontier urls and link-graph nodes follow ONE policy 416// (OLH_MAXURL == the measured 1500 junk threshold that was WC_MAXURL). 417// wc_urlok MOVED below the R4 canon helpers (it now consults them) -- see wc_urlok after wc_host_is_canon. 418// frontier key for a URL: "fr:<decimal cid of the url bytes>" 419static wc_frkey_t_g: *u8 420func wc_frkey(u: *u8, ul: i64, out: *u8) -> i64 { 421 let cid: i64 = ci_hash(u, ul) 422 out[0] = 102 as u8 // f 423 out[1] = 114 as u8 // r 424 out[2] = 58 as u8 // : 425 var m: i64 = cid 426 // HOISTED 2026-08-06 (see wc_frstate above): 1 of the 5 pages leaked per candidate href. Reached 427 // per-href via wc_frstate and the harvest new-url path. `t` holds decimal digits that are copied 428 // into `out` before return -- nothing retains it, so a single reused scratch is safe. 429 if (wc_frkey_t_g as i64) == 0 { wc_frkey_t_g = sys_mmap(28) } 430 let t: *u8 = wc_frkey_t_g 431 var k: i64 = 0 432 if m == 0 { t[0] = 48 as u8; k = 1 } 433 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 434 var i: i64 = 0 435 while i < k { out[3 + i] = t[k - 1 - i]; i = i + 1 } 436 out[3 + k] = 0 as u8 437 return 3 + k 438} 439// out:<cid(page_url)> = the LINK GRAPH edge list for PageRank (P1): value = packed i64 target cid(url)s. 440// Node identity = cid(url) (SAME hash as the frontier's fr:<cid(url)>), so an outlink resolves to a graph 441// node with zero extra index; serve-time authority = content_cid -> url:<cid> -> ci_hash(url) -> pr:<cid>. 442// Key shape lives in the SHARED lib (one producer policy with nx_cc_ingest). 443func wc_outkey(cid: i64, out: *u8) -> i64 { return olh_outkey(cid, out) } 444// frontier state for a URL: 0 = unknown (no row), 1 = pending, 2 = done 445// ---- HOISTED 2026-08-06: THIS FUNCTION WAS 3 OF THE 5 PAGES LEAKED PER CANDIDATE HREF ---- 446// MEASURED: ./nx_web_crawl_step.elf 400 reached 2662 MB RSS in ~11 min, ~6.6 MB retained PER PAGE, and 447// drove the box to 828 permille swap and I/O-bound (procs_blocked 6 > running 5). sys_mmap is PAGE 448// GRANULAR here -- this file says so itself at lines 283-284, "a per-row sys_mmap would burn a page per 449// frontier row" -- so these three 16-64 byte requests cost 3 x 4096 B, per href, forever. 450// WHY IT WENT UNSEEN: the crawl loop body (1440+) really is clean; its "no mmap in the crawl loop" 451// comment is TRUE. Every leaked byte entered through a CALLEE. A loop is not the extent of a loop. 452// WHY THE GUARD DID NOT SAVE US: the WC_MAXDISC test at the harvest site gates on discbox[0], which only 453// increments on a NEW bank. Once discovered_new decays to 0 (this file's own R5 note: 121 -> 35 -> 0) the 454// guard NEVER trips, so every page allocates at full rate. It bounds the DISCOVERY budget, not the 455// ALLOCATION -- the allocation happens before the discovery test. The leak is WORST in the steady cron regime. 456// WHY HOIST AND NOT munmap: there are four post-allocation return paths below; a munmap pairing needs all 457// of them and one miss is a silent partial leak. Hoisting is one edit with no exit-path discipline. 458// CORRECTNESS, checked not assumed: `key` is consumed by ss_hget IN-CALL; pp/pl are out-boxes whose 459// pp[0] points into the MAPPED SHARD, not into pp. Nothing retains a pointer past the call. The proof is 460// in this organ's own hot path -- main already passes a SINGLE reused `key` buffer to ss_hget/ss_add for 461// every page, so if those retained rather than copied, the live crawler would already corrupt every row. 462// Lazy init: statics default 0, so gate fixtures and unit harnesses need no ordering rule. 463static wc_frst_key_g: *u8 464static wc_frst_pp_g: *i64 465static wc_frst_pl_g: *i64 466func wc_frstate(h: *i64, u: *u8, ul: i64) -> i64 { 467 if (h as i64) == 0 { return 0 } 468 if (wc_frst_key_g as i64) == 0 { wc_frst_key_g = sys_mmap(64) } 469 if (wc_frst_pp_g as i64) == 0 { wc_frst_pp_g = sys_mmap(16) as *i64 } 470 if (wc_frst_pl_g as i64) == 0 { wc_frst_pl_g = sys_mmap(16) as *i64 } 471 let key: *u8 = wc_frst_key_g 472 wc_frkey(u, ul, key) 473 let pp: *i64 = wc_frst_pp_g 474 let pl: *i64 = wc_frst_pl_g 475 if ss_hget(h, key, pp, pl) != 1 { return 0 } 476 if pl[0] < 1 { return 0 } 477 let vp: *u8 = pp[0] as *u8 478 if vp[0] == (80 as u8) { return 1 } // P 479 if vp[0] == (68 as u8) { return 2 } // D 480 return 0 481} 482// bank a frontier state row: value = <P|D><url> (append-only; ss_hget last-wins = the state flip) 483// ---- HOISTED 2026-08-08: THE REMAINING 2 OF THE 5 PAGES LEAKED PER CANDIDATE HREF ---- 484// The 2026-08-06 pass above hoisted wc_frstate (3 pages) and wc_frkey (1). wc_frmark's OWN two 485// allocations were left, so the per-href cost fell 5 pages -> 2, not to 0. A partial fix on a 486// per-item path is still an unbounded leak; it only changes the slope. 487// MEASURED 2026-08-08: nx_web_crawl_step pid 19016 was the estate's #1 grower at 22-40 MB/s, grew_in 488// 5/5 in 2 of 3 nx_memvel samples and 5-6x the next process, while the box sat at 853 permil swap. 489// CORRECTNESS, checked not assumed: `key` is consumed by ss_add IN-CALL, exactly as wc_frstate's key is 490// consumed by ss_hget in-call, and main already passes a SINGLE reused key buffer to ss_add for every 491// page -- a retaining ss_add would already corrupt every row today. wc_frkey writes at most 3+20+1 492// bytes, so 64 is bounded by construction. 493// `val` is variable-length, so the hoist is GUARDED rather than assumed: WC_MAXURL (1500) bounds every 494// url that reaches the frontier, so the 2048 static covers the whole real population -- but the 495// oversize branch is KEPT and falls back to a per-call mmap instead of truncating. Behaviour is 496// therefore byte-identical for every input and the hoist can only remove an allocation, never change a 497// row. Fail-safe in the direction of doing nothing. 498static wc_frm_key_g: *u8 499static wc_frm_val_g: *u8 500// HOISTED 2026-08-08 (harvest + walk paths). wc_ubuf_g IS THE 5TH OF THE 5 PAGES leaked per candidate 501// href: the 2026-08-06 pass hoisted wc_frstate (3) and wc_frkey (1) and stopped one allocation short. 502// It is paid BEFORE wc_urlok judges the url, so every REJECTED href was charged for it too. 503// wc_kbuf_g is worse per run: one page per fr: row per frontier walk, and this shard has held >1040 504// distinct fr: keys, so a single walk burned megabytes before reading a byte of it. 505// CORRECTNESS: every one of these is consumed IN-CALL and nothing retains a pointer past the call -- 506// nx_url_canon WRITES into ubuf, wc_urlok/wc_frstate only READ it, wc_runseen COPIES frk9 into its own 507// 32B slots, ss_hget reads kbuf, and ss_add2 BYTE-COPIES both key and val into the writer buffer 508// (nx_seg_store.nx:193, read not assumed). So a single reused buffer cannot alias a live row. 509static wc_ubuf_g: *u8 510static wc_frk9_g: *u8 511static wc_kbuf_g: *u8 512static wc_okey_g: *u8 513// HOISTED 2026-08-08 (host-health + seed-cursor paths). Same shape, lower rate than the per-href set 514// above: these run per HOST and per SEED rather than per href, which is why they survived two prior 515// passes -- but a per-host allocation is still unbounded in a crawl that keeps discovering hosts, and 516// leaving them is what lets this whole class regrow quietly. 13 pages across 5 functions. 517// CORRECTNESS is the SAME argument already proven for the set above, not a new one: key is consumed 518// in-call by ss_hget/ss_add (ss_add2 byte-copies), pp/pl are out-boxes whose pp[0] points INTO the 519// mapped shard and never into pp itself, val is fixed-width, and each `t` is a decimal-digit scratch 520// fully copied into `out`/`val` before return. Nothing retains a pointer past the call. 521// The three digit-scratch statics that used to sit here (wc_hdk_t_g / wc_hdp_t_g / wc_srp_t_g) are GONE: 522// their functions now call ccz_cat_num, which writes into the caller's buffer and allocates nothing, so 523// there is no scratch to hoist. What remains below is genuinely per-call state that must persist across 524// the call (keys, out-boxes, value buffers) and is correctly a lazy static. 525static wc_hds_key_g: *u8 526static wc_hds_pp_g: *i64 527static wc_hds_pl_g: *i64 528static wc_hdp_key_g: *u8 529static wc_hdp_val_g: *u8 530static wc_src_key_g: *u8 531static wc_src_pp_g: *i64 532static wc_src_pl_g: *i64 533static wc_srp_key_g: *u8 534static wc_srp_val_g: *u8 535// per-PAGE (not per-href): olh_scan WRITES the edge list into it and ss_add2 byte-copies it straight 536// out into the writer, so it is consumed in-call like every buffer above. OLH_MAXEDGE*8 every harvest. 537static wc_edgebuf_g: *i64 538func wc_frmark(w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, u: *u8, ul: i64, state: i64) -> i64 { 539 if (wc_frm_key_g as i64) == 0 { wc_frm_key_g = sys_mmap(64) } 540 let key: *u8 = wc_frm_key_g 541 wc_frkey(u, ul, key) 542 var val: *u8 = 0 as *u8 543 if ul + 2 > WC_MAGIC_2048 { val = sys_mmap(ul + 2) } else { 544 if (wc_frm_val_g as i64) == 0 { wc_frm_val_g = sys_mmap(WC_MAGIC_2048) } 545 val = wc_frm_val_g 546 } 547 if state == 1 { val[0] = 80 as u8 } else { val[0] = 68 as u8 } 548 var i: i64 = 0 549 while i < ul { val[1 + i] = u[i]; i = i + 1 } 550 if ss_add(w, 1, key, val, ul + 1) < 0 { 551 // writer full: commit, then REUSE the buffer (w[0]=buf w[1]=len w[2]=cap; reset len = fresh writer) 552 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 553 segidbox[0] = segidbox[0] + 1 554 w[1] = 0 555 ss_add(w, 1, key, val, ul + 1) 556 } 557 return 0 558} 559// R8 (2026-08-05): the canonical host table, module-level so the shared URL policy can consult it 560// WITHOUT threading it through wc_harvest's already-wide arity. Filled ONCE in main after the seed 561// file parses; zero until then, so every gate fixture and any run without a seed file sees exactly 562// the pre-R8 policy (statics default 0 -> allowq=0 -> byte-identical). 563static wc_canh_g: *u8 564static wc_ncan_g: i64 565static wc_qscratch_g: *u8 566 567// ---- R4 CANONICAL LANE (2026-08-05) ----------------------------------------------------------- 568// MEASURED that day: the corpus held ~89,000 docs but only 64 from en.wikipedia.org, 0 from who.int, 569// 1 from python.org and 2 from irs.gov -- while ALL of those hosts sat in canonical_seeds.txt and 570// answered HTTP 200. They were never blocked, never walled and never retired. They were STARVED. 571// wc_pending walks segments in stable append order and stops at WC_MAXPEND, so once the frontier held 572// more than 2048 P rows the canonical rows (banked LATER, so living in NEWER segments) never entered 573// the window at all, and the shared WC_MAXDISC budget was spent by whichever junk host happened to be 574// processed first. Ranking work could not have fixed this: the documents did not exist to rank. 575// ★A FLAT WALK OVER A FRONTIER THAT GREW JUNK-FIRST IS A PERMANENT BLOCKADE OF ITS OWN HEAD. 576// The lane is ADDITIVE (rule 25 -- build intelligence, never strip): nothing is filtered out and no 577// row is dropped. Canonical rows are pulled in a SECOND host-filtered pass and placed at the FRONT of 578// the work list, so they are fetched first and get first claim on the discovery budget; every other 579// row keeps its original order behind the reserve. 580 581// byte-equal NUL-terminated strings 582func wc_streq(a: *u8, b: *u8) -> i64 { 583 var i: i64 = 0 584 while a[i] == b[i] { if a[i] == (0 as u8) { return 1 } i = i + 1 } 585 return 0 586} 587// the seed file's DISTINCT hosts with a leading "www." stripped, so a seed of https://www.who.int/ 588// covers who.int, www.who.int AND data.who.int -- the same dot-suffix contract the search engine's 589// site: filter already uses. 64-byte slots. Returns the host count. 590func wc_canon_hosts(seedstore: *u8, nseeds: i64, out: *u8) -> i64 { 591 var n: i64 = 0 592 var i: i64 = 0 593 let hb: *u8 = sys_mmap(64) 594 while i < nseeds { 595 let sp: *u8 = (seedstore as i64 + i * WC_MAGIC_2048) as *u8 596 let hl: i64 = wc_host(sp, hb) 597 if hl > 0 { if hl < 60 { 598 hb[hl] = 0 as u8 599 var st: i64 = 0 600 if hl > 4 { if hb[0] == (119 as u8) { if hb[1] == (119 as u8) { if hb[2] == (119 as u8) { if hb[3] == (46 as u8) { st = 4 } } } } } 601 let hp: *u8 = (hb as i64 + st) as *u8 602 var dup: i64 = 0 603 var d: i64 = 0 604 while d < n { 605 if wc_streq((out as i64 + d * 64) as *u8, hp) == 1 { dup = 1; d = n } else { d = d + 1 } 606 } 607 if dup == 0 { if n < WC_MAXCANH { 608 let dst: *u8 = (out as i64 + n * 64) as *u8 609 var c: i64 = 0 610 while hp[c] != (0 as u8) { dst[c] = hp[c]; c = c + 1 } 611 dst[c] = 0 as u8 612 n = n + 1 613 } } 614 } } 615 i = i + 1 616 } 617 return n 618} 619// 1 iff u's host IS a canonical host or a dot-suffixed subdomain of one ("en.wikipedia.org" matches a 620// canonical "wikipedia.org"; "notwikipedia.org" does NOT, because the boundary dot is required). 621// hb = caller-owned 64B scratch, allocated ONCE per walk -- a per-row sys_mmap would burn a page per 622// frontier row. 623// HOST-STRING form of the canonical test. The fetch loop already holds the page's host (phost/phl), 624// so re-parsing a url there would be waste -- and duplicating the match would be two copies of one 625// rule (rule 15). This is the ONE implementation; wc_host_is_canon is the url-taking wrapper. 626func wc_host_str_is_canon(canh: *u8, ncan: i64, hb: *u8, hl: i64) -> i64 { 627 if ncan <= 0 { return 0 } 628 if hl <= 0 { return 0 } 629 var i: i64 = 0 630 while i < ncan { 631 let cp: *u8 = (canh as i64 + i * 64) as *u8 632 var cl: i64 = 0 633 while cp[cl] != (0 as u8) { cl = cl + 1 } 634 if cl > 0 { if cl <= hl { 635 var eq: i64 = 1 636 var x: i64 = 0 637 while x < cl { if hb[hl - cl + x] != cp[x] { eq = 0; x = cl } else { x = x + 1 } } 638 if eq == 1 { 639 if hl == cl { return 1 } 640 if hb[hl - cl - 1] == (46 as u8) { return 1 } 641 } 642 } } 643 i = i + 1 644 } 645 return 0 646} 647func wc_host_is_canon(canh: *u8, ncan: i64, u: *u8, hb: *u8) -> i64 { 648 if ncan <= 0 { return 0 } 649 let hl: i64 = wc_host(u, hb) 650 if hl <= 0 { return 0 } 651 if hl >= 60 { return 0 } 652 hb[hl] = 0 as u8 653 return wc_host_str_is_canon(canh, ncan, hb, hl) 654} 655// R8: the ONE content-URL policy, now trust-aware. A curated seed host may carry its CONTENT on 656// query strings (measured: graphis.ne.jp galleries are model.php?ID=... -- the blanket '?' reject 657// made that entire vertical structurally unreachable, so seeding the hub could never yield a page). 658// Everything else keeps the blanket reject, which is what stops tracking junk and pagination traps 659// on uncurated hosts. Same shape as the R4b depth cap: the LIMIT is right, its SUBJECT's trust is 660// what changed. wc_ncan_g==0 (gates, seedless runs) -> allowq=0 -> the exact pre-R8 contract. 661// ---- R10 URL POLICY: JUDGE THE SHAPE, NOT THE HOST ------------------------------------------------ 662// R8 admitted query strings ONLY on curated seed hosts. That is an ALLOWLIST, and an allowlist is the 663// wrong shape for a search engine: most of the web's content rides query strings, so "uncurated => 664// unreachable" meant this crawler could only ever see hosts a human had already typed in. That scales 665// to a DIRECTORY, never to an INDEX -- and it is why a real gravure result like 666// www.v2ph.com/actor/JULIA?page=3&hl=en was refused before a single byte was fetched. 667// Operator 2026-08-06: "that's a dumb way to have a search engine setup." Correct. 668// R10 judges the QUERY ITSELF, host-independently: 669// - a TRACKING param (utm_*, fbclid, session ids) means this URL is a DUPLICATE of the clean one 670// -> reject, because indexing it twice is worse than not indexing it 671// - too many params, or an overlong query, is a facet/calendar TRAP -> reject 672// - anything else is CONTENT -> admit, curated or not 673// Curation survives as a BOOST (a curated host gets a bigger param budget), never as the gate. 674// The lists are DATA in knowledge/store/urlpolicy-, loaded ONCE per run: adding a tracking param must 675// never require a recompile. ★AN ALLOWLIST ANSWERS "WHO DO WE TRUST"; AN INDEX MUST ANSWER "IS THIS A PAGE". 676const WC_URLPOL_PLANE: *u8 = "knowledge/store/urlpolicy-" 677const WC_QSLOT: i64 = 32 678const WC_QMAXDENY: i64 = 192 679const WC_QPLANECAP: i64 = 65536 680const WC_QDEF_MAXP: i64 = 4 // default param budget for an UNCURATED host 681const WC_QDEF_MAXL: i64 = 120 // default max query length in bytes 682const WC_QCAN_BOOST: i64 = 4 // curated hosts get this many EXTRA params (trust boost, not a gate) 683static wc_qdeny_g: *u8 684static wc_nqdeny_g: i64 685static wc_qmaxp_g: i64 686static wc_qmaxlen_g: i64 687func wc_qeq(a: *u8, b: *u8, bl: i64) -> i64 { 688 var k: i64 = 0 689 while k < bl { if a[k] != b[k] { return 0 } k = k + 1 } 690 if a[bl] != (0 as u8) { return 0 } 691 return 1 692} 693func wc_qnum(b: *u8, s: i64, e: i64) -> i64 { 694 var v: i64 = 0 695 var any: i64 = 0 696 var i: i64 = s 697 while i < e { let c: i64 = b[i] as i64; if c >= 48 { if c <= 57 { v = v * 10 + (c - 48); any = 1 } } i = i + 1 } 698 if any == 0 { return 0 - 1 } 699 return v 700} 701// Load the policy plane. ABSENT plane = the built-in defaults, NEVER a closed gate: a missing data file 702// must not silently return the crawler to allowlist behaviour, which is the failure nobody would notice. 703func wc_load_urlpolicy() -> i64 { 704 wc_qdeny_g = sys_mmap(WC_QSLOT * WC_QMAXDENY) 705 wc_nqdeny_g = 0 706 wc_qmaxp_g = WC_QDEF_MAXP 707 wc_qmaxlen_g = WC_QDEF_MAXL 708 let b: *u8 = sys_mmap(WC_QPLANECAP) 709 let n: i64 = sts_load(WC_URLPOL_PLANE, b, WC_QPLANECAP - 16) 710 if n <= 0 { return 0 } 711 var i: i64 = 0 712 while i < n { 713 let ls: i64 = i 714 var le: i64 = ls 715 var s: i64 = 1 716 while s == 1 { if le >= n { s = 0 } else { if b[le] == (10 as u8) { s = 0 } else { le = le + 1 } } } 717 i = le + 1 718 if le - ls > 2 { if b[ls] != (35 as u8) { 719 var t: i64 = 0 - 1 720 var j: i64 = ls 721 while j < le { if b[j] == (9 as u8) { t = j; j = le } else { j = j + 1 } } 722 if t > ls { 723 let nl: i64 = t - ls 724 // config knobs are named __maxparams / __maxqlen so they cannot collide with a param name 725 if wc_qeq("__maxparams\x00" as *u8, (b as i64 + ls) as *u8, nl) == 1 { 726 let v: i64 = wc_qnum(b, t + 1, le); if v > 0 { wc_qmaxp_g = v } 727 } 728 else { if wc_qeq("__maxqlen\x00" as *u8, (b as i64 + ls) as *u8, nl) == 1 { 729 let v2: i64 = wc_qnum(b, t + 1, le); if v2 > 0 { wc_qmaxlen_g = v2 } 730 } 731 else { 732 if nl < WC_QSLOT - 1 { if wc_nqdeny_g < WC_QMAXDENY { 733 let dst: *u8 = (wc_qdeny_g as i64 + wc_nqdeny_g * WC_QSLOT) as *u8 734 var c: i64 = 0 735 while c < nl { dst[c] = b[ls + c]; c = c + 1 } 736 dst[nl] = 0 as u8 737 wc_nqdeny_g = wc_nqdeny_g + 1 738 } } 739 } } 740 } 741 } } 742 } 743 return wc_nqdeny_g 744} 745// is this param name on the deny list? (exact match, so "sid" never kills "sid_of_article") 746func wc_qparam_denied(u: *u8, s: i64, e: i64) -> i64 { 747 if wc_nqdeny_g <= 0 { return 0 } 748 if (wc_qdeny_g as i64) == 0 { return 0 } 749 let nl: i64 = e - s 750 if nl <= 0 { return 0 } 751 var i: i64 = 0 752 while i < wc_nqdeny_g { 753 let cp: *u8 = (wc_qdeny_g as i64 + i * WC_QSLOT) as *u8 754 var cl: i64 = 0 755 while cp[cl] != (0 as u8) { cl = cl + 1 } 756 if cl == nl { 757 var eq: i64 = 1 758 var k: i64 = 0 759 while k < nl { if u[s + k] != cp[k] { eq = 0; k = nl } else { k = k + 1 } } 760 if eq == 1 { return 1 } 761 } 762 i = i + 1 763 } 764 return 0 765} 766// THE QUERY JUDGE. 1 = a content URL worth fetching. Host-independent by design. 767func wc_query_ok(u: *u8, ul: i64, canon: i64) -> i64 { 768 var q: i64 = 0 - 1 769 var i: i64 = 0 770 while i < ul { if u[i] == (63 as u8) { q = i; i = ul } else { i = i + 1 } } 771 if q < 0 { return 1 } // no query at all -- nothing to judge 772 let qlen: i64 = ul - q - 1 773 if qlen <= 0 { return 0 } // a bare trailing '?' is the clean URL wearing a costume 774 // SELF-DEFAULTING, NOT LOADER-DEPENDENT. These budgets live in statics that only wc_load_urlpolicy 775 // sets, so ANY entry path that skips the loader (the gate, a future caller, a unit harness) would 776 // otherwise read 0 and reject EVERY query url -- a silent regression to something STRICTER than the 777 // allowlist this replaced. The gate caught exactly that. Defaults belong where the value is READ. 778 // ★A LIMIT THAT READS 0 WHEN UNINITIALISED IS A CLOSED GATE WEARING AN OPEN GATE'S NAME. 779 var maxlen: i64 = wc_qmaxlen_g 780 if maxlen <= 0 { maxlen = WC_QDEF_MAXL } 781 if qlen > maxlen { return 0 } 782 var budget: i64 = wc_qmaxp_g 783 if budget <= 0 { budget = WC_QDEF_MAXP } 784 if canon == 1 { budget = budget + WC_QCAN_BOOST } 785 var nparam: i64 = 0 786 var ps: i64 = q + 1 787 while ps < ul { 788 // end of this param = the next '&', or the end of the url 789 var end: i64 = ps 790 var f: i64 = 0 791 while f == 0 { if end >= ul { f = 1 } else { if u[end] == (38 as u8) { f = 1 } else { end = end + 1 } } } 792 // the NAME runs to the first '=' inside this param (a valueless param is all name) 793 var nameend: i64 = ps 794 var g: i64 = 0 795 while g == 0 { if nameend >= end { g = 1 } else { if u[nameend] == (61 as u8) { g = 1 } else { nameend = nameend + 1 } } } 796 if nameend > ps { if wc_qparam_denied(u, ps, nameend) == 1 { return 0 } } 797 nparam = nparam + 1 798 if nparam > budget { return 0 } 799 ps = end + 1 800 } 801 return 1 802} 803func wc_urlok(u: *u8, ul: i64) -> i64 { 804 var canon: i64 = 0 805 if wc_ncan_g > 0 { if (wc_canh_g as i64) != 0 { if (wc_qscratch_g as i64) != 0 { 806 if wc_host_is_canon(wc_canh_g, wc_ncan_g, u, wc_qscratch_g) == 1 { canon = 1 } 807 } } } 808 // allowq=1 ALWAYS: the shared filter's blanket '?' kill is retired here in favour of the shape judge 809 // below. Everything else it enforces (scheme, fragments, wiki namespaces, binary extensions) still runs. 810 if olh_urlok_q(u, ul, 1) == 0 { return 0 } 811 return wc_query_ok(u, ul, canon) 812} 813// enumerate PENDING frontier urls: walk every segment's key index for "fr:"-prefixed keys (dedup by key), 814// ss_hget each key's LATEST value, keep the P ones. urlsout = flat 2KB slots. Returns count. 815// R4: canh/ncan/canonly extend the walk WITHOUT duplicating it (rule 15 -- one walk, one contract). 816// canonly=0 ignores canh/ncan entirely and is byte-identical to the pre-R4 behaviour, which is what 817// the original full-frontier call site passes and what gate T2 keeps pinned. 818// ==== G0 HOST-BALANCED PULL WINDOW (2026-09-03, /compare/search rung G0, contract wc_frontier_fair) ==== 819// MEASURED, not designed. Two windows of knowledge/status/crawl_outcomes.log 41 minutes apart (epochs 820// 1788448047 and 1788450535) were BOTH 100 percent hostcap-defer and every url in both was 821// en.wikipedia.org; nx_crawlaudit independently re-fetched a newest-first sample and returned 6 of 8 822// disagreements reading "we DROPPED it, independent re-check finds REAL PROSE" -- Kierkegaard at 182147 823// chars, Ricoeur at 42727. The R7 rotation above fixes POSITIONAL starvation (rows beyond the window 824// never getting a turn) and it CANNOT fix HOST starvation, because a rotated slice of a 825// Wikipedia-dominated frontier is still Wikipedia. wc_hostcap_defers' own note names the regime we are 826// in: past ONE pull window a deferred row can be shadowed by rows ahead of it, and the pending 827// population is far past one window. 828// THE QUOTA IS DERIVED AND NO CAP IS LOOSENED. The fetch stage indexes at most WC_HOSTCAP_CANON pages 829// from one host in a step, so admitting more than that many rows for the SAME host is provably wasted: 830// the surplus can only become hostcap-defer while consuming a slot another host could have used. The 831// window quota is therefore the LARGEST per-host index cap in the system, applied one stage earlier -- 832// which means a row the fetch stage COULD have indexed is never excluded, so this can only remove rows 833// that were guaranteed to be deferred. The max (canonical) cap is used rather than the per-host cap on 834// purpose: the main-lane caller passes ncan=0, so canonicality is not knowable here, and guessing it in 835// the TIGHT direction would under-fill a canonical host the fetch stage could take 40 pages from. 836// WC_HOSTCAP and WC_HOSTCAP_CANON are UNTOUCHED -- a run that indexed more pages by raising them would 837// have failed rung G0, not passed it. At a full WC_MAXPEND window this guarantees at least 51 distinct 838// hosts are offered whenever that many are pending. 839const WC_WINQ: i64 = WC_HOSTCAP_CANON 840// Distinct hosts are LINEAR-SCANNED, not hashed, and that is deliberate twice over: wc_hosthash is 841// defined BELOW wc_pending so calling it here would be a forward reference, and writing a second host 842// hash beside it would be the duplicate-ruler defect. The scan is bounded by DISTINCT hosts, at most 843// maxout/WC_WINQ, so it is a smaller order than the dedup scan wc_pending already performs per row. 844// Table rows are WC_HOSTBUF bytes of NUL-terminated host, parallel to one i64 count. 845func wc_winq_admit(tblh: *u8, tblc: *i64, nh: *i64, slots: i64, hb: *u8, hl: i64, quota: i64) -> i64 { 846 var d: i64 = 0 847 while d < nh[0] { 848 let sp: *u8 = (tblh as i64 + d * WC_HOSTBUF) as *u8 849 var eq: i64 = 1 850 var x: i64 = 0 851 while x <= hl { if sp[x] != hb[x] { eq = 0; x = hl + 1 } else { x = x + 1 } } 852 if eq == 1 { 853 if tblc[d] >= quota { return 0 } 854 tblc[d] = tblc[d] + 1 855 return 1 856 } 857 d = d + 1 858 } 859 // TABLE FULL: ADMIT rather than refuse. A sizing error must be wrong in the direction of doing 860 // nothing -- refusing here would starve the crawl on a bookkeeping limit, which is a worse defect 861 // than the imbalance this function exists to correct. 862 if nh[0] >= slots { return 1 } 863 let dst: *u8 = (tblh as i64 + nh[0] * WC_HOSTBUF) as *u8 864 var c: i64 = 0 865 while c <= hl { dst[c] = hb[c]; c = c + 1 } 866 tblc[nh[0]] = 1 867 nh[0] = nh[0] + 1 868 return 1 869} 870// THE CONTRACT SYMBOL for /compare/search rung G0. A matrix watch row measures that this symbol EXISTS, 871// so it must BE the invariant rather than a decoration named after one: it answers "is this window 872// host-balanced?" for a (rows, distinct-hosts) pair, which is exactly the pair the G1 census reports. 873// A window is fair when no host could have exceeded its quota, i.e. rows <= hosts * WC_WINQ. Kept PURE 874// and separate from wc_winq_admit on purpose -- admit ENFORCES per row, this JUDGES a finished window, 875// and a census that re-derived the arithmetic itself would be a second ruler that can drift from the 876// enforcer. One definition, two readers. 877func wc_frontier_fair(rows: i64, hosts: i64) -> i64 { 878 if rows <= 0 { return 1 } // an empty window is vacuously balanced, and saying so beats dividing by zero 879 if hosts <= 0 { return 0 } // rows with NO host attributed is not a fair window, it is an unmeasured one 880 if rows <= hosts * WC_WINQ { return 1 } 881 return 0 882} 883func wc_pending(h: *i64, prefix: *u8, urlsout: *u8, maxout: i64, canh: *u8, ncan: i64, canonly: i64) -> i64 { 884 if (h as i64) == 0 { return 0 } 885 let ns: i64 = h[0] 886 wc_pc_seen_g = 0 // R7: this walk's pending population, counted by wc_pc_take 887 let hbuf4: *u8 = sys_mmap(64) // R4 host scratch, allocated ONCE per walk 888 let seenk: *u8 = sys_mmap(64 * (WC_MAXPEND * 4 + 16)) 889 // G0: the host-balance table for THIS window. At most `maxout` DISTINCT hosts can occupy a 890 // maxout-row window, so that is the exact slot count -- nothing to guess and no way to overflow. 891 let wqh: *u8 = sys_mmap(WC_HOSTBUF * (maxout + 1)) 892 let wqc: *i64 = sys_mmap(8 * (maxout + 1)) as *i64 893 let wqn: *i64 = sys_mmap(16) as *i64 894 wqn[0] = 0 895 var nseen: i64 = 0 896 var npend: i64 = 0 897 let pp: *i64 = sys_mmap(16) as *i64 898 let pl: *i64 = sys_mmap(16) as *i64 899 var s: i64 = 0 900 while s < ns { 901 let kb: *u8 = h[1 + 8 * s] as *u8 902 if h[2 + 8 * s] >= 8 { 903 let m9: i64 = ss_r32(kb, 4) 904 var e9: i64 = 0 905 while e9 < m9 { 906 let eo: i64 = 8 + 4 * m9 + ss_r32(kb, 8 + 4 * e9) 907 if (kb[eo] as i64) == 1 { 908 let kl9: i64 = ss_r32(kb, eo + 1) 909 if kl9 >= 4 { if kl9 < 60 { 910 if kb[eo + 5] == (102 as u8) { if kb[eo + 6] == (114 as u8) { if kb[eo + 7] == (58 as u8) { 911 // copy the key NUL-terminated (bounded by the kl9 < 60 guard above, so 64 912 // holds key+NUL; hoisted 2026-08-08 -- this ran once PER fr: ROW PER WALK) 913 if (wc_kbuf_g as i64) == 0 { wc_kbuf_g = sys_mmap(64) } 914 let kbuf: *u8 = wc_kbuf_g 915 var c: i64 = 0 916 while c < kl9 { kbuf[c] = kb[eo + 5 + c]; c = c + 1 } 917 kbuf[kl9] = 0 as u8 918 // STATE FIRST, then dedup among COLLECTED urls only (seq1288, measured 919 // 2026-07-29): the old pre-state seen-list capped at WC_MAXPEND*4+16 and 920 // SATURATED once the shard held >~1040 distinct fr: keys -- every later key 921 // was never state-checked, so a compounding crawl read pending=0 while 922 // holding hundreds of P rows (pending decayed 256 -> 2 -> 0 in one day; the 923 // coverage leg silently self-wedged). ss_hget is last-wins across segments, 924 // so re-checking a key already walked in another segment is idempotent; the 925 // only dedup that matters is among the <=maxout COLLECTED urls, which is 926 // bounded by construction and cannot saturate. 927 if ss_hget(h, kbuf, pp, pl) == 1 { if pl[0] > 1 { 928 let vp: *u8 = pp[0] as *u8 929 if vp[0] == (80 as u8) { if wc_pc_take(npend, maxout) == 1 { if pl[0] < WC_MAGIC_2000 { 930 var dup: i64 = 0 931 var d: i64 = 0 932 while d < npend { 933 let sp: *u8 = (seenk as i64 + d * 64) as *u8 934 var eq: i64 = 1 935 var x: i64 = 0 936 while x <= kl9 { if sp[x] != kbuf[x] { eq = 0; x = kl9 + 1 } else { x = x + 1 } } 937 if eq == 1 { dup = 1; d = npend } else { d = d + 1 } 938 } 939 if dup == 0 { 940 let dst: *u8 = (seenk as i64 + npend * 64) as *u8 941 var c2: i64 = 0 942 while c2 <= kl9 { dst[c2] = kbuf[c2]; c2 = c2 + 1 } 943 let uo: *u8 = (urlsout as i64 + npend * WC_MAGIC_2048) as *u8 944 var y: i64 = 0 945 while y < pl[0] - 1 { uo[y] = vp[1 + y]; y = y + 1 } 946 uo[pl[0] - 1] = 0 as u8 947 // R4 CANONICAL LANE: canonly=1 keeps ONLY canonical-seed hosts. 948 // The slot is written BEFORE the test and simply not committed 949 // when it fails (npend not incremented), so the next candidate 950 // overwrites it -- no second buffer, no shifting. 951 var keep4: i64 = 1 952 if canonly == 1 { if wc_host_is_canon(canh, ncan, uo, hbuf4) == 0 { keep4 = 0 } } 953 // G0 HOST-BALANCED WINDOW: refuse a row whose host already holds 954 // WC_WINQ slots in THIS window (see wc_winq_admit above). The 955 // surplus was guaranteed to become hostcap-defer at the fetch 956 // stage, so no indexable row is lost -- only a wasted slot. 957 if keep4 == 1 { 958 let hl5: i64 = wc_host(uo, hbuf4) 959 if hl5 > 0 { if wc_winq_admit(wqh, wqc, wqn, maxout, hbuf4, hl5, WC_WINQ) == 0 { keep4 = 0 } } 960 } 961 if keep4 == 1 { npend = npend + 1 } 962 } 963 } } } 964 } } 965 } } } 966 } } 967 } 968 e9 = e9 + 1 969 } 970 } 971 s = s + 1 972 } 973 return npend 974} 975// has this frontier key been banked THIS run? (the open handle can't see the uncommitted writer, so 976// without this a url found on two pages double-banks). runseen = 32-byte frkey slots. 977func wc_runseen(runseen: *u8, nrs: *i64, frk: *u8) -> i64 { 978 var d: i64 = 0 979 while d < nrs[0] { 980 let sp: *u8 = (runseen as i64 + d * 32) as *u8 981 var eq: i64 = 1 982 var x: i64 = 0 983 var go: i64 = 1 984 while go == 1 { 985 if sp[x] != frk[x] { eq = 0; go = 0 } else { 986 if frk[x] == (0 as u8) { go = 0 } else { x = x + 1 } 987 } 988 } 989 if eq == 1 { return 1 } 990 d = d + 1 991 } 992 if nrs[0] < WC_DISC_CEIL + 7 { 993 let dst: *u8 = (runseen as i64 + nrs[0] * 32) as *u8 994 var c: i64 = 0 995 var go2: i64 = 1 996 while go2 == 1 { if c >= 31 { go2 = 0 } else { dst[c] = frk[c]; if frk[c] == (0 as u8) { go2 = 0 } else { c = c + 1 } } } 997 dst[31] = 0 as u8 998 nrs[0] = nrs[0] + 1 999 } 1000 return 0 1001} 1002// link extraction (the nx_web_ingest mechanics, feeding the FILTER + frontier instead of a BFS queue). 1003// Returns how many NEW pending rows were banked. 1004// ENTRY SCRATCH -> LAZY STATIC (2026-08-19 lane F, the 8 KiB/page mapping the debt row named -- the one 1005// three hoist passes stepped over; measured class-mate of the nx_feed_extract 272 kB/call fix, same 1006// nx_crawl_callee_probe campaign). Rewritten per call; fetch children fork their own copy. 1007static wc_hscr_g: *u8 1008func wc_harvest(hh: *u8, hlen: i64, base: *u8, blen: i64, h: *i64, w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, discbox: *i64, runseen: *u8, nrs: *i64) -> i64 { 1009 var banked: i64 = 0 1010 if (wc_hscr_g as i64) == 0 { wc_hscr_g = sys_mmap(WC_MAGIC_8192) } 1011 let scratch: *u8 = wc_hscr_g 1012 var i: i64 = 0 1013 while i < hlen { 1014 var step: i64 = 1 1015 // href= (case-insensitive) 1016 var ish: i64 = 0 1017 if i + 5 < hlen { 1018 var c0: i64 = hh[i] as i64 1019 if c0 >= 0x41 { if c0 <= 0x5a { c0 = c0 + 0x20 } } 1020 if c0 == 0x68 { 1021 var c1: i64 = hh[i+1] as i64 1022 if c1 >= 0x41 { if c1 <= 0x5a { c1 = c1 + 0x20 } } 1023 var c2: i64 = hh[i+2] as i64 1024 if c2 >= 0x41 { if c2 <= 0x5a { c2 = c2 + 0x20 } } 1025 var c3: i64 = hh[i+3] as i64 1026 if c3 >= 0x41 { if c3 <= 0x5a { c3 = c3 + 0x20 } } 1027 if c1 == 0x72 { if c2 == 0x65 { if c3 == 0x66 { if (hh[i+4] as i64) == 0x3d { ish = 1 } } } } 1028 } 1029 } 1030 if ish == 1 { 1031 let q: i64 = hh[i+5] as i64 1032 var quoted: i64 = 0 1033 if q == 0x22 { quoted = 1 } 1034 if q == 0x27 { quoted = 1 } 1035 if quoted == 1 { 1036 let cs: i64 = i + 6 1037 var e: i64 = cs 1038 var run: i64 = 1 1039 while run == 1 { run = 0; if e < hlen { if (hh[e] as i64) != q { e = e + 1; run = 1 } } } 1040 let clen: i64 = e - cs 1041 if clen > 0 { if discbox[0] < wc_disc_cap() { 1042 let hp: *u8 = ((hh as i64) + cs) as *u8 1043 var cand: *u8 = 0 as *u8 1044 var candlen: i64 = 0 1045 var abs: i64 = 0 1046 if clen >= 8 { if wc_hassub(hp, 8, "https://" as *u8) == 1 { abs = 1 } } 1047 if abs == 0 { if clen >= 7 { if wc_hassub(hp, 7, "http://" as *u8) == 1 { abs = 1 } } } 1048 if abs == 1 { 1049 cand = hp 1050 candlen = clen 1051 } else { 1052 if (hp[0] as i64) == 0x2f { 1053 var protorel: i64 = 0 1054 if clen >= 2 { if (hp[1] as i64) == 0x2f { protorel = 1 } } 1055 if protorel == 0 { 1056 // host-based via the SHARED resolver (was base+path concat, which banked phantom 1057 // urls like ".../Trust_law/about" -> 404 fetches + rank leaked to fake nodes) 1058 let rl: i64 = olh_resolve_root(base, hp, clen, scratch) 1059 if rl > 0 { 1060 cand = scratch 1061 candlen = rl 1062 } 1063 } 1064 } 1065 } 1066 if (cand as i64) != 0 { 1067 // CANONICALISE FIRST (debt 1786031857): an href is HTML *text*, so `&amp;` must 1068 // become `&` before wc_urlok judges it, before the fr: key is hashed, and long 1069 // before the fetcher sees it -- otherwise every discovered MULTI-PARAM url is 1070 // malformed, the request 404s or 403s, and the HOST gets blamed (dead-host streak) 1071 // for OUR parsing bug. The SHARED nx_url_canon is the same pass the link-graph 1072 // harvest below runs, so a frontier row and its graph node cannot disagree about 1073 // what the url IS. 3x buffer for worst-case percent-encoding expansion; canon 1074 // returns 0 (skip the link) rather than truncate -- a truncated url is a WRONG 1075 // url, not a shorter one. 1076 var ubuf: *u8 = 0 as *u8 1077 if candlen * 3 + 8 > WC_MAGIC_8192 { ubuf = sys_mmap(candlen * 3 + 8) } else { 1078 if (wc_ubuf_g as i64) == 0 { wc_ubuf_g = sys_mmap(WC_MAGIC_8192) } 1079 ubuf = wc_ubuf_g 1080 } 1081 let cnl: i64 = nx_url_canon(cand, candlen, ubuf, candlen * 3 + 4) 1082 if cnl > 0 { 1083 if wc_urlok(ubuf, cnl) == 1 { 1084 if wc_frstate(h, ubuf, cnl) == 0 { 1085 if (wc_frk9_g as i64) == 0 { wc_frk9_g = sys_mmap(64) } 1086 let frk9: *u8 = wc_frk9_g 1087 wc_frkey(ubuf, cnl, frk9) 1088 if wc_runseen(runseen, nrs, frk9) == 0 { 1089 wc_frmark(w, prefix, segidbox, segsbox, ubuf, cnl, 1) 1090 discbox[0] = discbox[0] + 1 1091 banked = banked + 1 1092 } 1093 } 1094 } 1095 } 1096 } 1097 } } 1098 step = (e - i) + 1 1099 } 1100 } 1101 i = i + step 1102 } 1103 // P1 LINK GRAPH: canonical edge harvest via the SHARED olh_scan (the same pass nx_cc_ingest runs -> 1104 // identical node identity + resolution), stored as ONE out:<cid(base)> row (writer-full handling mirrors 1105 // wc_frmark). Decoupled from the frontier walk above: edges keep flowing after WC_MAXDISC trips. 1106 if (wc_edgebuf_g as i64) == 0 { wc_edgebuf_g = sys_mmap(OLH_MAXEDGE * 8) as *i64 } 1107 let edgebuf: *i64 = wc_edgebuf_g 1108 let nedge: i64 = olh_scan(hh, hlen, base, blen, edgebuf, OLH_MAXEDGE) 1109 if nedge > 0 { if (w as i64) != 0 { 1110 if (wc_okey_g as i64) == 0 { wc_okey_g = sys_mmap(64) } 1111 let okey: *u8 = wc_okey_g 1112 wc_outkey(ci_hash(base, blen), okey) 1113 if ss_add(w, 1, okey, edgebuf as *u8, nedge * 8) < 0 { 1114 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 1115 segidbox[0] = segidbox[0] + 1 1116 w[1] = 0 1117 ss_add(w, 1, okey, edgebuf as *u8, nedge * 8) 1118 } 1119 } } 1120 return banked 1121} 1122 1123// SEED INJECTION (factored 2026-07-26 so one URL and a whole seed FILE share ONE code path -- the 1124// single-seed logic was inline and a second copy would have drifted). Banks the url as a PENDING fr: row 1125// and prepends it to THIS run's work list (the store write is not visible to the already-open handle). 1126// No-op if the frontier already knows the url, so re-seeding the same list is idempotent. 1127func wc_seed_add(h: *i64, w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, urls: *u8, npendbox: *i64, seed: *u8, sl: i64) -> i64 { 1128 if sl <= 0 { return 0 } 1129 if wc_frstate(h, seed, sl) != 0 { return 0 } 1130 var npend: i64 = npendbox[0] 1131 var sh: i64 = npend 1132 if sh > WC_MAXPEND - 1 { sh = WC_MAXPEND - 1 } 1133 while sh > 0 { 1134 let dst9: *u8 = (urls as i64 + sh * WC_MAGIC_2048) as *u8 1135 let src9: *u8 = (urls as i64 + (sh - 1) * WC_MAGIC_2048) as *u8 1136 var y9: i64 = 0 1137 var go9: i64 = 1 1138 while go9 == 1 { dst9[y9] = src9[y9]; if src9[y9] == (0 as u8) { go9 = 0 } y9 = y9 + 1 } 1139 sh = sh - 1 1140 } 1141 let u0: *u8 = urls 1142 var z9: i64 = 0 1143 while z9 < sl { u0[z9] = seed[z9]; z9 = z9 + 1 } 1144 u0[sl] = 0 as u8 1145 if npend < WC_MAXPEND { npendbox[0] = npend + 1 } 1146 wc_frmark(w, prefix, segidbox, segsbox, seed, sl, 1) 1147 return 1 1148} 1149 1150// ---- R15 (2026-08-25): THE DOMAIN-MAP ADOPTION -- reconnaissance findings reach the frontier ------- 1151// nx_domain_map is the /compare/webscraping R8 organ. It discovers a domain's urls from robots.txt 1152// Sitemap: lines, from sitemap trees (RECURSING sitemapindex and inflating .gz), from feeds, from the 1153// Wayback CDX and from crt.sh -- and it fingerprints the host's not-found page so a 200-that-is-really 1154// -a-404 gets labelled SOFT404 instead of being crawled. 1155// MEASURED 2026-08-25: nx_catalog reports it SOURCE+BUILT+PROMOTED+REGISTERED+INVOKED with its own 1156// gate, and an importer census over buildroot/runtime (coverage_complete=1 corpus_complete=1) returns 1157// SEVEN matches of which EVERY ONE is the organ itself or its own gate. Nothing in the crawl path 1158// consumed it. So sitemap recursion has existed, complete and proven, one organ away from a frontier 1159// sitting flat at ~1,880 urls. 1160// A CAPABILITY THAT IS BUILT, GATED, AND UNREACHABLE FROM THE THING THAT NEEDS IT IS INDISTINGUISHABLE 1161// FROM ONE NOBODY EVER WROTE -- and it is more expensive, because it also looks done. 1162// 1163// WHY A READER OF ITS ARTIFACT RATHER THAN AN IN-PROCESS CALL TO dm_scan: nx_domain_map is 1164// RECONNAISSANCE. Its own header says "one map is a reconnaissance, not a crawl" and it spends up to 1165// DM_DEF_MAX_FETCHES (64) fetches per domain. Calling it in-process would put a 64-fetch census inside 1166// a crawl step, per host, every run -- and this crawler's entire in-flight budget is WC_POOL_N = 8. 1167// The two organs compose correctly at the ARTIFACT: the map writes rows on its own cadence, the 1168// crawler reads them. No fetch is duplicated, no sitemap parser is re-implemented (rule 15), and 1169// neither organ has to learn anything about the other's internals. 1170// 1171// WHY BY CANONICAL HOST RATHER THAN BY DIRECTORY WALK: iterating the canonical host table costs at 1172// most WC_MAXCANH opens of a KNOWN path. Enumerating DM_OUTDIR would need a getdents64 LOOP, and ONE 1173// getdents64 CALL IS NOT A DIRECTORY LISTING -- a partial read there would silently seed a PREFIX of 1174// the corpus and report success, which is the silent-cap defect with a frontier attached. 1175const WC_DM_DIR: *u8 = "knowledge/status/domain_map" 1176const WC_DM_HOSTSLOT: i64 = 64 // == wc_canon_hosts' stride; DERIVED from that writer, not guessed 1177const WC_DM_MAX_ROWS: i64 = 4096 // rows read per host map. The map APPENDS one run per invocation, 1178 // so a long-lived map is mostly repeats -- and wc_seed_add dedupes 1179 // by frontier key, which makes a re-read idempotent rather than 1180 // duplicative. The cap bounds the read, it does not bound coverage. 1181 1182// PURE, so the seeding RULE is gate-testable with no filesystem: does this (kind, verdict) pair name a 1183// url we are entitled to put in the frontier? 1184// ADMITTED: 1185// url + LISTED -- an absolute url a sitemap or a feed DECLARED. This is the sitemap payload, and it 1186// is the row class that makes this rung worth building. 1187// path + LIVE -- a probe path the host actually SERVED, and which the map's own soft-404 1188// fingerprint did not classify as the not-found page. 1189// REFUSED -- each for its own stated reason, never by omission: 1190// MISSING / UNREACHABLE / BUDGET-REFUSED -- the map found no page there. Seeding these would spend 1191// crawl budget rediscovering an absence the reconnaissance has already paid for once. 1192// SOFT404 -- the map PROVED this is the not-found page wearing a 200. Seeding it is precisely the 1193// waste the fingerprint exists to prevent. 1194// ROBOTS-REFUSED -- THE COMPLIANCE TOOTH. The map recorded that the host's robots.txt refused this 1195// url. A seeder that admitted it would launder a refusal into a fetch through a side door, 1196// defeating wc_rb_allowed without ever calling it. 1197// FINGERPRINT -- not a url at all; it is the "status=.. len=.. title_hash=.." record itself. 1198// DECLARED -- a robots.txt Allow/Disallow PATH. A path named in a rule is not a page that exists; 1199// DECLARED means "this string appeared in a directive", never "this resource was served". 1200// CERT / ARCHIVED -- crt.sh hostnames and Wayback rows. Out of scope for this rung, and named here 1201// so the next reader knows they were considered and declined rather than forgotten. 1202func wc_dm_seedable(kind: *u8, verdict: *u8) -> i64 { 1203 if wc_streq(verdict, "LISTED" as *u8) == 1 { if wc_streq(kind, "url" as *u8) == 1 { return 1 } } 1204 if wc_streq(verdict, "LIVE" as *u8) == 1 { if wc_streq(kind, "path" as *u8) == 1 { return 1 } } 1205 return 0 1206} 1207 1208// Read ONE host's map and bank its seedable rows. Returns urls NEWLY added to the frontier. 1209// foundbox[0] is incremented when a map FILE existed, which is what separates "this host has no map" 1210// from "this host's map held nothing new" -- two states with completely different remedies that a 1211// single added-count would render identical. 1212func wc_dm_seed_host(h: *i64, w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, 1213 urls: *u8, npendbox: *i64, host: *u8, foundbox: *i64) -> i64 { 1214 let p: *u8 = sys_mmap(WC_MAGIC_1024) 1215 var o: i64 = wc_outcat(p, 0, WC_DM_DIR) 1216 o = wc_outcat(p, o, "/" as *u8) 1217 var hk: i64 = 0 1218 while host[hk] != (0 as u8) { p[o] = host[hk]; o = o + 1; hk = hk + 1 } 1219 o = wc_outcat(p, o, ".tsv" as *u8) 1220 p[o] = 0 as u8 1221 let szb: *i64 = sys_mmap(16) as *i64 1222 szb[0] = 0 1223 let fb: *u8 = ss_loadfile(p, szb, 0) 1224 if (fb as i64) == 0 { return 0 } 1225 if szb[0] <= 0 { return 0 } 1226 foundbox[0] = foundbox[0] + 1 1227 // Row: <epoch> TAB <source> TAB <kind> TAB <verdict> TAB <value>. Only fields 2,3,4 are read. 1228 // ALL FOUR BUFFERS ARE HOISTED OUT OF THE ROW LOOP -- a per-row sys_mmap here would burn a page 1229 // per row across up to WC_MAXCANH maps (NEVER ALLOCATE IN A HOT LOOP). 1230 let kind: *u8 = sys_mmap(WC_DM_HOSTSLOT) 1231 let verd: *u8 = sys_mmap(WC_DM_HOSTSLOT) 1232 let val: *u8 = sys_mmap(WC_MAGIC_2048) 1233 let su: *u8 = sys_mmap(WC_MAGIC_2048) 1234 var added: i64 = 0 1235 var rows: i64 = 0 1236 var i: i64 = 0 1237 var fld: i64 = 0 1238 var kl: i64 = 0 1239 var vl: i64 = 0 1240 var al: i64 = 0 1241 while i <= szb[0] { 1242 var ch: i64 = 10 1243 if i < szb[0] { ch = fb[i] as i64 } 1244 if ch == 10 { 1245 if fld >= 4 { if rows < WC_DM_MAX_ROWS { 1246 kind[kl] = 0 as u8 1247 verd[vl] = 0 as u8 1248 val[al] = 0 as u8 1249 rows = rows + 1 1250 if al > 0 { if wc_dm_seedable(kind, verd) == 1 { 1251 var so: i64 = 0 1252 // a LISTED url is absolute; a LIVE path is relative to the host whose map this is 1253 if wc_streq(kind, "path" as *u8) == 1 { 1254 so = wc_outcat(su, 0, "https://" as *u8) 1255 var hh2: i64 = 0 1256 while host[hh2] != (0 as u8) { su[so] = host[hh2]; so = so + 1; hh2 = hh2 + 1 } 1257 } 1258 var vv: i64 = 0 1259 while vv < al { su[so] = val[vv]; so = so + 1; vv = vv + 1 } 1260 su[so] = 0 as u8 1261 // COMPOSE THE INCUMBENT FILTER, never a second one: everything the ordinary crawl 1262 // refuses (tracking params, tag farms, binary extensions, oversize urls) must be 1263 // refused on this path too, or the map becomes a bypass around wc_urlok. 1264 if wc_urlok(su, so) == 1 { 1265 added = added + wc_seed_add(h, w, prefix, segidbox, segsbox, urls, npendbox, su, so) 1266 } 1267 } } 1268 } } 1269 fld = 0; kl = 0; vl = 0; al = 0 1270 } else { 1271 if ch == 9 { fld = fld + 1 } 1272 else { if ch != 13 { 1273 if fld == 2 { if kl < WC_DM_HOSTSLOT - 1 { kind[kl] = ch as u8; kl = kl + 1 } } 1274 if fld == 3 { if vl < WC_DM_HOSTSLOT - 1 { verd[vl] = ch as u8; vl = vl + 1 } } 1275 if fld == 4 { if al < WC_MAGIC_2048 - 2 { val[al] = ch as u8; al = al + 1 } } 1276 } } 1277 } 1278 i = i + 1 1279 } 1280 return added 1281} 1282// djb2 host hash (factored: the per-host index-cap map and the pool's politeness/dead-host lists must 1283// agree on host identity, so ONE hash function). 1284// ---- R11 CRAWL OUTCOME LOG: every fetch decision becomes MINEABLE --------------------------------- 1285// Operator 2026-08-06: "make sure success isnt accidental and that we have an error log we are mining 1286// and improving our crawler on." Both halves were real. MEASURED the same morning: the SAME v2ph url 1287// that ingested 2,176 chars overnight returned a 403 Cloudflare "Just a moment" interstitial on EVERY 1288// leg -- so that win was WEATHER, not capability, and nothing recorded the difference. 1289// The interstitial guard below already REFUSED to index it (correct), but it refused SILENTLY: no line, 1290// no row, nothing to count. A crawler that discards a page without saying why cannot be improved -- and 1291// worse, the host-health streak then retires the host, laundering OUR capability gap into permanent 1292// coverage loss. This writes ONE tab-separated row per terminal decision so every loss becomes DATA. 1293// class: ok | botwall | http | fetchfail | timeout | thin | dup | hostcap 1294// A CRAWLER THAT CANNOT SAY WHY IT DROPPED A PAGE CANNOT BE IMPROVED, ONLY GUESSED AT. 1295const WC_OUTLOG: *u8 = "knowledge/status/crawl_outcomes.log" 1296static wc_outfd_g: i64 1297func wc_outopen() -> i64 { 1298 // O_WRONLY|O_CREAT|O_APPEND (1089) -- APPEND is load-bearing: this is an event stream spanning runs, 1299 // and a truncating open would erase exactly the history the miner exists to read. 1300 wc_outfd_g = __syscall(56, 0 - 100, WC_OUTLOG as i64, WC_MAGIC_1089, 420, 0, 0) 1301 return wc_outfd_g 1302} 1303func wc_outn(b: *u8, at: i64, v: i64) -> i64 { 1304 var a: i64 = at 1305 var x: i64 = v 1306 if x < 0 { b[a] = 45 as u8; a = a + 1; x = 0 - x } 1307 let t: *u8 = sys_mmap(24) 1308 var k: i64 = 0 1309 if x == 0 { t[0] = 48 as u8; k = 1 } 1310 while x > 0 { t[k] = (48 + x % 10) as u8; x = x / 10; k = k + 1 } 1311 var j: i64 = 0 1312 while j < k { b[a] = t[k - 1 - j]; a = a + 1; j = j + 1 } 1313 return a 1314} 1315func wc_outcat(b: *u8, at: i64, s: *u8) -> i64 { 1316 var a: i64 = at 1317 var i: i64 = 0 1318 while s[i] != (0 as u8) { b[a] = s[i]; a = a + 1; i = i + 1 } 1319 return a 1320} 1321func wc_outcome(cls: *u8, status: i64, bytes: i64, url: *u8, ul: i64) -> i64 { 1322 if wc_outfd_g <= 0 { return 0 } 1323 let b: *u8 = sys_mmap(WC_MAGIC_4096) 1324 var o: i64 = 0 1325 o = wc_outn(b, o, sys_now_realtime_sec()) 1326 b[o] = 9 as u8; o = o + 1 1327 o = wc_outcat(b, o, cls) 1328 b[o] = 9 as u8; o = o + 1 1329 o = wc_outn(b, o, status) 1330 b[o] = 9 as u8; o = o + 1 1331 o = wc_outn(b, o, bytes) 1332 b[o] = 9 as u8; o = o + 1 1333 var i: i64 = 0 1334 while i < ul { if o < WC_MAGIC_4000 { b[o] = url[i]; o = o + 1 } i = i + 1 } 1335 b[o] = 10 as u8; o = o + 1 1336 sys_write(wc_outfd_g, b, o) 1337 sys_munmap(b, WC_MAGIC_4096) 1338 return 1 1339} 1340func wc_hosthash(hostp: *u8, hl: i64) -> i64 { 1341 var hh: i64 = WC_MAGIC_5381 1342 var i: i64 = 0 1343 while i < hl { hh = (((hh << 5) + hh) + (hostp[i] as i64)) & WC_HASH_MASK; i = i + 1 } 1344 return hh 1345} 1346func wc_deadhost_has(dh: *i64, ndh: i64, hh: i64) -> i64 { 1347 var i: i64 = 0 1348 while i < ndh { if dh[i] == hh { return 1 } i = i + 1 } 1349 return 0 1350} 1351func wc_deadhost_add(dh: *i64, ndhbox: *i64, hh: i64) -> i64 { 1352 if wc_deadhost_has(dh, ndhbox[0], hh) == 1 { return 0 } 1353 if ndhbox[0] < WC_DEADHOSTS { dh[ndhbox[0]] = hh; ndhbox[0] = ndhbox[0] + 1; return 1 } 1354 return 0 1355} 1356// ==== R3 HOST-HEALTH (2026-08-04, debt 1785892352): persistent per-host dead-run streak as 1357// hd:<hosthash> rows in the SAME shard (the frontier's store is the crawler's only state). MEASURED 1358// need: post-widening, 1983 of the 2048-row pull window were dead-host rows (97% rot) recurring in 1359// stable walk order -- skip-without-retire made the rot immortal. Streak++ once per run the host is 1360// found dead; reset on any completed HTTP response; at WC_HD_RETIRE the batch selector RETIRES the 1361// rows it skips (P->D, same last-wins flip as a fetched url). A retired url is re-addable the moment 1362// any live page links to it again -- rows are cheap, seeds are not (see the exemption). 1363func wc_hdkey(hh: i64, out: *u8) -> i64 { 1364 out[0] = 104 as u8; out[1] = 100 as u8; out[2] = 58 as u8 // "hd:" 1365 var o: i64 = 3 1366 // DELEGATED 2026-08-08 -- this was a hand-rolled integer->ascii clone, so it is one of the ~1792. 1367 // ccz_cat_num (nx_itoa_lib) writes MSB-first INTO THE CALLER'S buffer and allocates NOTHING, which 1368 // is strictly better than the lazy static I put here this morning: no scratch buffer exists at all, 1369 // so there is nothing to hoist and nothing for nx_mmapbal to convict. 1370 // BEHAVIOUR IDENTITY CHECKED, NOT ASSUMED: for hh==0 the old code wrote '0' then NUL at offset 3 and 1371 // returned 4; ccz_cat_num writes '0', NUL and returns the NUL offset = 4. For hh>0 both emit the same 1372 // MSB-first digits, NUL-terminate, and return the offset OF the NUL. Same bytes, same return, no alloc. 1373 return ccz_cat_num(out, o, hh) 1374} 1375// the host's persisted dead-run streak (0 = healthy/unknown). Reads the RUN-START handle: retire 1376// decisions deliberately see the streak as of run start, so death #N retires on run N+1, never same-run. 1377func wc_hd_streak(h: *i64, hh: i64) -> i64 { 1378 if (h as i64) == 0 { return 0 } 1379 if (wc_hds_key_g as i64) == 0 { wc_hds_key_g = sys_mmap(64) } 1380 let key: *u8 = wc_hds_key_g 1381 wc_hdkey(hh, key) 1382 if (wc_hds_pp_g as i64) == 0 { wc_hds_pp_g = sys_mmap(16) as *i64 } 1383 if (wc_hds_pl_g as i64) == 0 { wc_hds_pl_g = sys_mmap(16) as *i64 } 1384 let pp: *i64 = wc_hds_pp_g 1385 let pl: *i64 = wc_hds_pl_g 1386 if ss_hget(h, key, pp, pl) != 1 { return 0 } 1387 let vp: *u8 = pp[0] as *u8 1388 var v: i64 = 0 1389 var i: i64 = 0 1390 while i < pl[0] { 1391 var c: i64 = vp[i] as i64 1392 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48) } } 1393 i = i + 1 1394 } 1395 return v 1396} 1397// bank the streak (append-only, last-wins; mirrors wc_frmark's writer-full discipline) 1398func wc_hd_put(w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, hh: i64, v: i64) -> i64 { 1399 if (wc_hdp_key_g as i64) == 0 { wc_hdp_key_g = sys_mmap(64) } 1400 let key: *u8 = wc_hdp_key_g 1401 wc_hdkey(hh, key) 1402 if (wc_hdp_val_g as i64) == 0 { wc_hdp_val_g = sys_mmap(28) } 1403 let val: *u8 = wc_hdp_val_g 1404 // DELEGATED 2026-08-08 (same reasoning as wc_hdkey): ccz_cat_num writes MSB-first straight into 1405 // val and allocates NOTHING, so the scratch buffer and its hoisted static both disappear. 1406 // It returns the offset OF the NUL, which is exactly the digit count this code computed as vl -- 1407 // v==0 -> 1, v>0 -> k. The extra NUL it writes at val[vl] is in bounds (val is 28 B) and harmless 1408 // because ss_add is given vl explicitly and never reads past it. 1409 let vl: i64 = ccz_cat_num(val, 0, v) 1410 if ss_add(w, 1, key, val, vl) < 0 { 1411 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 1412 segidbox[0] = segidbox[0] + 1 1413 w[1] = 0 1414 ss_add(w, 1, key, val, vl) 1415 } 1416 return 0 1417} 1418// ---- R5 SEED FRESHNESS RE-QUEUE (2026-08-05, debt 1785937865) --------------------------------- 1419// MEASURED across 4 consecutive runs: frontier_remaining 2047->1963->1769->1748 while 1420// discovered_new fell 121->35->0 -- run 3 fetched 300 pages and banked ZERO new frontier rows. 1421// wc_harvest only banks a NEVER-SEEN url, so once a host's known links are exhausted its depth 1422// stops compounding. ★A CRAWLER IS A CLOSED LOOP: IF CONSUMPTION EXCEEDS DISCOVERY, THE FRONTIER 1423// IS A BURN-DOWN AND COVERAGE PLATEAUS NO MATTER HOW FAIRLY THE BUDGET IS SCHEDULED. The single 1424// biggest replenishment blocker is done-is-forever: a seed hub fetched once is D and can never 1425// rediscover links -- but hub pages (portals, news indexes, topic lists) CHANGE, which is exactly 1426// why they are seeds. This re-queue walks the seed list with a PERSISTED cursor (sr:cursor row, 1427// same ASCII-digit encoding as the hd: streak rows) and re-marks up to WC_SEED_REFRESH already-D 1428// seed urls back to P each run. Bounded by construction; P and unknown rows are left alone. 1429func wc_srkey(out: *u8) -> i64 { 1430 out[0] = 115 as u8; out[1] = 114 as u8; out[2] = 58 as u8 1431 out[3] = 99 as u8; out[4] = 117 as u8; out[5] = 114 as u8 1432 out[6] = 0 as u8 1433 return 6 1434} 1435func wc_sr_cursor(h: *i64) -> i64 { 1436 if (h as i64) == 0 { return 0 } 1437 if (wc_src_key_g as i64) == 0 { wc_src_key_g = sys_mmap(64) } 1438 let key: *u8 = wc_src_key_g 1439 wc_srkey(key) 1440 if (wc_src_pp_g as i64) == 0 { wc_src_pp_g = sys_mmap(16) as *i64 } 1441 if (wc_src_pl_g as i64) == 0 { wc_src_pl_g = sys_mmap(16) as *i64 } 1442 let pp: *i64 = wc_src_pp_g 1443 let pl: *i64 = wc_src_pl_g 1444 if ss_hget(h, key, pp, pl) != 1 { return 0 } 1445 let vp: *u8 = pp[0] as *u8 1446 var v: i64 = 0 1447 var i: i64 = 0 1448 while i < pl[0] { 1449 var c: i64 = vp[i] as i64 1450 if c >= 48 { if c <= 57 { v = v * 10 + (c - 48) } } 1451 i = i + 1 1452 } 1453 return v 1454} 1455func wc_sr_put(w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, v: i64) -> i64 { 1456 if (wc_srp_key_g as i64) == 0 { wc_srp_key_g = sys_mmap(64) } 1457 let key: *u8 = wc_srp_key_g 1458 wc_srkey(key) 1459 if (wc_srp_val_g as i64) == 0 { wc_srp_val_g = sys_mmap(28) } 1460 let val: *u8 = wc_srp_val_g 1461 // DELEGATED 2026-08-08 -- identical case to wc_hd_put above; see that comment for the identity check. 1462 let vl: i64 = ccz_cat_num(val, 0, v) 1463 if ss_add(w, 1, key, val, vl) < 0 { 1464 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 1465 segidbox[0] = segidbox[0] + 1 1466 w[1] = 0 1467 ss_add(w, 1, key, val, vl) 1468 } 1469 return 0 1470} 1471// re-mark up to WC_SEED_REFRESH done seeds as pending, copying each into dst (2KB slots) so the 1472// caller's canonical block fetches them THIS run. dstbox[0]=count in/out, dstbox[1]=slot capacity 1473// (2-slot box keeps the arity at 9 -- the tree's proven ceiling). Examines each seed at most once 1474// per run (lim <= nseeds) so a short list cannot be requeued twice through the uncommitted writer. 1475func wc_seed_refresh(h: *i64, w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, seedstore: *u8, nseeds: i64, dst: *u8, dstbox: *i64) -> i64 { 1476 if nseeds <= 0 { return 0 } 1477 var cur: i64 = wc_sr_cursor(h) 1478 if cur < 0 { cur = 0 } 1479 cur = cur % nseeds 1480 var lim: i64 = WC_SEED_REFRESH 1481 if lim > nseeds { lim = nseeds } 1482 var rq: i64 = 0 1483 var j: i64 = 0 1484 while j < lim { 1485 let ix: i64 = (cur + j) % nseeds 1486 let sp: *u8 = (seedstore as i64 + ix * WC_MAGIC_2048) as *u8 1487 var sl: i64 = 0 1488 while sp[sl] != (0 as u8) { sl = sl + 1 } 1489 if sl > 0 { if wc_frstate(h, sp, sl) == 2 { 1490 wc_frmark(w, prefix, segidbox, segsbox, sp, sl, 1) 1491 if dstbox[0] < dstbox[1] { 1492 let dp: *u8 = (dst as i64 + dstbox[0] * WC_MAGIC_2048) as *u8 1493 var c: i64 = 0 1494 while c <= sl { dp[c] = sp[c]; c = c + 1 } 1495 dstbox[0] = dstbox[0] + 1 1496 } 1497 rq = rq + 1 1498 } } 1499 j = j + 1 1500 } 1501 wc_sr_put(w, prefix, segidbox, segsbox, (cur + lim) % nseeds) 1502 return rq 1503} 1504// seed-file membership (exact url match) -- seed rows are NEVER retired 1505func wc_is_seed(seedstore: *u8, nseeds: i64, u: *u8, ul: i64) -> i64 { 1506 var s: i64 = 0 1507 while s < nseeds { 1508 let sp: *u8 = (seedstore as i64 + s * WC_MAGIC_2048) as *u8 1509 var eq: i64 = 1 1510 var i: i64 = 0 1511 while i < ul { if sp[i] != u[i] { eq = 0; i = ul } else { i = i + 1 } } 1512 if eq == 1 { if sp[ul] == (0 as u8) { return 1 } } 1513 s = s + 1 1514 } 1515 return 0 1516} 1517// the retire decision + act, factored so the gate can bite it network-free. Returns 1 = row retired. 1518// SEED EXEMPTION LIVES AT THE CALL SITE (wc_is_seed guard) -- 8 params, within the tree's proven arity. 1519func wc_retire_if_rotten(h: *i64, w: *i64, prefix: *u8, segidbox: *i64, segsbox: *i64, u: *u8, ul: i64, hh: i64) -> i64 { 1520 if wc_hd_streak(h, hh) < WC_HD_RETIRE { return 0 } 1521 wc_frmark(w, prefix, segidbox, segsbox, u, ul, 2) 1522 return 1 1523} 1524// spool path for a worker slot: /tmp/nx_wcpool_<slot>.bin. Slot-indexed names cannot collide: the 1525// searchcrawl sub's liveness guard enforces ONE crawler instance, and a slot is reused only after its 1526// child was reaped and its spool consumed. 1527func wc_spool_path(slot: i64, out: *u8) -> i64 { 1528 let lit: *u8 = "/tmp/nx_wcpool_" as *u8 1529 var o: i64 = 0 1530 while lit[o] != (0 as u8) { out[o] = lit[o]; o = o + 1 } 1531 let t: *u8 = sys_mmap(28) 1532 var m: i64 = slot 1533 var k: i64 = 0 1534 if m == 0 { t[0] = 48 as u8; k = 1 } 1535 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 1536 var i: i64 = 0 1537 while i < k { out[o] = t[k - 1 - i]; o = o + 1; i = i + 1 } 1538 let ext: *u8 = ".bin" as *u8 1539 var j: i64 = 0 1540 while ext[j] != (0 as u8) { out[o] = ext[j]; o = o + 1; j = j + 1 } 1541 out[o] = 0 as u8 1542 return o 1543} 1544// CHILD: one fetch -> spool file [status][len][body] -> exit. All child memory is freed by the kernel 1545// on exit (the windowed-fork idiom); the PARENT owns the wall-clock budget, so the child needs no timer 1546// of its own. exit 0 = body spooled; 1 = fetch returned nothing; 2 = spool unwritable. 1547// ==================== R11: ROBOTS.TXT -- THE HOST'S RULES OVER THE HOST'S CONTENT ==================== 1548// (2026-08-06) MEASURED GAP, not a suspected one: this crawler contained ZERO references to robots. 1549// `nx_robots.nx` -- a complete RFC 9309 parser (most-specific user-agent group, longest-match 1550// Allow/Disallow with Allow winning ties, Crawl-delay extraction), tested by nx_robots_test -- has 1551// existed the whole time and was imported by exactly ONE organ (nx_media_import). Its own header 1552// declares `wired_status: FULLY_WIRED`. And `pace_set_crawl_delay`, the slot the pacer keeps for 1553// precisely this number, was called from NOTHING but its own gate. So the crawler paced every host on 1554// a fixed 1s floor and never once asked a host what pace IT wanted. 1555// **A SELF-DECLARED wired_status IS A COMMENT, NOT AN ADOPTION MEASUREMENT.** 1556// **POLITENESS THAT IS BUILT BUT NOT WIRED IS RUDENESS WITH GOOD INTENTIONS.** 1557// 1558// This closes a loop the estate had already reasoned its way to: on 2026-08-06 loc.gov served two 1559// pages and 403'd the rest, and the independent auditor proved that was RATE LIMITING rather than a 1560// bot wall. The lever named that day was PACING -- and the host had been publishing its preferred 1561// pace in a file we never fetched. 1562// 1563// CONTRACT (deliberately conservative: a robots failure must never become a coverage failure) 1564// - THE THREE-WAY RFC 9309 s2.3.1 SPLIT (corrected 2026-08-25; this bullet USED to read "absent / 1565// 404 / unreachable / empty -> ALLOW", which CONFLATED two cases the RFC separates, and the 1566// conflation was the ALLOWING one): 1567// * 2xx -> s2.3.1.1 "Successful Access": follow the parseable rules. A 2xx with an EMPTY body 1568// is a successful fetch of a file that states no rules, so it allows -- by the RULES 1569// path, not by a failure path. 1570// * 4xx -> s2.3.1.3 "Unavailable": VERBATIM, "If a server status code indicates that the 1571// robots.txt file is unavailable to the crawler, then the crawler MAY access any 1572// resources on the server." ALLOW is correct and is what we already did. 1573// * 5xx or a transport failure with NO status line (connect / TLS / read) -> s2.3.1.4 1574// "Unreachable": VERBATIM, "If the robots.txt file is unreachable due to server or 1575// network errors, this means the robots.txt file is undefined and the crawler MUST 1576// assume complete disallow." We now DISALLOW THE WHOLE HOST for the run. 1577// WHAT WAS BROKEN: a host answering 500 on /robots.txt was crawled exactly like a host answering 1578// 404, because both landed in the same len-0 cache slot and wc_rb_allowed read len<=0 as ALLOW. 1579// The old comment's reasoning -- "our own inability to fetch a file is not a rule" -- is TRUE of 1580// 4xx and FALSE of 5xx: the RFC deliberately makes the server's own failure a DISALLOW, because 1581// a host that cannot serve its rules cannot be assumed to have none. 1582// - NO DEVIATION IS TAKEN. Google's published parser retries 5xx and, after a long outage, falls 1583// back to treating the host as unavailable; RFC 9309 s2.3.1.4 itself permits that after "a 1584// reasonably long period of time (for example, 30 days)". We do NOT implement it, because a 1585// per-host 30-day outage memory is state we do not keep, and INVENTING the fallback without the 1586// memory would just be the old bug with a citation attached. WC_RB_DEVIATION_30D names the 1587// choice so it is reviewable rather than accidental. 1588// - a disallowed url is marked DONE and logged `robots`, and is NEVER charged to the host's 1589// dead-host streak. Refusing to fetch is OUR decision; laundering it into "the host is dead" 1590// would retire the host permanently (wc_seed_add refuses any existing row) -- the exact harm the 1591// R9 retire exemption was written to prevent. 1592// - Crawl-delay feeds pace_set_crawl_delay CLAMPED, so one hostile or fat-fingered robots.txt 1593// cannot wedge a whole run; the clamp is LOGGED, because a silent clamp is a lie about what we 1594// actually honoured. 1595// - the robots.txt fetch is ITSELF paced and is attempted AT MOST ONCE PER HOST PER RUN -- a 1596// politeness probe that hammers is not politeness. 1597// - WC_ROBOTS_ON is a named switch, so turning this off is a deliberate, reviewable one-line act. 1598// UA NOTE: we match the group `nishibot` (the NishiBot/1.0 product token already used elsewhere in 1599// the fetch stack), falling back to `*` per RFC 9309. No site publishes a nishibot group today, so in 1600// practice `*` governs -- which is the conservative reading, and is honest about what we are. 1601const WC_ROBOTS_ON: i64 = 1 1602const WC_ROBOTS_HOSTS: i64 = 256 // distinct hosts whose robots.txt we cache per run 1603const WC_ROBOTS_ARENA: i64 = 1048576 // ONE packed arena for all cached bodies (256 x 16KB would be 4MB of holes) 1604const WC_ROBOTS_MAX: i64 = 16384 // per-host body cap 1605const WC_ROBOTS_FCAP: i64 = 262144 // fetch buffer for robots.txt 1606const WC_RB_UALEN: i64 = 8 // len("nishibot") 1607 1608// ---- RFC 9309 s2.3.1 ACCESS-RESULT STATES (2026-08-25) ---------------------------------------- 1609// These are ordinals naming the RFC's three outcomes. They are NOT thresholds and nothing compares 1610// them with < or >; a reader who sees 0/1/2 should read the RFC section, not an ordering. 1611const WC_RB_ST_RULES: i64 = 0 // s2.3.1.1 Successful Access -- a body was parsed, follow it 1612const WC_RB_ST_ALLOW_ALL: i64 = 1 // s2.3.1.3 Unavailable -- "MAY access any resources" 1613const WC_RB_ST_DISALLOW_ALL: i64 = 2 // s2.3.1.4 Unreachable -- "MUST assume complete disallow" 1614 1615// HTTP status CLASS bounds, from RFC 9110 s15 (Successful 2xx / Redirection 3xx / Client Error 4xx / 1616// Server Error 5xx). Written as named bounds rather than bare 200/400/500 so the mapping onto the 1617// RFC 9309 states above is legible at the comparison site instead of being folklore. 1618const WC_HTTP_2XX_LO: i64 = 200 1619const WC_HTTP_2XX_HI: i64 = 299 1620const WC_HTTP_3XX_LO: i64 = 300 1621const WC_HTTP_3XX_HI: i64 = 399 1622const WC_HTTP_4XX_LO: i64 = 400 1623const WC_HTTP_4XX_HI: i64 = 499 1624const WC_HTTP_5XX_LO: i64 = 500 1625const WC_HTTP_5XX_HI: i64 = 599 1626// ff_core_x sets out_status[0] = 0 before the loop and only overwrites it once a response LINE has 1627// been parsed. So 0 is not a status -- it is the transport never having produced one (bad url, 1628// connect refused, TLS handshake failure, read failure). RFC 9309 s2.3.1.4 names exactly this class 1629// alongside 5xx: "unreachable due to server OR NETWORK errors". 1630const WC_HTTP_NO_STATUS: i64 = 0 1631 1632// RFC 9309 s2.3.1.2 Redirects: "The crawlers SHOULD follow at least five consecutive redirects". 1633// MEASURED DEFECT 2026-08-25: this call passed 3, which is below that SHOULD -- a robots.txt behind 1634// 4 hops read as unreachable and (post-fix) would now DISALLOW the whole host. Raising it to the 1635// RFC's floor is therefore not cosmetic: under the corrected 5xx rule an under-budgeted redirect 1636// chain converts into lost coverage. 1637const WC_ROBOTS_REDIRECTS: i64 = 5 1638// s2.3.1.2 also says: "If there are more than five consecutive redirects, crawlers MAY assume that 1639// the robots.txt file is unavailable." So a chain still unresolved AFTER the budget is ALLOW_ALL by 1640// explicit permission -- it is not folded into the unreachable class. 1641 1642// ---- CRAWL-DELAY: WE NO LONGER CLAMP THE NUMBER DOWN (2026-08-25) ----------------------------- 1643// WC_ROBOTS_CDMAX used to rewrite any published Crawl-delay above 30 s DOWN to 30 s. That is a 1644// COMPLIANCE VIOLATION, not a safety valve: a host publishing "Crawl-delay: 120" was then crawled 1645// every 30 s, i.e. four times faster than it asked. Crawl-delay is absent from RFC 9309, but it is 1646// load-bearing for verified-crawler status -- Cloudflare's verified-bot policy names "an AI Crawler 1647// that does not respect the crawl-delay directive in robots.txt" as a removal-triggering breach. 1648// THE FIX IS NOT A BIGGER CLAMP, IT IS DEFERRAL: the published number is now adopted EXACTLY, and a 1649// host whose interval has not elapsed is left PENDING by pace_should_defer_tbl and picked up on a 1650// later run. Deferral costs nothing (the frontier row is already persisted) and is the only 1651// behaviour that both honours the host and keeps the coverage. 1652// The one remaining bound is a HORIZON, not a rate clamp, and when it binds we crawl the host LESS, 1653// never more. Its value is DERIVED from RFC 9309 s2.4: "Crawlers SHOULD NOT use the cached version 1654// for more than 24 hours" -- a Crawl-delay longer than the maximum lifetime of the very file that 1655// published it cannot be honoured coherently within one rule-lifetime, so we decline the host for 1656// the run and say so, rather than invent a faster pace it never agreed to. 1657const WC_ROBOTS_CD_HORIZON_S: i64 = 86400 // 24 h, per RFC 9309 s2.4 cache lifetime 1658const WC_ROBOTS_CD_MS_PER_S: i64 = 1000 // seconds -> ms for pace_set_crawl_delay 1659 1660// The 30-day "treat a long-unreachable robots.txt as unavailable" fallback that RFC 9309 s2.3.1.4 1661// PERMITS ("crawlers MAY assume that the robots.txt file is unavailable") and that Google's parser 1662// implements. 0 = NOT TAKEN. Implementing it needs a persisted per-host first-unreachable timestamp 1663// that this crawler does not keep, and a fallback without that memory is indistinguishable from the 1664// bug this whole block exists to fix. Named so the choice is reviewable, never accidental. 1665const WC_RB_DEVIATION_30D: i64 = 0 1666 1667static wc_rb_arena_g: *u8 1668static wc_rb_used_g: i64 1669static wc_rb_hash_g: *i64 1670static wc_rb_off_g: *i64 1671static wc_rb_len_g: *i64 1672static wc_rb_n_g: i64 1673static wc_rb_skip_g: i64 1674static wc_rb_cd_g: i64 1675// The RFC 9309 s2.3.1 access-result state per cached host. THIS ARRAY IS THE FIX: before it existed 1676// the only per-host fact carried out of wc_rb_load was the BODY LENGTH, and a 404 and a 500 both 1677// produce length 0 -- so the two cases the RFC most carefully separates were byte-identical to 1678// every reader downstream. A length cannot express a policy. 1679static wc_rb_state_g: *i64 1680static wc_rb_deny_g: i64 // hosts this run put into DISALLOW_ALL (observability for the new rule) 1681 1682func wc_rb_ua() -> *u8 { return "nishibot" as *u8 } 1683 1684func wc_rb_init() -> i64 { 1685 wc_rb_arena_g = sys_mmap(WC_ROBOTS_ARENA) 1686 wc_rb_hash_g = sys_mmap(WC_ROBOTS_HOSTS * 8) as *i64 1687 wc_rb_off_g = sys_mmap(WC_ROBOTS_HOSTS * 8) as *i64 1688 wc_rb_len_g = sys_mmap(WC_ROBOTS_HOSTS * 8) as *i64 1689 wc_rb_state_g = sys_mmap(WC_ROBOTS_HOSTS * 8) as *i64 1690 wc_rb_used_g = 0 1691 wc_rb_n_g = 0 1692 wc_rb_skip_g = 0 1693 wc_rb_cd_g = 0 1694 wc_rb_deny_g = 0 1695 return 0 1696} 1697 1698// THE CLASSIFIER, EXTRACTED AS A PURE FUNCTION ON PURPOSE (2026-08-25). 1699// It takes only (status, body_bytes) and returns one of the three WC_RB_ST_* states, so the RFC 9309 1700// s2.3.1 rule can be gate-proven with NO network, NO TrustStore and NO cache seeding -- the whole 1701// reason the old bug survived is that the decision was three scattered lines inside a function that 1702// cannot run without a socket, and a rule you cannot call is a rule you cannot test. 1703// status 2xx, n > 0 -> RULES (s2.3.1.1, follow the parseable rules) 1704// status 2xx, n == 0 -> ALLOW_ALL (a successful fetch of a file that states no rules allows 1705// everything -- reached by the RULES path, never by a failure path) 1706// status 2xx, n < 0 -> DISALLOW_ALL. The response line said 200 but the fetch machinery could 1707// not deliver a body (dechunk / inflate / truncation). We did NOT read the 1708// rules, and "the server said the rules exist and we failed to obtain 1709// them" is exactly s2.3.1.4's undefined case. Note this check lives INSIDE 1710// the 2xx arm on purpose: a 4xx with an undecodable error page is still a 1711// definitive "not there", so a blanket n<0 test ahead of the status would 1712// wrongly disallow every 404 whose error page failed to decode. 1713// status 4xx -> ALLOW_ALL (s2.3.1.3 "unavailable": "MAY access any resources") 1714// status 5xx -> DISALLOW_ALL(s2.3.1.4 "unreachable": "MUST assume complete disallow") 1715// no status line at all-> DISALLOW_ALL(s2.3.1.4 names "server OR NETWORK errors") 1716// status 3xx left over -> ALLOW_ALL (s2.3.1.2: past the redirect budget a crawler "MAY assume 1717// that the robots.txt file is unavailable") 1718// anything else -> DISALLOW_ALL, and DELIBERATELY NOT FOLDED INTO THE 5xx BUCKET. The RFC 1719// defines no outcome for a 1xx or a malformed status, and "we could not 1720// determine the rules" is the unreachable case, not the unavailable one. 1721// It gets its OWN outcome label at the call site so the class is countable: 1722// if this ever fires in volume it is a parser bug wearing a policy hat, and 1723// a shared counter would hide that. 1724func wc_rb_classify(status: i64, nbytes: i64) -> i64 { 1725 if status >= WC_HTTP_2XX_LO { if status <= WC_HTTP_2XX_HI { 1726 if nbytes > 0 { return WC_RB_ST_RULES } 1727 if nbytes == 0 { return WC_RB_ST_ALLOW_ALL } 1728 return WC_RB_ST_DISALLOW_ALL 1729 } } 1730 if status >= WC_HTTP_4XX_LO { if status <= WC_HTTP_4XX_HI { return WC_RB_ST_ALLOW_ALL } } 1731 if status >= WC_HTTP_5XX_LO { if status <= WC_HTTP_5XX_HI { return WC_RB_ST_DISALLOW_ALL } } 1732 if status >= WC_HTTP_3XX_LO { if status <= WC_HTTP_3XX_HI { return WC_RB_ST_ALLOW_ALL } } 1733 if status == WC_HTTP_NO_STATUS { return WC_RB_ST_DISALLOW_ALL } 1734 return WC_RB_ST_DISALLOW_ALL 1735} 1736 1737// The Crawl-delay HORIZON rule, as a pure predicate for the same reason the classifier is one: a rule 1738// that can only be exercised by a live robots.txt fetch is a rule with no test. 1739// 0 = adopt the published delay EXACTLY. 1 = decline the host for this run. 1740// There is deliberately no third answer, because the third answer people reach for -- "adopt a smaller 1741// number" -- is the violation this replaced. 1742func wc_rb_cd_policy(cd_s: i64) -> i64 { 1743 if cd_s > WC_ROBOTS_CD_HORIZON_S { return 1 } 1744 return 0 1745} 1746 1747// Did the classifier reach DISALLOW_ALL via the UNDEFINED-status door rather than a defined one? 1748// Split out so the caller can label the outcome row distinctly without re-deriving the class. 1749func wc_rb_status_undefined(status: i64) -> i64 { 1750 if status == WC_HTTP_NO_STATUS { return 0 } 1751 if status >= WC_HTTP_2XX_LO { if status <= WC_HTTP_5XX_HI { return 0 } } 1752 return 1 1753} 1754 1755func wc_rb_find(hh: i64) -> i64 { 1756 var i: i64 = 0 1757 while i < wc_rb_n_g { if wc_rb_hash_g[i] == hh { return i } i = i + 1 } 1758 return 0 - 1 1759} 1760 1761// path (+query) of a url, always at least "/". robots matching is defined over this, not the host. 1762func wc_rb_path(url: *u8, ul: i64, out: *u8, outcap: i64) -> i64 { 1763 var s: i64 = 0 1764 var i: i64 = 0 1765 var d: i64 = 0 1766 while d == 0 { 1767 if i + 2 >= ul { d = 1 } 1768 else { 1769 if url[i] == (58 as u8) { if url[i+1] == (47 as u8) { if url[i+2] == (47 as u8) { s = i + 3; d = 1 } } } 1770 if d == 0 { i = i + 1 } 1771 } 1772 } 1773 var p: i64 = s 1774 var f: i64 = 0 - 1 1775 while p < ul { if url[p] == (47 as u8) { f = p; p = ul } else { p = p + 1 } } 1776 if f < 0 { out[0] = 47 as u8; out[1] = 0 as u8; return 1 } 1777 var o: i64 = 0 1778 var k: i64 = f 1779 while k < ul { if o + 1 < outcap { out[o] = url[k]; o = o + 1 } k = k + 1 } 1780 out[o] = 0 as u8 1781 return o 1782} 1783 1784// Fetch + cache this host's robots.txt for the run. Returns the cache index. The slot is recorded 1785// EVEN ON FAILURE, which is what bounds us to one probe per host per run -- otherwise a host with no 1786// robots.txt would be re-probed on every single url and we would be the impolite one. 1787func wc_rb_load(host: *u8, hl: i64, hh: i64, store: *TrustStore) -> i64 { 1788 if (wc_rb_arena_g as i64) == 0 { return 0 - 1 } 1789 if wc_rb_n_g >= WC_ROBOTS_HOSTS { return 0 - 1 } 1790 let ru: *u8 = sys_mmap(WC_MAGIC_1024) 1791 var o: i64 = 0 1792 o = wc_outcat(ru, o, "https://" as *u8) 1793 var k: i64 = 0 1794 while k < hl { ru[o] = host[k]; o = o + 1; k = k + 1 } 1795 o = wc_outcat(ru, o, "/robots.txt" as *u8) 1796 ru[o] = 0 as u8 1797 1798 let buf: *u8 = sys_mmap(WC_ROBOTS_FCAP) 1799 let st: *i64 = sys_mmap(16) as *i64 1800 st[0] = 0 1801 pace_before(host, hl) 1802 let n: i64 = nx_https_fetch_follow_best(ru, store, buf, WC_ROBOTS_FCAP, WC_ROBOTS_REDIRECTS, st) 1803 pace_after(host, hl, st[0], 0) 1804 1805 let idx: i64 = wc_rb_n_g 1806 wc_rb_hash_g[idx] = hh 1807 wc_rb_off_g[idx] = 0 1808 wc_rb_len_g[idx] = 0 1809 // RFC 9309 s2.3.1: the fetch RESULT decides the host's access policy. This used to be three lines 1810 // collapsing every non-200 into one len-0 slot; it is now one call to a pure, gate-testable rule. 1811 let stt: i64 = wc_rb_classify(st[0], n) 1812 wc_rb_state_g[idx] = stt 1813 wc_rb_n_g = idx + 1 1814 1815 if stt == WC_RB_ST_DISALLOW_ALL { 1816 wc_rb_deny_g = wc_rb_deny_g + 1 1817 // TWO LABELS, ONE POLICY. The undefined-status door is counted separately because it is the 1818 // only one that would indicate a defect in US rather than in the host: if it ever appears in 1819 // volume, that is a response parser bug, and a shared counter with the honest 5xx case would 1820 // hide it behind traffic we expect to see. 1821 if wc_rb_status_undefined(st[0]) == 1 { wc_outcome("robots-undefined-status" as *u8, st[0], n, ru, o) } 1822 else { wc_outcome("robots-unreachable-disallow-all" as *u8, st[0], n, ru, o) } 1823 sys_munmap(buf, WC_ROBOTS_FCAP) 1824 return idx 1825 } 1826 if stt == WC_RB_ST_ALLOW_ALL { 1827 // label kept as "robots-none" DELIBERATELY: nx_crawlmine and the outcome log have counted this 1828 // string since R11, and renaming it would silently reset a denominator that spans months. 1829 wc_outcome("robots-none" as *u8, st[0], n, ru, o) 1830 sys_munmap(buf, WC_ROBOTS_FCAP) 1831 return idx 1832 } 1833 var rl: i64 = n 1834 if rl > WC_ROBOTS_MAX { rl = WC_ROBOTS_MAX } 1835 if wc_rb_used_g + rl > WC_ROBOTS_ARENA { 1836 wc_outcome("robots-arena-full" as *u8, 0, rl, ru, o) 1837 sys_munmap(buf, WC_ROBOTS_FCAP) 1838 return idx 1839 } 1840 var z: i64 = 0 1841 while z < rl { wc_rb_arena_g[wc_rb_used_g + z] = buf[z]; z = z + 1 } 1842 wc_rb_off_g[idx] = wc_rb_used_g 1843 wc_rb_len_g[idx] = rl 1844 wc_rb_used_g = wc_rb_used_g + rl 1845 sys_munmap(buf, WC_ROBOTS_FCAP) 1846 1847 let rp: *u8 = (wc_rb_arena_g as i64 + wc_rb_off_g[idx]) as *u8 1848 var cd: i64 = nx_robots_crawl_delay(rp, rl, wc_rb_ua(), WC_RB_UALEN) 1849 if cd > 0 { 1850 if wc_rb_cd_policy(cd) == 1 { 1851 // BEYOND THE HORIZON WE CRAWL LESS, NEVER MORE. The branch this replaces rewrote cd DOWN 1852 // to 30 s and carried on -- it answered "this host wants to be crawled slowly" by crawling 1853 // it fast. Declining costs one host for one run; clamping costs our standing as a crawler, 1854 // and it is the specific behaviour a CDN verified-bot policy names as a removal breach. 1855 wc_outcome("robots-cd-refused-host" as *u8, cd, WC_ROBOTS_CD_HORIZON_S, ru, o) 1856 wc_rb_state_g[idx] = WC_RB_ST_DISALLOW_ALL 1857 wc_rb_deny_g = wc_rb_deny_g + 1 1858 return idx 1859 } 1860 // ADOPTED EXACTLY AS PUBLISHED -- no clamp. pace_should_defer_tbl leaves the row PENDING until 1861 // the host's own interval has elapsed, so honouring even a very long delay costs coverage on no 1862 // run at all: the frontier row is already persisted and simply returns on a later step. 1863 pace_set_crawl_delay(host, hl, cd * WC_ROBOTS_CD_MS_PER_S) 1864 wc_rb_cd_g = wc_rb_cd_g + 1 1865 wc_outcome("robots-cd" as *u8, cd, rl, ru, o) 1866 } 1867 return idx 1868} 1869 1870// 1 = we may fetch this url, 0 = we may not. 1871// HEADER CORRECTED 2026-08-25. It used to read "EVERY failure path returns 1 (allow) by design" -- 1872// the defect stated as an intention. RFC 9309 s2.3.1.4 makes the SERVER's failure a DISALLOW, so 1873// "every failure allows" was never the design the RFC describes. 1874// The allow-on-failure paths that REMAIN are OUR OWN failures, and they stay ALLOW deliberately: 1875// robots switched off, arena unallocated, per-run cache full. Our inability to run the check is not 1876// a rule about the host -- which is exactly the distinction the old one-line rule collapsed, since a 1877// len-0 slot meant both "the host said nothing" and "the host could not answer". 1878func wc_rb_allowed(host: *u8, hl: i64, hh: i64, url: *u8, ul: i64, store: *TrustStore) -> i64 { 1879 if WC_ROBOTS_ON == 0 { return 1 } 1880 if (wc_rb_arena_g as i64) == 0 { return 1 } 1881 var idx: i64 = wc_rb_find(hh) 1882 if idx < 0 { idx = wc_rb_load(host, hl, hh, store) } 1883 // idx < 0 here means the per-run cache is FULL (WC_ROBOTS_HOSTS hosts already probed). That is 1884 // OUR budget, not the host's answer, and it is the one remaining case where we fetch without 1885 // having asked. Left as ALLOW to match the pre-existing contract rather than silently introducing 1886 // a coverage cliff at host 257, but named here so it is a known bound and not an assumption. 1887 if idx < 0 { return 1 } 1888 // ---- THE RFC 9309 s2.3.1 STATES. Order matters: DISALLOW_ALL is tested FIRST, because a host in 1889 // that state has a len-0 body and would otherwise fall straight into the len<=0 allow below -- 1890 // which is precisely how the 5xx case escaped for as long as it did. 1891 if wc_rb_state_g[idx] == WC_RB_ST_DISALLOW_ALL { return 0 } 1892 if wc_rb_state_g[idx] == WC_RB_ST_ALLOW_ALL { return 1 } 1893 if wc_rb_len_g[idx] <= 0 { return 1 } 1894 let pbuf: *u8 = sys_mmap(WC_MAGIC_2048) 1895 let pl: i64 = wc_rb_path(url, ul, pbuf, WC_MAGIC_2048) 1896 let rp: *u8 = (wc_rb_arena_g as i64 + wc_rb_off_g[idx]) as *u8 1897 let v: i64 = nx_robots_allowed(rp, wc_rb_len_g[idx], wc_rb_ua(), WC_RB_UALEN, pbuf, pl) 1898 sys_munmap(pbuf, WC_MAGIC_2048) 1899 return v 1900} 1901 1902// R17: THE PER-RUN DISCOVERY BUDGET, DERIVED RATHER THAN FIXED. 1903// MEASURED 2026-08-25: discovered_new read exactly 300 in 58 of 60 consecutive runs -- pinned at its 1904// ceiling every time, which means the harvest had MORE SUPPLY than the budget would accept, while the 1905// frontier had drained to 313 against a 2048-row pull window. A starved frontier and a pinned discovery 1906// cap in the same run is the cap being the binding constraint, not the link supply. 1907// Fill toward one pull window; never bank LESS than the old fixed budget, so this can only ever widen 1908// and a full frontier still behaves exactly as before. PURE, so it is gateable without a crawl. 1909func wc_disc_budget(npend_now: i64) -> i64 { 1910 var b: i64 = WC_DISC_CEIL - npend_now 1911 if b < WC_MAXDISC { b = WC_MAXDISC } 1912 if b > WC_DISC_CEIL { b = WC_DISC_CEIL } 1913 return b 1914} 1915// Set once per run from the measured pending count. The getter FLOORS to the old fixed budget when it is 1916// unset, so any path that runs before main computes it keeps the previous behaviour byte for byte -- 1917// an uninitialised budget must never mean "discover nothing". 1918static wc_disc_budget_g: i64 1919func wc_disc_cap() -> i64 { 1920 if wc_disc_budget_g <= 0 { return WC_MAXDISC } 1921 return wc_disc_budget_g 1922} 1923 1924// R16: does a host-capped row get DEFERRED (left pending) or RETIRED (marked done)? 1925// PURE, so it is testable without a store, a socket or a crawl -- the compliance lane learned the same 1926// lesson the hard way: a rule that can only be reached through a fetch is a rule nobody can gate. 1927// While the whole frontier fits inside one pull window, every pending row is pulled again next run, so a 1928// deferral is guaranteed to come back and the page is merely postponed. Past that width a deferred row 1929// can be shadowed by rows ahead of it, which is the blockade that made retirement the right call 1930// originally -- so past that width we still retire. No new constant: the bound IS the pull window. 1931func wc_hostcap_defers(npend_now: i64) -> i64 { 1932 if npend_now <= WC_MAXPEND { return 1 } 1933 return 0 1934} 1935 1936func wc_fetch_child(url: *u8, store: *TrustStore, spath: *u8) -> i64 { 1937 // CRAWL AS OURSELVES (2026-08-25). Until this line the bulk crawler sent a hardcoded Chrome 1938 // User-Agent -- MEASURED, not inferred: this child calls nx_https_fetch_follow_best, which reaches 1939 // nx_http_client_build_request_cookie_xhdr, which emitted the browser identity for every request. 1940 // Two things were wrong with that beyond the forgery. We MATCH the robots.txt group `nishibot` while 1941 // SENDING Chrome, so a site operator can write a rule for us, watch us obey it, and never be able to 1942 // tell it was us. And the identity mechanism added later appended a SECOND User-Agent header, which 1943 // RFC 9110 5.3 forbids and which receivers resolve by taking the FIRST -- so the honest identity was 1944 // never on the wire at all. 1945 // Set HERE, in the fetch child, rather than once in main: this is the exact process that performs a 1946 // crawl fetch, so the identity cannot be left set by one caller and inherited by an unrelated one. 1947 // The browser lane's Chrome UA is untouched and still default -- it is load-bearing for JA3 matching. 1948 hc_set_identity(HC_UA_BOT) 1949 let cap: i64 = WC_MAGIC_8388608 1950 let out: *u8 = sys_mmap(cap) 1951 let status: *i64 = sys_mmap(8) as *i64 1952 let n: i64 = nx_https_fetch_follow_best(url, store, out, cap, 6, status) 1953 let fd: i64 = sys_openat_wr(spath, WC_MODE_RW) 1954 if fd < 0 { sys_exit_group(2) } 1955 let hdr: *i64 = sys_mmap(WC_SPOOL_HDRB) as *i64 1956 hdr[0] = status[0] 1957 var bn: i64 = 0 1958 if n > 0 { bn = n } 1959 hdr[1] = bn 1960 // Redirect depth of THIS child's fetch, read immediately after it returns -- the static is 1961 // last-call-wins and this process performed exactly one fetch, so the read is unambiguous here 1962 // and would be meaningless in the parent. 1963 hdr[2] = nx_https_last_hops() 1964 sys_write(fd, hdr as *u8, WC_SPOOL_HDRB) 1965 if bn > 0 { sys_write(fd, out, bn) } 1966 sys_close(fd) 1967 if bn > 0 { sys_exit_group(0) } 1968 sys_exit_group(1) 1969 return 0 1970} 1971func main(argc: i64, argv: *i64) -> i64 { 1972 if argc < 2 { wc_puts("usage: nx_web_crawl_step <max_pages> [seed-url | seed-file | -] [query words]\n" as *u8); return 1 } 1973 let max_pages: i64 = wc_atoi(argv[1] as *u8) 1974 // HEAVY-I/O BOUND (2026-09-03): a crawl step fans out fetch children and seg-store writes, so it asks the 1975 // estate-wide concurrency bound BEFORE the 4 MB certdata load. DEFER exits 4 (the estate's refused-admission 1976 // code): the crawl clock re-fires the step. UNOBSERVABLE proceeds, announced. 1977 let hio: *i64 = sys_mmap(32) as *i64 1978 let hv: i64 = hio_admit(hio) 1979 hio_announce(1, hv, hio) 1980 if hv == HIO_DEFER { wc_puts("CRAWL-STEP DEFERRED: heavy-I/O bound reached; the clock re-fires\n" as *u8); return 4 } 1981 let r: i64 = nx_trust_store_load_from_certdata("data/mozilla_certdata.txt" as *u8, 512, WC_MAGIC_4194304) 1982 if r <= 0 { wc_puts("certdata load failed\n" as *u8); return 2 } 1983 let store: *TrustStore = r as *TrustStore 1984 wc_puts("=== nx_web_crawl_step: frontier-persisted crawl -> dp-web-pub- ===\n" as *u8) 1985 wc_rb_init() // R11: per-run robots.txt cache 1986 1987 let prefix: *u8 = sys_mmap(512) 1988 dss_prefix("web" as *u8, prefix) 1989 // seq1730 ADOPTION (2026-07-31): the digit-parse above was CORRECT and still deadlocked this 1990 // organ. It took max+1 over EVERY id in the manifest -- and 64 of this plane's 65 entries are 1991 // already pointer-shaped poison from before the guard existed -- so it computed 1992 // 140712850411540, which ss_commit's own guard then correctly REFUSED on every crawl step. 1993 // Five consecutive steps logged `segments=0` while still printing CRAWL-STEP GREEN: fetched, 1994 // parsed and discarded. The fix was right, the guard was right, the DATA was poisoned, and the 1995 // leaf never adopted the lib that already knew that. ss_next_segid skips pointer-band ids 1996 // (nx_seg_store.nx:1403) so a corrupted manifest HEALS on the next write, while the poisoned 1997 // segments stay on disk and readable (rule 13, additive-only -- nothing is deleted). 1998 // ★★★MIGRATE THE CHOKEPOINT LIB, NOT THE LEAF. 10+ organs already called ss_next_segid; this 1999 // crawler hand-rolled its own scan, and being the holdout cost it every commit it attempted. 2000 // ★★★A GREEN VERDICT ON A STEP THAT PERSISTED NOTHING IS THE LOUDEST KIND OF SILENT FAILURE. 2001 let segidbox: *i64 = sys_mmap(16) as *i64 2002 segidbox[0] = ss_next_segid(prefix) 2003 if segidbox[0] <= 0 { segidbox[0] = 1 } 2004 // MMAP-OPEN (2026-07-25, seq628 root cause): read-all ss_open pulls the ENTIRE shard into anonymous 2005 // RAM (the sev-9 nx_seg_store keystone) -- at ~2GB across 94 segments that is what made this crawler 2006 // hang after a single fetch on the NAS. nx_pagerank_build and nx_web_shard_compact were both moved to 2007 // ss_open2(prefix,1) earlier this month (measured -45% peak RSS); THIS ORGAN WAS MISSED IN THAT SWEEP. 2008 // Same primitive, same call shape: pages fault in on demand and stay evictable in the page cache. 2009 let h: *i64 = ss_open2(prefix, 1) 2010 let segsbox: *i64 = sys_mmap(16) as *i64; segsbox[0] = 0 2011 let discbox: *i64 = sys_mmap(16) as *i64; discbox[0] = 0 2012 2013 // pending frontier (persisted). A seed bootstraps or extends it. 2014 let urls: *u8 = sys_mmap(WC_MAGIC_2048 * (WC_MAXPEND + 2)) 2015 let canh: *u8 = sys_mmap(64 * WC_MAXCANH) // R4 canonical-seed hosts (filled once the seed file loads) 2016 // R7 ROTATION (2026-08-24): phase 0 skips the persisted cursor; phase 1 wraps to the head when the window is 2017 // not full; the cursor then advances by the rows taken and wraps at the pending population -- every pending 2018 // row is offered in turn instead of the same head of the walk order every run. 2019 let pc0: i64 = wc_pc_cursor(h) 2020 wc_pc_skip_g = pc0 2021 wc_pc_stop_g = 0 2022 var npend: i64 = wc_pending(h, prefix, urls, WC_MAXPEND, canh, 0, 0) 2023 let pcseen: i64 = wc_pc_seen_g 2024 var npend1: i64 = 0 2025 if npend < WC_MAXPEND { if pc0 > 0 { 2026 wc_pc_skip_g = 0 2027 wc_pc_stop_g = pc0 2028 npend1 = wc_pending(h, prefix, (urls as i64 + npend * WC_MAGIC_2048) as *u8, WC_MAXPEND - npend, canh, 0, 0) 2029 npend = npend + npend1 2030 } } 2031 wc_pc_skip_g = 0 2032 wc_pc_stop_g = 0 2033 let pcnext: i64 = wc_pc_next(pc0, npend, pcseen) 2034 wc_puts(" rotation cursor=" as *u8); wc_num(pc0); wc_puts(" -> " as *u8); wc_num(pcnext); wc_puts(" pending_seen=" as *u8); wc_num(pcseen); wc_puts(" wrapped=" as *u8); wc_num(npend1); wc_puts("\n" as *u8) 2035 let w: *i64 = ss_begin() 2036 let npendbox: *i64 = sys_mmap(16) as *i64 2037 npendbox[0] = npend 2038 wc_pc_put(w, prefix, segidbox, segsbox, pcnext) 2039 // R3: seed urls banked for the retire exemption (filled by the seed-injection block below) 2040 let seedstore: *u8 = sys_mmap(WC_MAGIC_2048 * WC_MAXSEEDS) 2041 var nseeds: i64 = 0 2042 // R5 (2026-08-24): argv[2] = "-" means NO seed (so a query can be given as argv[3] without inventing one) 2043 if argc >= 3 { if wc_streq(argv[2] as *u8, "-" as *u8) == 0 { 2044 let sarg: *u8 = argv[2] as *u8 2045 // SEED FILE vs SEED URL (2026-07-26): if argv[2] names a readable non-empty file, EVERY non-empty 2046 // non-# line is a seed url -- that is how a curated canonical-site list gets aimed at the frontier 2047 // in one call. Otherwise argv[2] is a single url (original behaviour, byte-identical path). 2048 let szb: *i64 = sys_mmap(16) as *i64 2049 szb[0] = 0 2050 let fb: *u8 = ss_loadfile(sarg, szb, 0) 2051 var isfile: i64 = 0 2052 if (fb as i64) != 0 { if szb[0] > 0 { isfile = 1 } } 2053 if isfile == 1 { 2054 let line: *u8 = sys_mmap(WC_MAGIC_2048) 2055 var added: i64 = 0 2056 var sdropped: i64 = 0 // R5: seeds beyond WC_MAXSEEDS are REFUSED LOUDLY, never silently 2057 var i9: i64 = 0 2058 var ll: i64 = 0 2059 while i9 <= szb[0] { 2060 var ch: i64 = 10 2061 if i9 < szb[0] { ch = fb[i9] as i64 } 2062 if ch == 10 { if ll > 0 { 2063 line[ll] = 0 as u8 2064 if line[0] != (35 as u8) { 2065 added = added + wc_seed_add(h, w, prefix, segidbox, segsbox, urls, npendbox, line, ll) 2066 // R3: bank EVERY seed url (not just newly-added) for the retire exemption 2067 if nseeds < WC_MAXSEEDS { 2068 let sd9: *u8 = (seedstore as i64 + nseeds * WC_MAGIC_2048) as *u8 2069 var sc9: i64 = 0 2070 while sc9 < ll { sd9[sc9] = line[sc9]; sc9 = sc9 + 1 } 2071 sd9[ll] = 0 as u8 2072 nseeds = nseeds + 1 2073 } else { sdropped = sdropped + 1 } 2074 } 2075 ll = 0 2076 } } else { 2077 if ch != 13 { if ll < WC_MAGIC_2048 - 2 { line[ll] = ch as u8; ll = ll + 1 } } 2078 } 2079 i9 = i9 + 1 2080 } 2081 wc_puts(" seed-file " as *u8); wc_puts(sarg); wc_puts(" -> new pending seeds=" as *u8); wc_num(added); wc_puts("\n" as *u8) 2082 if sdropped > 0 { wc_puts(" [seed-cap] REFUSED " as *u8); wc_num(sdropped); wc_puts(" seed rows beyond WC_MAXSEEDS -- raise the const or trim the file; a dropped seed silently loses its retire exemption AND its canonical-lane membership\n" as *u8) } 2083 } else { 2084 wc_seed_add(h, w, prefix, segidbox, segsbox, urls, npendbox, sarg, wc_len(sarg)) 2085 let sl9: i64 = wc_len(sarg) 2086 if sl9 < WC_MAGIC_2048 - 1 { 2087 var sc8: i64 = 0 2088 while sc8 < sl9 { seedstore[sc8] = sarg[sc8]; sc8 = sc8 + 1 } 2089 seedstore[sl9] = 0 as u8 2090 nseeds = 1 2091 } 2092 } 2093 } } 2094 npend = npendbox[0] 2095 wc_puts(" frontier pending=" as *u8); wc_num(npend); wc_puts("\n" as *u8) 2096 // R17: set the per-run discovery budget from the frontier we just measured, and PRINT it. A budget 2097 // that is derived but invisible is indistinguishable from the old fixed one in the log, and this is 2098 // the number that decides whether the frontier grows -- so it goes on the record every run. 2099 wc_disc_budget_g = wc_disc_budget(npend) 2100 wc_puts(" discovery budget=" as *u8); wc_num(wc_disc_budget_g) 2101 wc_puts(" (floor " as *u8); wc_num(WC_MAXDISC) 2102 wc_puts(", ceiling " as *u8); wc_num(WC_DISC_CEIL); wc_puts(" = one pull window)\n" as *u8) 2103 // R5 QUERY-SCOPED CRAWL (2026-08-24): argv[3] = query words. The general window is ranked by query hits in the 2104 // url (then shallow-first), and the run STOPS EARLY once the sufficiency judge clears its bar. Without a query 2105 // the crawl is the breadth crawl it was, ranked shallow-first only. 2106 if argc >= 4 { 2107 let qarg: *u8 = argv[3] as *u8 2108 let qn9: i64 = wc_query_set(qarg) 2109 wc_cs_g = cs_new(qarg, wc_len(qarg)) 2110 wc_puts(" query-scoped crawl terms=" as *u8); wc_num(qn9); wc_puts(" confidence_bar=" as *u8); wc_num(wc_cs_g[CS_S_CONF_STOP]); wc_puts(" saturation_bar=" as *u8); wc_num(wc_cs_g[CS_S_SAT_STOP]); wc_puts("\n" as *u8) 2111 } 2112 wc_rank_window(urls, npend) 2113 // ---- R4 CANONICAL LANE: reserve the HEAD of the work list for canonical-seed hosts ----------- 2114 // Runs here because it needs the seed file, which is parsed above; the first wc_pending call 2115 // (canonly=0) has already filled `urls` with the ordinary window. This second pass walks the SAME 2116 // frontier host-filtered, then rebuilds the work list as [canonical rows] ++ [everything else, 2117 // original order, byte-deduped]. ncan==0 (no seed file) -> the whole block is skipped and the run 2118 // is byte-identical to pre-R4. 2119 var k4ncan: i64 = wc_canon_hosts(seedstore, nseeds, canh) 2120 // R8: publish the table to the shared URL policy (see wc_urlok). AFTER the seed parse, BEFORE 2121 // any harvest -- so this run's admissions are already trust-aware. 2122 wc_canh_g = canh 2123 wc_ncan_g = k4ncan 2124 wc_qscratch_g = sys_mmap(64) 2125 // R10: load the URL-shape policy (tracking-param denylist + budgets) BEFORE any harvest, so this 2126 // run's admissions are judged by shape rather than by host membership. 2127 let npol: i64 = wc_load_urlpolicy() 2128 wc_puts(" url-policy deny_params=" as *u8); wc_num(npol) 2129 wc_puts(" max_params=" as *u8); wc_num(wc_qmaxp_g) 2130 wc_puts(" max_qlen=" as *u8); wc_num(wc_qmaxlen_g) 2131 // R11 PATH AXIS ANNOUNCE: a feature that prints nothing is indistinguishable from one that was 2132 // never compiled in. 5 = the built-in 2026-08-04 defaults are firing (plane holds no path rules); 2133 // any other count means the plane is AUTHORITATIVE and policy came from DATA. 2134 wc_puts(" path_rules=" as *u8); wc_num(olh_load_pathpolicy()) 2135 if npol == 0 { wc_puts(" (plane ABSENT -- built-in defaults, query URLs still admitted by shape)" as *u8) } 2136 // R15 DOMAIN-MAP ADOPTION. Placed HERE deliberately: AFTER wc_canh_g/wc_ncan_g and AFTER 2137 // wc_load_urlpolicy(), because wc_dm_seed_host filters every candidate through wc_urlok and that 2138 // filter reads both. Seeding before the policy loaded would admit urls the ordinary crawl refuses 2139 // -- the map would become a bypass around our own url filter rather than a feeder into it. 2140 let dmfound: *i64 = sys_mmap(16) as *i64 2141 dmfound[0] = 0 2142 var dmseed: i64 = 0 2143 var dmi: i64 = 0 2144 while dmi < k4ncan { 2145 let dmh: *u8 = (canh as i64 + dmi * WC_DM_HOSTSLOT) as *u8 2146 dmseed = dmseed + wc_dm_seed_host(h, w, prefix, segidbox, segsbox, urls, npendbox, dmh, dmfound) 2147 dmi = dmi + 1 2148 } 2149 wc_puts("\n domain-map adoption: maps_found=" as *u8); wc_num(dmfound[0]) 2150 wc_puts(" of canonical_hosts=" as *u8); wc_num(k4ncan) 2151 wc_puts(" new_pending_seeds=" as *u8); wc_num(dmseed) 2152 // BOTH NUMBERS ARE PRINTED BECAUSE ONE CANNOT BE READ WITHOUT THE OTHER. maps_found=0 means the 2153 // reconnaissance has never been run for these hosts (remedy: run nx_domain_map). maps_found>0 with 2154 // new_pending_seeds=0 means every discovered url was ALREADY in the frontier (dedupe working as 2155 // designed -- not a failure). A single counter would render those two identical, and they have 2156 // opposite remedies. 2157 if dmfound[0] == 0 { wc_puts(" (no map for any canonical host -- run nx_domain_map <domain> to feed this)" as *u8) } 2158 wc_puts("\n" as *u8) 2159 wc_puts("\n" as *u8) 2160 wc_outopen() 2161 if wc_outfd_g <= 0 { wc_puts(" [outcome-log] UNAVAILABLE -- this run's drops will not be mineable\n" as *u8) } 2162 if k4ncan > 0 { 2163 let k4urls: *u8 = sys_mmap(WC_MAGIC_2048 * (WC_CANON_RESERVE + WC_SEED_REFRESH + 2)) 2164 // The reserve is a FLOOR DERIVED FROM THE RUN'S OWN BUDGET, not a fixed ceiling: half of 2165 // max_pages goes to canonical hosts and half stays with general discovery, so the lane can 2166 // never become a takeover of the crawl (rule 25 -- nothing is stripped). WC_CANON_RESERVE is 2167 // only the upper CAP on that half. MEASURED 2026-08-05 on the first live run: reserved=384 2168 // SATURATED, i.e. at least 384 canonical rows were already sitting pending and starved. 2169 var k4res: i64 = max_pages / 2 2170 if k4res > WC_CANON_RESERVE { k4res = WC_CANON_RESERVE } 2171 if k4res < 1 { k4res = 1 } 2172 let k4n0: i64 = wc_pending(h, prefix, k4urls, k4res, canh, k4ncan, 1) 2173 // R5: re-queue stale seed hubs INTO the canonical block. The R4c round-robin pass below 2174 // interleaves them by host, so a refreshed hub is fetched early without displacing any 2175 // other canonical host's first row. 2176 let k4box5: *i64 = sys_mmap(16) as *i64 2177 k4box5[0] = k4n0 2178 k4box5[1] = WC_CANON_RESERVE + WC_SEED_REFRESH 2179 let k4rf: i64 = wc_seed_refresh(h, w, prefix, segidbox, segsbox, seedstore, nseeds, k4urls, k4box5) 2180 let k4n: i64 = k4box5[0] 2181 if k4rf > 0 { wc_puts(" seed-refresh requeued=" as *u8); wc_num(k4rf); wc_puts("\n" as *u8) } 2182 if k4n > 0 { 2183 let k4merged: *u8 = sys_mmap(WC_MAGIC_2048 * (WC_MAXPEND + 2)) 2184 // ---- R4c ROUND-ROBIN THE CANONICAL BLOCK BY HOST ---------------------------------- 2185 // MEASURED 2026-08-05, and it is the R4 bug repeating one level down: with the block in 2186 // stable walk order, whichever canonical host happens to hold the most pending rows eats 2187 // the whole reserve. Observed old.reddit.com taking 18 of 64 ingests in a single run 2188 // while who.int and en.wikipedia.org -- the hosts the reserve EXISTS for -- waited. 2189 // ★★A FAIRNESS FIX APPLIED TO A SET, NOT TO THE MEMBERS WITHIN IT, JUST MOVES THE 2190 // STARVATION INSIDE THE SET. rank = how many EARLIER canonical rows share this host; 2191 // emitting all rank-0 rows, then all rank-1, gives EVERY canonical host its first row 2192 // before ANY host takes a second. Host identity via the same djb2 the dead-host list and 2193 // the host cap use, so "same host" means one thing everywhere in this organ. 2194 let k4hh: *i64 = sys_mmap(8 * (WC_CANON_RESERVE + WC_SEED_REFRESH + 4)) as *i64 2195 let k4rk: *i64 = sys_mmap(8 * (WC_CANON_RESERVE + WC_SEED_REFRESH + 4)) as *i64 2196 let k4hb: *u8 = sys_mmap(64) 2197 var k4z: i64 = 0 2198 while k4z < k4n { 2199 let k4rp: *u8 = (k4urls as i64 + k4z * WC_MAGIC_2048) as *u8 2200 let k4hl: i64 = wc_host(k4rp, k4hb) 2201 k4hh[k4z] = wc_hosthash(k4hb, k4hl) 2202 var k4r: i64 = 0 2203 var k4y: i64 = 0 2204 while k4y < k4z { if k4hh[k4y] == k4hh[k4z] { k4r = k4r + 1 } k4y = k4y + 1 } 2205 k4rk[k4z] = k4r 2206 k4z = k4z + 1 2207 } 2208 var k4mn: i64 = 0 2209 var k4p: i64 = 0 2210 while k4p < k4n { 2211 var k4q2: i64 = 0 2212 while k4q2 < k4n { 2213 if k4rk[k4q2] == k4p { 2214 let k4s: *u8 = (k4urls as i64 + k4q2 * WC_MAGIC_2048) as *u8 2215 let k4d: *u8 = (k4merged as i64 + k4mn * WC_MAGIC_2048) as *u8 2216 var k4c: i64 = 0 2217 while k4s[k4c] != (0 as u8) { k4d[k4c] = k4s[k4c]; k4c = k4c + 1 } 2218 k4d[k4c] = 0 as u8 2219 k4mn = k4mn + 1 2220 } 2221 k4q2 = k4q2 + 1 2222 } 2223 k4p = k4p + 1 2224 } 2225 var k4oi: i64 = 0 2226 while k4oi < npend { 2227 let k4op: *u8 = (urls as i64 + k4oi * WC_MAGIC_2048) as *u8 2228 var k4dup: i64 = 0 2229 var k4q: i64 = 0 2230 while k4q < k4n { 2231 if wc_streq(k4op, (k4urls as i64 + k4q * WC_MAGIC_2048) as *u8) == 1 { k4dup = 1; k4q = k4n } else { k4q = k4q + 1 } 2232 } 2233 if k4dup == 0 { if k4mn < WC_MAXPEND { 2234 let k4dp: *u8 = (k4merged as i64 + k4mn * WC_MAGIC_2048) as *u8 2235 var k4c2: i64 = 0 2236 while k4op[k4c2] != (0 as u8) { k4dp[k4c2] = k4op[k4c2]; k4c2 = k4c2 + 1 } 2237 k4dp[k4c2] = 0 as u8 2238 k4mn = k4mn + 1 2239 } } 2240 k4oi = k4oi + 1 2241 } 2242 var k4cb: i64 = 0 2243 while k4cb < k4mn { 2244 let k4sp: *u8 = (k4merged as i64 + k4cb * WC_MAGIC_2048) as *u8 2245 let k4dp2: *u8 = (urls as i64 + k4cb * WC_MAGIC_2048) as *u8 2246 var k4c3: i64 = 0 2247 while k4sp[k4c3] != (0 as u8) { k4dp2[k4c3] = k4sp[k4c3]; k4c3 = k4c3 + 1 } 2248 k4dp2[k4c3] = 0 as u8 2249 k4cb = k4cb + 1 2250 } 2251 npend = k4mn 2252 npendbox[0] = k4mn 2253 } 2254 wc_puts(" canon-lane hosts=" as *u8); wc_num(k4ncan); wc_puts(" reserved=" as *u8); wc_num(k4n); wc_puts(" worklist=" as *u8); wc_num(npend); wc_puts("\n" as *u8) 2255 } 2256 if npend == 0 { 2257 wc_puts("CRAWL-STEP GREEN (frontier empty -- give a seed url to bootstrap)\n" as *u8) 2258 return 0 2259 } 2260 2261 let cap: i64 = WC_MAGIC_8388608 2262 let out: *u8 = sys_mmap(cap) 2263 let hydrbuf: *u8 = sys_mmap(cap) // R-JS-HEADLESS: serialized hydrated DOM for inline-script pages 2264 let tcap: i64 = WC_MAGIC_1048576 2265 let text: *u8 = sys_mmap(tcap) 2266 let status: *i64 = sys_mmap(8) as *i64 2267 let keptfp: *i64 = sys_mmap(128 * 8) as *i64 2268 let key: *u8 = sys_mmap(64) 2269 let ukey: *u8 = sys_mmap(64) 2270 let pbox: *i64 = sys_mmap(16) as *i64 2271 let lbox: *i64 = sys_mmap(16) as *i64 2272 let base: *u8 = sys_mmap(WC_MAGIC_2048) 2273 // Sized from the CEILING, not the floor: the budget is now dynamic, so a buffer dimensioned off the 2274 // floor would be overrun the moment the frontier had headroom. 32 * (2048 + 8) is ~66 KB, once. 2275 let runseen: *u8 = sys_mmap(32 * (WC_DISC_CEIL + 8)) 2276 let nrs: *i64 = sys_mmap(16) as *i64; nrs[0] = 0 2277 2278 // feed-seed scratch (allocated ONCE, reused per page -- no mmap in the crawl loop) 2279 let feedhref: *u8 = sys_mmap(WC_MAGIC_4096) 2280 let feedabs: *u8 = sys_mmap(WC_MAGIC_4096) 2281 let fhost: *u8 = sys_mmap(512) 2282 let fxml: *u8 = sys_mmap(cap) 2283 let fstat: *i64 = sys_mmap(16) as *i64 2284 let fit: *u8 = sys_mmap(WC_MAGIC_2048) 2285 let fil: *u8 = sys_mmap(WC_MAGIC_2048) 2286 let fisum: *u8 = sys_mmap(WC_MAGIC_8192) 2287 let ffk: *u8 = sys_mmap(64) 2288 // per-host INDEX-count map for THIS run (host_hash -> count), linear-scan (few hosts/run). Diversity cap. 2289 let hc_hash: *i64 = sys_mmap(512*8) as *i64 2290 let hc_cnt: *i64 = sys_mmap(512*8) as *i64 2291 var hc_n: i64 = 0 2292 // ---- seq628 batched parallel fetch; single-writer ingest stays in the parent ---- 2293 let dh: *i64 = sys_mmap(WC_DEADHOSTS * 8) as *i64 2294 let ndhbox: *i64 = sys_mmap(16) as *i64 2295 ndhbox[0] = 0 2296 let bidx: *i64 = sys_mmap(WC_POOL_N * 8) as *i64 2297 let bpid: *i64 = sys_mmap(WC_POOL_N * 8) as *i64 2298 let bhh: *i64 = sys_mmap(WC_POOL_N * 8) as *i64 2299 let brc: *i64 = sys_mmap(WC_POOL_N * 8) as *i64 2300 let bstart: *i64 = sys_mmap(WC_POOL_N * 8) as *i64 2301 let spaths: *u8 = sys_mmap(WC_SPOOLPATH_B * WC_POOL_N) 2302 var sp0: i64 = 0 2303 while sp0 < WC_POOL_N { wc_spool_path(sp0, (spaths as i64 + sp0 * WC_SPOOLPATH_B) as *u8); sp0 = sp0 + 1 } 2304 let phost: *u8 = sys_mmap(512) 2305 let stbox: *i64 = sys_mmap(16) as *i64 2306 let shdr: *i64 = sys_mmap(WC_SPOOL_HDRB) as *i64 2307 var fetched: i64 = 0 2308 var ingested: i64 = 0 2309 var present: i64 = 0 2310 var skipdead: i64 = 0 2311 var retired: i64 = 0 // R3: dead-host rows retired (P->D) this run 2312 var deferred: i64 = 0 // R13: rows LEFT PENDING because their host is in throttle backoff (2026-08-25) 2313 // R13: rows retired P->D by the SELECTION-TIME host cap. This outflow existed since R12 and was the 2314 // ONLY one of the four with no counter, so frontier_remaining~= below overstated the frontier by 2315 // ~176 rows EVERY RUN -- and the resulting flat-at-~1900 reading was the GAUGE, not the frontier. 2316 // A COUNT THAT IS NEVER TAKEN CANNOT BE RECONCILED, AND AN UNRECONCILED PARTITION IS A LEAK. 2317 var hostcapped: i64 = 0 2318 // R16: rows the host cap SKIPPED THIS ROUND but LEFT PENDING (they index on a later run when the cap 2319 // resets). Counted separately from hostcapped because they are the opposite outcome: one is a page 2320 // deferred, the other is a page discarded, and a single counter would hide which one we did. 2321 var hostcap_deferred: i64 = 0 2322 // R13b: ONE pacing-table buffer for the WHOLE RUN, refilled once per BATCH (see pace_should_defer_tbl). 2323 // The per-row 0-arg form would mmap and read 160 KB per candidate row -- WC_MAXPEND = 2048 rows, i.e. 2324 // ~335 MB of leaked anon VMA and ~335 MB of read I/O per run, in the parent. A CHANGE THAT HAMMERS THE 2325 // BOX IS A BUG EVEN WHEN ITS FEATURE WORKS. 2326 let pacetbl: *u8 = sys_mmap(PACE_TBL_BYTES) 2327 var qi: i64 = 0 2328 while qi < npend { 2329 if fetched >= max_pages { qi = npend } else { 2330 // BATCH SELECT: up to WC_POOL_N urls, one DISTINCT host per slot. A url whose host is already 2331 // in this batch CLOSES the batch (it leads the next one) so same-host depth is never starved; 2332 // a url whose host went DEAD this run is skipped (stays PENDING for a future run). 2333 var nb: i64 = 0 2334 var scan: i64 = 1 2335 // Refill the pacing snapshot for THIS batch. pace_after rewrote the table during the reap of 2336 // the previous batch, so this is exactly as fresh as a per-row load would have been. 2337 pace_load_tbl_into(pacetbl) 2338 if wc_cs_stop_g == 1 { qi = npend } // R5: the sufficiency judge cleared its bar -- no new batch 2339 while scan == 1 { 2340 if qi >= npend { scan = 0 } else { 2341 if nb >= WC_POOL_N { scan = 0 } else { 2342 if fetched + nb >= max_pages { scan = 0 } else { 2343 let us1: *u8 = (urls as i64 + qi * WC_MAGIC_2048) as *u8 2344 let pl1: i64 = wc_host(us1, phost) 2345 let hh9: i64 = wc_hosthash(phost, pl1) 2346 if wc_deadhost_has(dh, ndhbox[0], hh9) == 1 { 2347 skipdead = skipdead + 1 2348 // R3: the host's PERSISTED streak has hit the bar -> retire this row (P->D) so 2349 // the rot stops blockading the pull window. Seeds exempt; rediscovery re-adds. 2350 // R9 (2026-08-05): a CURATED HOST IS NEVER RETIRED. wc_is_seed exempts only 2351 // the exact seed URLs, so a curated host's DISCOVERED rows were still being 2352 // retired after 3 dead runs -- and we cannot tell "the host is down" from 2353 // "we cannot speak its TLS". MEASURED that day: our client advertises 0x1302 2354 // (nginx's default) and cannot decrypt it, so such hosts fail EVERY run, 2355 // hit the streak bar, and are retired PERMANENTLY (wc_seed_add refuses any 2356 // existing row). ★A TRANSPORT-LAYER CAPABILITY GAP MUST NOT BE LAUNDERED 2357 // INTO PERMANENT COVERAGE LOSS -- RETIRE ON EVIDENCE THE HOST IS DEAD, 2358 // NEVER ON EVIDENCE OF OUR OWN INABILITY. We curated these hosts, so their 2359 // absence is OUR bug to fix; keeping the row P means they return for free 2360 // the moment the client learns the cipher. Uncurated rot still retires. 2361 var r9keep: i64 = 0 2362 if wc_is_seed(seedstore, nseeds, us1, wc_len(us1)) == 1 { r9keep = 1 } 2363 if wc_ncan_g > 0 { if (wc_qscratch_g as i64) != 0 { 2364 if wc_host_is_canon(wc_canh_g, wc_ncan_g, us1, wc_qscratch_g) == 1 { r9keep = 1 } 2365 } } 2366 if r9keep == 0 { 2367 retired = retired + wc_retire_if_rotten(h, w, prefix, segidbox, segsbox, us1, wc_len(us1), hh9) 2368 } 2369 qi = qi + 1 2370 } else { 2371 // R11 ROBOTS: ask the host BEFORE spending a fetch on it. Checked HERE, at 2372 // batch SELECTION, and deliberately not after the fork: a url skipped after 2373 // launch has no spool file, which the reaper reads as a fetch failure, which 2374 // bumps the dead-host streak, which retires the host permanently. Filtering 2375 // before selection means our own policy can never be mistaken for the host 2376 // being dead. 2377 var rbok: i64 = 1 2378 if WC_ROBOTS_ON == 1 { rbok = wc_rb_allowed(phost, pl1, hh9, us1, wc_len(us1), store) } 2379 if rbok == 0 { 2380 wc_outcome("robots" as *u8, 0, 0, us1, wc_len(us1)) 2381 wc_frmark(w, prefix, segidbox, segsbox, us1, wc_len(us1), 0) 2382 wc_rb_skip_g = wc_rb_skip_g + 1 2383 qi = qi + 1 2384 } else { 2385 // R12 HOSTCAP AT SELECTION (2026-08-12): the per-run host diversity cap used to be 2386 // enforced only AFTER fetch+parse+dedup (the index gate below), so every page of an 2387 // already-capped host still paid a FULL fetch. MEASURED (nx_crawlmine, 6.16 days): 2388 // hostcap = 16,001 of 50,427 rows = 32% of the whole fetch budget spent on pages that 2389 // could never index this run (13,992/18,115 en.wikipedia rows). Checked HERE like 2390 // robots (R11): our own policy must never cost a fetch. SAME TERMINAL SEMANTICS as the 2391 // post-fetch gate (frmark done + a hostcap outcome row, so the miner keeps its 2392 // denominator and the frontier never wedges) -- but with http=0, which is the 2393 // PRE-DECLARED accept signature: post-fix, hostcap rows with http=200 (paid) must 2394 // collapse toward zero and the ok/day rate must rise; referee = nx_crawlmine over the 2395 // post-fix window. Deferral (keep the row pending so the page indexes on a FUTURE run 2396 // when the cap resets) is the v2 once the pull window can rotate capped rows without 2397 // blockading -- today's behavior discards these pages anyway, so this is strictly 2398 // better: same index outcome, fetch refunded. The post-fetch gate below STAYS: it 2399 // catches hosts that fill their cap while a batch is already in flight. 2400 var hcok: i64 = 1 2401 // R13 THROTTLE DEFERRAL (2026-08-25). A host in throttle backoff is skipped HERE and 2402 // its row LEFT PENDING, instead of being selected and then slept on. 2403 // WHY AT SELECTION AND NOT IN THE LAUNCH LOOP: every slot that enters the batch is 2404 // marked DONE by the process loop ("done FIRST -- a dead url must not wedge the 2405 // frontier"), so a url skipped after selection would be silently retired UNFETCHED. 2406 // Deferral has to happen before bidx[] is written, or it becomes data loss. 2407 // WHAT IT FIXES, MEASURED 2026-08-25: pace_before sleeps IN THE PARENT inside the 2408 // launch loop, so ONE throttled host stalls the whole WC_POOL_N-way pool -- 2409 // books.google.com was fetched at ~62 s intervals 41 consecutive times while crawl 2410 // throughput fell 280.7 -> 72.3 docs/hour. 2411 // AND IT SERVES THE BACKOFF FOR THE FIRST TIME: PACE_WAIT_CAP_MS caps the WAIT at 2412 // 60 s, so a 300 s backoff was never actually honoured -- the host was re-hit at 60 s, 2413 // returned another 429, and consec could never decay (pace_decay needs a success). 2414 // Deferring is the only thing that lets a backoff elapse. 2415 // NOT A THRESHOLD CHANGE: pace_should_defer keys on consec_throttle, so ordinary 2416 // politeness gaps are still slept through and only ACTIVE REFUSALS are deferred. 2417 if pace_should_defer_tbl(pacetbl, phost, pl1) == 1 { 2418 wc_outcome("pacedefer" as *u8, 0, 0, us1, wc_len(us1)) 2419 wc_puts(" [pace] DEFER host in throttle backoff, row stays PENDING " as *u8); wc_puts(us1); wc_puts("\n" as *u8) 2420 deferred = deferred + 1 2421 hcok = 0 2422 qi = qi + 1 2423 } 2424 var hci9: i64 = 0 - 1 2425 var hj9: i64 = 0 2426 while hj9 < hc_n { if hc_hash[hj9] == hh9 { hci9 = hj9; hj9 = hc_n } else { hj9 = hj9 + 1 } } 2427 // GUARDED: a row already DEFERRED above must not also be host-capped. Without this 2428 // it would advance qi twice AND wc_frmark the row DONE -- turning a deferral into a 2429 // silent retirement of a url that was never fetched. 2430 if hcok == 1 { if hci9 >= 0 { 2431 var hcap9: i64 = WC_HOSTCAP 2432 if wc_host_str_is_canon(canh, k4ncan, phost, pl1) == 1 { hcap9 = WC_HOSTCAP_CANON } 2433 if hc_cnt[hci9] >= hcap9 { 2434 // R16 DEFER-INSTEAD-OF-RETIRE (2026-08-25). The host cap is a PER-ROUND 2435 // diversity measure, yet it marked the row DONE -- permanently. MEASURED on 2436 // the live beat the day the counter for it first existed: hostcap_retired=227 2437 // in ONE run, i.e. 227 pages discarded and never indexed, while the frontier 2438 // had drained to 313. The cap exists to stop one host dominating a PAGE, not 2439 // to rule that a page is worthless. 2440 // THE PRECONDITION THE ORIGINAL COMMENT DEMANDED IS NOW TESTED, NOT ASSUMED. 2441 // It said deferral was the v2 "once the pull window can rotate capped rows 2442 // without blockading". That is exactly decidable: while the whole frontier 2443 // fits inside one pull window, every pending row IS pulled next run, so a 2444 // deferred row cannot be shadowed. Past that it could be, which is the 2445 // blockade the original reasoning cited -- so past that we still retire. 2446 // Derived from WC_MAXPEND; it self-corrects as the frontier grows, and no 2447 // new constant is introduced to be guessed wrong. 2448 let hc_defer: i64 = wc_hostcap_defers(npend) 2449 if hc_defer == 1 { 2450 wc_outcome("hostcap-defer" as *u8, 0, hc_cnt[hci9], us1, wc_len(us1)) 2451 hostcap_deferred = hostcap_deferred + 1 2452 } else { 2453 wc_outcome("hostcap" as *u8, 0, hc_cnt[hci9], us1, wc_len(us1)) 2454 wc_frmark(w, prefix, segidbox, segsbox, us1, wc_len(us1), 2) 2455 hostcapped = hostcapped + 1 2456 } 2457 wc_puts(" [host-cap] skip FETCH (cap hit pre-selection) " as *u8); wc_puts(us1); wc_puts("\n" as *u8) 2458 hcok = 0 2459 qi = qi + 1 2460 } 2461 } } 2462 if hcok == 1 { 2463 var infl: i64 = 0 2464 var b9: i64 = 0 2465 while b9 < nb { if bhh[b9] == hh9 { infl = 1; b9 = nb } else { b9 = b9 + 1 } } 2466 if infl == 1 { scan = 0 } else { 2467 bidx[nb] = qi 2468 bhh[nb] = hh9 2469 nb = nb + 1 2470 qi = qi + 1 2471 } 2472 } 2473 } 2474 } 2475 } } } 2476 } 2477 if nb == 0 { qi = npend } else { 2478 // LAUNCH: pace in the PARENT (the persisted pacing state keeps one writer), then fork the fetch. 2479 var b: i64 = 0 2480 while b < nb { 2481 let us2: *u8 = (urls as i64 + bidx[b] * WC_MAGIC_2048) as *u8 2482 let pl2: i64 = wc_host(us2, phost) 2483 pace_before(phost, pl2) 2484 let spb: *u8 = (spaths as i64 + b * WC_SPOOLPATH_B) as *u8 2485 sys_unlinkat(spb) 2486 let pid: i64 = sys_fork() 2487 if pid == 0 { wc_fetch_child(us2, store, spb) } 2488 if pid > 0 { bpid[b] = pid } else { bpid[b] = 0; brc[b] = 0 - 3 } 2489 if pid > 0 { brc[b] = 0 - 2 } 2490 bstart[b] = sys_clock_now_us() 2491 b = b + 1 2492 } 2493 // REAP with the wall-clock budget: WNOHANG poll; past budget -> SIGKILL + blocking reap. 2494 var live: i64 = 0 2495 var bl: i64 = 0 2496 while bl < nb { if bpid[bl] > 0 { live = live + 1 } bl = bl + 1 } 2497 while live > 0 { 2498 var b2: i64 = 0 2499 while b2 < nb { 2500 if bpid[b2] > 0 { 2501 let rp: i64 = sys_wait4(bpid[b2], stbox, WC_WNOHANG) 2502 if rp == bpid[b2] { 2503 brc[b2] = wait_exit_code(stbox[0]) 2504 bpid[b2] = 0 2505 live = live - 1 2506 } else { 2507 if sys_clock_now_us() - bstart[b2] > WC_FETCH_BUDGET_MS * WC_US_PER_MS { 2508 nx_kill(bpid[b2], WC_SIGKILL) 2509 sys_wait4(bpid[b2], stbox, 0) 2510 brc[b2] = 0 - 1 2511 bpid[b2] = 0 2512 live = live - 1 2513 } 2514 } 2515 } 2516 b2 = b2 + 1 2517 } 2518 if live > 0 { sys_sleep_ms(WC_POLL_MS) } 2519 } 2520 // PROCESS sequentially (single seg-store writer): spool -> the unchanged ingest/harvest path. 2521 var b3: i64 = 0 2522 while b3 < nb { 2523 let uslot: *u8 = (urls as i64 + bidx[b3] * WC_MAGIC_2048) as *u8 2524 let ul: i64 = wc_len(uslot) 2525 let phl: i64 = wc_host(uslot, phost) 2526 fetched = fetched + 1 2527 // done FIRST (even on failure -- a dead url must not wedge the frontier) 2528 wc_frmark(w, prefix, segidbox, segsbox, uslot, ul, 2) 2529 var n: i64 = 0 2530 // -1 = UNSPOOLED: no header read, so no depth reported. Distinct from 0, which means the 2531 // child fetched with no redirect. One value for both would report "never measured" as 2532 // "measured zero" -- the exact defect this signal exists to expose. 2533 var fhops: i64 = 0 - 1 2534 status[0] = 0 2535 if brc[b3] == 0 { 2536 let spb3: *u8 = (spaths as i64 + b3 * WC_SPOOLPATH_B) as *u8 2537 let fd3: i64 = sys_openat_rd(spb3) 2538 if fd3 >= 0 { 2539 if sys_read(fd3, shdr as *u8, WC_SPOOL_HDRB) == WC_SPOOL_HDRB { 2540 status[0] = shdr[0] 2541 fhops = shdr[2] 2542 var want: i64 = shdr[1] 2543 if want > cap { want = cap } 2544 var got: i64 = 0 2545 var rr: i64 = 1 2546 while rr == 1 { 2547 if got >= want { rr = 0 } else { 2548 let g: i64 = sys_read(fd3, (out as i64 + got) as *u8, want - got) 2549 if g <= 0 { rr = 0 } else { got = got + g } 2550 } 2551 } 2552 n = got 2553 } 2554 sys_close(fd3) 2555 sys_unlinkat(spb3) 2556 } 2557 } 2558 pace_after(phost, phl, status[0], 0) 2559 // R3 host-health: any completed response heals the streak; a failure bumps it ONCE per run 2560 // (wc_deadhost_add returns 1 only on the host's FIRST death this run). 2561 let hhx: i64 = wc_hosthash(phost, phl) 2562 if brc[b3] == 0 { if status[0] > 0 { 2563 if wc_hd_streak(h, hhx) > 0 { wc_hd_put(w, prefix, segidbox, segsbox, hhx, 0) } 2564 } } 2565 if brc[b3] != 0 { 2566 // timeout (-1), fetch-fail (1/2) or fork-fail (-3): defer the host for the rest of this run 2567 if wc_deadhost_add(dh, ndhbox, hhx) == 1 { 2568 wc_hd_put(w, prefix, segidbox, segsbox, hhx, wc_hd_streak(h, hhx) + 1) 2569 } 2570 if brc[b3] == (0 - 1) { 2571 wc_outcome("timeout" as *u8, status[0], n, uslot, ul) 2572 wc_puts(" [fetch-timeout] " as *u8); wc_puts(uslot); wc_puts(" (budget " as *u8); wc_num(WC_FETCH_BUDGET_MS); wc_puts("ms) -> host deferred this run\n" as *u8) 2573 } else { 2574 wc_outcome("fetchfail" as *u8, status[0], n, uslot, ul) 2575 wc_puts(" [fetch-fail] " as *u8); wc_puts(uslot); wc_puts(" -> host deferred this run\n" as *u8) 2576 } 2577 } 2578 // QUALITY: don't ingest a bot-interstitial (verification/challenge wall) as content 2579 // R4 (2026-08-24): the three literals that lived here are ROWS in knowledge/antibot_markers.conf now, and the 2580 // classifier names the vendor. RATE-LIMIT and STATUS-BLOCK keep their existing http / pacer paths below. 2581 var intr: i64 = 0 2582 var abtc: i64 = 0 2583 if n > 0 { abtc = abt_classify(status[0], out, n); if abtc == ABT_VENDOR_WALL { intr = 1 } if abtc == ABT_GENERIC_WALL { intr = 1 } } 2584 // R11: SAY IT OUT LOUD. Both of these used to end the page's life in silence -- the 2585 // interstitial branch simply fell through the `intr == 0` guard with no print and no row, 2586 // so a Cloudflare wall and a genuinely empty host were INDISTINGUISHABLE downstream. 2587 if intr == 1 { 2588 wc_outcome("botwall" as *u8, status[0], n, uslot, ul) 2589 wc_puts(" [botwall " as *u8); sys_write(1, abt_last_vendor_g, abt_last_vendor_len_g); wc_puts("] " as *u8); wc_puts(uslot); wc_puts(" -> " as *u8); sys_write(1, abt_last_reason_g, abt_last_reason_len_g); wc_puts(", NOT indexed (logged for mining)\n" as *u8) 2590 } 2591 else { if n > 0 { if status[0] != 200 { 2592 wc_outcome("http" as *u8, status[0], n, uslot, ul) 2593 wc_puts(" [http-" as *u8); wc_num(status[0]); wc_puts("] " as *u8); wc_puts(uslot); wc_puts(" -> not indexed (logged)\n" as *u8) 2594 } } } 2595 if intr == 0 { if n > 0 { if status[0] == 200 { 2596 // R-JS-HEADLESS hydration -- DEFAULT-OFF since 2026-07-25 (seq628 root cause, seq1002). 2597 // js_render_page executes ARBITRARY page JavaScript in an interpreter that has NO execution 2598 // budget (measured: zero fuel/step-cap/deadline/watchdog anywhere in nx_js_eval.nx), so ONE 2599 // page with a long or non-terminating script hangs this crawler FOREVER -- which is exactly 2600 // the stall: fetch 1 completes, hydration never returns, the log freezes, the process lives. 2601 // A crawler that indexes raw HTML is strictly better than one that wedges on page 1, so the 2602 // enhancement yields to the invariant. Re-enable ONLY once the interpreter is budgeted; the 2603 // link harvest and text extraction below never needed hydration to work. 2604 var phtml: *u8 = out; var phn: i64 = n 2605 if WC_JS_HYDRATE == 1 { if js_has_inline_script(out, n) == 1 { let hyr: i64 = js_render_page(out, n, hydrbuf, cap); if hyr > 0 { phtml = hydrbuf; phn = hyr } } } 2606 // R6 (2026-08-24): index the density-FIT text -- nav, footer, sidebar and link farms dropped by the 2607 // published Kohlschuetter rule; the lib falls back to the full render when nothing survives, and says so. 2608 var tlen: i64 = 0 2609 if WC_FIT_TEXT == 1 { 2610 tlen = bd_fit_text(phtml, phn, text, tcap) 2611 wc_fit_pages_g = wc_fit_pages_g + 1 2612 wc_fit_saved_g = wc_fit_saved_g + (phn - bd_last_fit_html_g) 2613 if bd_last_fallback_g == 1 { wc_fit_fallback_g = wc_fit_fallback_g + 1 } 2614 } else { tlen = nx_html_to_text(phtml, phn, text, tcap) } 2615 // INDEX only real content -- measured bimodal gap (junk<=56 chars, real>=1251, nothing between). 2616 // The link HARVEST below is SEPARATE and runs for ALL pages, so filtering the index never 2617 // starves the frontier (a thin tag/index page is junk to index but a fine link source). 2618 if tlen < WC_MININDEX { wc_outcome("thin" as *u8, status[0], tlen, uslot, ul) } 2619 if tlen >= WC_MININDEX { 2620 let fp: i64 = nx_simhash_fingerprint(text, tlen) 2621 var dup: i64 = 0 2622 var k: i64 = 0 2623 // Past the window NOTHING is compared, so "unique" beyond it is UNCHECKED, not proven. 2624 if fetched > WC_DEDUP_WINDOW { if wc_dwarn_g == 0 { wc_dwarn_g = 1 2625 wc_puts(" [dedup-window] compared " as *u8); wc_num(WC_DEDUP_WINDOW) 2626 wc_puts(" of " as *u8); wc_num(fetched); wc_puts(" fingerprints -- beyond this UNCHECKED\n" as *u8) } } 2627 while k < fetched { if k < WC_DEDUP_WINDOW { if nx_simhash_hamming(fp, keptfp[k]) <= WC_SIMHASH_HAM { dup = 1 } } k = k + 1 } 2628 if dup == 0 { 2629 if fetched < 128 { keptfp[fetched] = fp } 2630 // per-host diversity cap lookup (the factored djb2 -- one host-identity hash) 2631 let hh: i64 = wc_hosthash(phost, phl) 2632 var hci: i64 = 0 - 1; var hj: i64 = 0 2633 while hj < hc_n { if hc_hash[hj] == hh { hci = hj; hj = hc_n } else { hj = hj + 1 } } 2634 var hcnt: i64 = 0 2635 if hci >= 0 { hcnt = hc_cnt[hci] } 2636 // R4b: a CURATED host earns the depth cap; everything else keeps the 2637 // diversity cap. The seed list is the only thing that grants this, so the 2638 // privilege is data-driven and revocable by editing one file. 2639 var hcap: i64 = WC_HOSTCAP 2640 if wc_host_str_is_canon(canh, k4ncan, phost, phl) == 1 { hcap = WC_HOSTCAP_CANON } 2641 if hcnt >= hcap { 2642 wc_outcome("hostcap" as *u8, status[0], hcnt, uslot, ul) 2643 wc_puts(" [host-cap] skip index (>=" as *u8); wc_num(hcap); wc_puts("/host this run) " as *u8); wc_puts(uslot); wc_puts("\n" as *u8) 2644 } else { 2645 var tn: i64 = tlen 2646 if tn > CI_DOCCAP { tn = CI_DOCCAP } 2647 let cid: i64 = ci_hash(text, tn) 2648 dss_mkkey(cid, key) 2649 var already: i64 = 0 2650 if (h as i64) != 0 { if ss_hget(h, key, pbox, lbox) == 1 { already = 1 } } 2651 // R11b: `already_present` is a TERMINAL decision and was unlogged, so a 2652 // steady-state run (every page already indexed) produced an EMPTY outcome 2653 // log and the miner had no denominator. Measured immediately: fetched=2 2654 // ingested=0 already_present=2 -> 0 rows. An instrument blind to the 2655 // COMMON case only ever sees crises. 2656 if already == 1 { present = present + 1; wc_outcome("dup" as *u8, status[0], tn, uslot, ul) } else { 2657 if ss_add(w, 1, key, text, tn) < 0 { 2658 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 2659 segidbox[0] = segidbox[0] + 1 2660 w[1] = 0 2661 ss_add(w, 1, key, text, tn) 2662 } 2663 ci_mkurlkey(cid, ukey) 2664 if ss_add(w, 1, ukey, uslot, ul) < 0 { 2665 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 2666 segidbox[0] = segidbox[0] + 1 2667 w[1] = 0 2668 ss_add(w, 1, ukey, uslot, ul) 2669 } 2670 // PERSIST the fingerprint this run ALREADY computed. It was used to 2671 // dedupe within the run and then discarded, so intra-host near-duplication 2672 // -- the real doorway signature -- could only be asked by re-fetching the 2673 // whole corpus. Now it is a query. Additive row: absent fp: simply means 2674 // "ingested before this shipped", never a wrong answer. 2675 if (wc_fpk_g as i64) == 0 { wc_fpk_g = sys_mmap(64) } 2676 if (wc_fpv_g as i64) == 0 { wc_fpv_g = sys_mmap(32) } 2677 ci_mkfpkey(cid, wc_fpk_g) 2678 let fpn: i64 = ccz_cat_num(wc_fpv_g, 0, fp) 2679 if ss_add(w, 1, wc_fpk_g, wc_fpv_g, fpn) < 0 { 2680 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 2681 segidbox[0] = segidbox[0] + 1 2682 w[1] = 0 2683 ss_add(w, 1, wc_fpk_g, wc_fpv_g, fpn) 2684 } 2685 // S12 (2026-09-17): the FETCH EPOCH beside the doc (fe:<cid>), the same commit dance as the fp row, 2686 // so every result can say how old its bytes are; an absent row (older ingests) reads as unobserved. 2687 if (wc_fek_g as i64) == 0 { wc_fek_g = sys_mmap(64) } 2688 if (wc_fev_g as i64) == 0 { wc_fev_g = sys_mmap(32) } 2689 ci_mkfekey(cid, wc_fek_g) 2690 let fen: i64 = ccz_cat_num(wc_fev_g, 0, sys_now_realtime_sec()) 2691 if ss_add(w, 1, wc_fek_g, wc_fev_g, fen) < 0 { 2692 if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } 2693 segidbox[0] = segidbox[0] + 1 2694 w[1] = 0 2695 ss_add(w, 1, wc_fek_g, wc_fev_g, fen) 2696 } 2697 ingested = ingested + 1 2698 // R5: feed the sufficiency judge the FIT text just indexed; its verdict ends the run at the next batch 2699 if (wc_cs_g as i64) != 0 { cs_add_doc(wc_cs_g, text, tn); if cs_should_stop(wc_cs_g) != CS_CONTINUE { wc_cs_stop_g = 1 } } 2700 if hci < 0 { if hc_n < 512 { hc_hash[hc_n] = hh; hc_cnt[hc_n] = 1; hc_n = hc_n + 1 } } else { hc_cnt[hci] = hc_cnt[hci] + 1 } 2701 wc_outcome("ok" as *u8, status[0], tn, uslot, ul) 2702 wc_puts(" ingested #" as *u8); wc_num(ingested); wc_puts(" " as *u8); wc_puts(uslot); wc_puts(" (" as *u8); wc_num(tn); wc_puts(" chars hops=" as *u8); wc_num(fhops); wc_puts(")\n" as *u8) 2703 } 2704 } 2705 } 2706 } 2707 // HARVEST links from EVERY 200 page (NOT index-gated): thin index/tag pages are good link sources. 2708 var blen: i64 = 0 2709 var bi: i64 = 0 2710 var hit: i64 = 0 2711 while hit == 0 { 2712 if bi + 2 >= ul { hit = 1 } else { 2713 if uslot[bi] == (58 as u8) { if uslot[bi+1] == (47 as u8) { if uslot[bi+2] == (47 as u8) { 2714 var e2: i64 = bi + 3 2715 var go2: i64 = 1 2716 while go2 == 1 { if e2 >= ul { go2 = 0 } else { if uslot[e2] == (47 as u8) { go2 = 0 } else { e2 = e2 + 1 } } } 2717 var k2: i64 = 0 2718 while k2 < e2 { base[k2] = uslot[k2]; k2 = k2 + 1 } 2719 base[e2] = 0 as u8 2720 blen = e2 2721 hit = 1 2722 } } } 2723 if hit == 0 { bi = bi + 1 } 2724 } 2725 } 2726 wc_harvest(out, n, base, blen, h, w, prefix, segidbox, segsbox, discbox, runseen, nrs) 2727 // FEED FRONTIER SEED: discover the page's RSS/Atom feed, fetch it (paced), enqueue each 2728 // ARTICLE url into the frontier -- clean links > <a> harvest; coverage COMPOUNDS via feeds. 2729 if nx_feed_discover(out, n, feedhref, WC_MAGIC_4096) == 1 { 2730 wc_resolve(uslot, feedhref, feedabs) 2731 let fhl: i64 = wc_host(feedabs, fhost) 2732 pace_before(fhost, fhl) 2733 let fnn: i64 = nx_https_fetch_follow_best(feedabs, store, fxml, cap, 6, fstat) 2734 pace_after(fhost, fhl, fstat[0], 0) 2735 if fstat[0] == 200 { if fnn > 0 { 2736 var fidx: i64 = 0; var fseed: i64 = 0; var floop: i64 = 1 2737 while floop == 1 { 2738 if fidx >= 100 { floop = 0 } else { 2739 if nx_feed_item_at(fxml, fnn, fidx, fit, WC_MAGIC_2048, fil, WC_MAGIC_2048, fisum, WC_MAGIC_8192) == 1 { 2740 let fill: i64 = wc_len(fil) 2741 if fill >= 8 { if discbox[0] < wc_disc_cap() { 2742 if wc_urlok(fil, fill) == 1 { if wc_frstate(h, fil, fill) == 0 { 2743 wc_frkey(fil, fill, ffk) 2744 if wc_runseen(runseen, nrs, ffk) == 0 { 2745 wc_frmark(w, prefix, segidbox, segsbox, fil, fill, 1) 2746 discbox[0] = discbox[0] + 1; fseed = fseed + 1 2747 } 2748 } } 2749 } } 2750 fidx = fidx + 1 2751 } else { floop = 0 } 2752 } 2753 } 2754 if fseed > 0 { wc_puts(" feed-seeded " as *u8); wc_num(fseed); wc_puts(" article URLs -> frontier (" as *u8); wc_puts(feedabs); wc_puts(")\n" as *u8) } 2755 } } 2756 } 2757 } } } 2758 b3 = b3 + 1 2759 } 2760 } 2761 } 2762 } 2763 if w[1] > 0 { if ss_commit(prefix, w, segidbox[0]) == 0 { segsbox[0] = segsbox[0] + 1 } } 2764 wc_puts("CRAWL-STEP done: fetched=" as *u8); wc_num(fetched) 2765 wc_puts(" ingested=" as *u8); wc_num(ingested) 2766 wc_puts(" already_present=" as *u8); wc_num(present) 2767 wc_puts(" discovered_new=" as *u8); wc_num(discbox[0]) 2768 wc_puts(" skipped_deadhost=" as *u8); wc_num(skipdead) 2769 wc_puts(" retired_deadrows=" as *u8); wc_num(retired) 2770 wc_puts(" robots_hosts=" as *u8); wc_num(wc_rb_n_g) 2771 wc_puts(" robots_cd_adopted=" as *u8); wc_num(wc_rb_cd_g) 2772 wc_puts(" robots_skipped=" as *u8); wc_num(wc_rb_skip_g) 2773 wc_puts(" hostcap_retired=" as *u8); wc_num(hostcapped) 2774 // DEFERRALS ARE PRINTED BESIDE THE RETIREMENTS ON PURPOSE: the pair is the whole point. A run showing 2775 // hostcap_retired=227 discarded 227 pages; a run showing hostcap_deferred=227 kept all 227 for a later 2776 // pass. One number alone cannot tell those apart, and they are opposite outcomes for the crawl. 2777 wc_puts(" hostcap_deferred=" as *u8); wc_num(hostcap_deferred) 2778 wc_puts(" pace_deferred=" as *u8); wc_num(deferred) 2779 // FRONTIER GAUGE, CORRECTED 2026-08-25. It used to read `npend - fetched + disc`, subtracting ONE of 2780 // the FOUR ways a row leaves the frontier. Measured over six consecutive runs, the residual it hid was 2781 // 138/171/189/219/164 rows -- mean 176/run -- all of it the selection-time host cap, which marks rows 2782 // D with no counter. The gauge therefore reported ~2000-2060 while true pending was 1871-1883, and an 2783 // investigation into "why is the frontier flat at ~1900" was reading this number, not the frontier. 2784 // pace_deferred is deliberately NOT subtracted: those rows stay PENDING by design, which is the whole 2785 // point of deferring them. A PARTITION IS A CLAIM: THESE PARTS NOW SUM. 2786 wc_puts(" frontier_remaining~=" as *u8); wc_num(wc_frontier_remaining(npend, fetched, retired, wc_rb_skip_g, hostcapped, discbox[0])) 2787 wc_puts(" segments=" as *u8); wc_num(segsbox[0]); wc_puts("\n" as *u8) 2788 // R6 / R5 announce: a feature that prints nothing is indistinguishable from one that was never compiled in 2789 wc_puts(" fit_text pages=" as *u8); wc_num(wc_fit_pages_g); wc_puts(" html_bytes_removed=" as *u8); wc_num(wc_fit_saved_g); wc_puts(" fallback=" as *u8); wc_num(wc_fit_fallback_g); wc_puts("\n" as *u8) 2790 if (wc_cs_g as i64) != 0 { 2791 let csr: *u8 = sys_mmap(WC_MAGIC_4096) 2792 let csn: i64 = cs_report(wc_cs_g, csr, WC_MAGIC_4096) 2793 wc_puts(" " as *u8); sys_write(1, csr, csn) 2794 if wc_cs_stop_g == 1 { wc_puts(" [sufficiency] STOPPED EARLY: the judge cleared its bar before the page budget\n" as *u8) } 2795 } 2796 if fetched >= 1 { wc_puts("CRAWL-STEP GREEN\n" as *u8); return 0 } 2797 wc_puts("CRAWL-STEP RED (nothing fetched)\n" as *u8) 2798 return 3 2799}