code wiki / (root) / nx_collocation.nx

nx_collocation.nx source

↩ module page · 421 lines · 16207 B

1// nx_collocation.nx -- statistical association metrics for word pairs. 2// 3// First brick of the corpus-linguistics S-class substrate per cardinal 4// feedback-corpus-linguistics-s-class-substrate-sketch-engine-displacement. 5// Substrate goal: benchmark + exceed Sketch Engine on every measurable 6// axis, patent-clean, all NishiLang. 7// 8// === What this primitive answers ==================================== 9// 10// Given a co-occurrence count table (n_a, n_b, n_ab, n_total) — the 11// fundamental contingency-table summary of how often word A appears, 12// how often word B appears, how often they co-occur, and the corpus 13// size — emit the standard statistical association metrics: 14// 15// PMI Church & Hanks 1990 log2( p(a,b) / (p(a)*p(b)) ) 16// log-likelihood Dunning 1993 -2 * sum(O * ln(O/E)) 17// T-score Manning & Schütze (O - E) / sqrt(O) 18// log-Dice Rychly 2008 14 + log2( 2*n_ab / (n_a + n_b) ) 19// 20// All Q10 fixed-point. No f64. All patent-clean (public NLP literature 21// from 1990-2008). 22// 23// === Composition ==================================================== 24// 25// nx_essentials.log2_floor -- integer log2 used everywhere 26// nx_tier.nx -- nx_int aliases 27// 28// === Sealed-enum strength bands ==================================== 29// 30// Per dual-reading cardinal: every primitive emits BOTH a quantitative 31// Q10 number AND a qualitative sealed-enum band. Downstream tooling 32// consumes the band without re-deriving thresholds. 33// 34// genealogy_id: church_hanks_1990_pmi + dunning_1993_log_likelihood + 35// manning_schutze_1999_t_score + rychly_2008_log_dice + 36// firth_1957_collocations + halliday_1961_categories 37// lineage_id: nx_collocation_v1 38 39// nx_safety_envelope: 40// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 41// sil_target: SIL1 42// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 43// verdict: NOT_YET_EVALUATED 44 45import "nx_syscalls.nx" 46import "nx_tier.nx" 47import "nx_essentials.nx" 48const NX_MAGIC_20480: i64 = 20480 49const NX_MAGIC_14336: i64 = 14336 50 51const NX_COL_Q: nx_int = 1024 // Q10 fixed-point base 52 53// ===== Sealed-enum: strength bands ================================= 54// 55// Universal across all four metrics. Each metric has its own threshold 56// constants but the band names + ordering match. 57 58const NX_COL_BAND_NONE: nx_int = 0 // no association 59const NX_COL_BAND_WEAK: nx_int = 1 60const NX_COL_BAND_MODERATE: nx_int = 2 61const NX_COL_BAND_STRONG: nx_int = 3 62const NX_COL_BAND_VERY_STRONG: nx_int = 4 63const NX_COL_N_BANDS: nx_int = 5 64 65func nx_col_band_is_valid(b: nx_int) -> nx_int { 66 if b < 0 { return 0 } 67 if b >= NX_COL_N_BANDS { return 0 } 68 return 1 69} 70 71// ===== Sealed-enum: metric type ==================================== 72// 73// Lets a downstream caller request a specific metric or iterate over 74// all of them. 75 76const NX_COL_METRIC_PMI: nx_int = 0 77const NX_COL_METRIC_LOG_LIKELIHOOD: nx_int = 1 78const NX_COL_METRIC_T_SCORE: nx_int = 2 79const NX_COL_METRIC_LOG_DICE: nx_int = 3 80const NX_COL_N_METRICS: nx_int = 4 81 82func nx_col_metric_is_valid(m: nx_int) -> nx_int { 83 if m < 0 { return 0 } 84 if m >= NX_COL_N_METRICS { return 0 } 85 return 1 86} 87 88// ===== Internal: Q10 natural-log approximation ===================== 89// 90// ln(x) for integer x >= 1. Uses log2(x) * NX_LN2_Q20 / 2^10 — we 91// already have nx_log2_floor; combining with the constant ln(2) in 92// Q10 yields a workable ln(x). Approximation error < 5% for x in 93// [2, 2^32] which covers every realistic corpus frequency. 94// 95// Returns ln(x) in Q10. ln(1) = 0; ln(2) ≈ 0.693 → 710; ln(1024) ≈ 96// 6.93 → 7099. 97const NX_LN2_Q10: nx_int = 710 // 0.693147 * 1024 98 99func _col_ln_q10(x: nx_int) -> nx_int { 100 if x <= 0 { return 0 } 101 if x == 1 { return 0 } 102 let lg: nx_int = nx_log2_floor(x) 103 // log2(x) refined: log2_floor undercounts by up to 1 bit. Linear 104 // refinement using the bottom part: 105 // x = 2^lg + r where 0 <= r < 2^lg 106 // log2(x) ≈ lg + r/2^lg 107 // In Q10: refinement = (r * 1024) / 2^lg 108 var pow_lg: nx_int = 1 109 var i: nx_int = 0 110 while i < lg { pow_lg = pow_lg * 2; i = i + 1 } 111 let r: nx_int = x - pow_lg 112 var refine: nx_int = 0 113 if pow_lg > 0 { refine = (r * NX_COL_Q) / pow_lg } 114 let log2_q10: nx_int = lg * NX_COL_Q + refine 115 // ln = log2 * ln(2) -> Q10 multiply then divide by Q 116 return (log2_q10 * NX_LN2_Q10) / NX_COL_Q 117} 118 119// ===== Internal: Q10 log2 with linear refinement ==================== 120// 121// log2(x) in Q10. Same refinement as _col_ln_q10 but skipping the 122// ln(2) multiply. Useful for PMI + log-Dice which are defined in log2. 123 124func _col_log2_q10(x: nx_int) -> nx_int { 125 if x <= 0 { return 0 } 126 if x == 1 { return 0 } 127 let lg: nx_int = nx_log2_floor(x) 128 var pow_lg: nx_int = 1 129 var i: nx_int = 0 130 while i < lg { pow_lg = pow_lg * 2; i = i + 1 } 131 let r: nx_int = x - pow_lg 132 var refine: nx_int = 0 133 if pow_lg > 0 { refine = (r * NX_COL_Q) / pow_lg } 134 return lg * NX_COL_Q + refine 135} 136 137// ===== Internal: Newton-Raphson integer sqrt ======================= 138// 139// Used by T-score's sqrt(O) denominator. Returns floor(sqrt(n)) for 140// n >= 0. 141 142func _col_isqrt(n: nx_int) -> nx_int { 143 if n <= 0 { return 0 } 144 if n < 2 { return 1 } 145 var x: nx_int = n 146 var shift: nx_int = 0 147 while x > 0 { x = x / 2; shift = shift + 1 } 148 var r: nx_int = 1 149 var i: nx_int = 0 150 while i < (shift / 2 + 1) { r = r * 2; i = i + 1 } 151 var iter: nx_int = 0 152 while iter < 20 { 153 if r <= 0 { return 0 } 154 let next: nx_int = (r + n / r) / 2 155 if next >= r { return r } 156 r = next 157 iter = iter + 1 158 } 159 return r 160} 161 162// ===== Public: PMI in Q10 ========================================= 163// 164// Pointwise Mutual Information. Church & Hanks 1990. 165// 166// PMI(a, b) = log2( p(a,b) / (p(a) * p(b)) ) 167// = log2( N * n_ab / (n_a * n_b) ) 168// 169// where N = n_total. 170// 171// Returns Q10. 0 = independence; positive = association; negative = 172// repulsion. For typical corpora the practical range is roughly 173// [-10, +15] before bands saturate (so Q10 outputs roughly 174// [-10240, +15360]). 175// 176// Edge cases: 177// n_ab = 0 -> -inf substituted with sentinel NX_COL_PMI_FLOOR 178// n_a = 0 or n_b = 0 -> 0 (undefined; treat as no observation) 179 180// NishiLang consts must be plain literals (no compile-time arith). 181// PMI floor = -20.0 in Q10 = -20480. Exposed via getter. 182func nx_col_pmi_floor() -> nx_int { return 0 - NX_MAGIC_20480 } 183 184func nx_col_pmi_q10(n_a: nx_int, n_b: nx_int, 185 n_ab: nx_int, n_total: nx_int) -> nx_int { 186 if n_a <= 0 { return 0 } 187 if n_b <= 0 { return 0 } 188 if n_total <= 0 { return 0 } 189 if n_ab <= 0 { return nx_col_pmi_floor() } 190 // numerator = N * n_ab; denominator = n_a * n_b 191 // log2 of ratio = log2(N * n_ab) - log2(n_a * n_b) 192 // = log2(N) + log2(n_ab) - log2(n_a) - log2(n_b) 193 let log_N: nx_int = _col_log2_q10(n_total) 194 let log_ab: nx_int = _col_log2_q10(n_ab) 195 let log_a: nx_int = _col_log2_q10(n_a) 196 let log_b: nx_int = _col_log2_q10(n_b) 197 return log_N + log_ab - log_a - log_b 198} 199 200// PMI band thresholds in Q10. 201// PMI >= 6.0 -> VERY_STRONG (very rare elsewhere; idioms) 202// PMI >= 3.0 -> STRONG 203// PMI >= 1.0 -> MODERATE 204// PMI >= 0.0 -> WEAK 205// PMI < 0.0 -> NONE (or repulsion) 206 207const NX_COL_PMI_VERY_STRONG_Q10: nx_int = 6144 208const NX_COL_PMI_STRONG_Q10: nx_int = 3072 209const NX_COL_PMI_MODERATE_Q10: nx_int = 1024 210const NX_COL_PMI_WEAK_Q10: nx_int = 0 211 212func nx_col_pmi_band(pmi_q10: nx_int) -> nx_int { 213 if pmi_q10 >= NX_COL_PMI_VERY_STRONG_Q10 { return NX_COL_BAND_VERY_STRONG } 214 if pmi_q10 >= NX_COL_PMI_STRONG_Q10 { return NX_COL_BAND_STRONG } 215 if pmi_q10 >= NX_COL_PMI_MODERATE_Q10 { return NX_COL_BAND_MODERATE } 216 if pmi_q10 >= NX_COL_PMI_WEAK_Q10 { return NX_COL_BAND_WEAK } 217 return NX_COL_BAND_NONE 218} 219 220// ===== Public: log-likelihood in Q10 =============================== 221// 222// Dunning 1993. -2 * sum over 2x2 contingency table of O * ln(O/E). 223// More robust than PMI for low-frequency events. 224// 225// Contingency table for word A x word B in N-token corpus: 226// O11 = n_ab (a + b co-occur) 227// O12 = n_a - n_ab (a without b) 228// O21 = n_b - n_ab (b without a) 229// O22 = N - n_a - n_b + n_ab (neither) 230// 231// Expected: E_ij = (row_total * col_total) / N 232// E11 = n_a * n_b / N 233// E12 = n_a * (N - n_b) / N 234// E21 = (N - n_a) * n_b / N 235// E22 = (N - n_a) * (N - n_b) / N 236// 237// Returns Q10. Range typically [0, 1e6+] for strong associations in 238// large corpora; we clamp via the Q10 representation. 239 240func _col_ll_term(o: nx_int, e: nx_int) -> nx_int { 241 if o <= 0 { return 0 } // 0 * ln(0/e) = 0 by convention 242 if e <= 0 { return 0 } // skip degenerate cells 243 // O * ln(O/E) = O * (ln(O) - ln(E)) 244 let ln_o: nx_int = _col_ln_q10(o) 245 let ln_e: nx_int = _col_ln_q10(e) 246 let diff: nx_int = ln_o - ln_e 247 return o * diff // O is integer, diff is Q10 -> result is Q10 248} 249 250func nx_col_log_likelihood_q10(n_a: nx_int, n_b: nx_int, 251 n_ab: nx_int, n_total: nx_int) -> nx_int { 252 if n_total <= 0 { return 0 } 253 if n_a <= 0 { return 0 } 254 if n_b <= 0 { return 0 } 255 if n_ab < 0 { return 0 } 256 // Observed 257 let o11: nx_int = n_ab 258 let o12: nx_int = n_a - n_ab 259 let o21: nx_int = n_b - n_ab 260 let o22: nx_int = n_total - n_a - n_b + n_ab 261 // Expected 262 let e11: nx_int = (n_a * n_b) / n_total 263 let e12: nx_int = (n_a * (n_total - n_b)) / n_total 264 let e21: nx_int = ((n_total - n_a) * n_b) / n_total 265 let e22: nx_int = ((n_total - n_a) * (n_total - n_b)) / n_total 266 let s11: nx_int = _col_ll_term(o11, e11) 267 let s12: nx_int = _col_ll_term(o12, e12) 268 let s21: nx_int = _col_ll_term(o21, e21) 269 let s22: nx_int = _col_ll_term(o22, e22) 270 let s: nx_int = s11 + s12 + s21 + s22 271 // G² = 2 * Σ O · ln(O/E) (Dunning 1993) 272 // Non-negative by maximum-likelihood property; large when O ≠ E. 273 return 2 * s 274} 275 276// log-likelihood bands (Q10). Sketch Engine uses these thresholds for 277// statistical-significance roundtripping (Dunning 1993 critical values). 278// LL >= 30000 (~30 nats) -> VERY_STRONG (Pearson critical at p < 1e-7 for 1 df) 279// LL >= 15000 -> STRONG 280// LL >= 6000 -> MODERATE 281// LL >= 3000 -> WEAK 282// LL < 3000 -> NONE (not significant at p < 0.05) 283 284const NX_COL_LL_VERY_STRONG_Q10: nx_int = 30720 // ~30 285const NX_COL_LL_STRONG_Q10: nx_int = 15360 // ~15 286const NX_COL_LL_MODERATE_Q10: nx_int = 6144 // ~6 287const NX_COL_LL_WEAK_Q10: nx_int = 3072 // ~3 288 289func nx_col_log_likelihood_band(ll_q10: nx_int) -> nx_int { 290 if ll_q10 >= NX_COL_LL_VERY_STRONG_Q10 { return NX_COL_BAND_VERY_STRONG } 291 if ll_q10 >= NX_COL_LL_STRONG_Q10 { return NX_COL_BAND_STRONG } 292 if ll_q10 >= NX_COL_LL_MODERATE_Q10 { return NX_COL_BAND_MODERATE } 293 if ll_q10 >= NX_COL_LL_WEAK_Q10 { return NX_COL_BAND_WEAK } 294 return NX_COL_BAND_NONE 295} 296 297// ===== Public: T-score in Q10 ====================================== 298// 299// Manning & Schütze 1999. Compares observed to expected, normalised 300// by sqrt of observed. Useful for filtering low-frequency noise. 301// 302// T = (O11 - E11) / sqrt(O11) 303// 304// Returns Q10. Sketch Engine treats T >= 2.0 as moderate, T >= 4.0 305// as strong. 306 307func nx_col_t_score_q10(n_a: nx_int, n_b: nx_int, 308 n_ab: nx_int, n_total: nx_int) -> nx_int { 309 if n_total <= 0 { return 0 } 310 if n_a <= 0 { return 0 } 311 if n_b <= 0 { return 0 } 312 if n_ab <= 0 { return 0 } 313 let e11: nx_int = (n_a * n_b) / n_total 314 let diff: nx_int = n_ab - e11 // integer 315 let sqrt_o: nx_int = _col_isqrt(n_ab) 316 if sqrt_o <= 0 { return 0 } 317 // T = diff / sqrt_o, scaled to Q10 318 return (diff * NX_COL_Q) / sqrt_o 319} 320 321// T-score bands (Q10). 322// T >= 6.0 -> VERY_STRONG 323// T >= 4.0 -> STRONG 324// T >= 2.0 -> MODERATE 325// T >= 1.0 -> WEAK 326// T < 1.0 -> NONE 327 328const NX_COL_T_VERY_STRONG_Q10: nx_int = 6144 329const NX_COL_T_STRONG_Q10: nx_int = 4096 330const NX_COL_T_MODERATE_Q10: nx_int = 2048 331const NX_COL_T_WEAK_Q10: nx_int = 1024 332 333func nx_col_t_score_band(t_q10: nx_int) -> nx_int { 334 if t_q10 >= NX_COL_T_VERY_STRONG_Q10 { return NX_COL_BAND_VERY_STRONG } 335 if t_q10 >= NX_COL_T_STRONG_Q10 { return NX_COL_BAND_STRONG } 336 if t_q10 >= NX_COL_T_MODERATE_Q10 { return NX_COL_BAND_MODERATE } 337 if t_q10 >= NX_COL_T_WEAK_Q10 { return NX_COL_BAND_WEAK } 338 return NX_COL_BAND_NONE 339} 340 341// ===== Public: log-Dice in Q10 ===================================== 342// 343// Rychly 2008 (Sketch Engine's preferred metric — Sketch Engine's 344// own innovation for the FrameNet-style word sketch, but the FORMULA 345// itself is a simple log of the Dice coefficient, public math). 346// 347// logDice = 14 + log2( 2 * n_ab / (n_a + n_b) ) 348// 349// The +14 offset normalises the typical range to roughly [0, 14] 350// so most useful collocations appear in [5, 14] regardless of 351// corpus size. This is its key advantage over PMI (corpus-size- 352// independent) and over LL (better dynamic range). 353 354func nx_col_log_dice_q10(n_a: nx_int, n_b: nx_int, n_ab: nx_int) -> nx_int { 355 if n_a <= 0 { return 0 } 356 if n_b <= 0 { return 0 } 357 if n_ab <= 0 { return 0 } 358 // log2(2 * n_ab / (n_a + n_b)) 359 let num: nx_int = 2 * n_ab 360 let den: nx_int = n_a + n_b 361 if den <= 0 { return 0 } 362 // log2(num / den) = log2(num) - log2(den) 363 let log_num: nx_int = _col_log2_q10(num) 364 let log_den: nx_int = _col_log2_q10(den) 365 // 14 in Q10 = 14336 366 return NX_MAGIC_14336 + log_num - log_den 367} 368 369// log-Dice bands (Q10). Rychly's published thresholds: 370// logDice >= 10 -> VERY_STRONG (top collocations: "Bahasa Indonesia") 371// logDice >= 7 -> STRONG (idiomatic: "look forward") 372// logDice >= 4 -> MODERATE 373// logDice >= 1 -> WEAK 374// logDice < 1 -> NONE 375 376const NX_COL_DICE_VERY_STRONG_Q10: nx_int = 10240 377const NX_COL_DICE_STRONG_Q10: nx_int = 7168 378const NX_COL_DICE_MODERATE_Q10: nx_int = 4096 379const NX_COL_DICE_WEAK_Q10: nx_int = 1024 380 381func nx_col_log_dice_band(dice_q10: nx_int) -> nx_int { 382 if dice_q10 >= NX_COL_DICE_VERY_STRONG_Q10 { return NX_COL_BAND_VERY_STRONG } 383 if dice_q10 >= NX_COL_DICE_STRONG_Q10 { return NX_COL_BAND_STRONG } 384 if dice_q10 >= NX_COL_DICE_MODERATE_Q10 { return NX_COL_BAND_MODERATE } 385 if dice_q10 >= NX_COL_DICE_WEAK_Q10 { return NX_COL_BAND_WEAK } 386 return NX_COL_BAND_NONE 387} 388 389// ===== Composite: emit all four metrics into a buffer ============= 390// 391// Single call yields all four metrics + their bands, written into a 392// caller-supplied flat i64 buffer of length 8 (4 metric Q10 values 393// + 4 band sealed-enum values). Lets a downstream caller filter on 394// the band closest to their evaluation criterion. 395 396const NX_COL_OUT_PMI_Q10: nx_int = 0 397const NX_COL_OUT_LL_Q10: nx_int = 1 398const NX_COL_OUT_T_Q10: nx_int = 2 399const NX_COL_OUT_DICE_Q10: nx_int = 3 400const NX_COL_OUT_PMI_BAND: nx_int = 4 401const NX_COL_OUT_LL_BAND: nx_int = 5 402const NX_COL_OUT_T_BAND: nx_int = 6 403const NX_COL_OUT_DICE_BAND: nx_int = 7 404const NX_COL_OUT_FIELDS: nx_int = 8 405 406func nx_col_emit_all(n_a: nx_int, n_b: nx_int, n_ab: nx_int, n_total: nx_int, 407 out: *i64) -> nx_int { 408 let pmi: nx_int = nx_col_pmi_q10(n_a, n_b, n_ab, n_total) 409 let ll: nx_int = nx_col_log_likelihood_q10(n_a, n_b, n_ab, n_total) 410 let t: nx_int = nx_col_t_score_q10(n_a, n_b, n_ab, n_total) 411 let dice: nx_int = nx_col_log_dice_q10(n_a, n_b, n_ab) 412 out[NX_COL_OUT_PMI_Q10] = pmi 413 out[NX_COL_OUT_LL_Q10] = ll 414 out[NX_COL_OUT_T_Q10] = t 415 out[NX_COL_OUT_DICE_Q10] = dice 416 out[NX_COL_OUT_PMI_BAND] = nx_col_pmi_band(pmi) 417 out[NX_COL_OUT_LL_BAND] = nx_col_log_likelihood_band(ll) 418 out[NX_COL_OUT_T_BAND] = nx_col_t_score_band(t) 419 out[NX_COL_OUT_DICE_BAND] = nx_col_log_dice_band(dice) 420 return 0 421}