twpipeline 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.
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module TWPipeline
6
+ module Sources
7
+ class HPLT
8
+ EXCLUDED_TLDS = %w[cn hk mo sg my].to_set.freeze
9
+ TAIWAN_TLD = "tw"
10
+ TAIWAN_LANG = /\Azh-(?:TW|Hant-TW)\b/i
11
+ URL_HEAD = 4096
12
+ URL = /"u":"([^"]*)"/n
13
+ HTML_LANG = /"html_lang":\[([^\]]*)\]/n
14
+ REGISTERS = %w[MT LY SP ID NA HI IN OP IP].freeze
15
+
16
+ Decision = Data.define(:keep, :gate, :host)
17
+
18
+ def initialize(allowlist: Set.new)
19
+ @allowlist = allowlist
20
+ end
21
+
22
+ def each_record(io)
23
+ return to_enum(:each_record, io) unless block_given?
24
+
25
+ io.each_line do |line|
26
+ decision = decide(line)
27
+ next if decision.gate == :excluded
28
+
29
+ yield build(line, decision) if decision.keep || decision.gate == :residue
30
+ end
31
+ end
32
+
33
+ def decide(line)
34
+ host = host_of(line)
35
+ return Decision.new(keep: false, gate: :excluded, host: host) if host.nil?
36
+
37
+ labels = host.split(".")
38
+ tld = labels.last
39
+ return Decision.new(keep: false, gate: :excluded, host: host) if EXCLUDED_TLDS.include?(tld)
40
+ return Decision.new(keep: true, gate: :tld, host: host) if tld == TAIWAN_TLD
41
+ return Decision.new(keep: true, gate: :allowlist, host: host) if @allowlist.include?(host)
42
+ return Decision.new(keep: true, gate: :html_lang, host: host) if taiwan_lang?(line)
43
+
44
+ Decision.new(keep: false, gate: :residue, host: host)
45
+ end
46
+
47
+ def host_of(line)
48
+ url = probe(line, URL_HEAD)[URL, 1] || line[URL, 1]
49
+ return nil if url.nil?
50
+
51
+ URI.parse(url).host&.downcase&.delete_prefix("www.")
52
+ rescue URI::InvalidURIError
53
+ url[%r{\Ahttps?://([^/:?#]+)}i, 1]&.downcase&.delete_prefix("www.")
54
+ end
55
+
56
+ def probe(line, size) = line.byteslice(0, size).force_encoding(Encoding::BINARY)
57
+
58
+ def taiwan_lang?(line)
59
+ captured = line[HTML_LANG, 1]
60
+ return false if captured.nil?
61
+
62
+ captured.scan(/"([^"]*)"/).flatten.any? { |tag| tag.match?(TAIWAN_LANG) }
63
+ end
64
+
65
+ def build(line, decision)
66
+ document = Jsonl.parse(line.dup.force_encoding(Encoding::UTF_8).scrub)
67
+ {
68
+ id: document[:id],
69
+ source: "hplt",
70
+ url: document[:u],
71
+ host: decision.host,
72
+ gate: decision.gate.to_s,
73
+ keep: decision.keep,
74
+ crawl: document[:crawl_id],
75
+ html_lang: document[:html_lang],
76
+ lang: Array(document[:lang]).first,
77
+ lang_prob: Array(document[:prob]).first,
78
+ doc_score: score(document),
79
+ register: register(document),
80
+ text: document[:text].to_s,
81
+ ok: true,
82
+ findings: []
83
+ }
84
+ end
85
+
86
+ def score(document)
87
+ scores = Array(document[:doc_scores]).grep(Numeric)
88
+ scores.empty? ? nil : (scores.sum / scores.length.to_f).round(2)
89
+ end
90
+
91
+ def register(document)
92
+ labels = document[:"web-register"]
93
+ return nil unless labels.is_a?(Hash)
94
+
95
+ labels.max_by { |_, value| value.to_f }&.first&.to_s
96
+ end
97
+
98
+ class << self
99
+ def open(path, &block)
100
+ command = path.to_s.end_with?(".zst") ? ["zstd", "-dc", "-T0", path.to_s] : ["cat", path.to_s]
101
+ IO.popen(command, "rb") { |io| block.call(io) }
102
+ end
103
+
104
+ def allowlist(path)
105
+ return Set.new if path.nil? || !Pathname(path).exist?
106
+
107
+ Pathname(path).each_line.map { |line| line.strip.downcase }.reject(&:empty?).to_set
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ class Stage
5
+ REGISTRY = {}
6
+
7
+ class << self
8
+ def register(slug, order)
9
+ @slug = slug
10
+ @order = order
11
+ REGISTRY[slug] = self
12
+ end
13
+
14
+ def slug = @slug
15
+
16
+ def order = @order
17
+
18
+ def ordered = REGISTRY.values.sort_by(&:order)
19
+
20
+ def lookup(name)
21
+ REGISTRY[name] ||
22
+ REGISTRY.values.find { |stage| stage.slug.split("_", 2).last == name } ||
23
+ raise(Error, "unknown stage: #{name} (known: #{REGISTRY.keys.join(", ")})")
24
+ end
25
+ end
26
+
27
+ def initialize(policy: TWFilter::Policy.corpus, drop: false, resources: TWPipeline.resources, **options)
28
+ @policy = policy
29
+ @drop = drop
30
+ @resources = resources
31
+ @options = options
32
+ end
33
+
34
+ attr_reader :policy, :resources, :options
35
+
36
+ def drop? = @drop
37
+
38
+ def call(input, output) = raise NotImplementedError
39
+
40
+ private
41
+
42
+ def reject(record, findings)
43
+ record.merge(ok: false, findings: record.fetch(:findings, []) + findings.map(&:to_h))
44
+ end
45
+
46
+ def flag(record, findings)
47
+ return record if findings.empty?
48
+
49
+ merged = record.fetch(:findings, []) + findings.map(&:to_h)
50
+ record.merge(ok: record.fetch(:ok, true) && findings.none?(&:reject?), findings: merged)
51
+ end
52
+ end
53
+
54
+ class MapStage < Stage
55
+ def call(input, output)
56
+ Parallel.pipe(input, output, workers: resources.cores) { |line| process(line) }
57
+ end
58
+
59
+ def process(line)
60
+ stripped = line.strip
61
+ return nil if stripped.empty?
62
+
63
+ record = transform(Jsonl.parse(stripped))
64
+ return nil if record.nil? || (drop? && record[:ok] == false)
65
+
66
+ Jsonl.dump(record)
67
+ end
68
+
69
+ def transform(record) = raise NotImplementedError
70
+ end
71
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ module Stages
5
+ class Count < Stage
6
+ register("09_count", 9)
7
+
8
+ DEFAULT_ORDERS = [1, 2, 3, 4].freeze
9
+
10
+ def call(input, output)
11
+ sinks = {}
12
+ pipelines = orders.to_h { |order| [order, sink(order, sinks)] }
13
+ records = 0
14
+
15
+ Jsonl.each(input) do |record|
16
+ records += 1
17
+ units = units_of(record)
18
+ part = part_of(record)
19
+ orders.each do |order|
20
+ emit(units, order) { |gram| pipelines.fetch(order).call(part ? "#{gram}\t#{part}" : gram) }
21
+ end
22
+ end
23
+
24
+ results = close(sinks)
25
+ output.puts(Jsonl.dump({records: records, orders: results}))
26
+ {records: records, orders: results}
27
+ end
28
+
29
+ private
30
+
31
+ def orders = @orders ||= Array(options.fetch(:orders, DEFAULT_ORDERS)).map(&:to_i).sort
32
+
33
+ def floor = options.fetch(:floor, 1).to_i
34
+
35
+ def unit = options.fetch(:unit, "token")
36
+
37
+ def once_per = options[:once_per]&.to_s
38
+
39
+ def part_of(record)
40
+ return options[:parts]&.then { |field| record[field.to_sym] } if once_per.nil?
41
+
42
+ record[once_per.to_sym] || record[:doc] || record[:id]
43
+ end
44
+
45
+ def directory = Pathname(options.fetch(:out, TWPipeline.work.join("counts"))).tap(&:mkpath)
46
+
47
+ def units_of(record)
48
+ case unit
49
+ in "token"
50
+ Array(record[:tokens])
51
+ in "char"
52
+ record.fetch(:text, "").chars
53
+ in other
54
+ raise Error, "unknown unit: #{other}"
55
+ end
56
+ end
57
+
58
+ def emit(units, order)
59
+ return if units.length < order
60
+
61
+ joiner = unit == "token" ? " " : ""
62
+ units.each_cons(order) { |gram| yield gram.join(joiner) }
63
+ end
64
+
65
+ def sink(order, sinks)
66
+ path = directory.join("#{unit}_#{order}gram.tsv")
67
+ holder = {path: path, thread: nil, writer: nil}
68
+ reader, writer = IO.pipe
69
+ holder[:writer] = writer
70
+ holder[:thread] = Thread.new do
71
+ Sorting.tally(path, floor: floor, resources: resources, collapse: !once_per.nil?) do |push|
72
+ reader.each_line { |line| push.call(line.chomp) }
73
+ end
74
+ end
75
+ sinks[order] = holder
76
+
77
+ -> (gram) { writer.puts(gram) }
78
+ end
79
+
80
+ def close(sinks)
81
+ sinks.transform_values do |holder|
82
+ holder.fetch(:writer).close
83
+ result = holder.fetch(:thread).value
84
+ {path: holder.fetch(:path).to_s}.merge(result)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module TWPipeline
6
+ module Stages
7
+ class Dedup < Stage
8
+ register("07_dedup", 7)
9
+
10
+ MASK = (1 << 62) - 1
11
+
12
+ def call(input, output)
13
+ seen = Set.new
14
+ hosts = Hash.new { |memo, key| memo[key] = Set.new }
15
+ read = 0
16
+ written = 0
17
+ repeats = 0
18
+
19
+ Jsonl.each(input) do |record|
20
+ read += 1
21
+ key = fingerprint(record.fetch(:text, ""))
22
+ hosts[key] << record[:host] if record[:host] && hosts[key].size < ubiquity
23
+
24
+ if seen.add?(key)
25
+ written += 1
26
+ output.puts(Jsonl.dump(record))
27
+ else
28
+ repeats += 1
29
+ output.puts(Jsonl.dump(reject(record, [duplicate]))) unless drop?
30
+ end
31
+ end
32
+
33
+ boilerplate = hosts.count { |_, group| group.size >= ubiquity }
34
+ {read: read, written: written, duplicates: repeats, types: seen.size, boilerplate_types: boilerplate}
35
+ end
36
+
37
+ private
38
+
39
+ def ubiquity = options.fetch(:ubiquity, 5).to_i
40
+
41
+ def duplicate = TWFilter::Finding.new(check: :dedup, code: :duplicate)
42
+
43
+ def fingerprint(text) = Digest::SHA256.digest(text).unpack1("Q>") & MASK
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ module Stages
5
+ class Evidence < Stage
6
+ register("06_evidence", 6)
7
+
8
+ BATCH = 20_000
9
+
10
+ def call(input, output)
11
+ blocks = 0
12
+ kept = 0
13
+ dropped = 0
14
+ batch = []
15
+
16
+ flush = lambda do
17
+ Parallel.map(batch, workers: resources.cores) { |records| judge(records) }.each do |verdict|
18
+ blocks += 1
19
+ verdict.each do |record|
20
+ record[:ok] == false ? dropped += 1 : kept += 1
21
+ output.puts(Jsonl.dump(record)) unless drop? && record[:ok] == false
22
+ end
23
+ end
24
+
25
+ batch = []
26
+ end
27
+
28
+ each_block(input) do |records|
29
+ batch << records
30
+ flush.call if batch.length >= BATCH
31
+ end
32
+
33
+ flush.call if batch.any?
34
+
35
+ {blocks: blocks, kept: kept, dropped: dropped}
36
+ end
37
+
38
+ private
39
+
40
+ def key(record) = options.fetch(:group, "doc").then { |field| record[field.to_sym] }
41
+
42
+ def size = options.fetch(:block, TWFilter::Block::SIZE).to_i
43
+
44
+ def each_block(input)
45
+ current = nil
46
+ buffer = []
47
+
48
+ Jsonl.each(input) do |record|
49
+ identity = key(record)
50
+ if (identity != current && !buffer.empty?) || buffer.length >= size
51
+ yield buffer
52
+ buffer = []
53
+ end
54
+
55
+ current = identity
56
+ buffer << record
57
+ end
58
+
59
+ yield buffer unless buffer.empty?
60
+ end
61
+
62
+ def judge(records)
63
+ rejected = records.count { |record| record[:ok] == false }
64
+ evidence = TWFilter::Evidence.count(records.map { |record| record.fetch(:text, "") }.join)
65
+ required = policy.evidence_per_100 * records.length / 100.0
66
+ contaminated = rejected > policy.block_tolerance * records.length
67
+ finding = block_finding(contaminated, evidence, required)
68
+
69
+ records.map { |record| finding ? flag(record, [finding]) : record.merge(evidence: evidence) }
70
+ end
71
+
72
+ def block_finding(contaminated, evidence, required)
73
+ return TWFilter::Finding.new(check: :block, code: :contaminated_block) if contaminated
74
+ return nil if evidence >= required
75
+
76
+ TWFilter::Finding.new(check: :block, code: :no_taiwan_evidence, detail: format("%.2f", evidence))
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ module Stages
5
+ class Ingest < Stage
6
+ register("01_ingest", 1)
7
+
8
+ def call(input, output)
9
+ kept = 0
10
+ residue = 0
11
+ gates = Hash.new(0)
12
+ sink = residue_path&.then { |path|
13
+ path.dirname.mkpath
14
+ path.open("w")
15
+ }
16
+
17
+ per_host = Hash.new(0)
18
+
19
+ reader(input) do |io|
20
+ source.each_record(io) do |record|
21
+ gates[record[:gate]] += 1
22
+ next if capped?(per_host, record)
23
+
24
+ if record[:keep]
25
+ kept += 1
26
+ output.puts(Jsonl.dump(record.except(:keep)))
27
+ elsif sink
28
+ residue += 1
29
+ sink.puts(Jsonl.dump(record.except(:keep)))
30
+ end
31
+ end
32
+ end
33
+
34
+ sink&.close
35
+ {kept: kept, residue: residue, gates: gates}
36
+ end
37
+
38
+ private
39
+
40
+ def host_cap = options[:host_cap]&.to_i
41
+
42
+ def capped?(per_host, record)
43
+ return false if host_cap.nil? || record[:host].nil?
44
+
45
+ per_host[record[:host]] += 1
46
+ per_host[record[:host]] > host_cap
47
+ end
48
+
49
+ def source
50
+ @source ||= case options.fetch(:source, "hplt")
51
+ in "hplt"
52
+ Sources::HPLT.new(allowlist: Sources::HPLT.allowlist(options[:allowlist]))
53
+ in unknown
54
+ raise Error, "unknown source: #{unknown}"
55
+ end
56
+ end
57
+
58
+ def residue_path = options[:residue]&.then { |path| Pathname(path) }
59
+
60
+ def reader(input, &block)
61
+ path = options[:input]
62
+ path ? Sources::HPLT.open(path, &block) : block.call(input)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ module Stages
5
+ class Lexicon < MapStage
6
+ register("05_lexicon", 5)
7
+
8
+ CHECKS = [TWFilter::Checks::Lexicon, TWFilter::Checks::Erhua, TWFilter::Checks::Wenyan].freeze
9
+
10
+ def transform(record)
11
+ subject = TWFilter::Subject.new(record.fetch(:text, ""), policy: policy)
12
+ flag(record, CHECKS.flat_map { |check| check.call(subject) })
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ module Stages
5
+ class Normalize < MapStage
6
+ register("02_normalize", 2)
7
+
8
+ def transform(record)
9
+ original = record.fetch(:text, "")
10
+ normalized = TWFilter.normalize(original, collapse_repeats: options.fetch(:collapse_repeats, true))
11
+ record.merge(text: normalized, normalized: normalized != original)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ module Stages
5
+ class Script < MapStage
6
+ register("04_script", 4)
7
+
8
+ def transform(record)
9
+ subject = TWFilter::Subject.new(record.fetch(:text, ""), policy: policy)
10
+ flag(record.merge(tier: TWFilter::Checks::Script.tier_of(subject.text)), TWFilter::Checks::Script.call(subject))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ module Stages
5
+ class Segment < Stage
6
+ register("03_segment", 3)
7
+
8
+ def call(input, output)
9
+ counts = Parallel.pipe(input, output, workers: resources.cores) do |line|
10
+ stripped = line.strip
11
+ next nil if stripped.empty?
12
+
13
+ split(Jsonl.parse(stripped)).map { |record| Jsonl.dump(record) }
14
+ end
15
+
16
+ {documents: counts.fetch(:read), sentences: counts.fetch(:written)}
17
+ end
18
+
19
+ private
20
+
21
+ def split(record)
22
+ TWFilter::Sentences
23
+ .split(record.fetch(:text, ""), clause: options.fetch(:clause, true))
24
+ .each_with_index
25
+ .filter_map { |text, index| sentence(record, text, index) }
26
+ end
27
+
28
+ CARRIED = %i[source host gate register].freeze
29
+
30
+ def sentence(record, text, index)
31
+ subject = TWFilter::Subject.new(text, policy: policy)
32
+ findings = TWFilter::Checks::Shape.call(subject)
33
+ return nil if drop? && findings.any?(&:reject?)
34
+
35
+ flag(
36
+ record.slice(*CARRIED).merge(
37
+ id: "#{record[:id]}##{index}",
38
+ doc: record[:id],
39
+ index: index,
40
+ han: subject.han,
41
+ text: text,
42
+ ok: true,
43
+ findings: []
44
+ ),
45
+ findings
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ module Stages
5
+ class Tokenize < MapStage
6
+ register("08_tokenize", 8)
7
+
8
+ FIT_LINES = 200_000
9
+
10
+ def call(input, output)
11
+ head = []
12
+ lines = input.each_line
13
+ lines.each { |line|
14
+ head << line
15
+ break if head.length >= fit_lines
16
+ }
17
+ prime(head)
18
+
19
+ Parallel.pipe(head.each + lines, output, workers: resources.cores) { |line| process(line) }
20
+ end
21
+
22
+ def transform(record) = record.merge(tokens: segmenter.call(record.fetch(:text, "")))
23
+
24
+ private
25
+
26
+ def fit_lines = options.fetch(:fit_lines, FIT_LINES).to_i
27
+
28
+ def segmenter = @segmenter ||= Segmenter.load(vocabulary)
29
+
30
+ def vocabulary
31
+ options[:vocabulary] ||
32
+ ENV["TWP_VOCABULARY"] ||
33
+ raise(Error, "--vocabulary is required (JSON with \"words\" and \"chars\")")
34
+ end
35
+
36
+ def prime(head)
37
+ sample = head.filter_map { |line| Jsonl.parse(line.strip)[:text] unless line.strip.empty? }
38
+ segmenter.fit(Segmenter.runs(sample.join("\n")), rounds: options.fetch(:rounds, 2).to_i)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TWPipeline
4
+ VERSION = "0.1.0"
5
+ end
data/lib/twpipeline.rb ADDED
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "pathname"
5
+ require "set"
6
+ require "twfilter"
7
+
8
+ require_relative "twpipeline/version"
9
+ require_relative "twpipeline/resources"
10
+ require_relative "twpipeline/parallel"
11
+ require_relative "twpipeline/rusage"
12
+ require_relative "twpipeline/bench"
13
+ require_relative "twpipeline/jsonl"
14
+ require_relative "twpipeline/sorting"
15
+ require_relative "twpipeline/frequency"
16
+ require_relative "twpipeline/segmenter"
17
+ require_relative "twpipeline/stage"
18
+ require_relative "twpipeline/sources/hplt"
19
+ require_relative "twpipeline/stages/ingest"
20
+ require_relative "twpipeline/stages/normalize"
21
+ require_relative "twpipeline/stages/segment"
22
+ require_relative "twpipeline/stages/script"
23
+ require_relative "twpipeline/stages/lexicon"
24
+ require_relative "twpipeline/stages/evidence"
25
+ require_relative "twpipeline/stages/dedup"
26
+ require_relative "twpipeline/stages/tokenize"
27
+ require_relative "twpipeline/stages/count"
28
+
29
+ module TWPipeline
30
+ class Error < StandardError
31
+ end
32
+
33
+ ROOT = Pathname(__dir__).parent
34
+
35
+ class << self
36
+ attr_writer :resources
37
+
38
+ def resources = @resources ||= Resources.detect
39
+
40
+ def work = Pathname(ENV.fetch("TWP_WORK", ROOT.parent.join("work").to_s))
41
+
42
+ def data(*parts) = ROOT.join("data", *parts)
43
+
44
+ def warm_up
45
+ TWFilter::Checks::Script.tiers
46
+ TWFilter::Checks::Lexicon.hard_terms
47
+ TWFilter::Checks::Lexicon.soft_terms
48
+ TWFilter::Checks::Lexicon.exceptions
49
+ TWFilter::Checks::Lexicon.regional_terms
50
+ TWFilter::Checks::Lexicon.foreign_topics
51
+ TWFilter::Checks::Lexicon.cantonese_chars
52
+ TWFilter::Checks::Erhua.headed
53
+ TWFilter::Checks::Erhua.tailed
54
+ TWFilter::Checks::Wenyan.markers
55
+ TWFilter::Evidence.terms
56
+ self
57
+ end
58
+ end
59
+ end