nx_block_density.nx
buildroot/runtime/nx_block_density.nx
about
nx_block_density.nx -- BOILERPLATE BLOCK SCORING for HTML: the /compare/webscraping R6 contract
(symbol bd_fit_text). Kohlschuetter, Fankhauser, Nejdl (WSDM 2010) shallow text features -- per-block
TEXT DENSITY and LINK DENSITY -- computed in integer permil over the flat tag stream, so an indexer
stores the article and not the nav, footer and sidebar around it.
WHY THIS SHAPE (2026-08-24): nx_html_to_text is a tag-stripping renderer with 51 importers and a frozen
byte contract; the /compare row that credited it with a "block-aware boilerplate strip" overclaimed --
no density scoring existed anywhere in the tree (link_density / text_density: 0 matches, corpus
complete). This lib is the missing ruler. It COMPOSES nx_html_to_text for the final text so the estate
still has exactly ONE HTML-to-text renderer: fit = score blocks -> rebuild HTML from the kept spans
-> nx_html_to_text. No second entity table, no second whitespace policy, no second tag list.
A BLOCK is the span between two block-level tag boundaries (open or close of p, div, li, h1-h6, section,
article, aside, nav, header, footer, main, table, tr, td, th, blockquote, pre, ul, ol, dl, dt, dd,
figure, figcaption, form, hr, body). script / style / noscript / template / svg bodies are never text and
are removed from every block before counting; sup / sub text (citation brackets) is not counted as words.
Per block: text bytes (whitespace runs collapsed), markup bytes, words, link words (words that begin
inside an <a>). link_density = link_words * 1000 / words, in permil.
THE DECISION IS THE PUBLISHED ONE, NOT AN INVENTED SCORE. Calibration on four real pages (2026-08-24,
two Wikipedia articles, a Python docs page, an LWN article) refuted a weighted text-density score:
Wikipedia prose measures ~7 pct text density and ~60 pct character link density because every link
carries ~100 bytes of markup and citation brackets are anchor text -- the score dropped 86 real
paragraphs on one page. Kohlschuetter's densitometric classifier uses WORD COUNTS of the previous,
current and next block plus word-based link density; the thresholds below are the learned values his
reference implementation (boilerpipe NumWordsRulesClassifier) ships, cited on the /compare row:
if curr.ld <= ld_curr_max:
if prev.ld <= ld_prev_max:
if curr.words <= words_curr_short:
if next.words <= words_next_short: (prev.words <= words_prev_short ? BOILERPLATE : CONTENT)
else CONTENT
else CONTENT
else: if curr.words <= words_curr_long: (next.words <= words_next_long ? BOILERPLATE : CONTENT)
else CONTENT
else BOILERPLATE
prev/next are the nearest blocks WITH words (empty wrapper and close-tag blocks are structural and kept
so a page with no boilerplate rebuilds byte-identical). Blocks inside a semantic element the conf drops
(nav / footer / aside by default) are dropped by construction. If nothing survives, or the page has one
block, the FULL text is returned and bd_last_fallback_g says so -- a filter that can return an empty
page is worse than no filter.
dependencies 3 imports · 6 importers
imports: nx_syscalls.nxnx_lane_conf.nxnx_html_to_text.nx
imported by: nx_block_density_cli.nxnx_block_density_gate.nxnx_crawl_sufficiency_cli.nxnx_docportal_lib.nxnx_fetchwall_lib.nxnx_web_crawl_step.nx
structs
| none |
consts
| 50 | const BD_CONF: *u8 = "knowledge/block_density.conf" |
| 52 | const BD_DEF_LD_CURR_MAX: i64 = 333 // boilerpipe NumWordsRulesClassifier: curr linkDensity <= 0.333333 |
| 53 | const BD_DEF_LD_PREV_MAX: i64 = 556 // prev linkDensity <= 0.555556 |
| 54 | const BD_DEF_WORDS_CURR_SHORT: i64 = 16 // curr numWords <= 16 |
| 55 | const BD_DEF_WORDS_NEXT_SHORT: i64 = 15 // next numWords <= 15 |
| 56 | const BD_DEF_WORDS_PREV_SHORT: i64 = 4 // prev numWords <= 4 |
| 57 | const BD_DEF_WORDS_CURR_LONG: i64 = 40 // (prev linky) curr numWords <= 40 |
| 58 | const BD_DEF_WORDS_NEXT_LONG: i64 = 17 // (prev linky) next numWords <= 17 |
| 59 | const BD_DEF_DROP_NAV: i64 = 1 // HTML5 semantic elements mean what they say; header stays because article headers hold the title |
| 60 | const BD_DEF_DROP_FOOTER: i64 = 1 |
| 61 | const BD_DEF_DROP_ASIDE: i64 = 1 |
| 62 | const BD_DEF_DROP_HEADER: i64 = 0 |
| 63 | const BD_DEF_DROP_FORM: i64 = 0 // MEASURED 2026-08-24: LWN wraps its whole comment thread in a <form>; dropping forms |
| 66 | const BD_PERMIL: i64 = 1000 |
| 68 | const BD_REC: i64 = 10 |
| 69 | const BD_F_START: i64 = 0 |
| 70 | const BD_F_END: i64 = 1 |
| 71 | const BD_F_TEXT: i64 = 2 |
| 72 | const BD_F_MARKUP: i64 = 3 |
| 73 | const BD_F_LINK: i64 = 4 |
| 74 | const BD_F_WORDS: i64 = 5 |
| 75 | const BD_F_SCORE: i64 = 6 |
| 76 | const BD_F_KEPT: i64 = 7 |
| 77 | const BD_F_SEM: i64 = 8 |
| 78 | const BD_F_LWORDS: i64 = 9 |
| 79 | const BD_KEPT_NO: i64 = 0 |
| 80 | const BD_KEPT_YES: i64 = 1 |
| 81 | const BD_KEPT_RESCUED: i64 = 2 |
| 82 | const BD_KEPT_SEMANTIC: i64 = 3 // dropped because inside nav/footer/aside/form |
| 83 | const BD_KEPT_STRUCT: i64 = 4 // no words: a wrapper or close tag, kept for structure, contributes no text |
| 85 | const BD_SEM_NAV: i64 = 0 |
| 86 | const BD_SEM_FOOTER: i64 = 1 |
| 87 | const BD_SEM_ASIDE: i64 = 2 |
| 88 | const BD_SEM_HEADER: i64 = 3 |
| 89 | const BD_SEM_FORM: i64 = 4 |
| 90 | const BD_SEM_N: i64 = 5 |
| 91 | const BD_LT: i64 = 60 |
| 92 | const BD_GT: i64 = 62 |
| 93 | const BD_SLASH: i64 = 47 |
| 94 | const BD_BANG: i64 = 33 |
| 95 | const BD_DASH: i64 = 45 |
| 96 | const BD_SPACE: i64 = 32 |
functions
| 123 | func bd_conf_one(key: *u8, dflt: i64) -> i64 |
| 129 | func bd_load_conf() -> i64 |
| 147 | func bd_conf_reset() -> i64 { bd_conf_loaded_g = 0; return 0 } called by 1: main |
| 150 | func bd_text_density(text: i64, markup: i64) -> i64 called by 1: bd_map |
| 155 | func bd_link_density(words: i64, linkwords: i64) -> i64 |
| 162 | func bd_classify_t(pw: i64, pld: i64, cw: i64, cld: i64, nw: i64, |
| 182 | func bd_classify(pw: i64, pld: i64, cw: i64, cld: i64, nw: i64) -> i64 |
| 189 | func bd_lc(c: i64) -> i64 { if c >= 65 { if c <= 90 { return c + 32 } } return c } |
| 190 | func bd_is_ws(c: i64) -> i64 called by 1: bd_scan |
| 198 | func bd_is_name(c: i64) -> i64 called by 1: bd_scan |
| 205 | func bd_name_eq(src: *u8, off: i64, len: i64, lit: *u8) -> i64 |
| 217 | func bd_is_block(src: *u8, off: i64, len: i64) -> i64 |
| 248 | func bd_sem_slot(src: *u8, off: i64, len: i64) -> i64 |
| 256 | func bd_sem_dropped(slot: i64) -> i64 called by 1: bd_scan |
| 265 | func bd_is_suppress(src: *u8, off: i64, len: i64) -> i64 |
| 274 | func bd_find_close(src: *u8, n: i64, p: i64, nameoff: i64, namelen: i64) -> i64 |
| 291 | func bd_rec(tbl: *i64, i: i64, f: i64) -> i64 { return tbl[i * BD_REC + f] } |
| 292 | func bd_set(tbl: *i64, i: i64, f: i64, v: i64) -> i64 { tbl[i * BD_REC + f] = v; return 0 } |
| 296 | func bd_scan(src: *u8, n: i64, tbl: *i64, depth: *i64) -> i64 called by 2: bd_fit_textbd_map calls 10: bd_setbd_recbd_is_namebd_is_suppressbd_find_closebd_name_eq+4 |
| 418 | func bd_prev_content(tbl: *i64, j: i64) -> i64 |
| 423 | func bd_next_content(tbl: *i64, nb: i64, j: i64) -> i64 |
| 428 | func bd_decide(tbl: *i64, nb: i64) -> i64 called by 2: bd_fit_textbd_map calls 6: bd_recbd_link_densitybd_setbd_prev_contentbd_next_contentbd_classify_t |
| 468 | func bd_count_lt(src: *u8, n: i64) -> i64 |
| 476 | func bd_fit_text(src: *u8, n: i64, out: *u8, cap: i64) -> i64 |
| 521 | func bd_map_num(out: *u8, cap: i64, o: i64, v: i64) -> i64 |
| 533 | func bd_map_str(out: *u8, cap: i64, o: i64, s: *u8) -> i64 called by 1: bd_map |
| 540 | func bd_map(src: *u8, n: i64, out: *u8, cap: i64) -> i64 |