twfilter 0.1.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +37 -0
  3. data/LICENSE +19 -0
  4. data/NOTICES.md +93 -0
  5. data/README.md +364 -0
  6. data/lib/twfilter/block.rb +36 -0
  7. data/lib/twfilter/checks/erhua.rb +40 -0
  8. data/lib/twfilter/checks/lexicon.rb +83 -0
  9. data/lib/twfilter/checks/script.rb +71 -0
  10. data/lib/twfilter/checks/shape.rb +40 -0
  11. data/lib/twfilter/checks/wenyan.rb +32 -0
  12. data/lib/twfilter/data/MANIFEST.json +85 -0
  13. data/lib/twfilter/data/cantonese.txt +17 -0
  14. data/lib/twfilter/data/converted_orthography.txt +14 -0
  15. data/lib/twfilter/data/erhua_headed.txt +14 -0
  16. data/lib/twfilter/data/erhua_tailed.txt +36 -0
  17. data/lib/twfilter/data/foreign_topics.txt +80 -0
  18. data/lib/twfilter/data/mainland_exceptions.tsv +6 -0
  19. data/lib/twfilter/data/mainland_hard.tsv +149 -0
  20. data/lib/twfilter/data/mainland_soft.tsv +38 -0
  21. data/lib/twfilter/data/moe_common.txt +4808 -0
  22. data/lib/twfilter/data/moe_exception.txt +12 -0
  23. data/lib/twfilter/data/moe_rare.txt +18356 -0
  24. data/lib/twfilter/data/moe_secondary.txt +6343 -0
  25. data/lib/twfilter/data/regional_hard.tsv +19 -0
  26. data/lib/twfilter/data/simplified_only.txt +3785 -0
  27. data/lib/twfilter/data/taiwan_grammar.txt +20 -0
  28. data/lib/twfilter/data/taiwan_lexicon.txt +51 -0
  29. data/lib/twfilter/data/taiwan_markers.txt +66 -0
  30. data/lib/twfilter/data/taiwan_particles.txt +7 -0
  31. data/lib/twfilter/data/variants_used_in_taiwan.txt +16 -0
  32. data/lib/twfilter/data/wenyan.txt +4 -0
  33. data/lib/twfilter/errors.rb +12 -0
  34. data/lib/twfilter/evidence.rb +44 -0
  35. data/lib/twfilter/finding.rb +42 -0
  36. data/lib/twfilter/han.rb +30 -0
  37. data/lib/twfilter/policy.rb +68 -0
  38. data/lib/twfilter/punctuation.rb +106 -0
  39. data/lib/twfilter/sentences.rb +32 -0
  40. data/lib/twfilter/subject.rb +28 -0
  41. data/lib/twfilter/tables.rb +140 -0
  42. data/lib/twfilter/version.rb +5 -0
  43. data/lib/twfilter.rb +57 -0
  44. data/licenses/APACHE-2.0.txt +202 -0
  45. data/sig/twfilter.rbs +215 -0
  46. metadata +137 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9fa81150f2ea0cdd0793e84dc5bca8b568fe7ab699693b90a6023a3933e00e40
4
+ data.tar.gz: 1264fea5c38f14348f7b2767acaf1583c89b793c341eebfd5864e106f38070a9
5
+ SHA512:
6
+ metadata.gz: dd27ae0c7be37abc15e95a0b014d07cb41c8096fd4ad8a98b5574309e3706155f53086360ccea33d20e009399270f23ccd2ab08cfed514ca1f67ef114d89632a
7
+ data.tar.gz: 7aa05c02c19e57255e452def5ba72ea17aa253d5ea4aa485fc51eba965740bb9a10ccd940be1e5e3b791e959d4962f3727e452b5cb6960556fbc5bf2dbc3d2ed
data/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-08-01
4
+
5
+ First release.
6
+
7
+ - Provenance analysis over five independent checks, each reporting typed findings with a
8
+ severity rather than a boolean (`TWFilter::Report`, `TWFilter::Finding`)
9
+ - MOE character-chart tiering: 常用 4 808, 次常用 6 343, 罕用 18 356, plus 12 dictionary
10
+ headword characters outside all three charts. Any Han character outside that inventory is
11
+ rejected unconditionally (`TWFilter::Checks::Script`)
12
+ - Simplified-form detection derived from the OpenCC `STCharacters` / `TSCharacters`
13
+ round-trip, minus 16 forms that appear in an MOE chart despite occurring in the
14
+ simplification tables
15
+ - Mainland traditional orthography: 裏着衞爲説麽綫墻眞啓衆産囯麪
16
+ - Mainland lexical markers graded by attestation against a native reference corpus:
17
+ 149 rejecting, 38 marking, 6 exception masks (`TWFilter::Checks::Lexicon`)
18
+ - Hong Kong, written-Cantonese and Singapore markers: 17 characters and 19 word forms
19
+ - 80 non-Taiwanese toponyms and institutions, tagged rather than rejected, with an address
20
+ guard for the Taipei streets named after mainland cities
21
+ - Erhua two-window analysis over 兒 as a root morpheme (`TWFilter::Checks::Erhua`)
22
+ - Literary Chinese density over 矣哉汝吾 (`TWFilter::Checks::Wenyan`)
23
+ - Positive Taiwan evidence over lexicon, sentence-final particles, ROC institutions and the
24
+ 有 + V perfective outside negation (`TWFilter::Evidence`)
25
+ - Punctuation normalization to 教育部《重訂標點符號手冊》: quotation brackets, the
26
+ U+2027 separator, six-dot ellipsis, box-drawing dash, width folding, invisible removal
27
+ (`TWFilter::Punctuation`)
28
+ - Sentence segmentation over terminator and closer classes, linear in input length
29
+ (`TWFilter::Sentences`)
30
+ - Block-level judgement with a contamination tolerance and an evidence floor
31
+ (`TWFilter::Block`)
32
+ - Three policies — `corpus`, `publishable`, `permissive` — as immutable value objects
33
+ (`TWFilter::Policy`)
34
+ - Reference tables resolvable from an external directory, with a checksum manifest and a
35
+ provenance fingerprint for recording alongside derived measurements (`TWFilter::Tables`)
36
+ - A stated membership rule per table, enforced against the shipped data
37
+ (`spec/twfilter/membership_spec.rb`)
data/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Den Patin <hi@dpat.in>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this
6
+ software and associated documentation files (the "Software"), to deal in the Software
7
+ without restriction, including without limitation the rights to use, copy, modify, merge,
8
+ publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
9
+ to whom the Software is furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all copies or
12
+ substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
15
+ INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
16
+ PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
17
+ FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
18
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
+ DEALINGS IN THE SOFTWARE.
data/NOTICES.md ADDED
@@ -0,0 +1,93 @@
1
+ # Notices
2
+
3
+ The code in this gem is MIT. The reference tables under `lib/twfilter/data/` are not all
4
+ mine. This file records the provenance and terms of each. **Every table may be used in
5
+ commercial products**, subject to the attribution stated below.
6
+
7
+ ## Table provenance
8
+
9
+ | Table | Source | Terms |
10
+ | --- | --- | --- |
11
+ | `moe_common.txt`, `moe_secondary.txt`, `moe_rare.txt` | 全字庫 CNS 11643, field `CNS_source`, entries marked 常用國字標準字體表 / 次常用國字標準字體表 / 罕用字體表 | OGDL v1.0 |
12
+ | `moe_exception.txt` | Characters occurring as headwords in the MOE dictionaries but in none of the three charts | Factual inventory; see below |
13
+ | `simplified_only.txt` | Computed from OpenCC `STCharacters` ∖ `TSCharacters` | Apache-2.0 |
14
+ | `mainland_hard.tsv`, `mainland_soft.tsv` | Candidate pairs from OpenCC `TWPhrases` and from hand curation; retention and banding computed by me | Apache-2.0 |
15
+ | all other tables | Authored by me | MIT |
16
+
17
+ ## OGDL v1.0 — 政府資料開放授權條款 第1版
18
+
19
+ Applies to the three character charts, obtained from 全字庫 (數位發展部), which offers its
20
+ data under OGDL v1.0 or SIL OFL 1.1 at the user's option. I take OGDL v1.0.
21
+
22
+ Grants: unrestricted use, reproduction, adaptation, distribution and public transmission,
23
+ including for commercial purposes and in derivative products, worldwide and without time
24
+ limit.
25
+
26
+ Requires: attribution (註明出處). **Failure to attribute voids the grant ab initio.**
27
+
28
+ Attribution string:
29
+
30
+ ```text
31
+ 數位發展部,CNS11643中文標準交換碼全字庫網站
32
+ ```
33
+
34
+ ## Apache-2.0 — OpenCC
35
+
36
+ Applies to `simplified_only.txt` and to the candidate pairs behind `mainland_hard.tsv` and
37
+ `mainland_soft.tsv`. Full text in `licenses/APACHE-2.0.txt` (§4(a)). OpenCC ships no NOTICE
38
+ file, so §4(d) does not apply.
39
+
40
+ Per §4(b), the derived files are **modified**:
41
+
42
+ - `simplified_only.txt` is a set difference over two OpenCC conversion tables, further
43
+ reduced by 16 forms standard in Taiwan. It is not an OpenCC table and does not convert.
44
+ - `mainland_hard.tsv` and `mainland_soft.tsv` retain 187 of 780 candidate pairs. Retention,
45
+ banding and the attestation counts are mine. The excluded 593 are excluded because
46
+ `TWPhrases` is an orthographic mapping, not a provenance signal.
47
+
48
+ Attribution string:
49
+
50
+ ```text
51
+ OpenCC — https://github.com/BYVoid/OpenCC, Apache License 2.0
52
+ ```
53
+
54
+ ## MOE dictionaries — CC BY-ND 3.0 TW
55
+
56
+ `moe_exception.txt` lists 12 characters found as dictionary headwords. A character
57
+ inventory states which characters occur; it reproduces no definition, no example and no
58
+ expression, and is therefore neither a copy nor an adaptation of the dictionaries. Taiwan
59
+ 著作權法 art. 9(1)(3) excludes 通用之符號 and 表格 from protection for want of creativity.
60
+
61
+ Attribution is given regardless:
62
+
63
+ ```text
64
+ 中華民國教育部《重編國語辭典修訂本》《國語辭典簡編本》
65
+ ```
66
+
67
+ The ND term restricts distribution of adaptations. No dictionary text is shipped here, so
68
+ the term is not engaged. Consumers who ship dictionary *content* must satisfy it
69
+ themselves.
70
+
71
+ ## My own tables
72
+
73
+ `variants_used_in_taiwan.txt`, `converted_orthography.txt`, `cantonese.txt`, `wenyan.txt`,
74
+ `mainland_exceptions.tsv`, `regional_hard.tsv`, `foreign_topics.txt`, `taiwan_markers.txt`,
75
+ `taiwan_lexicon.txt`, `taiwan_particles.txt`, `taiwan_grammar.txt`, `erhua_headed.txt`,
76
+ `erhua_tailed.txt` — MIT, same as the code.
77
+
78
+ These are word lists. Individual words are not copyrightable; the selection and arrangement
79
+ are mine and are released under MIT.
80
+
81
+ ## What a consumer must do
82
+
83
+ Shipping this gem in a commercial product requires reproducing two attribution strings —
84
+ 全字庫 and OpenCC — somewhere a user can reach, and including `licenses/APACHE-2.0.txt`.
85
+ `NOTICES.md` satisfies both if shipped as is.
86
+
87
+ Nothing here restricts commercial use, field of endeavour, or the license of the work you
88
+ build. No table carries a NonCommercial, ShareAlike or copyleft term.
89
+
90
+ ## Downstream output
91
+
92
+ Verdicts, findings and counts produced by this gem are yours. They are measurements over
93
+ your input, not derivatives of these tables.
data/README.md ADDED
@@ -0,0 +1,364 @@
1
+ # twfilter
2
+
3
+ Provenance analysis for Chinese text: is this span Taiwan Mandarin (臺灣華語), and if not,
4
+ on what evidence. Pure Ruby, no runtime dependencies, no native extensions, no network,
5
+ deterministic output.
6
+
7
+ Traditional Chinese is written in Taiwan, Hong Kong and Macau, and by mainland publishers
8
+ converting from simplified. Script alone does not identify origin. Every string below is
9
+ written in traditional characters, and only three of them are Taiwan Mandarin.
10
+
11
+ ```ruby
12
+ TWFilter.keep?("這個政策有沒有經過完整評估?") # => true
13
+ TWFilter.keep?("请把这个信息转发给同事") # => false (simplified script)
14
+ TWFilter.keep?("請把這個信息轉發給同事") # => false (lexicon: 信息, Taiwan writes 資訊)
15
+ TWFilter.keep?("他站在門裏面等了很久") # => false (orthography: 裏, Taiwan writes 裡)
16
+ TWFilter.keep?("佢哋唔知道發生咗咩事") # => false (Cantonese particles)
17
+ TWFilter.keep?("這事兒辦得挺漂亮的") # => false (erhua suffix on 事)
18
+ TWFilter.keep?("他的女兒今年考上大學") # => true (兒 here is a root morpheme)
19
+ ```
20
+
21
+ Depth matters as much as breadth. Conversion tables are not provenance signals, and
22
+ subject matter is not provenance either:
23
+
24
+ ```ruby
25
+ # 程序 is ordinary Taiwanese legal vocabulary, not a mainland form
26
+ TWFilter.keep?("行政程序法所定之程序應由主管機關進行") # => true
27
+
28
+ # 了嗎 is attested in every native Taiwanese source; it is not a marker
29
+ TWFilter.keep?("你吃飯了嗎?我們等一下再出發。") # => true
30
+
31
+ # Ambiguous vocabulary is flagged for review, not discarded
32
+ TWFilter.examine("這個視頻的畫質很不錯").marks.map(&:code) # => [:mainland_soft]
33
+
34
+ # Taiwanese reporting about China is Taiwan Mandarin; the topic is tagged, not rejected
35
+ TWFilter.examine("行政院昨天回應了北京的最新聲明").marks.map(&:code) # => [:foreign_topic]
36
+
37
+ # 南京 is also a Taipei street; an address suffix suppresses the topic tag
38
+ TWFilter.examine("臺北市南京東路五段的捷運站出口").findings # => []
39
+ ```
40
+
41
+ Provenance is decided on orthography, character inventory, lexical choice, morphology and
42
+ a positive evidence signal, and every criterion that fired is reported.
43
+
44
+ ## What it does and does not do
45
+
46
+ **Does** — decide provenance of a span of text; normalize punctuation to the Taiwan
47
+ standard; segment into sentences; report a character-difficulty tier; report every reason
48
+ for every decision.
49
+
50
+ **Does not** — segment words, tag parts of speech, detect language (assume Han script
51
+ input), translate, convert between simplified and traditional, or judge grammaticality.
52
+ A span rejected here is not ungrammatical; it is *not Taiwanese*.
53
+
54
+ ## Applications
55
+
56
+ 1. **Learner-facing corpora.** Extracted from [TaiwanCards](https://taiwancards.com), where
57
+ it gates every sentence, gloss and collocation before it can reach a card. The
58
+ `publishable` policy encodes the boundary of the pedagogical inventory: MOE 常用 4 808
59
+ only, no ambiguous vocabulary, MOE punctuation only.
60
+ 2. **Web corpus construction.** Filtering crawl output where the language identifier
61
+ reports `zho_Hant` but the provenance is unknown. Traditional-script crawls conflate
62
+ four written standards; this separates them at 10⁴ sentences per second per core.
63
+ 3. **Training-set curation.** Building or auditing a monolingual set for a model intended
64
+ to produce Taiwan Mandarin rather than converted mainland text. `Report#findings` is
65
+ machine-readable, so exclusions stay attributable.
66
+ 4. **Editorial and localization QA.** Detecting mainland vocabulary, orthography and
67
+ punctuation in copy that is supposed to be Taiwanese — the `mainland_soft` band is
68
+ designed for review queues rather than automatic rejection.
69
+
70
+ ## Decision procedure
71
+
72
+ ```mermaid
73
+ flowchart TD
74
+ T["input span"] --> N["Punctuation.normalize<br/>教育部《重訂標點符號手冊》"]
75
+ N --> SH
76
+
77
+ subgraph SH["Checks::Shape"]
78
+ A1["han count ∈ policy.han_range"]
79
+ A2["han ratio ≥ policy.min_han_ratio"]
80
+ A3["inventory ⊆ Han ∪ Latin ∪ Bopomofo ∪ punctuation"]
81
+ end
82
+
83
+ SH --> SC
84
+ subgraph SC["Checks::Script — character inventory"]
85
+ B1["simplified-only set<br/>OpenCC STCharacters ∖ TSCharacters ∖ Taiwan variants"]
86
+ B2["converted orthography 裏着衞爲説麽綫墻眞啓衆産囯麪"]
87
+ B3["MOE tier ≤ policy.max_tier"]
88
+ end
89
+
90
+ SC --> LX
91
+ subgraph LX["Checks::Lexicon, Erhua, Wenyan"]
92
+ C1["mainland hard ×149 → reject"]
93
+ C2["mainland soft ×38 → mark"]
94
+ C3["Cantonese characters ×17, HK/SEA lexis ×19 → reject"]
95
+ C4["foreign topics ×80 → mark"]
96
+ C5["erhua: 兒 outside a headed or tailed window → reject"]
97
+ C6["literary density > 0.05 → reject"]
98
+ end
99
+
100
+ LX --> EV["Evidence — Taiwan lexicon, particles, institutions,<br/>有 + V perfective outside negation"]
101
+ EV --> R["Report{ok?, findings[], tier, han, evidence}"]
102
+ ```
103
+
104
+ Positive evidence never rejects. `了嗎` is not a defect: it is attested in every native
105
+ source measured. `有` as a perfective marker counts in favour, never against — but only
106
+ outside negation, since `沒有看` and `有沒有看` are pan-Mandarin and carry no Taiwan signal.
107
+
108
+ ## Input and output
109
+
110
+ **Input** — anything responding to `to_s`. Empty, whitespace-only, Latin-only and
111
+ supplementary-plane input are reported, not raised on. Invalid byte sequences must be
112
+ scrubbed by the caller. There is no length limit; segmentation and normalization are
113
+ linear in input length.
114
+
115
+ **Output** — `TWFilter::Report`:
116
+
117
+ | member | meaning |
118
+ | --- | --- |
119
+ | `ok?` | true iff no finding has severity `:reject` |
120
+ | `findings` | every criterion that fired, in check order |
121
+ | `rejects`, `marks` | the two severity partitions; their union is `findings` |
122
+ | `tier` | highest MOE chart index reached: 0 常用, 1 次常用, 2 罕用, `nil` if unlisted |
123
+ | `han` | Han character count |
124
+ | `evidence` | count of positive Taiwan signals |
125
+ | `reasons` | `rejects` rendered as `"code: detail"` strings |
126
+ | `to_h` | plain data, JSON-serializable |
127
+
128
+ A `Finding` is `{check, code, severity, detail}`. Codes are stable across releases;
129
+ severity depends on the policy. Nothing is ever discarded silently — `keep?` is a
130
+ convenience over `examine`, not a separate path.
131
+
132
+ ## Policies
133
+
134
+ Immutable value objects. Derive with `#with`.
135
+
136
+ | | `corpus` | `publishable` | `permissive` |
137
+ | --- | --- | --- | --- |
138
+ | han count | 6–60 | 6–40 | 1–∞ |
139
+ | han ratio | ≥ 0.65 | ≥ 0.70 | 0 |
140
+ | character tier | ≤ 罕用 | ≤ 常用 4 808 | ≤ 罕用 |
141
+ | soft lexical markers | mark | reject | mark |
142
+ | foreign topics | mark | reject | mark |
143
+ | punctuation inventory | wide | MOE strict | wide |
144
+ | converted orthography | reject | reject | reject |
145
+
146
+ ```ruby
147
+ TWFilter::Policy.corpus.with(max_tier: :secondary, han_range: (4..80))
148
+ ```
149
+
150
+ ## Usage
151
+
152
+ ```ruby
153
+ require "twfilter"
154
+
155
+ # Provenance, with reasons
156
+ report = TWFilter.examine("请把这个信息转发给同事")
157
+ report.ok? # => false
158
+ report.reasons # => ["simplified: 请这个转", "unlisted: 请这个转", "mainland: 信息"]
159
+ report.findings.map(&:code) # => [:simplified, :unlisted, :mainland]
160
+
161
+ # Editorial review rather than rejection
162
+ report = TWFilter.examine("這個程式的運行狀況非常穩定")
163
+ report.ok? # => true
164
+ report.marks.map(&:detail) # => ["運行"]
165
+ TWFilter::Checks::Lexicon.taiwan_form("運行") # => "執行"
166
+
167
+ # Pedagogical gate
168
+ TWFilter.keep?("他每天都會去公園散步運動。", policy: TWFilter::Policy.publishable) # => true
169
+ TWFilter.keep?("饕餮紋飾在青銅器上十分常見。", policy: TWFilter::Policy.publishable) # => false
170
+
171
+ # Punctuation to the Taiwan standard
172
+ TWFilter.normalize("他說“好”...") # => "他說「好」……"
173
+ TWFilter.normalize("馬丁·路德PDF") # => "馬丁‧路德PDF"
174
+
175
+ # Segmentation: 。!?…; terminate, closers stay attached, 、 does not break
176
+ TWFilter.sentences("他說:「好。」我走了。") # => ["他說:「好。」", "我走了。"]
177
+
178
+ # Character difficulty
179
+ TWFilter.tier("散步") # => 0
180
+ TWFilter.tier("饕餮") # => 1
181
+
182
+ # Block judgement — a paragraph is accepted as a unit or not at all
183
+ TWFilter::Block.judge(paragraph_sentences)
184
+
185
+ # Table fingerprint, to record beside derived measurements
186
+ TWFilter.provenance
187
+ # => {version: "1.0.0", dir: "…/lib/twfilter/data", tables: 19, digest: "14cd9fd7fc97dc9a"}
188
+ ```
189
+
190
+ ## Placement in a pipeline
191
+
192
+ Normalize before segmenting: the ellipsis and dash rules change sentence boundaries.
193
+ Segment before examining: the shape thresholds are defined on sentences. Examine before
194
+ deduplicating and counting, so rejected material never reaches the statistics.
195
+
196
+ ```text
197
+ acquire → normalize → segment → examine → deduplicate → tokenize → count
198
+ │ │ │
199
+ Punctuation Sentences Report
200
+ ```
201
+
202
+ `Block.judge` is an optional stage between segmentation and examination for sources whose
203
+ documents are topically coherent: it rejects a whole window when any member fails, and
204
+ requires a minimum density of positive evidence across the window. Use it on crawls, not
205
+ on dictionaries.
206
+
207
+ ## Performance
208
+
209
+ Single core, Apple M5 Max, on 199 936 sentences of Taiwanese web text (6.2 M characters):
210
+
211
+ | operation | throughput | per 10⁷ sentences |
212
+ | --- | ---: | ---: |
213
+ | `Sentences.split` | 741 000 /s · 68 MB/s | 13 s |
214
+ | `Punctuation.normalize` | 67 000 /s · 6.1 MB/s | 149 s |
215
+ | `tier` | 134 000 /s · 12 MB/s | 75 s |
216
+ | `examine` (all five checks) | **10 700 /s · 1.0 MB/s** | 934 s |
217
+
218
+ Suitable for corpora of arbitrary size. Cost is linear in input length and dominated by
219
+ `Checks::Lexicon`, which scans 264 union-compiled patterns per span; the other four checks
220
+ together account for under a third of the total. Memory is constant — tables load once
221
+ (≈ 3 MB resident) and every entry point is a pure function over a string, so the work
222
+ parallelizes by `fork` or by thread without coordination.
223
+
224
+ A 37.7 M-sentence corpus takes ≈ 3.5 minutes of wall time across 18 cores. In practice
225
+ I/O, JSON parsing and deduplication dominate; this library is not usually the bottleneck.
226
+
227
+ ## Reference tables
228
+
229
+ `lib/twfilter/data/` — plain text, one record per line, tab-separated where a record has
230
+ fields. Diffable, reviewable and versioned with the gem.
231
+
232
+ | table | rows | content |
233
+ | --- | ---: | --- |
234
+ | `moe_common/secondary/rare.txt` | 4 808 / 6 343 / 18 356 | 教育部標準字體表 |
235
+ | `moe_exception.txt` | 12 | dictionary headword characters outside all three charts |
236
+ | `simplified_only.txt` | 3 785 | derived from the OpenCC round-trip |
237
+ | `variants_used_in_taiwan.txt` | 16 | simplified-looking forms standard in Taiwan |
238
+ | `converted_orthography.txt` | 14 | mainland traditional variants |
239
+ | `mainland_hard.tsv` | 149 | rejecting pairs, with attestation counts |
240
+ | `mainland_soft.tsv` | 38 | marking pairs, ambiguous or dominated |
241
+ | `mainland_exceptions.tsv` | 6 | superstrings in which a marker is legitimate |
242
+ | `cantonese.txt` | 17 | written-Cantonese characters outside the 常用 chart |
243
+ | `regional_hard.tsv` | 19 | Hong Kong, Cantonese-sequence and Singapore forms |
244
+ | `foreign_topics.txt` | 80 | non-Taiwanese toponyms and institutions — subject matter, not provenance |
245
+ | `taiwan_markers/lexicon/particles/grammar.txt` | 66 / 51 / 7 / 20 | positive evidence |
246
+ | `erhua_headed/tailed.txt` | 14 / 36 | 兒 as a root morpheme |
247
+ | `wenyan.txt` | 4 | literary particles for density estimation |
248
+ | `MANIFEST.json` | — | row count and SHA-256 per table |
249
+
250
+ **Grading.** Candidate pairs are graded by attestation ratio against a reference corpus of
251
+ ten natively Taiwanese sources, 11 923 110 characters: *hard* when the mainland form is
252
+ unattested and the Taiwan form occurs at least three times; *soft* when the Taiwan form
253
+ dominates by 20 : 1 or the pair is hand-graded ambiguous; excluded otherwise. Most candidates
254
+ are excluded — conversion tables built for orthographic mapping contain many pairs where both
255
+ forms are ordinary Taiwanese.
256
+
257
+ The reference corpus admits no source with an open authorship gate. Open web comments on
258
+ Taiwanese channels are Taiwan-hosted but not Taiwan-authored: every mainland form attested
259
+ anywhere in the corpus occurs there and only there, so counting them would license the
260
+ mainland vocabulary of mainland commenters as Taiwanese.
261
+
262
+ **Membership.** Each table states the rule that admits a row, and `spec/twfilter/membership_spec.rb`
263
+ enforces it against the shipped data:
264
+
265
+ | table | a row is admitted iff |
266
+ | --- | --- |
267
+ | `cantonese.txt` | the character is used in written Cantonese and is not in 常用國字標準字體表 |
268
+ | `regional_hard.tsv` | the form is unattested in Taiwan and its Taiwan counterpart occurs ≥ 3 times |
269
+ | `converted_orthography.txt` | the form is in no MOE chart and the Taiwan form outnumbers it ≥ 20 : 1 |
270
+ | `variants_used_in_taiwan.txt` | the form looks simplified but is in an MOE chart |
271
+ | `mainland_hard.tsv` | the mainland form is unattested and the Taiwan form occurs ≥ 3 times |
272
+ | `mainland_soft.tsv` | the pair is a real opposition but the mainland form is attested here |
273
+ | `mainland_exceptions.tsv` | the head term is a detector in one of the two bands |
274
+ | `taiwan_lexicon.txt` | a mainland writer would not use this string for this referent |
275
+ | `taiwan_markers.txt` | the name denotes an ROC institution or a Taiwanese place |
276
+ | `taiwan_particles.txt` | the graph is the Taiwan-preferred spelling of the particle |
277
+ | `taiwan_grammar.txt` | the construction is a Southern Min substrate feature |
278
+
279
+ The rules exclude more than they admit. Subject matter is not provenance, so Taiwanese food
280
+ names are not evidence; A-not-A interrogatives are pan-Mandarin, so `好不好` is not evidence;
281
+ and a character in everyday Taiwanese use cannot mark another variety, however
282
+ characteristic of it that character may be.
283
+
284
+ **Topics are not provenance.** `foreign_topics.txt` is a subject-matter tag. Taiwanese media
285
+ covers the mainland constantly, several entries are ordinary ROC administrative vocabulary,
286
+ and Taipei streets are named after mainland cities. It marks under `corpus` and rejects only
287
+ under `publishable`, where excluding foreign subject matter is an editorial decision.
288
+
289
+ ## Influencing the criteria
290
+
291
+ Preferred: contribute to the published dataset. The tables are maintained at
292
+ [huggingface.co/datasets/taiwan-corpora/twfilter-tables](https://huggingface.co/datasets/taiwan-corpora/twfilter-tables),
293
+ where each release carries its grading evidence, and the gem vendors a copy of a specific
294
+ version. A correction accepted there reaches every consumer at the next release, with a
295
+ record of what changed and why — which a local edit does not.
296
+
297
+ Three table sources are supported. All three are reported by `provenance`.
298
+
299
+ **Bundled** — the default, versioned with the gem, the only configuration whose results are
300
+ comparable with anyone else's.
301
+
302
+ **Replacement** — a directory holding the full set, e.g. a checkout of the dataset newer
303
+ than the gem:
304
+
305
+ ```ruby
306
+ TWFilter::Tables.dir = "path/to/twfilter-tables" # or TWFILTER_DATA_DIR
307
+ ```
308
+
309
+ **Overlay** — the bundled set plus your additions. Filename suffixes select the operation:
310
+
311
+ | file in the overlay directory | effect on `mainland_hard.tsv` |
312
+ | --- | --- |
313
+ | `mainland_hard.add.tsv` | rows appended |
314
+ | `mainland_hard.remove.tsv` | rows removed, matched on the full row or its first field |
315
+ | `mainland_hard.tsv` | table replaced |
316
+
317
+ ```ruby
318
+ TWFilter::Tables.overlay = "path/to/house-style" # or TWFILTER_OVERLAY_DIR
319
+ ```
320
+
321
+ Row format is validated on load and a violation raises `TWFilter::InvalidTableError`:
322
+
323
+ | shape | tables | row |
324
+ | --- | --- | --- |
325
+ | `:chars` | the MOE charts, `simplified_only`, `variants_used_in_taiwan`, `converted_orthography`, `cantonese`, `wenyan` | one Han character |
326
+ | `:pairs` | `mainland_hard`, `mainland_soft`, `mainland_exceptions` | two or more tab-separated fields, first non-empty |
327
+ | `:terms` | everything else, including tables you invent | one term, no tabs |
328
+
329
+ Files named `*.remove.*` carry keys only and are not shape-checked. UTF-8, one record per
330
+ line, no header, no comments; blank lines ignored.
331
+
332
+ Record `provenance` alongside any measurement derived this way. Two corpora filtered with
333
+ different tables are not comparable, and the digest is what makes that detectable.
334
+
335
+ Policy thresholds are the supported knob and need no table changes:
336
+
337
+ ```ruby
338
+ lenient = TWFilter::Policy.corpus.with(soft_lexicon_rejects: false, foreign_topics_reject: false)
339
+ ```
340
+
341
+ ## Development
342
+
343
+ ```bash
344
+ bundle install
345
+ bundle exec rake # specs and RBS validation
346
+ bundle exec rake tables # regenerate the tables and their manifest
347
+ ```
348
+
349
+ Specs require no data, no network and no credentials.
350
+
351
+ ## License
352
+
353
+ Code: MIT. Tables: mixed, all permitting commercial use. See [NOTICES.md](NOTICES.md) for
354
+ per-table provenance and terms.
355
+
356
+ Two attribution strings must be reproduced by anything shipping this gem:
357
+
358
+ ```text
359
+ 數位發展部,CNS11643中文標準交換碼全字庫網站
360
+ OpenCC — https://github.com/BYVoid/OpenCC, Apache License 2.0
361
+ ```
362
+
363
+ No table carries a NonCommercial, ShareAlike or copyleft term. Verdicts and counts produced
364
+ by the gem are the caller's, not derivatives of the tables.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWFilter
4
+ module Block
5
+ # Sentences per judged window, accepted or rejected as a unit. 300 is large enough for
6
+ # the evidence rate to be stable and small enough to stay within one document.
7
+ SIZE = 300
8
+
9
+ module_function
10
+
11
+ def judge(window, policy: Policy.corpus)
12
+ return [] if window.empty?
13
+
14
+ reports = window.map { |line| TWFilter.examine(line, policy: policy) }
15
+ return [] if reports.count { |report| !report.ok? } > policy.block_tolerance * window.length
16
+ return [] if Evidence.count(window.join) < policy.evidence_per_100 * window.length / 100
17
+
18
+ reports.filter_map { |report| report.text if report.ok? }
19
+ end
20
+
21
+ def each_kept(lines, size: SIZE, policy: Policy.corpus)
22
+ return to_enum(:each_kept, lines, size: size, policy: policy) unless block_given?
23
+
24
+ window = []
25
+ lines.each do |line|
26
+ window << line
27
+ next if window.length < size
28
+
29
+ judge(window, policy: policy).each { |kept| yield kept }
30
+ window = []
31
+ end
32
+
33
+ judge(window, policy: policy).each { |kept| yield kept } if window.any?
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWFilter
4
+ module Checks
5
+ module Erhua
6
+ NAME = :erhua
7
+ # 兒 is a root morpheme inside a headed or tailed window, and an erhua suffix elsewhere.
8
+ MARK = "兒"
9
+
10
+ class << self
11
+ def call(subject)
12
+ offender = offender(subject.text)
13
+ return [] if offender.nil?
14
+
15
+ [Finding.new(check: NAME, code: :erhua, detail: offender)]
16
+ end
17
+
18
+ def offender(text)
19
+ return nil if text == MARK
20
+
21
+ position = 0
22
+ while (position = text.index(MARK, position))
23
+ after = text[position, 2]
24
+ before = position.positive? ? text[position - 1, 2] : nil
25
+
26
+ return before || MARK unless headed.include?(after) || tailed.include?(before)
27
+
28
+ position += 1
29
+ end
30
+
31
+ nil
32
+ end
33
+
34
+ def headed = Tables.set("erhua_headed.txt")
35
+
36
+ def tailed = Tables.set("erhua_tailed.txt")
37
+ end
38
+ end
39
+ end
40
+ end