whatlanguage 2.0.1 → 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: 53fea55810c7a8eb102970d6d8c3fe039b031680ff0f23eb6a1c7fec6ad1a602
4
- data.tar.gz: f960efb55e0d04b5e457ef53f6c382434a334eea9d44fab2065e7ce9d4376747
3
+ metadata.gz: 93967e970b5e62eb58b9cb835866d4abe885f98761f7a339d0817dad8414eac8
4
+ data.tar.gz: b7e983bcd0a56f7b6f77675fdedd6d7724da71b42e26e3229014a92a3c32166b
5
5
  SHA512:
6
- metadata.gz: 99e1be592f692ba51e3a973a489703f955238da71985feb91b424e9203b8ae29d1ff635d5479ee6cea0226a06cd74c10c4f859a722c76914f277df3d41c3c779
7
- data.tar.gz: e13bf56aa67c969d86213e9f1bd34bb2f0aa4ab0ab5db4c1b735ef84c9c3d2ad8e7891f0104a0881833fb5f54c42235132cf48f7a75af38f1cf5f71280529a31
6
+ metadata.gz: f7c259a244e6c0fe0aa9084037fb29381bc96c9b5a4e549382b660b95943d82bf651d5e706fc4290cb93ff2bc820b1c15495f70bec025c5b98bfa3fb0ff91d6a
7
+ data.tar.gz: 6b2d67686a98ec148d4a3f3314cd270beee7ad3b3437411b8a516d69d6078d3a1a3bd230a4d7e06a5904f7b286339e8be1b2cfef29ba9df73e0da76efb0044a3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
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
+
28
+ ## 2.1.0 / 2026-09-05
29
+
30
+ - Added a fixed short-text preference for English, Chinese, Hindi, Spanish, and
31
+ French: a 600-point trigram bonus through 10 letters, fading to zero at 50.
32
+ Script routing, candidate restrictions, and minimum-length rules still apply.
33
+ - Replaced hard-coded ranges with Unicode script properties, recognizing
34
+ uppercase Georgian, extended Greek, and supplementary Han letters.
35
+ - Combined Han and kana when detecting Japanese instead of misclassifying
36
+ Han-heavy Japanese text as Chinese.
37
+ - Excluded digits, punctuation, and emoji from script votes and minimum-length
38
+ counting, and treated them as trigram separators while preserving combining marks.
39
+ - Added regression coverage for short texts and Unicode handling. Short-text
40
+ preferences are a heuristic; ambiguous fragments can still be misclassified.
41
+
3
42
  ## 2.0.1 / 2026-07-11
4
43
 
5
44
  - Added a `whatlanguage` command-line executable that reads from files given as
data/README.md CHANGED
@@ -17,14 +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
- Detection is in two stages. First, the dominant Unicode script is detected; scripts used by a single language (Greek, Korean, Thai, Japanese using Hiragana/Katakana) resolve immediately. For scripts shared by several languages (e.g. Latin, Cyrillic, Arabic, Hebrew) trigrams are ranked by frequency and compared against candidate language profiles.
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.
26
+
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.
25
28
 
26
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.
27
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.
28
36
 
29
37
  ## Usage
30
38
 
@@ -40,7 +48,17 @@ result.score # => 79018
40
48
  result.ranked # => [[:german, 79018], [:dutch, 77631], ... ]
41
49
  ```
42
50
 
43
- Return ranked scores, or the raw score hash:
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
+
61
+ Return ranked scores, or the score hash:
44
62
 
45
63
  ```ruby
46
64
  wl.ranked(text) # => [[:german, 79018], [:dutch, 77631], ... ]
@@ -53,12 +71,46 @@ Restrict candidate languages:
53
71
  wl = WhatLanguage.new(only: [:english, :german, :french])
54
72
  ```
55
73
 
56
- Short Latin-script fragments are ignored by default because there is not enough signal to rank shared-script languages reliably. The threshold applies to the statistical trigram stage; scripts that identify a single supported language, such as Greek, Korean, or Thai, can still resolve from shorter text. The threshold can be adjusted:
74
+ For short texts, English, Chinese (Mandarin), Hindi, Spanish, and French receive
75
+ a built-in preference. These five were selected using the
76
+ [2025 total-speaker ranking](https://www.visualcapitalist.com/ranked-the-worlds-most-spoken-languages-in-2025/)
77
+ (including second-language speakers). Eligible trigram candidates receive the
78
+ same bonus: 600 ranking points through 10 letters, decreasing linearly to zero
79
+ at 50 letters. Longer texts keep their original scores. The preference is not
80
+ configurable; it respects `only:` and script detection. Chinese already resolves
81
+ from its script, so it does not need a statistical bonus. Scores exposed by the
82
+ API include the bonus and remain ranking values, not probabilities.
83
+
84
+ Short shared-script fragments are ignored by default because there is not enough signal to rank their languages reliably. The threshold applies to the statistical trigram stage; scripts that identify a single supported language, such as Greek, Korean, or Thai, can still resolve from shorter text. The threshold can be adjusted:
57
85
 
58
86
  ```ruby
59
87
  wl = WhatLanguage.new(min_chars: 0)
60
88
  ```
61
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
+
62
114
  ## Command line
63
115
 
64
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:
@@ -72,11 +124,11 @@ de
72
124
 
73
125
  ## Known limitations
74
126
 
75
- - Short fragments are unreliable. For languages resolved by statistical comparison, fewer than 10 significant characters returns `nil` by default. **This is the biggest thing I want to fix.** I think there should be a mechanism for "weighting" languages so that less likely languages don't frequently appear for small English samples, say.
76
- - Scores are relative ranking values, not probabilities. Use `#ranked` or `#detect.ranked` when close runners-up matter.
77
- - Closely related written languages can be hard to separate, especially Norwegian Bokmål/Danish, Hebrew/Yiddish, and similar language pairs.
78
- - Kanji-only Japanese text can classify as Chinese because Han characters alone do not identify the language.
79
- - 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.
80
132
 
81
133
  ## Credits
82
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.0.1'
4
+ VERSION = '3.0.0'
5
5
  end
data/lib/whatlanguage.rb CHANGED
@@ -9,12 +9,15 @@ class WhatLanguage
9
9
  MAX_TOTAL_DISTANCE = MAX_TRIGRAM_DISTANCE * MAX_TRIGRAM_DISTANCE # 90_000
10
10
  TEXT_TRIGRAMS_SIZE = 600
11
11
  DEFAULT_MIN_CHARS = 10
12
+ SHORT_TEXT_PREFERENCES = %i[english chinese hindi spanish french].freeze
13
+ SHORT_TEXT_BONUS = 600
14
+ SHORT_TEXT_FADE_CHARS = 50
12
15
 
13
16
  Result = Struct.new(:language, :iso, :score, :ranked, keyword_init: true) do
14
17
  alias scores ranked
15
18
  end
16
19
 
17
- # Scripts that resolve to a single language by their Unicode block alone.
20
+ # Scripts that resolve to a single supported language from their letters.
18
21
  # (Hiragana and Katakana both indicate Japanese.) Scripts NOT listed here but
19
22
  # present in the trigram dataset are disambiguated statistically instead.
20
23
  DETERMINISTIC = {
@@ -27,43 +30,13 @@ class WhatLanguage
27
30
  'Katakana' => 'jpn'
28
31
  }.freeze
29
32
 
30
- # Unicode ranges per script, in detection priority order (mirrors whatlang's
31
- # scripts/detect.rs). The first script whose range contains a character claims
32
- # that character; the script with the most characters wins.
33
- SCRIPT_RANGES = [
34
- ['Latin', [[0x61,0x7A],[0x41,0x5A],[0x80,0xFF],[0x100,0x17F],[0x180,0x24F],
35
- [0x250,0x2AF],[0x1D00,0x1D7F],[0x1D80,0x1DBF],[0x1E00,0x1EFF],
36
- [0x2100,0x214F],[0x2C60,0x2C7F],[0xA720,0xA7FF],[0xAB30,0xAB6F]]],
37
- ['Cyrillic', [[0x400,0x484],[0x487,0x52F],[0x2DE0,0x2DFF],[0xA640,0xA69D],
38
- [0x1D2B,0x1D2B],[0x1D78,0x1D78],[0xA69F,0xA69F]]],
39
- ['Arabic', [[0x600,0x6FF],[0x750,0x7FF],[0x8A0,0x8FF],[0xFB50,0xFDFF],
40
- [0xFE70,0xFEFF],[0x10E60,0x10E7F],[0x1EE00,0x1EEFF]]],
41
- ['Mandarin', [[0x2E80,0x2E99],[0x2E9B,0x2EF3],[0x2F00,0x2FD5],[0x3005,0x3005],
42
- [0x3007,0x3007],[0x3021,0x3029],[0x3038,0x303B],[0x3400,0x4DB5],
43
- [0x4E00,0x9FCC],[0xF900,0xFA6D],[0xFA70,0xFAD9]]],
44
- ['Devanagari', [[0x900,0x97F],[0xA8E0,0xA8FF],[0x1CD0,0x1CFF]]],
45
- ['Hebrew', [[0x590,0x5FF]]],
46
- ['Ethiopic', [[0x1200,0x139F],[0x2D80,0x2DDF],[0xAB00,0xAB2F]]],
47
- ['Georgian', [[0x10A0,0x10FF]]],
48
- ['Bengali', [[0x980,0x9FF]]],
49
- ['Hangul', [[0xAC00,0xD7AF],[0x1100,0x11FF],[0x3130,0x318F],[0x3200,0x32FF],
50
- [0xA960,0xA97F],[0xD7B0,0xD7FF]]],
51
- ['Hiragana', [[0x3040,0x309F]]],
52
- ['Katakana', [[0x30A0,0x30FF]]],
53
- ['Greek', [[0x370,0x3FF]]],
54
- ['Kannada', [[0xC80,0xCFF]]],
55
- ['Tamil', [[0xB80,0xBFF]]],
56
- ['Thai', [[0xE00,0xE7F]]],
57
- ['Gujarati', [[0xA80,0xAFF]]],
58
- ['Gurmukhi', [[0xA00,0xA7F]]],
59
- ['Telugu', [[0xC00,0xC7F]]],
60
- ['Malayalam', [[0xD00,0xD7F]]],
61
- ['Oriya', [[0xB00,0xB7F]]],
62
- ['Myanmar', [[0x1000,0x109F]]],
63
- ['Sinhala', [[0xD80,0xDFF]]],
64
- ['Khmer', [[0x1780,0x17FF],[0x19E0,0x19FF]]],
65
- ['Armenian', [[0x530,0x58F],[0xFB13,0xFB17]]]
66
- ].freeze
33
+ # Unicode script properties include letters outside the original BMP blocks.
34
+ # Keep the model's historical "Mandarin" key for the Han script.
35
+ SCRIPT_PATTERNS = %w[
36
+ Latin Cyrillic Arabic Han Devanagari Hebrew Ethiopic Georgian Bengali
37
+ Hangul Hiragana Katakana Greek Kannada Tamil Thai Gujarati Gurmukhi
38
+ Telugu Malayalam Oriya Myanmar Sinhala Khmer Armenian
39
+ ].map { |script| [script == 'Han' ? 'Mandarin' : script, Regexp.new("\\p{#{script}}")] }.freeze
67
40
 
68
41
  # ISO 639-1 (with 639-3 fallback) lookup by language-name symbol, plus the
69
42
  # historical nil => nil entry. Internal; kept for backward compatibility.
@@ -76,8 +49,9 @@ class WhatLanguage
76
49
  end.freeze
77
50
 
78
51
  private_constant :MAX_TRIGRAM_DISTANCE, :MAX_TOTAL_DISTANCE, :TEXT_TRIGRAMS_SIZE,
79
- :DEFAULT_MIN_CHARS, :DETERMINISTIC, :SCRIPT_RANGES, :ISO_CODES,
80
- :NAME_TO_CODE
52
+ :DEFAULT_MIN_CHARS, :DETERMINISTIC, :SCRIPT_PATTERNS, :ISO_CODES,
53
+ :NAME_TO_CODE, :SHORT_TEXT_PREFERENCES, :SHORT_TEXT_BONUS,
54
+ :SHORT_TEXT_FADE_CHARS
81
55
 
82
56
  class << self
83
57
  def detect(text)
@@ -118,6 +92,30 @@ class WhatLanguage
118
92
  def default_detector
119
93
  @default_detector ||= new
120
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
121
119
  end
122
120
 
123
121
  def initialize(*selection, only: nil, min_chars: DEFAULT_MIN_CHARS)
@@ -145,7 +143,7 @@ class WhatLanguage
145
143
  def score_hash(text)
146
144
  results = Hash.new(0)
147
145
  text = normalize_text(text)
148
- script = detect_script(text)
146
+ script, char_count = script_and_letter_count(text)
149
147
  return results unless script
150
148
 
151
149
  if (code = DETERMINISTIC[script])
@@ -154,16 +152,31 @@ class WhatLanguage
154
152
  return results
155
153
  end
156
154
 
157
- candidates = self.class.profiles[script]
155
+ candidates = candidates_for(script)
158
156
  return results unless candidates
159
- return results if significant_char_count(text) < @min_chars
157
+ return results if char_count < @min_chars
160
158
 
159
+ bonus = short_text_bonus(char_count)
161
160
  positions = trigram_positions(text)
162
- candidates.each do |code, trigrams|
163
- name = CODE_INFO[code].first
164
- 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)
165
176
 
166
- results[name] = MAX_TOTAL_DISTANCE - distance(trigrams, positions)
177
+ results[name] = MAX_TOTAL_DISTANCE - distance(trigrams, positions)
178
+ results[name] += bonus if SHORT_TEXT_PREFERENCES.include?(name)
179
+ end
167
180
  end
168
181
  results
169
182
  end
@@ -188,16 +201,26 @@ class WhatLanguage
188
201
 
189
202
  # Most likely language as a name symbol, or nil when no language is detected.
190
203
  def language(text)
191
- detect(text)&.language
204
+ score_hash(text).max_by { |_name, score| score }&.first
192
205
  end
193
206
 
194
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.
195
210
  def language_iso(text)
196
- detect(text)&.iso
211
+ ISO_CODES[language(text)]
197
212
  end
198
213
 
199
214
  private
200
215
 
216
+ # A small fixed prior for widely spoken languages: full strength through ten
217
+ # letters, fading linearly to zero at fifty. These are ranking points, not
218
+ # probabilities. Script routing and the caller's candidate selection still win.
219
+ def short_text_bonus(char_count)
220
+ remaining = (SHORT_TEXT_FADE_CHARS - char_count).clamp(0, SHORT_TEXT_FADE_CHARS - DEFAULT_MIN_CHARS)
221
+ SHORT_TEXT_BONUS * remaining / (SHORT_TEXT_FADE_CHARS - DEFAULT_MIN_CHARS)
222
+ end
223
+
201
224
  def normalize_text(text)
202
225
  text.to_s.unicode_normalize(:nfkc)
203
226
  end
@@ -206,6 +229,20 @@ class WhatLanguage
206
229
  @selection.include?(:all) || languages.include?(name)
207
230
  end
208
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
+
209
246
  def validate_selection!
210
247
  requested = @selection.reject { |name| name == :all }
211
248
  unknown = requested.reject { |name| self.class.languages.include?(NAME_ALIASES.fetch(name, name)) }
@@ -214,53 +251,134 @@ class WhatLanguage
214
251
  raise ArgumentError, "Unknown language selection: #{unknown.map(&:inspect).join(', ')}"
215
252
  end
216
253
 
217
- def significant_char_count(text)
218
- text.each_char.count { |ch| !stop_char?(ch.ord) }
219
- 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
220
260
 
221
- # Dominant Unicode script of the text, or nil if it has no script characters.
222
- def detect_script(text)
223
261
  counts = Hash.new(0)
262
+ letter_count = 0
224
263
  text.each_char do |ch|
225
- cp = ch.ord
226
- next if stop_char?(cp)
264
+ next unless /\p{L}/.match?(ch)
265
+ letter_count += 1
227
266
 
228
- SCRIPT_RANGES.each do |name, ranges|
229
- if ranges.any? { |lo, hi| cp >= lo && cp <= hi }
267
+ i = 0
268
+ while i < SCRIPT_PATTERNS.size
269
+ name, pattern = SCRIPT_PATTERNS[i]
270
+ if pattern.match?(ch)
230
271
  counts[name] += 1
231
272
  break
232
273
  end
274
+ i += 1
233
275
  end
234
276
  end
235
- return nil if counts.empty?
277
+ return [nil, letter_count] if counts.empty?
278
+
279
+ # Japanese uses Han, Hiragana, and Katakana together. Combine their votes
280
+ # when kana is present, but still let a dominant unrelated script win.
281
+ if counts['Hiragana'] + counts['Katakana'] > 0
282
+ japanese = counts['Mandarin'] + counts['Hiragana'] + counts['Katakana']
283
+ counts.delete('Mandarin')
284
+ counts.delete('Katakana')
285
+ counts['Hiragana'] = japanese
286
+ end
236
287
 
237
- counts.max_by { |_name, n| n }.first
288
+ [counts.max_by { |_name, n| n }.first, letter_count]
238
289
  end
239
290
 
240
291
  # Text trigrams ranked by descending frequency, mapped to their rank index.
241
- # Mirrors whatlang's trigram extraction: punctuation/digits become spaces,
242
- # the stream is bounded by spaces, and runs of spaces are collapsed.
292
+ # Non-letter/mark characters become spaces. Preserve combining marks used
293
+ # by the profiles; bound the stream by spaces and ignore repeated spaces.
243
294
  def trigram_positions(text)
244
- chars = text.downcase.each_char.map { |c| stop_char?(c.ord) ? ' ' : c }
245
- 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
246
305
 
306
+ def unicode_trigram_counts(text)
247
307
  occurrences = Hash.new(0)
248
- c1 = ' '
249
- c2 = chars[0]
250
- (chars[1..] + [' ']).each do |c3|
251
- 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
252
319
  c1 = c2
253
320
  c2 = c3
254
321
  end
322
+ if c1 && c2 != ' '
323
+ trigram.replace(c1) << c2 << ' '
324
+ occurrences[trigram] += 1
325
+ end
255
326
 
256
- ranked = occurrences.to_a.sort { |a, b| [b[1], b[0]] <=> [a[1], a[0]] }.first(TEXT_TRIGRAMS_SIZE)
257
- positions = {}
258
- ranked.each_with_index { |(trigram, _count), i| positions[trigram] = i }
259
- 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
260
355
  end
261
356
 
262
357
  # Out-of-place distance between a language's ordered trigram profile and the
263
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
+
264
382
  def distance(profile, positions)
265
383
  total = 0
266
384
  profile.each_with_index do |trigram, i|
@@ -272,9 +390,4 @@ class WhatLanguage
272
390
  total -= (MAX_TRIGRAM_DISTANCE - count) * MAX_TRIGRAM_DISTANCE if MAX_TRIGRAM_DISTANCE > count
273
391
  total.clamp(0, MAX_TOTAL_DISTANCE)
274
392
  end
275
-
276
- # Space, ASCII punctuation, or digit: no value for script/language detection.
277
- def stop_char?(codepoint)
278
- codepoint <= 0x40 || (codepoint >= 0x5B && codepoint <= 0x60) || (codepoint >= 0x7B && codepoint <= 0x7E)
279
- end
280
393
  end
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.0.1
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter Cooper