twpipeline
Corpus construction for Taiwan Mandarin. Nine stages, one JSONL record contract,
stdin → stdout. Every stage is independently runnable and independently
verifiable. No record is ever discarded silently: rejection sets ok: false and
appends a typed finding; --drop is what removes it.
Linguistic decisions live in twfilter; this repository is the execution layer — sources, parallelism, external sorting, measurement.
For corpus linguists and dataset engineers who need Taiwan Mandarin
(cmn-Hant-TW) material with verifiable provenance. In: a web crawl or any
JSONL of documents. Out: filtered sentence types, word segmentation, n-gram and
frequency tables with per-host counting and a publication floor — the process
behind twngrams.
Single machine, no cluster: the full HPLT Taiwan slice (2.0 M documents)
builds in under two hours on a laptop.
Pipeline
flowchart TD
subgraph acquire["acquisition"]
S1["<b>01_ingest</b> — source adapter<br/>HPLT gate: <code>.tw</code> ∪ <code>html_lang ∈ zh-TW</code> ∪ allowlist<br/>hard exclusion: <code>.cn .hk .mo .sg .my</code><br/><code>--host-cap N</code> bounds per-host concentration"]
end
subgraph shape["orthographic normalization — mandatory for publishable text"]
S2["<b>02_normalize</b> — MOE 《重訂標點符號手冊》<br/>“ ” → 「 」 · → ‧ … → …… — → ──<br/>fullwidth Latin → ASCII, invisibles stripped"]
S3["<b>03_segment</b> — sentence boundaries<br/>terminators <code>[。!?…;]+[」』)》〉]*</code>, <code>、</code> is not a boundary<br/>shape: han ∈ [6, 60], han ratio ≥ 0.65"]
end
subgraph origin["origin filter — mandatory"]
S4["<b>04_script</b> — character inventory<br/>MOE 常用 4 808 / 次常用 6 343 / 罕用 18 356 + 12 exceptions<br/>OpenCC STCharacters ∖ TSCharacters round-trip<br/>converted orthography 裏着衞爲説麽…"]
S5["<b>05_lexicon</b> — lexical origin<br/>149 hard + 38 soft mainland pairs, corpus-verified<br/>written Cantonese, Hong Kong and Singapore forms, foreign-topic tag, erhua two-window rule<br/>literary-Chinese density > 0.05 with ≥ 2 hits and ≥ 8 han"]
end
subgraph corpus["corpus hygiene — optional, source-dependent"]
S6["<b>06_evidence</b> — block judgment<br/>window 300 sentences; reject block if any member fails<br/>require ≥ 1.0 Taiwan evidence per 100 sentences"]
S7["<b>07_dedup</b> — exact type dedup<br/>SHA-256 → 62-bit fingerprint<br/>flags types occurring on ≥ 5 distinct hosts as boilerplate"]
end
subgraph measure["measurement"]
S8["<b>08_tokenize</b> — Viterbi over a unigram cost model<br/>EM re-estimation, max word length 8"]
S9["<b>09_count</b> — n-gram counting by external sort<br/><code>sort -S … --parallel …</code> ∘ <code>uniq -c</code><br/>frequency floor for publication (Google Ngram rule)"]
end
S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7 --> S8 --> S9
S9 --> OUT["frequency, dispersion (DP),<br/>collocations (G², logDice)"]
classDef must fill:#0b3d2e,stroke:#0b3d2e,color:#fff
classDef may fill:#3d3a0b,stroke:#3d3a0b,color:#fff
class S2,S3,S4,S5 must
class S6,S7 may
Green stages are mandatory for any Taiwan Mandarin corpus. Amber stages depend on the source: block judgment assumes documents are topically coherent, type deduplication assumes the source is web-scale.
Record contract
One JSON object per line. Stages add fields; they never remove them.
| field | stage | meaning |
|---|---|---|
id, source |
01 | stable identity |
url, host, gate |
01 | provenance and the gate that admitted it |
register, doc_score |
01 | HPLT web-register argmax, mean segment score |
text |
01–02 | document text, then punctuation-normalized |
doc, index, han |
03 | parent document, ordinal, han count |
tier |
04 | 0 常用, 1 次常用, 2 罕用 |
evidence |
06 | Taiwan evidence count over the block |
tokens |
08 | word segmentation |
ok, findings[] |
all | verdict and every reason for it |
A finding is {check, code, severity, detail} with severity ∈ {reject, mark}.
Install
Ruby ≥ 3.4. The only runtime dependency is twfilter, pulled from RubyGems:
bundle install
bin/twpipeline stages
External sorting uses gsort (GNU coreutils) when installed and the system
sort otherwise; both understand -S and --parallel. Override with
TWP_SORT.
Usage
twpipeline run --source hplt --input cmn_Hant/7_1.jsonl.zst --cores 16 --memory 8G
cat raw.jsonl | twpipeline 04_script | twpipeline 05_lexicon > checked.jsonl
echo '這個政策有沒有經過評估?' | twpipeline check
twpipeline run --from 03_segment --to 07_dedup --drop --policy publishable
twpipeline resources
twpipeline stages
--policy corpus (default) keeps everything Taiwan-attested. --policy publishable restricts to MOE 常用 4 808, rejects soft mainland markers, and
enforces the strict MOE punctuation inventory — the boundary of what may reach a
commercial learner product.
Resources
Cores and memory are read from --cores / --memory, then TWP_CORES /
TWP_MEMORY, then autodetection (all cores, half of physical memory).
--ask prompts interactively.
Streaming stages fork cores workers per chunk; chunk size is bounded by bytes
(memory / 32, capped at 1 GiB, overridable with TWP_CHUNK_BYTES), not by
line count, so a source of 12 KiB documents and a source of 40-byte sentences
both stay within the same envelope. Counting never holds a hash table: keys go
through sort -S <memory> --parallel <cores>, so peak resident size is set by
the flag, not by the corpus.
Every stage writes one line to work/benchmarks.jsonl — wall time, CPU seconds,
mean cores used, peak RSS, and the stage's own counters. The work directory
defaults to ../work beside the checkout; override with TWP_WORK. See
BENCHMARKS.md.
Findings
Measured on HPLT 3.0 cmn_Hant, quality bins 7–10 (4 116 754 documents). The
admission gate kept 2 001 161 documents: 541 467 by .tw host, 1 459 694 by
html_lang. All of them were carried through stages 02–07 in 28 minutes
(twfilter 0.1.0, the published tables):
| stage | in | out | lost |
|---|---|---|---|
| 03_segment | 2 001 161 docs | 37 772 352 sentences | — |
| 04_script | 37 772 352 | 37 122 017 | 1.72 % |
| 05_lexicon | 37 122 017 | 36 455 533 | 1.80 % |
| 06_evidence | 36 455 533 | 29 089 952 | 20.20 % |
| 07_dedup | 29 089 952 | 12 339 761 types | 57.58 % |
Three results worth stating plainly.
Deduplication dominates every linguistic filter. More than half of all segmented sentences are exact repeats of another sentence — an order of magnitude more than the three origin filters remove between them. HPLT deduplicates documents, not sentences, and the Taiwanese long tail is largely search-optimized loan and hotel templates. Without stage 07 an n-gram table over this material measures template frequency, not language.
A stratified 279 198-document sample drawn from the head of each bin file put duplication at 71.0 % and evidence loss at 5.6 %; the true figures are 57.6 % and 20.2 %. Documents cluster by host inside a bin file, so a head sample overstates local repetition and understates topical variety. Sample to find the shape of a problem; measure on everything before publishing a number.
The two gates differ, but less than a head sample suggests. Unique Taiwan-attested types produced per admitted document:
| gate | documents | unique types | types per document |
|---|---|---|---|
.tw host |
541 467 | 4 230 061 | 7.81 |
html_lang on a generic TLD |
1 459 694 | 8 109 700 | 5.56 |
A 1.41× gradient, not the 2.4× the sample showed. The html_lang gate supplies
65.7 % of the final corpus at about two thirds of the per-document yield — worth keeping,
not worth trusting blindly. The gate field is preserved on every record, so
restricting to .tw afterwards costs nothing and remains available as a
higher-precision subset.
Boilerplate is sub-sentential, so no amount of sentence deduplication removes
it. The phrase 提供相關細節的諮詢服務 occurs 263 210 times in the deduplicated
corpus, inside 263 210 distinct sentences, across 222 900 documents — but only
1 948 hosts. Sentence dedup cannot see it (every string differs), per-document
counting cannot see it (every document differs), and --host-cap cannot see it
(the network spans 11 691 throwaway domains, none of them large). 貸款 comes out
as the 7th most frequent word in Taiwan Mandarin, which is false.
The fix is to change what a count means. --once-per host counts each n-gram
once per host instead of once per occurrence, so a frequency list measures how
many independent sites use a phrase rather than how many times a template was
published. On the phrase above that is a 135-fold correction. For web-derived
frequency this is the correct semantics regardless of spam: a generated page is
not an independent act of authorship.
Occurrence counting remains the default, because for an edited corpus — statutes, news, dictionaries — repetition is evidence rather than noise.
Measured effect on the full corpus. 貸款 falls from rank 7 to rank 1 299; 借錢, 包養, 二胎 and 信貸 drop below the 40-host floor and disappear entirely. The 1-gram list becomes 的 是 有 一 在 了 我 會 也 要 不 個, the 4-gram list 越來越多, 這是一個, 最重要的是, 更重要的是. In the run where both semantics were built, published rows fell by 7 % at order 1 and 28 % at order 4.
Known residual. Eleven of the top hundred 4-grams are fragments of one
string: 讓人們盡情分享,將這個世界變得更開闊,聯繫更緊密 — the Chinese Facebook
tagline, carried by the social-plugin embed on roughly 3 900 hosts, about once
per host, so per-host counting cannot suppress it: those hosts really are
independent. This is third-party embedded interface text, a
different problem from spam, and it needs either HTML-level boilerplate stripping
upstream or an explicit stoplist. It contaminates 0.03 % of sentences but a
visible share of the 4-gram head.
The surviving corpus spans 149 022 hosts: top 10 hold 8.8 % of sentences, top 100
20.8 %, top 1000 46.3 %, top 10 000 82.8 %. Diffuse enough to be a corpus rather
than a scrape of a few sites, but the single largest host is chinatimes.com at
411 005 sentences — 3.3 % of everything, one newspaper. Concentration of one
rightsholder's expression, not total size, is what has drawn takedowns against
comparable datasets; cap per host before publishing anything that carries text.
Layout
lib/twpipeline/
resources.rb cores and memory resolution
parallel.rb fork-based map, byte-bounded streaming
sorting.rb external sort, tally with a frequency floor
jsonl.rb record contract
bench.rb measurement, appended to work/benchmarks.jsonl
rusage.rb getrusage(2) via Fiddle
segmenter.rb Viterbi, EM re-estimation
frequency.rb deviation of proportions
stage.rb Stage, MapStage, registry
stages/ the nine stages
sources/ source adapters
tools/
regression.rb twfilter against every reference corpus
Tests
bundle exec rake
RSpec suite plus RBS signature validation. Fixtures carry no corpus material; the suite needs no data and no credentials.