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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE +21 -0
- data/README.md +236 -0
- data/bin/twpipeline +15 -0
- data/data/hplt/README.md +26 -0
- data/data/hplt/taiwan_domains.candidates.txt +300 -0
- data/data/hplt/taiwan_domains.txt +0 -0
- data/lib/twpipeline/bench.rb +55 -0
- data/lib/twpipeline/cli.rb +144 -0
- data/lib/twpipeline/frequency.rb +48 -0
- data/lib/twpipeline/jsonl.rb +30 -0
- data/lib/twpipeline/parallel.rb +92 -0
- data/lib/twpipeline/resources.rb +64 -0
- data/lib/twpipeline/rusage.rb +41 -0
- data/lib/twpipeline/segmenter.rb +134 -0
- data/lib/twpipeline/sorting.rb +51 -0
- data/lib/twpipeline/sources/hplt.rb +112 -0
- data/lib/twpipeline/stage.rb +71 -0
- data/lib/twpipeline/stages/count.rb +89 -0
- data/lib/twpipeline/stages/dedup.rb +46 -0
- data/lib/twpipeline/stages/evidence.rb +80 -0
- data/lib/twpipeline/stages/ingest.rb +66 -0
- data/lib/twpipeline/stages/lexicon.rb +16 -0
- data/lib/twpipeline/stages/normalize.rb +15 -0
- data/lib/twpipeline/stages/script.rb +14 -0
- data/lib/twpipeline/stages/segment.rb +50 -0
- data/lib/twpipeline/stages/tokenize.rb +42 -0
- data/lib/twpipeline/version.rb +5 -0
- data/lib/twpipeline.rb +59 -0
- data/sig/twpipeline.rbs +228 -0
- metadata +149 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module TWPipeline
|
|
6
|
+
class CLI
|
|
7
|
+
COMMANDS = %w[run check stages resources].freeze
|
|
8
|
+
|
|
9
|
+
def self.call(argv) = new(argv).call
|
|
10
|
+
|
|
11
|
+
def initialize(argv)
|
|
12
|
+
@argv = argv.dup
|
|
13
|
+
@options = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
parser.parse!(@argv, into: parsed = {})
|
|
18
|
+
@options = parsed.transform_keys { |key| key.to_s.tr("-", "_").to_sym }
|
|
19
|
+
TWPipeline.resources = resources
|
|
20
|
+
TWPipeline.warm_up
|
|
21
|
+
|
|
22
|
+
case @argv.shift
|
|
23
|
+
in nil
|
|
24
|
+
abort(parser.to_s)
|
|
25
|
+
in "run"
|
|
26
|
+
run
|
|
27
|
+
in "check"
|
|
28
|
+
check
|
|
29
|
+
in "stages"
|
|
30
|
+
stages
|
|
31
|
+
in "resources"
|
|
32
|
+
$stdout.puts(resources.to_s)
|
|
33
|
+
in name
|
|
34
|
+
stage(name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
0
|
|
38
|
+
rescue Error, TWFilter::Error => error
|
|
39
|
+
warn("twpipeline: #{error.message}")
|
|
40
|
+
1
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def resources
|
|
46
|
+
@resources ||= @options[:ask] ? Resources.ask : Resources.detect(
|
|
47
|
+
cores: @options[:cores],
|
|
48
|
+
memory: @options[:memory]
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parser
|
|
53
|
+
@parser ||= OptionParser.new do |parser|
|
|
54
|
+
parser.banner = <<~USAGE
|
|
55
|
+
usage: twpipeline [options] <command|stage>
|
|
56
|
+
|
|
57
|
+
commands: #{COMMANDS.join(", ")}
|
|
58
|
+
stages: #{Stage.ordered.map(&:slug).join(", ")}
|
|
59
|
+
USAGE
|
|
60
|
+
parser.on("--cores N", Integer)
|
|
61
|
+
parser.on("--memory SIZE", String)
|
|
62
|
+
parser.on("--ask", "prompt for cores and memory")
|
|
63
|
+
parser.on("--source NAME", String)
|
|
64
|
+
parser.on("--input PATH", String)
|
|
65
|
+
parser.on("--residue PATH", String)
|
|
66
|
+
parser.on("--allowlist PATH", String)
|
|
67
|
+
parser.on("--vocabulary PATH", String)
|
|
68
|
+
parser.on("--out PATH", String)
|
|
69
|
+
parser.on("--orders LIST", Array)
|
|
70
|
+
parser.on("--unit NAME", %w[token char])
|
|
71
|
+
parser.on("--floor N", Integer)
|
|
72
|
+
parser.on("--parts FIELD", String)
|
|
73
|
+
parser.on("--once-per FIELD", String)
|
|
74
|
+
parser.on("--block N", Integer)
|
|
75
|
+
parser.on("--ubiquity N", Integer)
|
|
76
|
+
parser.on("--host-cap N", Integer)
|
|
77
|
+
parser.on("--group FIELD", String)
|
|
78
|
+
parser.on("--policy NAME", %w[corpus publishable permissive])
|
|
79
|
+
parser.on("--drop", "remove rejected records instead of flagging them")
|
|
80
|
+
parser.on("--from STAGE", String)
|
|
81
|
+
parser.on("--to STAGE", String)
|
|
82
|
+
parser.on("--quiet")
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def policy = TWFilter::Policy.public_send(@options.fetch(:policy, "corpus"))
|
|
87
|
+
|
|
88
|
+
def settings = @options.slice(
|
|
89
|
+
*%i[source input residue allowlist vocabulary out orders unit floor parts once_per block group ubiquity host_cap]
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def build(klass) = klass.new(policy: policy, drop: @options.fetch(:drop, false), resources: resources, **settings)
|
|
93
|
+
|
|
94
|
+
def stage(name)
|
|
95
|
+
klass = Stage.lookup(name)
|
|
96
|
+
report(klass.slug) { build(klass).call($stdin, $stdout) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def stages
|
|
100
|
+
Stage.ordered.each { |klass| $stdout.puts(format("%-14s %s", klass.slug, klass.name)) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def selection
|
|
104
|
+
list = Stage.ordered
|
|
105
|
+
first = @options[:from] ? Stage.lookup(@options[:from]).order : list.first.order
|
|
106
|
+
last = @options[:to] ? Stage.lookup(@options[:to]).order : list.last.order
|
|
107
|
+
list.select { |klass| klass.order.between?(first, last) }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def run
|
|
111
|
+
directory = Pathname(@options.fetch(:out, TWPipeline.work.join("run"))).tap(&:mkpath)
|
|
112
|
+
previous = nil
|
|
113
|
+
|
|
114
|
+
selection.each do |klass|
|
|
115
|
+
target = directory.join("#{klass.slug}.jsonl")
|
|
116
|
+
report(klass.slug, out: target.to_s) do
|
|
117
|
+
open_input(previous) { |input| target.open("w") { |output| build(klass).call(input, output) } }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
previous = target
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def open_input(path, &block)
|
|
125
|
+
path.nil? ? block.call($stdin) : path.open(&block)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def check
|
|
129
|
+
$stdin.each_line do |line|
|
|
130
|
+
text = line.strip
|
|
131
|
+
next if text.empty?
|
|
132
|
+
|
|
133
|
+
report = TWFilter.examine(TWFilter.normalize(text), policy: policy)
|
|
134
|
+
$stdout.puts(Jsonl.dump(report.to_h))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def report(label, **facts)
|
|
139
|
+
sample = Bench.measure(label, **facts) { yield }
|
|
140
|
+
warn(Jsonl.dump(sample.to_h)) unless @options[:quiet]
|
|
141
|
+
sample
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Frequency
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def dispersion(counts, sizes)
|
|
8
|
+
total = counts.values.sum.to_f
|
|
9
|
+
corpus = sizes.values.sum.to_f
|
|
10
|
+
return 0.0 if total.zero? || corpus.zero?
|
|
11
|
+
|
|
12
|
+
0.5 * sizes.sum { |part, size| (counts.fetch(part, 0) / total - size / corpus).abs }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def each_key(path)
|
|
16
|
+
return to_enum(:each_key, path) unless block_given?
|
|
17
|
+
|
|
18
|
+
current = nil
|
|
19
|
+
parts = Hash.new(0)
|
|
20
|
+
|
|
21
|
+
Pathname(path).each_line do |line|
|
|
22
|
+
count, key, part = line.chomp.split("\t")
|
|
23
|
+
if key != current && !current.nil?
|
|
24
|
+
yield [current, parts]
|
|
25
|
+
parts = Hash.new(0)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
current = key
|
|
29
|
+
parts[part] += count.to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
yield [current, parts] unless current.nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def table(path, sizes)
|
|
36
|
+
each_key(path).map do |key, parts|
|
|
37
|
+
total = parts.values.sum
|
|
38
|
+
{
|
|
39
|
+
key: key,
|
|
40
|
+
frequency: total,
|
|
41
|
+
dispersion: dispersion(parts, sizes),
|
|
42
|
+
adjusted: (total * (1 - dispersion(parts, sizes))).round(2),
|
|
43
|
+
parts: parts.length
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TWPipeline
|
|
6
|
+
module Jsonl
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def parse(line)
|
|
10
|
+
JSON.parse(line, symbolize_names: true)
|
|
11
|
+
rescue JSON::ParserError => error
|
|
12
|
+
raise Error, "malformed JSONL: #{error.message}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dump(record) = JSON.generate(record)
|
|
16
|
+
|
|
17
|
+
def each(io)
|
|
18
|
+
return to_enum(:each, io) unless block_given?
|
|
19
|
+
|
|
20
|
+
io.each_line do |line|
|
|
21
|
+
stripped = line.strip
|
|
22
|
+
yield parse(stripped) unless stripped.empty?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def wrap(text, source: "stdin", index: 0)
|
|
27
|
+
{id: "#{source}:#{index}", source: source, text: text, ok: true, findings: []}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TWPipeline
|
|
4
|
+
module Parallel
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def map(items, workers: TWPipeline.resources.cores, &block)
|
|
8
|
+
list = items.to_a
|
|
9
|
+
return list.map(&block) if workers < 2 || list.length < 2
|
|
10
|
+
|
|
11
|
+
slices(list, workers)
|
|
12
|
+
.map { |slice| spawn(slice, &block) }
|
|
13
|
+
.map { |pid, reader| collect(pid, reader) }
|
|
14
|
+
.flatten(1)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def fold(items, workers: TWPipeline.resources.cores, &block)
|
|
18
|
+
list = items.to_a
|
|
19
|
+
return [block.call(list)] if workers < 2 || list.length < 2
|
|
20
|
+
|
|
21
|
+
slices(list, workers)
|
|
22
|
+
.map { |slice| spawn([slice], &block) }
|
|
23
|
+
.flat_map { |pid, reader| collect(pid, reader) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
CHUNK_CAP = 1 << 30
|
|
27
|
+
CHUNK_SHARE = 32
|
|
28
|
+
LINE_CAP = 500_000
|
|
29
|
+
|
|
30
|
+
def chunk_bytes(resources = TWPipeline.resources)
|
|
31
|
+
override = TWPipeline::Resources.parse(ENV["TWP_CHUNK_BYTES"])
|
|
32
|
+
override || [resources.memory_bytes / CHUNK_SHARE, CHUNK_CAP].min
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def pipe(input, output, workers: TWPipeline.resources.cores, budget: chunk_bytes, &block)
|
|
36
|
+
read = 0
|
|
37
|
+
written = 0
|
|
38
|
+
bytes = 0
|
|
39
|
+
buffer = []
|
|
40
|
+
|
|
41
|
+
flush = lambda do
|
|
42
|
+
map(buffer, workers: workers, &block).each do |row|
|
|
43
|
+
Array(row).each do |line|
|
|
44
|
+
output.puts(line)
|
|
45
|
+
written += 1
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
buffer = []
|
|
50
|
+
bytes = 0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
lines = input.respond_to?(:each_line) ? input.each_line : input.each
|
|
54
|
+
lines.each do |line|
|
|
55
|
+
read += 1
|
|
56
|
+
buffer << line
|
|
57
|
+
bytes += line.bytesize
|
|
58
|
+
flush.call if bytes >= budget || buffer.length >= LINE_CAP
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
flush.call if buffer.any?
|
|
62
|
+
{read: read, written: written}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def slices(list, workers)
|
|
66
|
+
size = (list.length / workers.to_f).ceil
|
|
67
|
+
list.each_slice([size, 1].max).to_a
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def spawn(slice, &block)
|
|
71
|
+
reader, writer = IO.pipe
|
|
72
|
+
pid = fork do
|
|
73
|
+
reader.close
|
|
74
|
+
writer.binmode
|
|
75
|
+
writer.write(Marshal.dump(slice.map(&block)))
|
|
76
|
+
writer.close
|
|
77
|
+
exit!(0)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
writer.close
|
|
81
|
+
[pid, reader]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def collect(pid, reader)
|
|
85
|
+
reader.binmode
|
|
86
|
+
payload = reader.read
|
|
87
|
+
reader.close
|
|
88
|
+
Process.wait(pid)
|
|
89
|
+
Marshal.load(payload)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module TWPipeline
|
|
6
|
+
Resources = Data.define(:cores, :memory_bytes) do
|
|
7
|
+
UNITS = {"K" => 1024, "M" => 1024 ** 2, "G" => 1024 ** 3, "T" => 1024 ** 4}.freeze
|
|
8
|
+
DEFAULT_SHARE = 0.5
|
|
9
|
+
|
|
10
|
+
def memory = format_bytes(memory_bytes)
|
|
11
|
+
|
|
12
|
+
def sort_buffer = format_bytes((memory_bytes * 0.8).to_i)
|
|
13
|
+
|
|
14
|
+
def memory_per_worker_bytes = memory_bytes / [cores, 1].max
|
|
15
|
+
|
|
16
|
+
def to_h = {cores: cores, memory_bytes: memory_bytes, memory: memory}
|
|
17
|
+
|
|
18
|
+
def to_s = "#{cores} cores, #{memory} usable"
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def format_bytes(bytes)
|
|
23
|
+
unit, factor = UNITS.to_a.reverse.find { |_, value| bytes >= value } || ["K", 1024]
|
|
24
|
+
"#{(bytes / factor.to_f).round}#{unit}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def detect(cores: nil, memory: nil)
|
|
29
|
+
new(
|
|
30
|
+
cores: (cores || ENV["TWP_CORES"] || Etc.nprocessors).to_i.clamp(1, 1024),
|
|
31
|
+
memory_bytes: parse(memory || ENV["TWP_MEMORY"]) || (total_memory * DEFAULT_SHARE).to_i
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ask(io: $stderr, input: $stdin)
|
|
36
|
+
suggested = detect
|
|
37
|
+
io.print("cores [#{suggested.cores}]: ")
|
|
38
|
+
cores = input.gets.to_s.strip
|
|
39
|
+
io.print("usable memory [#{suggested.memory}]: ")
|
|
40
|
+
memory = input.gets.to_s.strip
|
|
41
|
+
|
|
42
|
+
detect(cores: cores.empty? ? nil : cores, memory: memory.empty? ? nil : memory)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse(value)
|
|
46
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
47
|
+
return value if value.is_a?(Integer)
|
|
48
|
+
|
|
49
|
+
match = value.to_s.strip.upcase.match(/\A(\d+(?:\.\d+)?)\s*([KMGT])?B?\z/)
|
|
50
|
+
raise Error, "cannot parse memory size: #{value}" if match.nil?
|
|
51
|
+
|
|
52
|
+
(match[1].to_f * UNITS.fetch(match[2], 1)).to_i
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def total_memory
|
|
56
|
+
@total_memory ||= if RUBY_PLATFORM.include?("darwin")
|
|
57
|
+
`sysctl -n hw.memsize`.to_i
|
|
58
|
+
else
|
|
59
|
+
File.read("/proc/meminfo")[/MemTotal:\s+(\d+) kB/, 1].to_i * 1024
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
|
|
5
|
+
module TWPipeline
|
|
6
|
+
module Rusage
|
|
7
|
+
SELF = 0
|
|
8
|
+
CHILDREN = -1
|
|
9
|
+
MAXRSS_OFFSET = Fiddle::SIZEOF_LONG * 4
|
|
10
|
+
SCALE = RUBY_PLATFORM.include?("darwin") ? 1 : 1024
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def available? = !getrusage.nil?
|
|
14
|
+
|
|
15
|
+
def max_rss(who)
|
|
16
|
+
return 0 unless available?
|
|
17
|
+
|
|
18
|
+
buffer = Fiddle::Pointer.malloc(Fiddle::SIZEOF_LONG * 32, Fiddle::RUBY_FREE)
|
|
19
|
+
return 0 unless getrusage.call(who, buffer).zero?
|
|
20
|
+
|
|
21
|
+
buffer[MAXRSS_OFFSET, Fiddle::SIZEOF_LONG].unpack1("l!") * SCALE
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def peak = max_rss(SELF) + max_rss(CHILDREN)
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def getrusage
|
|
29
|
+
return @getrusage if defined?(@getrusage)
|
|
30
|
+
|
|
31
|
+
@getrusage = Fiddle::Function.new(
|
|
32
|
+
Fiddle.dlopen(nil)["getrusage"],
|
|
33
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP],
|
|
34
|
+
Fiddle::TYPE_INT
|
|
35
|
+
)
|
|
36
|
+
rescue Fiddle::DLError, NameError
|
|
37
|
+
@getrusage = nil
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TWPipeline
|
|
6
|
+
class Segmenter
|
|
7
|
+
MAX_WORD = 8
|
|
8
|
+
HAN_RUN = /[\u{3400}-\u{9FFF}]+/
|
|
9
|
+
|
|
10
|
+
Model = Data.define(:cost, :char_cost, :unseen_word, :unseen_char, :max_word)
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
def load(path)
|
|
14
|
+
data = JSON.parse(Pathname(path).read)
|
|
15
|
+
new(words: entries(data.fetch("words")), chars: entries(data.fetch("chars", [])))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def entries(value) = (value.is_a?(Hash) ? value.keys : Array(value)).to_set
|
|
19
|
+
|
|
20
|
+
def runs(text) = text.scan(HAN_RUN)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :words, :model
|
|
24
|
+
|
|
25
|
+
def initialize(words:, chars: Set.new, model: nil)
|
|
26
|
+
@words = words
|
|
27
|
+
@chars = chars
|
|
28
|
+
@limit = [words.map(&:length).max.to_i, MAX_WORD].min
|
|
29
|
+
@model = model
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def fit(runs, rounds: 2)
|
|
33
|
+
counts = naive_counts(runs)
|
|
34
|
+
rounds.times do
|
|
35
|
+
@model = build_model(*counts)
|
|
36
|
+
counts = recount(runs)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
@model = build_model(*counts)
|
|
40
|
+
self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(text) = self.class.runs(text).flat_map { |run| segment(run) }
|
|
44
|
+
|
|
45
|
+
def segment(run)
|
|
46
|
+
chars = run.chars
|
|
47
|
+
size = chars.length
|
|
48
|
+
best = Array.new(size + 1, Float::INFINITY)
|
|
49
|
+
best[0] = 0.0
|
|
50
|
+
back = Array.new(size + 1, 0)
|
|
51
|
+
|
|
52
|
+
(1..size).each do |stop|
|
|
53
|
+
([1, stop - @model.max_word + 1].max..stop).each do |start|
|
|
54
|
+
previous = best[start - 1]
|
|
55
|
+
next if previous.infinite?
|
|
56
|
+
|
|
57
|
+
token = chars[(start - 1)...stop].join
|
|
58
|
+
price = price_of(token)
|
|
59
|
+
next if price.nil?
|
|
60
|
+
|
|
61
|
+
value = previous + price
|
|
62
|
+
next unless value < best[stop]
|
|
63
|
+
|
|
64
|
+
best[stop] = value
|
|
65
|
+
back[stop] = start - 1
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
unwind(chars, back, size)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def price_of(token)
|
|
75
|
+
return @model.char_cost.fetch(token, @model.unseen_char) if token.length == 1
|
|
76
|
+
return @model.cost.fetch(token, @model.unseen_word) if @words.include?(token)
|
|
77
|
+
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def unwind(chars, back, size)
|
|
82
|
+
pieces = []
|
|
83
|
+
stop = size
|
|
84
|
+
while stop.positive?
|
|
85
|
+
start = back[stop]
|
|
86
|
+
pieces << chars[start...stop].join
|
|
87
|
+
stop = start
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
pieces.reverse
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def naive_counts(runs)
|
|
94
|
+
word_counts = Hash.new(0)
|
|
95
|
+
char_counts = Hash.new(0)
|
|
96
|
+
|
|
97
|
+
runs.each do |run|
|
|
98
|
+
chars = run.chars
|
|
99
|
+
chars.each { |char| char_counts[char] += 1 }
|
|
100
|
+
chars.length.times do |start|
|
|
101
|
+
(2..[@limit, chars.length - start].min).each do |length|
|
|
102
|
+
token = chars[start, length].join
|
|
103
|
+
word_counts[token] += 1 if @words.include?(token)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
[word_counts, char_counts]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def recount(runs)
|
|
112
|
+
word_counts = Hash.new(0)
|
|
113
|
+
char_counts = Hash.new(0)
|
|
114
|
+
|
|
115
|
+
runs.each do |run|
|
|
116
|
+
segment(run).each { |token| (token.length == 1 ? char_counts : word_counts)[token] += 1 }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
[word_counts, char_counts]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def build_model(word_counts, char_counts)
|
|
123
|
+
total = [word_counts.each_value.sum + char_counts.each_value.sum, 1].max
|
|
124
|
+
|
|
125
|
+
Model.new(
|
|
126
|
+
cost: word_counts.transform_values { |count| -Math.log(count.fdiv(total)) },
|
|
127
|
+
char_cost: char_counts.transform_values { |count| -Math.log(count.fdiv(total)) },
|
|
128
|
+
unseen_word: -Math.log(0.5 / total),
|
|
129
|
+
unseen_char: -Math.log(0.2 / total),
|
|
130
|
+
max_word: @limit
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module TWPipeline
|
|
6
|
+
module Sorting
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def program = @program ||= ENV["TWP_SORT"] || (system("command -v gsort > /dev/null 2>&1") ? "gsort" : "sort")
|
|
10
|
+
|
|
11
|
+
def sort(*extra, resources: TWPipeline.resources)
|
|
12
|
+
tmp = TWPipeline.work.join("tmp").tap(&:mkpath)
|
|
13
|
+
[program, "-S", resources.sort_buffer, "--parallel", resources.cores.to_s, "-T", tmp.to_s, *extra].shelljoin
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def unique_pipeline(floor:, resources:, collapse: false)
|
|
17
|
+
head = if collapse
|
|
18
|
+
["LC_ALL=C #{sort("-u", resources: resources)}", "LC_ALL=C cut -f1"]
|
|
19
|
+
else
|
|
20
|
+
["LC_ALL=C #{sort(resources: resources)}"]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
[
|
|
24
|
+
*head,
|
|
25
|
+
"LC_ALL=C uniq -c",
|
|
26
|
+
"LC_ALL=C sed -E 's/^ *([0-9]+) /\\1\t/'",
|
|
27
|
+
"LC_ALL=C awk -F'\t' -v floor=#{floor.to_i} '$1 >= floor'",
|
|
28
|
+
"LC_ALL=C #{sort("-t", "\t", "-k1,1nr", "-k2,2", resources: resources)}"
|
|
29
|
+
].join(" | ")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tally(output_path, floor: 1, resources: TWPipeline.resources, collapse: false)
|
|
33
|
+
Pathname(output_path).dirname.mkpath
|
|
34
|
+
command = "#{unique_pipeline(floor: floor, resources: resources, collapse: collapse)} > #{output_path.to_s.shellescape}"
|
|
35
|
+
written = 0
|
|
36
|
+
|
|
37
|
+
IO.popen(command, "w") do |sink|
|
|
38
|
+
yield(
|
|
39
|
+
-> (key) {
|
|
40
|
+
sink.puts(key)
|
|
41
|
+
written += 1
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
raise Error, "sort pipeline failed: #{command}" unless $CHILD_STATUS.success?
|
|
47
|
+
|
|
48
|
+
{keys_written: written, rows: Pathname(output_path).each_line.count}
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|