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,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