binpacker 0.1.0 → 0.3.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.
@@ -2,16 +2,20 @@
2
2
 
3
3
  module Binpacker
4
4
  class Orchestrator
5
- def initialize(config, passthrough: [])
5
+ BATCH_SIZE = 10
6
+
7
+ def initialize(config, passthrough: [], quiet: false, report_path: nil)
6
8
  @config = config
7
9
  @passthrough = passthrough
10
+ @quiet = quiet
11
+ @report_path = report_path
8
12
  end
9
13
 
10
14
  def run
11
15
  tests = discover
12
-
13
16
  timing = Timing.new(@config.timing_file)
14
17
  timings = timing.load_with_fallback(tests)
18
+ @timings = timings
15
19
 
16
20
  scheduler = Scheduler.for(@config.scheduler["algorithm"])
17
21
  queues = scheduler.partition(
@@ -20,21 +24,53 @@ module Binpacker
20
24
  timings: timings
21
25
  )
22
26
 
27
+ # Capture predicted per-Worker loads before execution drains the queues.
28
+ @predicted_loads = queues.map { |q| q.total_weight(timings) }
29
+
23
30
  runner_class = TestRunner.for(@config.test_runner)
24
31
  workers = queues.map.with_index do |queue, idx|
25
- Worker.new(idx, runner_class, passthrough: @passthrough).tap(&:start)
32
+ Worker.new(idx, runner_class, passthrough: @passthrough, quiet: @quiet).tap(&:start)
26
33
  end
27
34
 
35
+ if @config.scheduler["steal_enabled"]
36
+ run_dynamic(workers, queues, timing, tests)
37
+ else
38
+ run_static(workers, queues, timing, tests)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def discover
45
+ case @config.test_runner
46
+ when "rspec"
47
+ RSpecDiscovery.new(@config).enumerate
48
+ when "minitest"
49
+ MinitestDiscovery.new(@config).enumerate
50
+ else
51
+ raise ConfigError, "unsupported runner: #{@config.test_runner}"
52
+ end
53
+ end
54
+
55
+ def run_static(workers, queues, timing, tests)
56
+ queue_totals = queues.map(&:size)
57
+ progress = ProgressDisplay.new(workers.size)
58
+
28
59
  workers.zip(queues).each do |worker, queue|
29
60
  worker.send_tests(queue.remaining)
61
+ progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file || "")
30
62
  end
63
+ progress.refresh
64
+
65
+ workers.each(&:signal_done)
31
66
 
32
67
  all_timings = []
33
68
  all_passed = true
34
69
  total_examples = 0
35
70
  passed_examples = 0
36
-
37
- workers.each(&:signal_done)
71
+ worker_time = Array.new(workers.size, 0.0)
72
+ worker_examples = Array.new(workers.size, 0)
73
+ worker_passed = Array.new(workers.size, 0)
38
74
 
39
75
  workers.each do |worker|
40
76
  worker.collect_results
@@ -42,6 +78,11 @@ module Binpacker
42
78
  all_passed &&= worker.success?
43
79
  total_examples += worker.example_count
44
80
  passed_examples += worker.passed_count
81
+ worker_examples[worker.id] = worker.example_count
82
+ worker_passed[worker.id] = worker.passed_count
83
+ worker_time[worker.id] = worker.timings.sum { |t| t[:time] }
84
+ progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: "done")
85
+ progress.refresh
45
86
  rescue WorkerError => e
46
87
  $stderr.puts "worker #{worker.id} error: #{e.message}"
47
88
  all_passed = false
@@ -49,26 +90,178 @@ module Binpacker
49
90
  worker.cleanup
50
91
  end
51
92
 
93
+ progress.finish
94
+
95
+ worker_stats = workers.map.with_index do |w, i|
96
+ {
97
+ files: queue_totals[i],
98
+ total_time: worker_time[i],
99
+ examples: worker_examples[i],
100
+ passed: worker_passed[i]
101
+ }
102
+ end
103
+ progress.summary(worker_stats)
104
+ write_report(worker_stats, all_timings)
105
+
106
+ finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
107
+ end
108
+
109
+ def run_dynamic(workers, queues, timing, tests)
110
+ all_timings = []
111
+ all_passed = true
112
+ total_examples = 0
113
+ passed_examples = 0
114
+ active = []
115
+
116
+ queue_totals = queues.map(&:size)
117
+ worker_done = Array.new(workers.size, 0)
118
+ batch_sizes = Array.new(workers.size, 0)
119
+ worker_time = Array.new(workers.size, 0.0)
120
+ worker_examples = Array.new(workers.size, 0)
121
+ worker_passed = Array.new(workers.size, 0)
122
+
123
+ progress = ProgressDisplay.new(workers.size)
124
+
125
+ workers.zip(queues).each do |worker, queue|
126
+ batch = drain_batch(queue)
127
+ if batch.empty?
128
+ worker.signal_done
129
+ worker.collect_results
130
+ all_timings.concat(worker.timings)
131
+ all_passed &&= worker.success?
132
+ total_examples += worker.example_count
133
+ passed_examples += worker.passed_count
134
+ worker_examples[worker.id] = worker.example_count
135
+ worker_passed[worker.id] = worker.passed_count
136
+ worker.cleanup
137
+ worker_done[worker.id] = queue_totals[worker.id]
138
+ progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: "done")
139
+ else
140
+ worker.send_tests(batch)
141
+ worker.batch_done
142
+ active << worker
143
+ batch_sizes[worker.id] = batch.size
144
+ current_file = batch.first&.file || ""
145
+ progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: current_file)
146
+ end
147
+ end
148
+
149
+ until active.empty?
150
+ ready = active.find { |w| w.wait_for_batch }
151
+ unless ready
152
+ active.reject! { |w| w.status == :crashed || w.status == :error }
153
+ sleep 0.1
154
+ next
155
+ end
156
+
157
+ begin
158
+ all_passed &&= ready.success?
159
+ total_examples += ready.example_count
160
+ passed_examples += ready.passed_count
161
+
162
+ worker_done[ready.id] += batch_sizes[ready.id]
163
+ worker_examples[ready.id] = ready.example_count
164
+ worker_passed[ready.id] = ready.passed_count
165
+
166
+ own_queue = queues[ready.id]
167
+ next_batch = drain_batch(own_queue)
168
+
169
+ if next_batch.empty?
170
+ donor = queues.reject(&:empty?).max_by(&:size)
171
+ next_batch = drain_batch(donor) if donor
172
+ end
173
+
174
+ if next_batch.any?
175
+ ready.send_tests(next_batch)
176
+ ready.batch_done
177
+ batch_sizes[ready.id] = next_batch.size
178
+ current_file = next_batch.first&.file || ""
179
+ progress.update(ready.id, done: worker_done[ready.id], total: queue_totals[ready.id], file: current_file)
180
+ progress.refresh
181
+ else
182
+ ready.signal_done
183
+ all_timings.concat(ready.timings)
184
+ active.delete(ready)
185
+ worker_done[ready.id] = queue_totals[ready.id]
186
+ progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: "done")
187
+ progress.refresh
188
+ end
189
+ rescue WorkerError => e
190
+ $stderr.puts "worker #{ready.id} error: #{e.message}"
191
+ all_passed = false
192
+ active.delete(ready)
193
+ end
194
+ end
195
+
196
+ progress.finish
197
+
198
+ worker_stats = workers.map.with_index do |w, i|
199
+ tw = w.timings.sum { |t| t[:time] }
200
+ {
201
+ files: queue_totals[i],
202
+ total_time: tw > 0 ? tw : worker_time[i],
203
+ examples: worker_examples[i],
204
+ passed: worker_passed[i]
205
+ }
206
+ end
207
+ progress.summary(worker_stats)
208
+ write_report(worker_stats, all_timings)
209
+
210
+ workers.each(&:cleanup)
211
+ finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
212
+ end
213
+
214
+ def write_report(worker_stats, all_timings)
215
+ return unless @report_path
216
+
217
+ Report.new(
218
+ profile: @config.profile,
219
+ algorithm: @config.scheduler["algorithm"],
220
+ predicted_loads: @predicted_loads,
221
+ worker_stats: worker_stats,
222
+ all_timings: all_timings,
223
+ timings: @timings
224
+ ).write(@report_path)
225
+ end
226
+
227
+ def drain_batch(queue)
228
+ return [] if queue.nil? || queue.empty?
229
+ batch = []
230
+ BATCH_SIZE.times do
231
+ test = queue.pop
232
+ break unless test
233
+ batch << test
234
+ end
235
+ batch
236
+ end
237
+
238
+ def finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
52
239
  timing.append_all(all_timings) unless all_timings.empty?
240
+ empty_filter = minitest_empty_filter?(tests, total_examples)
241
+ all_passed = false if empty_filter
53
242
 
54
243
  {
55
244
  passed: all_passed,
56
245
  total: total_examples,
57
246
  passed_count: passed_examples,
58
- timings: all_timings
247
+ timings: all_timings,
248
+ empty_filter: empty_filter
59
249
  }
60
250
  end
61
251
 
62
- private
252
+ def minitest_empty_filter?(tests, total_examples)
253
+ return false unless @config.test_runner == "minitest"
254
+ return false unless tests.any?
255
+ return false unless total_examples.zero?
63
256
 
64
- def discover
65
- case @config.test_runner
66
- when "rspec"
67
- RSpecDiscovery.new(@config).enumerate
68
- when "minitest"
69
- MinitestDiscovery.new(@config).enumerate
70
- else
71
- raise ConfigError, "unsupported runner: #{@config.test_runner}"
257
+ minitest_include_filter?
258
+ end
259
+
260
+ def minitest_include_filter?
261
+ @passthrough.any? do |arg|
262
+ %w[--name --include -n -i].include?(arg) ||
263
+ arg.start_with?("--name=", "--include=") ||
264
+ (arg.start_with?("-n", "-i") && arg.length > 2)
72
265
  end
73
266
  end
74
267
  end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binpacker
4
+ class ProgressDisplay
5
+ CI_INTERVAL = 15 # seconds between CI output lines
6
+
7
+ def initialize(worker_count, tty: $stdout.tty?)
8
+ @worker_count = worker_count
9
+ @tty = tty
10
+ @workers = Array.new(worker_count) { { done: 0, total: 0, file: "", elapsed: 0.0 } }
11
+ @start = Time.now
12
+ @last_ci_output = Time.at(Time.now.to_f - CI_INTERVAL)
13
+ @lines_written = 0
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def update(worker_id, done:, total:, file:, elapsed: 0.0)
18
+ @mutex.synchronize do
19
+ w = @workers[worker_id]
20
+ w[:done] = done
21
+ w[:total] = total
22
+ w[:file] = file
23
+ w[:elapsed] = elapsed
24
+ end
25
+ end
26
+
27
+ def refresh
28
+ if @tty
29
+ redraw
30
+ else
31
+ periodic_output
32
+ end
33
+ end
34
+
35
+ def finish(worker_stats = [])
36
+ return unless @tty
37
+ redraw
38
+ $stdout.puts
39
+ end
40
+
41
+ def summary(worker_stats)
42
+ active = worker_stats.reject { |s| s[:files] == 0 && s[:examples] == 0 }
43
+ return if active.empty?
44
+
45
+ active.each_with_index do |s, i|
46
+ t = format_time(s[:total_time])
47
+ wid = worker_stats.index(s)
48
+ $stdout.puts " Worker #{wid}: #{s[:files]} tests, #{t} | #{s[:examples]} examples, #{s[:passed]} passed"
49
+ end
50
+ total_tests = active.sum { |s| s[:files] }
51
+ total_time = active.sum { |s| s[:total_time] }
52
+ total_examples = active.sum { |s| s[:examples] }
53
+ times = active.map { |s| s[:total_time] }
54
+ mean = total_time / active.size
55
+ max_dev = times.map { |t| (t - mean).abs }.max
56
+ dev_pct = mean > 0 ? (max_dev / mean * 100).round(1) : 0
57
+
58
+ $stdout.puts " ──"
59
+ $stdout.puts " Total: #{total_tests} tests, #{format_time(total_time)} | #{total_examples} examples"
60
+ $stdout.puts " Balance: max deviation #{format_time(max_dev)} (#{dev_pct}%)"
61
+ end
62
+
63
+ private
64
+
65
+ def redraw
66
+ @mutex.synchronize do
67
+ clear_lines
68
+ @workers.each_with_index do |w, i|
69
+ bar = build_bar(w[:done], w[:total])
70
+ status = w[:total] > 0 && w[:done] >= w[:total] ? "done" : w[:file][-50..] || ""
71
+ $stdout.puts format_line(i, bar, w[:done], w[:total], status, w[:elapsed])
72
+ end
73
+ @lines_written = @worker_count
74
+ end
75
+ end
76
+
77
+ def clear_lines
78
+ return if @lines_written == 0
79
+ @lines_written.times do
80
+ $stdout.print "\033[A\033[K"
81
+ end
82
+ end
83
+
84
+ def build_bar(done, total)
85
+ return "[----------]" if total == 0
86
+ width = 10
87
+ filled = (done.to_f / total * width).round
88
+ "[#{'█' * filled}#{'░' * (width - filled)}]"
89
+ end
90
+
91
+ def format_line(idx, bar, done, total, file, elapsed)
92
+ ts = format_time(elapsed)
93
+ "W#{idx} #{bar} #{done.to_s.rjust(3)}/#{total.to_s.ljust(3)} #{file.ljust(50)} #{ts}"
94
+ end
95
+
96
+ def periodic_output
97
+ now = Time.now
98
+ return if now - @last_ci_output < CI_INTERVAL
99
+ @last_ci_output = now
100
+
101
+ parts = @workers.map.with_index do |w, i|
102
+ ratio = w[:total] > 0 ? "#{w[:done]}/#{w[:total]}" : "0/?"
103
+ "W#{i}: #{ratio}"
104
+ end
105
+ elapsed = (now - @start).round(1)
106
+ $stdout.puts "[binpacker #{elapsed}s] #{parts.join(' | ')}"
107
+ $stdout.flush
108
+ end
109
+
110
+ def format_time(seconds)
111
+ return " 0.0s" if seconds < 0.001
112
+ m = (seconds / 60).floor
113
+ s = (seconds % 60).round(1)
114
+ m > 0 ? "#{m}m#{s.to_s.rjust(4, '0')}s" : "#{s.to_s.rjust(5)}s"
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Binpacker
4
+ # Inspects the working directory to report binpacker's setup state and
5
+ # recommend the next skill. Backs `binpacker describe`.
6
+ # See docs/design/agent-workflows.md.
7
+ class ProjectState
8
+ CONFIG_FILE = "binpacker.yml"
9
+ DEFAULT_TIMING_FILE = "binpacker.timings"
10
+ SUPPORTED_FRAMEWORKS = %w[rspec minitest].freeze
11
+ TESTUNIT_HINT = %r{test-unit|test/unit}
12
+
13
+ def config_present?
14
+ File.exist?(CONFIG_FILE)
15
+ end
16
+
17
+ def timing_present?
18
+ File.exist?(timing_file)
19
+ end
20
+
21
+ # "rspec", "minitest", "test-unit", or nil. Minitest and test-unit share
22
+ # the *_test.rb convention, so they are told apart by dependency hints.
23
+ def framework
24
+ return minitest_family if minitest_globs?
25
+ return "rspec" if Dir.glob("spec/**/*_spec.rb").any?
26
+ nil
27
+ end
28
+
29
+ # binpacker ships runners for rspec and minitest only. A detected framework
30
+ # outside that set (e.g. test-unit) is unsupported; nil is merely unknown.
31
+ def supported_framework?
32
+ fw = framework
33
+ fw.nil? || SUPPORTED_FRAMEWORKS.include?(fw)
34
+ end
35
+
36
+ def ci_wired?
37
+ Dir.glob(".github/workflows/*.{yml,yaml}").any? do |wf|
38
+ File.read(wf).include?("binpacker run")
39
+ end
40
+ end
41
+
42
+ # The skill the agent should run next.
43
+ def recommendation
44
+ return "binpacker-setup" unless config_present? && timing_present?
45
+ "binpacker-improve"
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ config_present: config_present?,
51
+ timing_present: timing_present?,
52
+ framework: framework,
53
+ ci_wired: ci_wired?,
54
+ recommendation: recommendation
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def minitest_globs?
61
+ Dir.glob("test*/**/*_test.rb").any? || Dir.glob("test*/**/test_*.rb").any?
62
+ end
63
+
64
+ def minitest_family
65
+ testunit_hinted? ? "test-unit" : "minitest"
66
+ end
67
+
68
+ def testunit_hinted?
69
+ sources = Dir.glob("Gemfile") + Dir.glob("*.gemspec") + Dir.glob("test*/**/*_test.rb").first(5)
70
+ sources.any? { |file| File.read(file).match?(TESTUNIT_HINT) }
71
+ rescue StandardError
72
+ false
73
+ end
74
+
75
+ def timing_file
76
+ return DEFAULT_TIMING_FILE unless config_present?
77
+ Config.new.timing_file
78
+ rescue StandardError
79
+ DEFAULT_TIMING_FILE
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Binpacker
6
+ # Builds the machine-readable Run report: predicted versus actual per-Worker
7
+ # durations plus the worst per-Test Drift. See docs/design/run-report-format.md.
8
+ class Report
9
+ SCHEMA = 1
10
+ DRIFT_LIMIT = 10
11
+
12
+ def initialize(profile:, algorithm:, predicted_loads:, worker_stats:, all_timings:, timings:)
13
+ @profile = profile
14
+ @algorithm = algorithm
15
+ @predicted_loads = predicted_loads
16
+ @worker_stats = worker_stats
17
+ @all_timings = all_timings
18
+ @timings = timings
19
+ end
20
+
21
+ def to_h
22
+ predicted = @predicted_loads
23
+ actual = @worker_stats.map { |s| s[:total_time] }
24
+
25
+ {
26
+ schema: SCHEMA,
27
+ profile: @profile,
28
+ algorithm: @algorithm,
29
+ worker_count: @worker_stats.size,
30
+ predicted_makespan: round(predicted.max || 0.0),
31
+ actual_makespan: round(actual.max || 0.0),
32
+ workers: workers,
33
+ balance: {
34
+ predicted_deviation_pct: deviation_pct(predicted),
35
+ actual_deviation_pct: deviation_pct(actual)
36
+ },
37
+ drift: drift
38
+ }
39
+ end
40
+
41
+ def write(path)
42
+ File.write(path, JSON.pretty_generate(to_h))
43
+ end
44
+
45
+ private
46
+
47
+ def workers
48
+ @worker_stats.map.with_index do |s, i|
49
+ {
50
+ id: i,
51
+ predicted: round(@predicted_loads[i] || 0.0),
52
+ actual: round(s[:total_time]),
53
+ tests: s[:files],
54
+ examples: s[:examples]
55
+ }
56
+ end
57
+ end
58
+
59
+ # Drift is reported per file (the scheduling unit), so predicted and actual
60
+ # are compared at the same granularity even when timings are recorded
61
+ # per example. Both sides are aggregated to normalized file paths.
62
+ def drift
63
+ actual = aggregate_by_file(@all_timings.map { |e| [e[:file], e[:time]] })
64
+ predicted = aggregate_by_file(@timings.map { |(file, _name), weight| [file, weight] })
65
+
66
+ (actual.keys | predicted.keys)
67
+ .map { |file| drift_entry(file, predicted[file], actual[file]) }
68
+ .sort_by { |d| -(d[:actual] - d[:predicted]).abs }
69
+ .first(DRIFT_LIMIT)
70
+ end
71
+
72
+ def drift_entry(file, predicted, actual)
73
+ {
74
+ file: file,
75
+ predicted: round(predicted || Timing::DEFAULT_WEIGHT),
76
+ actual: round(actual || 0.0)
77
+ }
78
+ end
79
+
80
+ def aggregate_by_file(pairs)
81
+ pairs.each_with_object(Hash.new(0.0)) do |(file, time), acc|
82
+ acc[normalize_file(file)] += time
83
+ end
84
+ end
85
+
86
+ def normalize_file(file)
87
+ file.to_s.sub(%r{\A\./}, "")
88
+ end
89
+
90
+ def deviation_pct(loads)
91
+ return 0.0 if loads.empty?
92
+ mean = loads.sum / loads.size
93
+ return 0.0 unless mean.positive?
94
+ max_dev = loads.map { |t| (t - mean).abs }.max
95
+ round(max_dev / mean * 100)
96
+ end
97
+
98
+ def round(value)
99
+ value.to_f.round(3)
100
+ end
101
+ end
102
+ end
@@ -2,7 +2,6 @@
2
2
 
3
3
  module Binpacker
4
4
  class Scheduler
5
- # Returns Array<WorkerQueue> one per worker.
6
5
  def partition(tests:, worker_count:, timings:)
7
6
  raise NotImplementedError
8
7
  end
@@ -10,28 +9,94 @@ module Binpacker
10
9
  def self.for(strategy)
11
10
  case strategy.to_s
12
11
  when "lpt" then LptScheduler.new
12
+ when "multifit" then MultifitScheduler.new
13
13
  else
14
14
  raise SchedulerError, "unknown scheduling algorithm: #{strategy}"
15
15
  end
16
16
  end
17
+
18
+ private
19
+
20
+ def weight(test, timings)
21
+ timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
22
+ end
23
+
24
+ def sorted_by_weight(tests, timings)
25
+ tests.sort_by { |t| -weight(t, timings) }
26
+ end
17
27
  end
18
28
 
19
29
  class LptScheduler < Scheduler
20
- # Longest Processing Time first.
21
- # Sort tests by descending weight, assign each to the least-loaded worker.
22
30
  def partition(tests:, worker_count:, timings:)
23
31
  queues = Array.new(worker_count) { |i| WorkerQueue.new(i) }
24
32
  loads = Array.new(worker_count, 0.0)
25
33
 
26
- # Sort by weight descending; unknown tests get default weight
27
- sorted = tests.sort_by { |t|
28
- -timings.fetch(t.key, Timing::DEFAULT_WEIGHT)
29
- }
30
-
31
- sorted.each do |test|
34
+ sorted_by_weight(tests, timings).each do |test|
32
35
  min_idx = loads.each_with_index.min_by { |load, _| load }.last
33
36
  queues[min_idx].push(test)
34
- loads[min_idx] += timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
37
+ loads[min_idx] += weight(test, timings)
38
+ end
39
+
40
+ queues
41
+ end
42
+ end
43
+
44
+ class MultifitScheduler < Scheduler
45
+ ITERATIONS = 7
46
+
47
+ def partition(tests:, worker_count:, timings:)
48
+ sorted = sorted_by_weight(tests, timings)
49
+
50
+ upper = lpt_makespan(sorted, worker_count, timings)
51
+ lower = [max_weight(sorted, timings), total_weight(sorted, timings) / worker_count.to_f].max
52
+
53
+ best_queues = nil
54
+ ITERATIONS.times do
55
+ mid = (upper + lower) / 2.0
56
+ queues = first_fit_decreasing(sorted, worker_count, mid, timings)
57
+
58
+ if queues
59
+ best_queues = queues
60
+ upper = mid
61
+ else
62
+ lower = mid
63
+ end
64
+ end
65
+
66
+ best_queues || LptScheduler.new.partition(tests: tests, worker_count: worker_count, timings: timings)
67
+ end
68
+
69
+ private
70
+
71
+ def lpt_makespan(sorted, worker_count, timings)
72
+ loads = Array.new(worker_count, 0.0)
73
+ sorted.each do |test|
74
+ min_idx = loads.each_with_index.min_by { |l, _| l }.last
75
+ loads[min_idx] += weight(test, timings)
76
+ end
77
+ loads.max
78
+ end
79
+
80
+ def max_weight(sorted, timings)
81
+ sorted.map { |t| weight(t, timings) }.max || 0.0
82
+ end
83
+
84
+ def total_weight(sorted, timings)
85
+ sorted.sum { |t| weight(t, timings) }
86
+ end
87
+
88
+ def first_fit_decreasing(sorted, worker_count, capacity, timings)
89
+ queues = Array.new(worker_count) { |i| WorkerQueue.new(i) }
90
+ loads = Array.new(worker_count, 0.0)
91
+
92
+ sorted.each do |test|
93
+ w = weight(test, timings)
94
+ idx = loads.each_with_index.find { |l, _| l + w <= capacity }&.last
95
+
96
+ return nil unless idx
97
+
98
+ queues[idx].push(test)
99
+ loads[idx] += w
35
100
  end
36
101
 
37
102
  queues