whatlanguage 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4707fce379ca4e085886aef33b8bd45f3480c79bd93c1a7d040c068aff16a94c
4
- data.tar.gz: e4b0156f7d3ee2dda4efb77cd2cd8c6abd6f96eac8c27d821166f927fe7421e6
3
+ metadata.gz: 93967e970b5e62eb58b9cb835866d4abe885f98761f7a339d0817dad8414eac8
4
+ data.tar.gz: b7e983bcd0a56f7b6f77675fdedd6d7724da71b42e26e3229014a92a3c32166b
5
5
  SHA512:
6
- metadata.gz: fa04498461b8e07de91debf3ddf5ebece5d701a2006fe45a08a0baa7f1d8e7947a1c90fa726b175cbe08a3201ac510041f3f952f4b89abfd61ba0d42de0e5c77
7
- data.tar.gz: 7f2ba53f9248b9f8559c06758205874b61a624c485a1e6bad02f5d192a5a93e09ae38018c73a5ac46bc93dc9e742a648757b6e00fc75ed12698588530f0124a9
6
+ metadata.gz: f7c259a244e6c0fe0aa9084037fb29381bc96c9b5a4e549382b660b95943d82bf651d5e706fc4290cb93ff2bc820b1c15495f70bec025c5b98bfa3fb0ff91d6a
7
+ data.tar.gz: 6b2d67686a98ec148d4a3f3314cd270beee7ad3b3437411b8a516d69d6078d3a1a3bd230a4d7e06a5904f7b286339e8be1b2cfef29ba9df73e0da76efb0044a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.0.0 / 2026-10-20
4
+
5
+ ### Breaking change: nine ISO output codes
6
+
7
+ - Normalized nine individual-language ISO outputs to CLDR macrolanguage aliases
8
+ (#51): `swh → sw`, `ayr → ay`, `knc → kr`, `quz → qu`, `khk → mn`,
9
+ `kng → kg`, `src → sc`, `uzn → uz`, and `als → sq`. This changes
10
+ `Result#iso`, instance/class `language_iso`, and CLI output; callers comparing
11
+ the previous codes must update those comparisons. Language names and detection
12
+ scores are unchanged. Northern Uzbek and Uzbek now both return `uz`.
13
+
14
+ ### Improvements
15
+
16
+ - Preserved the individual codes `quy`, `qug`, `koi`, and `min`, and documented
17
+ the selected CLDR aliases and historical overrides used for ISO output.
18
+ - Added shared trigram indexes to speed up repeated detection over the full
19
+ language set, without changing scores or rankings. First calls and restricted
20
+ selections retain the original scan.
21
+ - Reduced text-processing allocations with rolling trigram windows, a combined
22
+ script/letter-count pass, and an ASCII fast path. Cached restricted candidates
23
+ and avoided sorting when only the winning language is requested.
24
+ - Added equivalence tests and reproducible benchmarks for
25
+ [indexed scoring](benchmark/README.md) and
26
+ [text processing](benchmark/long-inputs.md), including allocation and memory costs.
27
+
3
28
  ## 2.1.0 / 2026-09-05
4
29
 
5
30
  - Added a fixed short-text preference for English, Chinese, Hindi, Spanish, and
data/README.md CHANGED
@@ -17,21 +17,22 @@ WhatLanguage.language("Que linguagem é essa? É uma pergunta sobre a língua po
17
17
  - **Best on sentence-length text or longer.** Short texts can have ambiguous results.
18
18
 
19
19
  > [!IMPORTANT]
20
- > v2.0 has many breaking changes as the entire library has been reimplemented, though the core `WhatLanguage.language` API remains similar. Versions 1.0.6 and earlier (so the 2007-2025 run of the library) used a Bloom-filter technique and had 5MB of binary files to handle ~20 languages. Version 2.0 is more accurate, faster, and supports more languages from a single 220KB JSON file :-)
20
+ > Version 3.0 changes nine ISO output codes, speeds up detection, and reduces
21
+ > allocations. See [Upgrading to 3.0](#upgrading-to-30) for the code changes.
21
22
 
22
23
  ## How it works
23
24
 
24
- > [!NOTE]
25
- > Version 2.1 improves Unicode and mixed-script Japanese handling and adds a fixed
26
- > preference for five widely spoken languages on short texts. The public API and
27
- > default 10-letter minimum for statistical detection are unchanged.
28
-
29
25
  Detection is in two stages. First, the dominant Unicode script is detected from letters; scripts used by a single supported language (Greek, Korean, Thai, Japanese using Hiragana/Katakana) resolve immediately. When kana is present, Han, Hiragana, and Katakana count together as Japanese, while a dominant unrelated script can still win. For scripts shared by several languages (e.g. Latin, Cyrillic, Arabic, Hebrew) trigrams are ranked by frequency and compared against candidate language profiles.
30
26
 
31
27
  Digits, punctuation, and emoji do not count toward the minimum text length and are treated as separators when extracting trigrams. Combining marks are preserved in trigrams but do not count as additional letters. Unicode script coverage follows the Unicode version supported by your Ruby runtime.
32
28
 
33
29
  The trigram profiles are vendored from [whatlang](https://github.com/greyblake/whatlang-rs), a port of [Franc](https://github.com/wooorm/franc), whose models are built from the public-domain UDHR corpus (see Credits). The model is a ~220 KB JSON file.
34
30
 
31
+ Repeated calls over the full language set use a shared trigram index. A rolling
32
+ window counts input trigrams without building a character array. Scores and
33
+ rankings are unchanged. See the [indexing benchmarks](benchmark/README.md) and
34
+ [long-input benchmarks](benchmark/long-inputs.md) for timings, allocations, and
35
+ memory costs.
35
36
 
36
37
  ## Usage
37
38
 
@@ -47,6 +48,16 @@ result.score # => 79018
47
48
  result.ranked # => [[:german, 79018], [:dutch, 77631], ... ]
48
49
  ```
49
50
 
51
+ `result.iso` and `language_iso` return ISO 639-1 symbols with an ISO 639-3
52
+ fallback. Selected individual languages use CLDR's macrolanguage aliases, such
53
+ as Swahili `swh → sw`, Mandarin `cmn → zh`, and Western Farsi `pes → fa`;
54
+ historical code mappings are also preserved. These aliases are a normalization
55
+ policy, not a BCP 47 requirement: `swh` remains a valid individual-language
56
+ subtag. Languages without a selected alias retain their individual codes.
57
+ The CLI uses the same mapping. Codes are not necessarily unique: both
58
+ `:northern_uzbek` and `:uzbek` return `:uz`. Use `result.language` to distinguish
59
+ the language names.
60
+
50
61
  Return ranked scores, or the score hash:
51
62
 
52
63
  ```ruby
@@ -76,6 +87,30 @@ Short shared-script fragments are ignored by default because there is not enough
76
87
  wl = WhatLanguage.new(min_chars: 0)
77
88
  ```
78
89
 
90
+ ## Upgrading to 3.0
91
+
92
+ Update comparisons against `result.iso`, `language_iso`, and CLI output:
93
+
94
+ | Language | Previous code | 3.0 code |
95
+ | --- | --- | --- |
96
+ | Swahili | `swh` | `sw` |
97
+ | Central Aymara | `ayr` | `ay` |
98
+ | Central Kanuri | `knc` | `kr` |
99
+ | Cusco Quechua | `quz` | `qu` |
100
+ | Halh Mongolian | `khk` | `mn` |
101
+ | Koongo | `kng` | `kg` |
102
+ | Logudorese Sardinian | `src` | `sc` |
103
+ | Northern Uzbek | `uzn` | `uz` |
104
+ | Tosk Albanian | `als` | `sq` |
105
+
106
+ Both Northern Uzbek and Uzbek now return `uz`; use `result.language` to
107
+ distinguish them. Language-name symbols, detection scores, rankings, options,
108
+ and return types are unchanged from 2.1.
109
+
110
+ If upgrading from 1.x, see the [2.0 changelog](CHANGELOG.md#200--2026-06-10) too.
111
+ Version 2.0 replaced the Bloom-filter engine with trigram profiles and removed
112
+ the `String#language` monkeypatch.
113
+
79
114
  ## Command line
80
115
 
81
116
  Installing the gem also installs a `whatlanguage` executable that reads from files given as arguments (or stdin) and prints the ISO 639 code of the detected language, or `und` if undetermined:
@@ -89,11 +124,11 @@ de
89
124
 
90
125
  ## Known limitations
91
126
 
92
- - Short fragments remain unreliable. For languages resolved by statistical comparison, fewer than 10 letters returns `nil` by default. The built-in preference helps some common phrases, but can also favor a preferred language over a less widely spoken one when evidence is weak. It does not lower the minimum length.
93
- - Scores are relative ranking values, not probabilities. Use `#ranked` or `#detect.ranked` when close runners-up matter.
94
- - Closely related written languages can be hard to separate, especially Norwegian Bokmål/Danish, Hebrew/Yiddish, and similar language pairs.
95
- - Kanji-only Japanese text can classify as Chinese because Han characters alone do not identify the language.
96
- - Romanized text is classified by Latin-script trigram profiles; it is not treated as native-script text.
127
+ - Short texts can be ambiguous. Built-in preferences help some phrases but can bias others toward preferred languages. Statistical detection returns `nil` below 10 letters by default.
128
+ - Scores indicate relative rank, not probability or confidence. Use `#ranked` to inspect alternatives.
129
+ - Similar written languages, such as Norwegian Bokmål/Danish and Hebrew/Yiddish, can be confused.
130
+ - Japanese without kana is classified as Chinese when Han is the dominant script.
131
+ - Romanized text is compared with Latin-script profiles, so it may not resolve to its original language.
97
132
 
98
133
  ## Credits
99
134
 
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # AUTO-GENERATED from the ISO 639-3 registry + whatlang dataset codes.
3
+ # Generated from the ISO 639-3 registry + whatlang dataset codes, with overrides.
4
4
  # Maps ISO 639-3 code => [language name symbol, ISO 639-1 (or 639-3 fallback) symbol].
5
5
  # Original gem languages keep their historical symbols and 2-letter codes.
6
+ # Selected individual languages use CLDR's 2-letter macrolanguage aliases:
7
+ # als, ayr, cmn, khk, knc, kng, pes, quz, src, swh, uzn. Preserve these overrides
8
+ # when regenerating; do not map every macrolanguage member to its parent code.
9
+ # Source: https://github.com/unicode-org/cldr/blob/main/common/supplemental/supplementalMetadata.xml
6
10
  class WhatLanguage
7
11
  CODE_INFO = {
8
12
  "ace" => [:achinese, :ace],
@@ -30,16 +34,16 @@ class WhatLanguage
30
34
  "cat" => [:catalan, :ca],
31
35
  "ceb" => [:cebuano, :ceb],
32
36
  "tzm" => [:central_atlas_tamazight, :tzm],
33
- "ayr" => [:central_aymara, :ayr],
37
+ "ayr" => [:central_aymara, :ay],
34
38
  "bcl" => [:central_bikol, :bcl],
35
- "knc" => [:central_kanuri, :knc],
39
+ "knc" => [:central_kanuri, :kr],
36
40
  "nhn" => [:central_nahuatl, :nhn],
37
41
  "nya" => [:chichewa, :ny],
38
42
  "qug" => [:chimborazo_highland_quichua, :qug],
39
43
  "cmn" => [:chinese, :zh],
40
44
  "cjk" => [:chokwe, :cjk],
41
45
  "hrv" => [:croatian, :hr],
42
- "quz" => [:cusco_quechua, :quz],
46
+ "quz" => [:cusco_quechua, :qu],
43
47
  "ces" => [:czech, :cs],
44
48
  "dan" => [:danish, :da],
45
49
  "nld" => [:dutch, :nl],
@@ -61,7 +65,7 @@ class WhatLanguage
61
65
  "ell" => [:greek, :el],
62
66
  "guj" => [:gujarati, :gu],
63
67
  "hat" => [:haitian, :ht],
64
- "khk" => [:halh_mongolian, :khk],
68
+ "khk" => [:halh_mongolian, :mn],
65
69
  "hau" => [:hausa, :ha],
66
70
  "heb" => [:hebrew, :he],
67
71
  "hil" => [:hiligaynon, :hil],
@@ -82,14 +86,14 @@ class WhatLanguage
82
86
  "kin" => [:kinyarwanda, :rw],
83
87
  "kir" => [:kirghiz, :ky],
84
88
  "koi" => [:komi_permyak, :koi],
85
- "kng" => [:koongo, :kng],
89
+ "kng" => [:koongo, :kg],
86
90
  "kor" => [:korean, :ko],
87
91
  "kur" => [:kurdish, :ku],
88
92
  "lat" => [:latin, :la],
89
93
  "lav" => [:latvian, :lv],
90
94
  "lin" => [:lingala, :ln],
91
95
  "lit" => [:lithuanian, :lt],
92
- "src" => [:logudorese_sardinian, :src],
96
+ "src" => [:logudorese_sardinian, :sc],
93
97
  "nds" => [:low_german, :nds],
94
98
  "lua" => [:luba_lulua, :lua],
95
99
  "lun" => [:lunda, :lun],
@@ -108,7 +112,7 @@ class WhatLanguage
108
112
  "ndo" => [:ndonga, :ng],
109
113
  "nep" => [:nepali, :ne],
110
114
  "dip" => [:northeastern_dinka, :dip],
111
- "uzn" => [:northern_uzbek, :uzn],
115
+ "uzn" => [:northern_uzbek, :uz],
112
116
  "nob" => [:norwegian, :no],
113
117
  "nno" => [:norwegian_nynorsk, :nn],
114
118
  "nyn" => [:nyankole, :nyn],
@@ -137,7 +141,7 @@ class WhatLanguage
137
141
  "spa" => [:spanish, :es],
138
142
  "suk" => [:sukuma, :suk],
139
143
  "sun" => [:sundanese, :su],
140
- "swh" => [:swahili, :swh],
144
+ "swh" => [:swahili, :sw],
141
145
  "ssw" => [:swati, :ss],
142
146
  "swe" => [:swedish, :sv],
143
147
  "tgl" => [:tagalog, :tl],
@@ -151,7 +155,7 @@ class WhatLanguage
151
155
  "tiv" => [:tiv, :tiv],
152
156
  "tpi" => [:tok_pisin, :tpi],
153
157
  "toi" => [:tonga, :toi],
154
- "als" => [:tosk_albanian, :als],
158
+ "als" => [:tosk_albanian, :sq],
155
159
  "tso" => [:tsonga, :ts],
156
160
  "tsn" => [:tswana, :tn],
157
161
  "tur" => [:turkish, :tr],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class WhatLanguage
4
- VERSION = '2.1.0'
4
+ VERSION = '3.0.0'
5
5
  end
data/lib/whatlanguage.rb CHANGED
@@ -92,6 +92,30 @@ class WhatLanguage
92
92
  def default_detector
93
93
  @default_detector ||= new
94
94
  end
95
+
96
+ # Shared index for a script: trigram => flat [candidate, rank, ...].
97
+ # Use the direct scan on the first call: one-shot CLI use should not pay
98
+ # to build an index. Flat arrays avoid an object for every posting.
99
+ def profile_index(script)
100
+ @profile_indexes ||= {}
101
+ unless @profile_indexes.key?(script)
102
+ @profile_indexes[script] = nil
103
+ return
104
+ end
105
+
106
+ @profile_indexes[script] ||= begin
107
+ candidates = profiles.fetch(script)
108
+ postings = {}
109
+ candidates.each_with_index do |(_code, trigrams), candidate|
110
+ trigrams.each_with_index do |trigram, rank|
111
+ (postings[trigram] ||= []).push(candidate, rank)
112
+ end
113
+ end
114
+ postings.each_value(&:freeze)
115
+ missing = candidates.map { |_code, trigrams| trigrams.size * MAX_TRIGRAM_DISTANCE }
116
+ [missing.freeze, postings.freeze].freeze
117
+ end
118
+ end
95
119
  end
96
120
 
97
121
  def initialize(*selection, only: nil, min_chars: DEFAULT_MIN_CHARS)
@@ -119,7 +143,7 @@ class WhatLanguage
119
143
  def score_hash(text)
120
144
  results = Hash.new(0)
121
145
  text = normalize_text(text)
122
- script = detect_script(text)
146
+ script, char_count = script_and_letter_count(text)
123
147
  return results unless script
124
148
 
125
149
  if (code = DETERMINISTIC[script])
@@ -128,19 +152,31 @@ class WhatLanguage
128
152
  return results
129
153
  end
130
154
 
131
- candidates = self.class.profiles[script]
155
+ candidates = candidates_for(script)
132
156
  return results unless candidates
133
- char_count = significant_char_count(text)
134
157
  return results if char_count < @min_chars
135
158
 
136
159
  bonus = short_text_bonus(char_count)
137
160
  positions = trigram_positions(text)
138
- candidates.each do |code, trigrams|
139
- name = CODE_INFO[code].first
140
- next unless allowed?(name)
161
+ # Small selections retain the direct scan rather than visiting postings for
162
+ # languages they cannot return.
163
+ distances = indexed_distances(script, positions) if @selection.include?(:all)
164
+ if distances
165
+ candidates.each_with_index do |(code, _trigrams), i|
166
+ name = CODE_INFO[code].first
167
+ next unless allowed?(name)
168
+
169
+ results[name] = MAX_TOTAL_DISTANCE - distances[i]
170
+ results[name] += bonus if SHORT_TEXT_PREFERENCES.include?(name)
171
+ end
172
+ else
173
+ candidates.each do |code, trigrams|
174
+ name = CODE_INFO[code].first
175
+ next unless allowed?(name)
141
176
 
142
- results[name] = MAX_TOTAL_DISTANCE - distance(trigrams, positions)
143
- results[name] += bonus if SHORT_TEXT_PREFERENCES.include?(name)
177
+ results[name] = MAX_TOTAL_DISTANCE - distance(trigrams, positions)
178
+ results[name] += bonus if SHORT_TEXT_PREFERENCES.include?(name)
179
+ end
144
180
  end
145
181
  results
146
182
  end
@@ -165,12 +201,14 @@ class WhatLanguage
165
201
 
166
202
  # Most likely language as a name symbol, or nil when no language is detected.
167
203
  def language(text)
168
- detect(text)&.language
204
+ score_hash(text).max_by { |_name, score| score }&.first
169
205
  end
170
206
 
171
207
  # Most likely language as an ISO 639-1 symbol (639-3 fallback), or nil.
208
+ # Includes selected CLDR macrolanguage aliases and historical code overrides;
209
+ # see whatlanguage/languages.rb. Codes need not uniquely identify a language.
172
210
  def language_iso(text)
173
- detect(text)&.iso
211
+ ISO_CODES[language(text)]
174
212
  end
175
213
 
176
214
  private
@@ -191,6 +229,20 @@ class WhatLanguage
191
229
  @selection.include?(:all) || languages.include?(name)
192
230
  end
193
231
 
232
+ def candidates_for(script)
233
+ candidates = self.class.profiles[script]
234
+ return candidates unless candidates && !@selection.include?(:all)
235
+
236
+ # languages returns a mutable array. Invalidate the cached selection when
237
+ # callers change it, preserving the existing selection behavior.
238
+ wanted = languages
239
+ if @selected_languages != wanted
240
+ @selected_languages = wanted.dup
241
+ @selected_profiles = {}
242
+ end
243
+ @selected_profiles[script] ||= candidates.select { |code, _trigrams| allowed?(CODE_INFO[code].first) }
244
+ end
245
+
194
246
  def validate_selection!
195
247
  requested = @selection.reject { |name| name == :all }
196
248
  unknown = requested.reject { |name| self.class.languages.include?(NAME_ALIASES.fetch(name, name)) }
@@ -199,24 +251,30 @@ class WhatLanguage
199
251
  raise ArgumentError, "Unknown language selection: #{unknown.map(&:inspect).join(', ')}"
200
252
  end
201
253
 
202
- def significant_char_count(text)
203
- text.each_char.count { |ch| /\p{L}/.match?(ch) }
204
- end
254
+ # Count all letters, including those outside the recognized scripts.
255
+ def script_and_letter_count(text)
256
+ if text.ascii_only?
257
+ letter_count = text.count('A-Za-z')
258
+ return [letter_count.zero? ? nil : 'Latin', letter_count]
259
+ end
205
260
 
206
- # Dominant Unicode script of the text, or nil if it has no script characters.
207
- def detect_script(text)
208
261
  counts = Hash.new(0)
262
+ letter_count = 0
209
263
  text.each_char do |ch|
210
264
  next unless /\p{L}/.match?(ch)
265
+ letter_count += 1
211
266
 
212
- SCRIPT_PATTERNS.each do |name, pattern|
267
+ i = 0
268
+ while i < SCRIPT_PATTERNS.size
269
+ name, pattern = SCRIPT_PATTERNS[i]
213
270
  if pattern.match?(ch)
214
271
  counts[name] += 1
215
272
  break
216
273
  end
274
+ i += 1
217
275
  end
218
276
  end
219
- return nil if counts.empty?
277
+ return [nil, letter_count] if counts.empty?
220
278
 
221
279
  # Japanese uses Han, Hiragana, and Katakana together. Combine their votes
222
280
  # when kana is present, but still let a dominant unrelated script win.
@@ -227,33 +285,100 @@ class WhatLanguage
227
285
  counts['Hiragana'] = japanese
228
286
  end
229
287
 
230
- counts.max_by { |_name, n| n }.first
288
+ [counts.max_by { |_name, n| n }.first, letter_count]
231
289
  end
232
290
 
233
291
  # Text trigrams ranked by descending frequency, mapped to their rank index.
234
292
  # Non-letter/mark characters become spaces. Preserve combining marks used
235
293
  # by the profiles; bound the stream by spaces and ignore repeated spaces.
236
294
  def trigram_positions(text)
237
- chars = text.downcase.each_char.map { |c| /[\p{L}\p{M}]/.match?(c) ? c : ' ' }
238
- return {} if chars.empty?
295
+ text = text.downcase
296
+ occurrences = text.ascii_only? ? ascii_trigram_counts(text) : unicode_trigram_counts(text)
297
+ ranked = occurrences.to_a.sort! do |a, b|
298
+ order = b[1] <=> a[1]
299
+ order.zero? ? b[0] <=> a[0] : order
300
+ end.first(TEXT_TRIGRAMS_SIZE)
301
+ positions = {}
302
+ ranked.each_with_index { |(trigram, _count), i| positions[trigram] = i }
303
+ positions
304
+ end
239
305
 
306
+ def unicode_trigram_counts(text)
240
307
  occurrences = Hash.new(0)
241
- c1 = ' '
242
- c2 = chars[0]
243
- (chars[1..] + [' ']).each do |c3|
244
- occurrences[c1 + c2 + c3] += 1 unless c2 == ' ' && (c1 == ' ' || c3 == ' ')
308
+ trigram = +''
309
+ c1 = nil
310
+ c2 = ' '
311
+ text.each_char do |character|
312
+ c3 = /[\p{L}\p{M}]/.match?(character) ? character : ' '
313
+ if c1 && !(c2 == ' ' && (c1 == ' ' || c3 == ' '))
314
+ # Hash copies new String keys, so this buffer can be reused for every
315
+ # window without changing already-counted trigrams.
316
+ trigram.replace(c1) << c2 << c3
317
+ occurrences[trigram] += 1
318
+ end
245
319
  c1 = c2
246
320
  c2 = c3
247
321
  end
322
+ if c1 && c2 != ' '
323
+ trigram.replace(c1) << c2 << ' '
324
+ occurrences[trigram] += 1
325
+ end
248
326
 
249
- ranked = occurrences.to_a.sort { |a, b| [b[1], b[0]] <=> [a[1], a[0]] }.first(TEXT_TRIGRAMS_SIZE)
250
- positions = {}
251
- ranked.each_with_index { |(trigram, _count), i| positions[trigram] = i }
252
- positions
327
+ occurrences
328
+ end
329
+
330
+ # Lowercased ASCII has only a-z letters and no combining marks. Use byte
331
+ # values and a reusable three-byte key to avoid allocating character strings.
332
+ def ascii_trigram_counts(text)
333
+ occurrences = Hash.new(0)
334
+ trigram = +"\0\0\0"
335
+ c1 = nil
336
+ c2 = 32
337
+ text.each_byte do |byte|
338
+ c3 = byte.between?(97, 122) ? byte : 32
339
+ if c1 && !(c2 == 32 && (c1 == 32 || c3 == 32))
340
+ trigram.setbyte(0, c1)
341
+ trigram.setbyte(1, c2)
342
+ trigram.setbyte(2, c3)
343
+ occurrences[trigram] += 1
344
+ end
345
+ c1 = c2
346
+ c2 = c3
347
+ end
348
+ if c1 && c2 != 32
349
+ trigram.setbyte(0, c1)
350
+ trigram.setbyte(1, c2)
351
+ trigram.setbyte(2, 32)
352
+ occurrences[trigram] += 1
353
+ end
354
+ occurrences
253
355
  end
254
356
 
255
357
  # Out-of-place distance between a language's ordered trigram profile and the
256
358
  # text's ranked trigrams. Lower means a closer match.
359
+ def indexed_distances(script, positions)
360
+ index = self.class.send(:profile_index, script)
361
+ return unless index
362
+
363
+ missing, postings = index
364
+ totals = missing.dup
365
+ positions.each do |trigram, position|
366
+ matches = postings[trigram]
367
+ next unless matches
368
+
369
+ i = 0
370
+ while i < matches.size
371
+ # Replace the missing-trigram penalty with the exact rank distance.
372
+ # This correction can be positive when the text rank is above 300.
373
+ totals[matches[i]] += (position - matches[i + 1]).abs - MAX_TRIGRAM_DISTANCE
374
+ i += 2
375
+ end
376
+ end
377
+
378
+ adjustment = [MAX_TRIGRAM_DISTANCE - positions.size, 0].max * MAX_TRIGRAM_DISTANCE
379
+ totals.map! { |total| (total - adjustment).clamp(0, MAX_TOTAL_DISTANCE) }
380
+ end
381
+
257
382
  def distance(profile, positions)
258
383
  total = 0
259
384
  profile.each_with_index do |trigram, i|
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: whatlanguage
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Cooper