binpacker 0.2.0 → 0.4.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 +4 -4
- data/exe/binpacker-worker +12 -2
- data/lib/binpacker/calibration.rb +20 -8
- data/lib/binpacker/cli.rb +132 -10
- data/lib/binpacker/config.rb +2 -1
- data/lib/binpacker/orchestrator.rb +106 -38
- data/lib/binpacker/progress.rb +20 -16
- data/lib/binpacker/project_state.rb +82 -0
- data/lib/binpacker/report.rb +102 -0
- data/lib/binpacker/skills.rb +36 -0
- data/lib/binpacker/test_discovery.rb +7 -1
- data/lib/binpacker/timing.rb +125 -22
- data/lib/binpacker/version.rb +1 -1
- data/lib/binpacker/worker_queue.rb +1 -1
- data/lib/binpacker.rb +3 -0
- data/sig/binpacker/calibration.rbs +6 -0
- data/sig/binpacker/cli.rbs +7 -0
- data/sig/binpacker/config.rbs +13 -0
- data/sig/binpacker/orchestrator.rbs +28 -0
- data/sig/binpacker/progress.rbs +5 -0
- data/sig/binpacker/project_state.rbs +16 -0
- data/sig/binpacker/report.rbs +10 -0
- data/sig/binpacker/scheduler.rbs +5 -0
- data/sig/binpacker/skills.rbs +10 -0
- data/sig/binpacker/test_discovery.rbs +15 -0
- data/sig/binpacker/test_runner.rbs +13 -0
- data/sig/binpacker/timing.rbs +17 -0
- data/sig/binpacker/worker.rbs +12 -0
- data/sig/binpacker/worker_queue.rbs +8 -0
- data/skills/binpacker-improve/SKILL.md +52 -0
- data/skills/binpacker-setup/SKILL.md +76 -0
- metadata +24 -5
data/lib/binpacker/progress.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Binpacker
|
|
|
7
7
|
def initialize(worker_count, tty: $stdout.tty?)
|
|
8
8
|
@worker_count = worker_count
|
|
9
9
|
@tty = tty
|
|
10
|
-
@workers = Array.new(worker_count) { { done: 0, total: 0, file:
|
|
10
|
+
@workers = Array.new(worker_count) { { done: 0, total: 0, file: '', elapsed: 0.0 } }
|
|
11
11
|
@start = Time.now
|
|
12
12
|
@last_ci_output = Time.at(Time.now.to_f - CI_INTERVAL)
|
|
13
13
|
@lines_written = 0
|
|
@@ -32,8 +32,9 @@ module Binpacker
|
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def finish(
|
|
35
|
+
def finish(_worker_stats = [])
|
|
36
36
|
return unless @tty
|
|
37
|
+
|
|
37
38
|
redraw
|
|
38
39
|
$stdout.puts
|
|
39
40
|
end
|
|
@@ -42,12 +43,13 @@ module Binpacker
|
|
|
42
43
|
active = worker_stats.reject { |s| s[:files] == 0 && s[:examples] == 0 }
|
|
43
44
|
return if active.empty?
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
worker_stats.each_with_index do |s, wid|
|
|
47
|
+
next if s[:files] == 0 && s[:examples] == 0
|
|
48
|
+
|
|
46
49
|
t = format_time(s[:total_time])
|
|
47
|
-
wid
|
|
48
|
-
$stdout.puts " Worker #{wid}: #{s[:files]} files, #{t} | #{s[:examples]} examples, #{s[:passed]} passed"
|
|
50
|
+
$stdout.puts " Worker #{wid}: #{s[:files]} tests, #{t} | #{s[:examples]} examples, #{s[:passed]} passed"
|
|
49
51
|
end
|
|
50
|
-
|
|
52
|
+
total_tests = active.sum { |s| s[:files] }
|
|
51
53
|
total_time = active.sum { |s| s[:total_time] }
|
|
52
54
|
total_examples = active.sum { |s| s[:examples] }
|
|
53
55
|
times = active.map { |s| s[:total_time] }
|
|
@@ -55,8 +57,8 @@ module Binpacker
|
|
|
55
57
|
max_dev = times.map { |t| (t - mean).abs }.max
|
|
56
58
|
dev_pct = mean > 0 ? (max_dev / mean * 100).round(1) : 0
|
|
57
59
|
|
|
58
|
-
$stdout.puts
|
|
59
|
-
$stdout.puts " Total: #{
|
|
60
|
+
$stdout.puts ' ──'
|
|
61
|
+
$stdout.puts " Total: #{total_tests} tests, #{format_time(total_time)} | #{total_examples} examples"
|
|
60
62
|
$stdout.puts " Balance: max deviation #{format_time(max_dev)} (#{dev_pct}%)"
|
|
61
63
|
end
|
|
62
64
|
|
|
@@ -67,7 +69,7 @@ module Binpacker
|
|
|
67
69
|
clear_lines
|
|
68
70
|
@workers.each_with_index do |w, i|
|
|
69
71
|
bar = build_bar(w[:done], w[:total])
|
|
70
|
-
status = w[:total] > 0 && w[:done] >= w[:total] ?
|
|
72
|
+
status = w[:total] > 0 && w[:done] >= w[:total] ? 'done' : w[:file][-50..] || ''
|
|
71
73
|
$stdout.puts format_line(i, bar, w[:done], w[:total], status, w[:elapsed])
|
|
72
74
|
end
|
|
73
75
|
@lines_written = @worker_count
|
|
@@ -76,13 +78,15 @@ module Binpacker
|
|
|
76
78
|
|
|
77
79
|
def clear_lines
|
|
78
80
|
return if @lines_written == 0
|
|
81
|
+
|
|
79
82
|
@lines_written.times do
|
|
80
83
|
$stdout.print "\033[A\033[K"
|
|
81
84
|
end
|
|
82
85
|
end
|
|
83
86
|
|
|
84
87
|
def build_bar(done, total)
|
|
85
|
-
return
|
|
88
|
+
return '[----------]' if total == 0
|
|
89
|
+
|
|
86
90
|
width = 10
|
|
87
91
|
filled = (done.to_f / total * width).round
|
|
88
92
|
"[#{'█' * filled}#{'░' * (width - filled)}]"
|
|
@@ -96,21 +100,21 @@ module Binpacker
|
|
|
96
100
|
def periodic_output
|
|
97
101
|
now = Time.now
|
|
98
102
|
return if now - @last_ci_output < CI_INTERVAL
|
|
103
|
+
|
|
99
104
|
@last_ci_output = now
|
|
100
105
|
|
|
101
106
|
parts = @workers.map.with_index do |w, i|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
else
|
|
105
|
-
"W#{i}: #{w[:done]}/#{w[:total]}"
|
|
106
|
-
end
|
|
107
|
+
ratio = w[:total] > 0 ? "#{w[:done]}/#{w[:total]}" : '0/?'
|
|
108
|
+
"W#{i}: #{ratio}"
|
|
107
109
|
end
|
|
108
110
|
elapsed = (now - @start).round(1)
|
|
109
111
|
$stdout.puts "[binpacker #{elapsed}s] #{parts.join(' | ')}"
|
|
112
|
+
$stdout.flush
|
|
110
113
|
end
|
|
111
114
|
|
|
112
115
|
def format_time(seconds)
|
|
113
|
-
return
|
|
116
|
+
return ' 0.0s' if seconds < 0.001
|
|
117
|
+
|
|
114
118
|
m = (seconds / 60).floor
|
|
115
119
|
s = (seconds % 60).round(1)
|
|
116
120
|
m > 0 ? "#{m}m#{s.to_s.rjust(4, '0')}s" : "#{s.to_s.rjust(5)}s"
|
|
@@ -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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Binpacker
|
|
4
|
+
# Registry for the gem-shipped, user-facing agent skills. Skills live in the
|
|
5
|
+
# gem's top-level skills/ directory, each as skills/<name>/SKILL.md.
|
|
6
|
+
# See docs/adr/0003-agent-driven-install-and-skills.md.
|
|
7
|
+
module Skills
|
|
8
|
+
ROOT = File.expand_path("../../skills", __dir__)
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# [{ name:, path: }], sorted by name.
|
|
13
|
+
def list
|
|
14
|
+
Dir.glob(File.join(ROOT, "*", "SKILL.md")).sort.map do |file|
|
|
15
|
+
{ name: File.basename(File.dirname(file)), path: file }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def exist?(name)
|
|
20
|
+
!path(name).nil?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Absolute path of a skill's SKILL.md, or nil when unknown.
|
|
24
|
+
def path(name)
|
|
25
|
+
file = File.join(ROOT, name, "SKILL.md")
|
|
26
|
+
File.file?(file) ? file : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The SKILL.md body for a skill.
|
|
30
|
+
def body(name)
|
|
31
|
+
file = path(name)
|
|
32
|
+
raise Error, "unknown skill: #{name}" unless file
|
|
33
|
+
File.read(file)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -69,7 +69,13 @@ module Binpacker
|
|
|
69
69
|
|
|
70
70
|
class MinitestDiscovery < TestDiscovery
|
|
71
71
|
def enumerate
|
|
72
|
-
|
|
72
|
+
begin
|
|
73
|
+
require "minitest"
|
|
74
|
+
rescue LoadError
|
|
75
|
+
raise DiscoveryError,
|
|
76
|
+
"minitest is not available. binpacker supports rspec and minitest; " \
|
|
77
|
+
"test-unit and other frameworks are not supported."
|
|
78
|
+
end
|
|
73
79
|
add_project_load_paths
|
|
74
80
|
def Minitest.autorun; end
|
|
75
81
|
Minitest.seed = 42
|
data/lib/binpacker/timing.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module Binpacker
|
|
6
6
|
class Timing
|
|
@@ -8,70 +8,173 @@ module Binpacker
|
|
|
8
8
|
|
|
9
9
|
DEFAULT_WEIGHT = 1.0
|
|
10
10
|
|
|
11
|
+
# Samples retained per test by #compact! and consulted by the
|
|
12
|
+
# median in #load_per_file. Three samples make a single anomalous
|
|
13
|
+
# run (GC pause, noisy CI neighbour) unable to move the weight.
|
|
14
|
+
MAX_SAMPLES_PER_TEST = 3
|
|
15
|
+
|
|
11
16
|
def initialize(path)
|
|
12
17
|
@path = Pathname(path)
|
|
13
18
|
end
|
|
14
19
|
|
|
20
|
+
# True once any timing samples exist, i.e. the project has been
|
|
21
|
+
# calibrated at least once. Callers use this to tell a measured
|
|
22
|
+
# run (weights in seconds) from a pure cold start (fallbacks only).
|
|
23
|
+
def calibrated?
|
|
24
|
+
!samples_by_test.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
15
27
|
def load_with_fallback(tests)
|
|
16
28
|
per_file = load_per_file
|
|
29
|
+
coefficient = seconds_per_kb(per_file)
|
|
17
30
|
tests.each_with_object({}) do |test, hash|
|
|
18
31
|
key = normalize_path(test.file)
|
|
19
|
-
hash[test.key] = per_file.fetch(key) {
|
|
32
|
+
hash[test.key] = per_file.fetch(key) { fallback_weight(test.file, coefficient) }
|
|
20
33
|
end
|
|
21
34
|
end
|
|
22
35
|
|
|
23
36
|
def load_raw
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
@path.each_line(encoding: "UTF-8")
|
|
27
|
-
.map { |line| parse_line(line) }
|
|
28
|
-
.compact
|
|
29
|
-
.group_by { |e| [normalize_path(e.file), e.name] }
|
|
30
|
-
.transform_values { |entries| entries.last.time }
|
|
37
|
+
@load_raw ||= samples_by_test.transform_values(&:last)
|
|
31
38
|
end
|
|
32
39
|
|
|
40
|
+
# Predicted weight per file: the median of each test's recent
|
|
41
|
+
# samples, summed per file. The append-only history must NOT be
|
|
42
|
+
# summed wholesale — a file present in N historical runs would
|
|
43
|
+
# weigh ~N times its true cost, so long-lived files dominate and
|
|
44
|
+
# newly added ones are starved, skewing the partition.
|
|
33
45
|
def load_per_file
|
|
34
|
-
|
|
46
|
+
samples_by_test.each_with_object({}) do |((file, _name), times), per_file|
|
|
47
|
+
weight = median(times.last(MAX_SAMPLES_PER_TEST))
|
|
48
|
+
per_file[file] = per_file.fetch(file, 0.0) + weight
|
|
49
|
+
end
|
|
50
|
+
end
|
|
35
51
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
52
|
+
# Rewrites the timing file keeping only the most recent
|
|
53
|
+
# MAX_SAMPLES_PER_TEST samples per test, so the append-only
|
|
54
|
+
# history (and any CI cache built from it) stays bounded instead
|
|
55
|
+
# of growing by one run per invocation.
|
|
56
|
+
def compact!
|
|
57
|
+
samples = samples_by_test
|
|
58
|
+
return if samples.empty?
|
|
59
|
+
|
|
60
|
+
tmp = Pathname("#{@path}.tmp")
|
|
61
|
+
tmp.open('w', encoding: 'UTF-8') do |io|
|
|
62
|
+
samples.each do |(file, name), times|
|
|
63
|
+
times.last(MAX_SAMPLES_PER_TEST).each do |time|
|
|
64
|
+
io.puts JSON.generate({ file: file, name: name, time: time })
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
File.rename(tmp.to_s, @path.to_s)
|
|
69
|
+
invalidate
|
|
41
70
|
end
|
|
42
71
|
|
|
43
72
|
def normalize_path(path)
|
|
44
|
-
Pathname(path).cleanpath.to_s.sub(
|
|
73
|
+
Pathname(path).cleanpath.to_s.sub(%r{\A\./}, '')
|
|
45
74
|
end
|
|
46
75
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
76
|
+
# True when a measured Weight already exists for this Test.
|
|
77
|
+
def measured?(file:, name:)
|
|
78
|
+
load_raw.key?([normalize_path(file), name])
|
|
50
79
|
end
|
|
51
80
|
|
|
52
81
|
def append(file:, name:, time:)
|
|
53
82
|
@path.dirname.mkpath unless @path.dirname.directory?
|
|
54
|
-
@path.open(
|
|
83
|
+
@path.open('a', encoding: 'UTF-8') { |io| io.puts JSON.generate({ file: file, name: name, time: time }) }
|
|
84
|
+
invalidate
|
|
55
85
|
end
|
|
56
86
|
|
|
57
87
|
def append_all(entries)
|
|
58
88
|
return if entries.empty?
|
|
89
|
+
|
|
59
90
|
@path.dirname.mkpath unless @path.dirname.directory?
|
|
60
|
-
@path.open(
|
|
91
|
+
@path.open('a', encoding: 'UTF-8') do |io|
|
|
61
92
|
entries.each { |e| io.puts JSON.generate({ file: e[:file], name: e[:name], time: e[:time] }) }
|
|
62
93
|
end
|
|
94
|
+
invalidate
|
|
63
95
|
end
|
|
64
96
|
|
|
65
97
|
private
|
|
66
98
|
|
|
99
|
+
# [normalized file, name] => [sample, ...] in append (= run) order.
|
|
100
|
+
def samples_by_test
|
|
101
|
+
@samples_by_test ||= begin
|
|
102
|
+
samples = Hash.new { |h, k| h[k] = [] }
|
|
103
|
+
if @path.exist?
|
|
104
|
+
@path.each_line(encoding: 'UTF-8') do |line|
|
|
105
|
+
entry = parse_line(line)
|
|
106
|
+
samples[[normalize_path(entry.file), entry.name]] << entry.time if entry
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
samples.default_proc = nil
|
|
110
|
+
samples
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def invalidate
|
|
115
|
+
@samples_by_test = nil
|
|
116
|
+
@load_raw = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def median(values)
|
|
120
|
+
sorted = values.sort
|
|
121
|
+
mid = sorted.size / 2
|
|
122
|
+
sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
123
|
+
end
|
|
124
|
+
|
|
67
125
|
def filesize_weight(file)
|
|
68
126
|
path = Pathname(file)
|
|
69
127
|
path.exist? ? [path.size / 1024.0, DEFAULT_WEIGHT].max : DEFAULT_WEIGHT
|
|
70
128
|
end
|
|
71
129
|
|
|
130
|
+
# Seconds-per-KB coefficient used to scale filesize fallbacks onto
|
|
131
|
+
# the same axis as measured Weights. Measured Weights are seconds;
|
|
132
|
+
# the raw filesize fallback is KB, so mixing them (batch floors,
|
|
133
|
+
# donor selection) compares apples to oranges — 30s of predicted
|
|
134
|
+
# work reads as "30 KB of files". We estimate a conversion from
|
|
135
|
+
# the files we DO have timings for: for each measured file that
|
|
136
|
+
# still exists on disk with size > 0, take measured_seconds /
|
|
137
|
+
# size_kb, and use the median of those ratios (robust to a few
|
|
138
|
+
# outlier files that are unusually fast or slow for their size).
|
|
139
|
+
# nil when nothing can be estimated (no measurements, or none of
|
|
140
|
+
# the measured files exist on disk) — callers then keep raw KB.
|
|
141
|
+
def seconds_per_kb(per_file)
|
|
142
|
+
ratios = per_file.filter_map do |file, seconds|
|
|
143
|
+
kb = size_kb(file)
|
|
144
|
+
next if kb.nil? || kb <= 0
|
|
145
|
+
|
|
146
|
+
seconds / kb
|
|
147
|
+
end
|
|
148
|
+
ratios.empty? ? nil : median(ratios)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Predicted Weight for an unmeasured Test. Without a coefficient we
|
|
152
|
+
# keep the legacy raw-KB behavior (floored at 1.0). With one we
|
|
153
|
+
# scale filesize into seconds: an existing file becomes
|
|
154
|
+
# size_kb * coefficient (floored at 0.01 to avoid degenerate zero
|
|
155
|
+
# weights, but NOT at 1.0 — sub-second predictions are meaningful
|
|
156
|
+
# now that the unit is seconds); a missing/unreadable file falls
|
|
157
|
+
# back to DEFAULT_WEIGHT as a plausible seconds-scale default.
|
|
158
|
+
def fallback_weight(file, coefficient)
|
|
159
|
+
return filesize_weight(file) if coefficient.nil?
|
|
160
|
+
|
|
161
|
+
kb = size_kb(file)
|
|
162
|
+
return DEFAULT_WEIGHT if kb.nil?
|
|
163
|
+
|
|
164
|
+
[kb * coefficient, 0.01].max
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# File size in KB, or nil when the file does not exist / is unreadable.
|
|
168
|
+
def size_kb(file)
|
|
169
|
+
path = Pathname(file)
|
|
170
|
+
path.exist? ? path.size / 1024.0 : nil
|
|
171
|
+
rescue SystemCallError
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
72
175
|
def parse_line(line)
|
|
73
176
|
data = JSON.parse(line.strip)
|
|
74
|
-
Entry.new(file: data[
|
|
177
|
+
Entry.new(file: data['file'], name: data['name'], time: data['time'])
|
|
75
178
|
rescue JSON::ParserError
|
|
76
179
|
nil
|
|
77
180
|
end
|
data/lib/binpacker/version.rb
CHANGED
data/lib/binpacker.rb
CHANGED
|
@@ -9,6 +9,9 @@ require_relative "binpacker/scheduler"
|
|
|
9
9
|
require_relative "binpacker/worker"
|
|
10
10
|
require_relative "binpacker/test_runner"
|
|
11
11
|
require_relative "binpacker/calibration"
|
|
12
|
+
require_relative "binpacker/report"
|
|
13
|
+
require_relative "binpacker/skills"
|
|
14
|
+
require_relative "binpacker/project_state"
|
|
12
15
|
require_relative "binpacker/orchestrator"
|
|
13
16
|
require_relative "binpacker/progress"
|
|
14
17
|
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Binpacker
|
|
2
|
+
class Config
|
|
3
|
+
def initialize: (?profile: untyped, ?config_path: untyped) -> void
|
|
4
|
+
def worker_count: () -> Integer
|
|
5
|
+
def test_runner: () -> String
|
|
6
|
+
def timing_file: () -> String
|
|
7
|
+
def test_pattern: () -> String
|
|
8
|
+
def test_exclude: () -> Array[String]
|
|
9
|
+
def test_granularity: () -> String
|
|
10
|
+
def report_file: () -> String?
|
|
11
|
+
def profile: () -> String
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Binpacker
|
|
2
|
+
class Orchestrator
|
|
3
|
+
MIN_BATCH_WEIGHT: Float
|
|
4
|
+
COLD_START_BATCHES_PER_WORKER: Integer
|
|
5
|
+
|
|
6
|
+
@config: untyped
|
|
7
|
+
@passthrough: untyped
|
|
8
|
+
@quiet: untyped
|
|
9
|
+
@report_path: untyped
|
|
10
|
+
@timings: Hash[untyped, Float]
|
|
11
|
+
@predicted_loads: Array[Float]
|
|
12
|
+
|
|
13
|
+
def initialize: (untyped, ?passthrough: untyped, ?quiet: untyped, ?report_path: untyped) -> void
|
|
14
|
+
def run: () -> { passed: untyped, total: untyped, passed_count: untyped, timings: Array[untyped], empty_filter: untyped }
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def discover: () -> untyped
|
|
19
|
+
def run_static: (untyped, untyped, Binpacker::Timing, untyped) -> untyped
|
|
20
|
+
def run_dynamic: (untyped, untyped, Binpacker::Timing, untyped, Float) -> untyped
|
|
21
|
+
def write_report: (untyped, Array[untyped]) -> void
|
|
22
|
+
def min_batch_weight: (Binpacker::Timing, Hash[untyped, Float]) -> Float
|
|
23
|
+
def drain_batch: (Binpacker::WorkerQueue?, Float) -> Array[untyped]
|
|
24
|
+
def finalize: (Binpacker::Timing, Array[untyped], untyped, untyped, untyped, untyped) -> { passed: untyped, total: untyped, passed_count: untyped, timings: Array[untyped], empty_filter: untyped }
|
|
25
|
+
def minitest_empty_filter?: (untyped, untyped) -> bool
|
|
26
|
+
def minitest_include_filter?: () -> bool
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Binpacker
|
|
2
|
+
class ProjectState
|
|
3
|
+
CONFIG_FILE: String
|
|
4
|
+
DEFAULT_TIMING_FILE: String
|
|
5
|
+
SUPPORTED_FRAMEWORKS: Array[String]
|
|
6
|
+
TESTUNIT_HINT: Regexp
|
|
7
|
+
|
|
8
|
+
def config_present?: () -> bool
|
|
9
|
+
def timing_present?: () -> bool
|
|
10
|
+
def framework: () -> String?
|
|
11
|
+
def supported_framework?: () -> bool
|
|
12
|
+
def ci_wired?: () -> bool
|
|
13
|
+
def recommendation: () -> String
|
|
14
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Binpacker
|
|
2
|
+
class Report
|
|
3
|
+
SCHEMA: Integer
|
|
4
|
+
DRIFT_LIMIT: Integer
|
|
5
|
+
|
|
6
|
+
def initialize: (profile: untyped, algorithm: untyped, predicted_loads: untyped, worker_stats: untyped, all_timings: untyped, timings: untyped) -> void
|
|
7
|
+
def to_h: () -> Hash[Symbol, untyped]
|
|
8
|
+
def write: (untyped) -> untyped
|
|
9
|
+
end
|
|
10
|
+
end
|