code wiki / _hdl_build / nx_textnorm_lib.nx
nx_textnorm_lib.nx source
↩ module page · 252 lines · 10520 B
1// nx_textnorm_lib.nx -- ONE COPY OF THE PRE-SHINGLING NORMALISATION DECISION.
2//
3// module: nishi-core.search.textnorm
4// capability: CORE_COMPUTE (pure; reads no conf, touches no store, writes no file)
5// license_tier: ORIGINAL No hw writes (Rule 26).
6//
7// WHY THIS EXISTS, MEASURED 2026-08-25 BY THE GATE THAT MOTIVATED IT:
8// nx_sitededup_gate fingerprints a REAL capture pair -- one document stored twice, the second copy
9// carrying an HTTP capture header and a bumped revision line -- and measured their Hamming distance
10// at SIX bits against the published k=3 bar. The collapse pass therefore did NOT collapse the very
11// duplicate class it was built for. A fingerprint taken over un-normalised bytes cannot: the header
12// and the revision id are TOKENS, and tokens move bits.
13//
14// THE ESTABLISHED FIX, and it is not ours: normalise the text BEFORE shingling, and in particular
15// normalise DIGITS TO A CONSTANT -- the step that collapses counter, date, revision and price
16// near-duplicates, which is exactly the class our store holds. Charikar (STOC 2002) defines the
17// fingerprint and Manku/Jain/Das Sarma (WWW 2007) the k=3 bar for 64-bit fingerprints; NEITHER says
18// anything about what text you hand it, and that omission is where our duplicates were surviving.
19// The normalisation is the missing half of the published recipe, not an invention of ours.
20//
21// COMPOSES, NEVER RE-IMPLEMENTS: nx_simhash for the fingerprint (there must stay exactly ONE
22// fingerprint kernel in the estate) and nx_html_to_text for markup (there must stay exactly ONE
23// markup stripper). This file adds only what neither has: the capture-header strip and the digit fold.
24//
25// WHAT IT DELIBERATELY DOES NOT DO: it does not re-implement block-density boilerplate extraction.
26// bd_fit_text (nx_block_density) is the ruler for that and belongs to the ingest lane, which already
27// applies it FORWARD. The residue actually present in already-stored rows is the HTTP capture header,
28// which is what tn_body_offset removes -- precisely, by parsing the header grammar, not by a
29// heuristic that could eat the first paragraph of a document.
30//
31// FAILS TOWARD DOING NOTHING IS NOT AVAILABLE HERE, SO SAY SO PLAINLY: every step below can only make
32// two texts MORE similar, so the direction of any error is a FALSE POSITIVE -- a document wrongly
33// judged a duplicate and hidden from search. That is why the consumer carries a neg-control of
34// genuinely different documents and measures its false-positive rate against REAL corpus documents,
35// never against fixtures written by the same hand that chose the rule.
36import "nx_syscalls.nx"
37import "nx_simhash.nx"
38import "nx_html_to_text.nx"
39
40const TN_ZERO: i64 = 48
41const TN_NINE: i64 = 57
42const TN_LT: i64 = 60
43const TN_SLASH: i64 = 47
44const TN_NL: i64 = 10
45const TN_CR: i64 = 13
46const TN_COLON: i64 = 58
47const TN_HYPHEN: i64 = 45
48const TN_UA: i64 = 65
49const TN_UZ: i64 = 90
50const TN_LA: i64 = 97
51const TN_LZ: i64 = 122
52const TN_WORD: i64 = 8
53const TN_BOX: i64 = 8
54const TN_DEC: i64 = 10
55// The capture-header sentinel, spelled as its bytes: H T T P then the slash.
56const TN_H_H: i64 = 72
57const TN_H_T: i64 = 84
58const TN_H_P: i64 = 80
59
60func tn_is_alpha(c: i64) -> i64 {
61 if c >= TN_UA { if c <= TN_UZ { return 1 } }
62 if c >= TN_LA { if c <= TN_LZ { return 1 } }
63 return 0
64}
65func tn_is_digit(c: i64) -> i64 {
66 if c >= TN_ZERO { if c <= TN_NINE { return 1 } }
67 return 0
68}
69
70// index of the newline at or after i0, or n when the line is unterminated.
71func tn_line_end(src: *u8, n: i64, i0: i64) -> i64 {
72 var i: i64 = i0
73 while i < n {
74 if (src[i] as i64) == TN_NL { return i }
75 i = i + 1
76 }
77 return n
78}
79
80// Does the line beginning at i0 have field-line shape -- a run of at least one token character
81// (letters or hyphen) followed immediately by a colon? This is a grammar test, not a guess, which
82// is what lets the strip be exact rather than heuristic.
83func tn_is_header_line(src: *u8, n: i64, i0: i64) -> i64 {
84 var i: i64 = i0
85 var len: i64 = 0
86 var go: i64 = 1
87 while go == 1 {
88 go = 0
89 if i < n {
90 let c: i64 = src[i] as i64
91 var tok: i64 = 0
92 if tn_is_alpha(c) == 1 { tok = 1 }
93 if c == TN_HYPHEN { tok = 1 }
94 if tok == 1 { len = len + 1; i = i + 1; go = 1 }
95 }
96 }
97 if len == 0 { return 0 }
98 if i >= n { return 0 }
99 if (src[i] as i64) == TN_COLON { return 1 }
100 return 0
101}
102
103// Byte offset of the first BODY byte: 0 when there is no capture header to remove.
104// Fires only on a document that literally begins with the HTTP status line, and REFUSES to strip
105// when the header grammar would swallow the whole document -- an empty result is never an
106// improvement, and a normaliser that can empty its input is a corpus-eating defect.
107func tn_body_offset(src: *u8, n: i64) -> i64 {
108 if n < 5 { return 0 }
109 if (src[0] as i64) != TN_H_H { return 0 }
110 if (src[1] as i64) != TN_H_T { return 0 }
111 if (src[2] as i64) != TN_H_T { return 0 }
112 if (src[3] as i64) != TN_H_P { return 0 }
113 if (src[4] as i64) != TN_SLASH { return 0 }
114 var i: i64 = tn_line_end(src, n, 0)
115 if i < n { i = i + 1 }
116 var go: i64 = 1
117 while go == 1 {
118 go = 0
119 if i < n {
120 let c: i64 = src[i] as i64
121 if c == TN_NL { i = i + 1 } else {
122 var blank: i64 = 0
123 if c == TN_CR { if i + 1 < n { if (src[i + 1] as i64) == TN_NL { i = i + 2; blank = 1 } } }
124 if blank == 0 {
125 if tn_is_header_line(src, n, i) == 1 {
126 i = tn_line_end(src, n, i)
127 if i < n { i = i + 1 }
128 go = 1
129 }
130 }
131 }
132 }
133 }
134 if i >= n { return 0 }
135 return i
136}
137
138// Is there real markup here? An open angle bracket followed by a letter or a slash. A bare angle
139// bracket in prose (a comparison, an arrow) is not markup and must not send a plain-text research
140// document through an HTML extractor.
141func tn_has_markup(src: *u8, n: i64) -> i64 {
142 var i: i64 = 0
143 while i + 1 < n {
144 if (src[i] as i64) == TN_LT {
145 let c: i64 = src[i + 1] as i64
146 if tn_is_alpha(c) == 1 { return 1 }
147 if c == TN_SLASH { return 1 }
148 }
149 i = i + 1
150 }
151 return 0
152}
153
154// THE DECISIVE STEP. Every ASCII digit becomes the SAME constant byte, so "revision 4821" and
155// "revision 9137" shingle to an identical token and a date, a counter, a price or a build number
156// stops being a content difference.
157// LENGTH IS PRESERVED ON PURPOSE: folding a digit RUN to a single byte would additionally erase
158// magnitude, turning "SRD 17" and "SRD 147" into one token -- and this corpus contains exactly that,
159// four distinct NIST Standard Reference Databases separated only by their number. The conservative
160// reading of "normalise digits to a constant" is the one that keeps those four distinguishable.
161// Counts what it changed, because a normaliser that silently did nothing is indistinguishable from
162// one that was never wired in.
163func tn_fold_digits(src: *u8, n: i64, out: *u8, cap: i64, box: *i64) -> i64 {
164 var w: i64 = 0
165 var i: i64 = 0
166 var folded: i64 = 0
167 while i < n {
168 if w >= cap { i = n } else {
169 let c: i64 = src[i] as i64
170 if tn_is_digit(c) == 1 { out[w] = TN_ZERO as u8; folded = folded + 1 } else { out[w] = src[i] }
171 w = w + 1
172 i = i + 1
173 }
174 }
175 box[1] = folded
176 return w
177}
178
179// THE CONTRACT. box[0] = capture-header bytes removed, box[1] = digit bytes folded,
180// box[2] = 1 when the markup extractor ran. Returns bytes written to out.
181func tn_normalize(src: *u8, n: i64, out: *u8, cap: i64, box: *i64) -> i64 {
182 box[0] = 0
183 box[1] = 0
184 box[2] = 0
185 if n <= 0 { return 0 }
186 let off: i64 = tn_body_offset(src, n)
187 box[0] = off
188 let bsrc: *u8 = ((src as i64) + off) as *u8
189 let bn: i64 = n - off
190 if tn_has_markup(bsrc, bn) == 1 {
191 box[2] = 1
192 // A stripper output is never longer than its input, so this buffer is DERIVED from the input
193 // size rather than capped at a guessed ceiling that could truncate in silence.
194 let scratch: *u8 = sys_mmap(bn + 1)
195 let tn2: i64 = nx_html_to_text(bsrc, bn, scratch, bn + 1)
196 let w2: i64 = tn_fold_digits(scratch, tn2, out, cap, box)
197 sys_munmap(scratch, bn + 1)
198 box[0] = off
199 box[2] = 1
200 return w2
201 }
202 let w: i64 = tn_fold_digits(bsrc, bn, out, cap, box)
203 box[0] = off
204 return w
205}
206
207// The normalised fingerprint: normalise, then hand the result to the ONE fingerprint kernel.
208// Allocation is sized from the input and RELEASED, because this runs once per document over a whole
209// shard and a per-document leak is a resource defect even when the answer is right.
210func tn_fingerprint_box(src: *u8, n: i64, box: *i64) -> i64 {
211 box[0] = 0
212 box[1] = 0
213 box[2] = 0
214 if n <= 0 { return 0 }
215 let cap: i64 = n + 1
216 let buf: *u8 = sys_mmap(cap)
217 let w: i64 = tn_normalize(src, n, buf, cap, box)
218 let fp: i64 = nx_simhash_fingerprint(buf, w)
219 sys_munmap(buf, cap)
220 return fp
221}
222func tn_fingerprint(src: *u8, n: i64) -> i64 {
223 let box: *i64 = sys_mmap(TN_BOX * TN_WORD) as *i64
224 let fp: i64 = tn_fingerprint_box(src, n, box)
225 sys_munmap(box as *u8, TN_BOX * TN_WORD)
226 return fp
227}
228
229// "fpn:" plus the cid -- the NORMALISED fingerprint plane, DELIBERATELY a different keyspace from
230// the raw "fp:" plane that ci_mkfpkey writes. The defect this whole lane exists to repair is two
231// different hash functions writing one keyspace; persisting a normalised fingerprint under the raw
232// prefix would commit that same defect a second time, in the same store, with the same symptom.
233// An fpn: row and an fp: row for one cid are both correct and mean different things.
234// MSB-first, so no scratch buffer is allocated per key (ci_mkfpkey leaks 24 bytes per call).
235func tn_mkfpnkey(cid: i64, out: *u8) -> i64 {
236 out[0] = 102 as u8
237 out[1] = 112 as u8
238 out[2] = 110 as u8
239 out[3] = 58 as u8
240 var o: i64 = 4
241 if cid == 0 { out[o] = TN_ZERO as u8; o = o + 1; out[o] = 0 as u8; return o }
242 var pw: i64 = 1
243 var m: i64 = cid
244 while m / pw >= TN_DEC { pw = pw * TN_DEC }
245 while pw > 0 {
246 out[o] = (TN_ZERO + ((m / pw) % TN_DEC)) as u8
247 o = o + 1
248 pw = pw / TN_DEC
249 }
250 out[o] = 0 as u8
251 return o
252}