lemans 1.4.0 → 1.5.1

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.
data/lib/lemans/agent.rb CHANGED
@@ -15,6 +15,9 @@ module Lemans
15
15
 
16
16
  attr_reader :profile, :model
17
17
 
18
+ # Whether #run can go on from the history (raw result) of an interrupted run
19
+ def self.recoverable? = false
20
+
18
21
  def initialize(profile:, model: nil)
19
22
  @profile = profile
20
23
 
@@ -28,7 +31,7 @@ module Lemans
28
31
  def install(_task, _environment) = nil
29
32
 
30
33
  # Run the task.
31
- def run(task, environment)
34
+ def run(task, environment, history: nil)
32
35
  raise NotImplementedError
33
36
  end
34
37
 
@@ -21,9 +21,13 @@ module Lemans
21
21
  cost_limit: :cost_ceiling_reached
22
22
  }.freeze
23
23
 
24
- def run(task, environment)
25
- run_result = obtain_result(task, environment)
24
+ def self.recoverable? = true
25
+
26
+ def run(task, environment, history: nil)
27
+ history &&= ::Miniswen::Agent::Result.from_h(JSON.parse(history))
28
+ run_result = obtain_result(task, environment, history)
26
29
  trajectory = trajectory_for(run_result)
30
+ raw_result = raw_result_for(run_result)
27
31
 
28
32
  # A failed model call is still an answer: the trial saves the
29
33
  # trajectory as evidence before failing.
@@ -39,16 +43,17 @@ module Lemans
39
43
 
40
44
  private
41
45
 
42
- def obtain_result(task, environment)
46
+ def obtain_result(task, environment, history)
43
47
  agent = agent_for(environment)
44
48
  begin
45
- agent.run(task.instruction)
49
+ agent.run(task.instruction, history:)
46
50
  rescue InfrastructureError, ::Miniswen::InfrastructureError => e
47
51
  agent.partial_result(e.message)
48
52
  end
49
53
  end
50
54
 
51
- def raw_result = nil
55
+ # The run as miniswen records it, signatures included: what a recovery continues from
56
+ def raw_result_for(run_result) = JSON.generate(run_result.to_h)
52
57
 
53
58
  def agent_for(environment)
54
59
  raise ConfigError, "miniswen needs a model to drive" if model.to_s.empty?
@@ -13,6 +13,7 @@ module Lemans
13
13
  class MiniswenInstalled < Miniswen
14
14
  NAME = "miniswen-installed"
15
15
  RESULTS_PATH = "/tmp/lemans-miniswen.result.json"
16
+ HISTORY_PATH = "/tmp/lemans-miniswen.history.json"
16
17
  INSTALL_TIMEOUT_SEC = 300
17
18
  # The CLI enforces max-time itself, but between steps only: a command
18
19
  # started just before the deadline runs to its own exec timeout first,
@@ -31,8 +32,9 @@ module Lemans
31
32
 
32
33
  # An in-sandbox run self-reports: everything but the verifier's reward
33
34
  # comes from a file the sandbox wrote.
34
- def obtain_result(task, environment)
35
- run = environment.exec(command_for(task), timeout: outer_timeout, env: provider_env(environment))
35
+ def obtain_result(task, environment, history)
36
+ upload_history(environment, history) if history
37
+ run = environment.exec(command_for(task, history:), timeout: outer_timeout, env: provider_env(environment))
36
38
 
37
39
  begin
38
40
  Tempfile.create(%w[miniswen .result.json]) do |file|
@@ -47,7 +49,7 @@ module Lemans
47
49
  end
48
50
  end
49
51
 
50
- attr_reader :raw_result
52
+ def raw_result_for(_run_result) = @raw_result
51
53
 
52
54
  def outer_timeout = profile.timeout + profile.exec_timeout + EXEC_SLACK_SEC
53
55
 
@@ -64,9 +66,18 @@ module Lemans
64
66
  policy.mode == "allowlist" ? policy.domains : []
65
67
  end
66
68
 
67
- def command_for(task)
69
+ def upload_history(environment, history)
70
+ Tempfile.create(%w[miniswen .history.json]) do |file|
71
+ file.write(JSON.generate(history.to_h))
72
+ file.flush
73
+ environment.upload(file.path, HISTORY_PATH)
74
+ end
75
+ end
76
+
77
+ def command_for(task, history: nil)
68
78
  argv = [ "miniswen", "-q", "--no-refresh-registry", "--jail",
69
- "-m", model.to_s, "-p", task.instruction,
79
+ "-m", model.to_s,
80
+ *(history ? [ "--continue-from", HISTORY_PATH ] : [ "-p", task.instruction ]),
70
81
  "--results-path", RESULTS_PATH,
71
82
  "--max-steps", profile.step_limit, "--max-time", profile.timeout.to_i,
72
83
  "--exec-timeout", profile.exec_timeout.to_i,
@@ -7,7 +7,7 @@ module Lemans
7
7
  class Nop < Agent
8
8
  NAME = "nop"
9
9
 
10
- def run(_task, _environment)
10
+ def run(_task, _environment, history: nil)
11
11
  Response.new(outcome: Result::Outcome.new(:completed), usage: Result::Usage.zero)
12
12
  end
13
13
  end
@@ -13,7 +13,7 @@ module Lemans
13
13
  ENTRYPOINT = "solve.sh"
14
14
  PATCH = "solution.patch"
15
15
 
16
- def run(task, environment)
16
+ def run(task, environment, history: nil)
17
17
  files = task.solution_files
18
18
  if files.empty?
19
19
  raise ConfigError, "#{task.name}: no solution/ to run — the oracle has nothing to prove" if
data/lib/lemans/agents.rb CHANGED
@@ -11,11 +11,13 @@ module Lemans
11
11
  "miniswen-installed" => "MiniswenInstalled"
12
12
  }.freeze
13
13
 
14
- def self.build(name, profile:, model: nil)
14
+ def self.build(name, profile:, model: nil) = lookup(name).new(profile: profile, model: model)
15
+
16
+ def self.lookup(name)
15
17
  constant = REGISTRY[name] or
16
18
  raise ConfigError, "unknown agent #{name.inspect} (known: #{REGISTRY.keys.join(", ")})"
17
19
 
18
- const_get(constant).new(profile: profile, model: model)
20
+ const_get(constant)
19
21
  end
20
22
  end
21
23
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "prism"
5
4
 
6
5
  module Lemans
7
6
  class CLI < Thor
@@ -12,13 +11,19 @@ module Lemans
12
11
  ALLOWED = "fail (allowed)"
13
12
  CHECKS = "checks.json"
14
13
 
15
- Mapping = Struct.new(:checks, :base_credit, :points, keyword_init: true) do
14
+ # A checks.json-shaped file: `checks`, `grading`, and the features as
15
+ # `features: { name => [checks] }`.
16
+ Mapping = Struct.new(:checks, :base_credit, :points, :features, :stray_features, keyword_init: true) do
16
17
  def self.from_json(data)
17
18
  checks = data["checks"] or raise ConfigError, "a mapping needs a `checks` section"
18
19
  grading = data["grading"] || {}
19
20
  declared = grading["points"] || {}
20
21
  allowed = checks.select { |_, status| status == ALLOWED }.keys
21
- new(checks:, base_credit: grading["base_credit"], points: allowed.to_h { [ it, declared.fetch(it, 1) ] })
22
+ features = data["features"] || {}
23
+ unknown = features.values.flatten - checks.keys
24
+ raise ConfigError, "the mapping's features name unknown checks: #{unknown.inspect}" if unknown.any?
25
+
26
+ new(checks:, base_credit: grading["base_credit"], points: allowed.to_h { [ it, declared.fetch(it, 1) ] }, features:)
22
27
  end
23
28
 
24
29
  def names = checks.keys.sort
@@ -28,82 +33,18 @@ module Lemans
28
33
  def grading = { base_credit:, points: }.compact
29
34
  end
30
35
 
31
- Change = Struct.new(:result, :reward, :credit, keyword_init: true)
32
-
33
- # Reads the grading schema off the test file without running it: every
34
- # `def test_*` and ActiveSupport `test "..."` is a check, an
35
- # `allow_failure` call inside makes it an extra worth its `points:`.
36
- class TestScanner < Prism::Visitor
37
- attr_reader :tests, :points, :base_credit
38
-
39
- def initialize
40
- super
41
- @scope = []
42
- @tests = []
43
- @points = {}
44
- @current = nil
45
- end
46
-
47
- def visit_module_node(node) = scoped(node) { super }
48
-
49
- def visit_class_node(node) = scoped(node) { super }
50
-
51
- def visit_def_node(node)
52
- return super unless node.name.start_with?("test_")
53
-
54
- within("#{@scope.join("::")}##{node.name}") { super }
55
- end
56
-
57
- def visit_call_node(node)
58
- case node.name
59
- when :test
60
- title = node.arguments&.arguments&.first
61
- if node.receiver.nil? && node.block && title.is_a?(Prism::StringNode)
62
- return within("#{@scope.join("::")}#test_#{title.unescaped.gsub(/\s+/, "_")}") { super }
63
- end
64
- when :allow_failure
65
- @points[@current] ||= points_of(node) if @current
66
- when :base_credit=
67
- @base_credit = node.arguments.arguments.first.value if node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :LemansReport
68
- end
69
- super
70
- end
71
-
72
- private
73
-
74
- def scoped(node)
75
- @scope.push(node.constant_path.full_name)
76
- yield
77
- ensure
78
- @scope.pop
79
- end
80
-
81
- def within(check)
82
- @tests << check
83
- @current = check
84
- yield
85
- ensure
86
- @current = nil
87
- end
88
-
89
- def points_of(node)
90
- keywords = node.arguments&.arguments&.grep(Prism::KeywordHashNode)&.first
91
- pair = keywords&.elements&.find { it.is_a?(Prism::AssocNode) && it.key.is_a?(Prism::SymbolNode) && it.key.unescaped == "points" }
92
- pair ? pair.value.value : 1
93
- end
94
- end
36
+ Change = Struct.new(:result, :reward, :credit, :features, keyword_init: true)
95
37
 
96
38
  class << self
97
39
  def mapping_from_file(path) = Mapping.from_json(JSON.parse(File.read(path)))
98
40
 
99
41
  def mapping_for(task)
100
- local, = task.test_files.find { |_, remote| remote == "verification_test.rb" }
101
- raise ConfigError, "#{task.name} has no verification_test.rb to read the grading from" unless local
42
+ scanner = Trial::Verifier::TestScanner.for(task)
43
+ raise ConfigError, "#{task.name} has no verification_test.rb to read the grading from" unless scanner
102
44
 
103
- scanner = TestScanner.new
104
- Prism.parse_file(local.to_s).value.accept(scanner)
105
45
  checks = scanner.tests.to_h { [ it, scanner.points.key?(it) ? ALLOWED : "fail" ] }
106
- Mapping.new(checks:, base_credit: scanner.base_credit, points: scanner.points)
46
+ Mapping.new(checks:, base_credit: scanner.base_credit, points: scanner.points,
47
+ features: scanner.features, stray_features: scanner.stray_features)
107
48
  end
108
49
  end
109
50
 
@@ -115,8 +56,14 @@ module Lemans
115
56
  @mapping = mapping
116
57
  end
117
58
 
118
- def results
119
- @results ||= store.query(task:).select(&:scored?).sort_by { it.id.to_s }
59
+ def results = runs.select(&:scored?)
60
+
61
+ # Older multistep results lack the task's step count; returns the runs that gained it.
62
+ def record_total_steps!(total)
63
+ runs.select { it.steps && it.total_steps != total }.each do |result|
64
+ result.total_steps = total
65
+ store.save(result)
66
+ end
120
67
  end
121
68
 
122
69
  # A statically read mapping is only trusted once a stored checks.json
@@ -149,6 +96,8 @@ module Lemans
149
96
 
150
97
  private
151
98
 
99
+ def runs = @runs ||= store.query(task:).sort_by { it.id.to_s }
100
+
152
101
  def checks_of(result)
153
102
  raw = store.read_artifact(result, CHECKS)
154
103
  raw && JSON.parse(raw)
@@ -164,11 +113,14 @@ module Lemans
164
113
  updated[:grading] = mapping.grading unless mapping.grading.empty?
165
114
  reward = failures.empty? ? 1.0 : 0.0
166
115
  credit = credit_of(statuses, reward)
167
- return if reward == result.reward && credit == result.credit && JSON.parse(JSON.generate(updated)) == checks
116
+ features = Trial::Verifier.features_from(statuses, mapping.features)
117
+ return if reward == result.reward && credit == result.credit && features == result.features &&
118
+ JSON.parse(JSON.generate(updated)) == checks
168
119
 
169
120
  store.save_artifact(result, "#{JSON.pretty_generate(updated)}\n", path: CHECKS, force: true)
170
- change = Change.new(result:, reward: [ result.reward, reward ], credit: [ result.credit, credit ])
171
- store.save(result.graded!(reward, credit:))
121
+ change = Change.new(result:, reward: [ result.reward, reward ], credit: [ result.credit, credit ],
122
+ features: [ result.features, features ])
123
+ store.save(result.graded!(reward, credit:, features:))
172
124
  change
173
125
  end
174
126
 
@@ -10,7 +10,7 @@ module Lemans
10
10
  # task, agent, model — "task-model" reads as two columns.
11
11
  class Aggregate
12
12
  KEYS = %i[task agent model].freeze
13
- METRICS = %i[score credit time cost steps tokens].freeze
13
+ METRICS = %i[score credit features time cost steps tokens].freeze
14
14
  METRIC_SOURCES = { credit: :credit, time: :duration, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
15
15
 
16
16
  attr_reader :report, :keys
@@ -32,32 +32,36 @@ module Lemans
32
32
  .sort_by { |group| keys.map { group[it].to_s } }
33
33
  end
34
34
 
35
- def order_by!(column)
36
- column = Report.sort_column(column, allowed: keys + METRICS)
37
- @groups =
38
- if keys.include?(column)
39
- Report.sort_rows(@groups) { it[column].to_s }
40
- elsif column == :score
41
- Report.sort_rows(@groups, descending: true) { [ Rational(it[:solved], it[:attempts]), it[:attempts] ] }
42
- else
43
- Report.sort_rows(@groups, descending: true) { it[METRIC_SOURCES.fetch(column)] }
44
- end
35
+ def order_by!(spec)
36
+ sort_keys = Report.sort_columns(spec, allowed: keys + METRICS + report.feature_columns).map do |column, reversed|
37
+ [ ->(group) { sort_value(group, column) }, !keys.include?(column) != reversed ]
38
+ end
39
+ @groups = Report.sort_rows(@groups, sort_keys)
45
40
  self
46
41
  end
47
42
 
48
43
  def to_rows
49
- metrics = report.fractional? ? METRICS : METRICS - [ :credit ]
50
- [ keys.map(&:to_s) + metrics.map(&:to_s) ] +
44
+ metrics = METRICS - [ (:credit unless report.fractional?), (:features unless report.features?) ].compact
45
+ columns = keys + metrics + report.shown_feature_columns
46
+ columns -= Report.hidden_columns(report.hide_columns, allowed: columns)
47
+ [ columns.map(&:to_s) ] +
51
48
  @groups.map do |group|
52
- keys.map { |key| display_key(key, group[key]) } + metrics.map { cell(it, group) }
49
+ columns.map do |column|
50
+ if keys.include?(column) then display_key(column, group[column])
51
+ elsif metrics.include?(column) then cell(column, group)
52
+ else pass_rate(group[column])
53
+ end
54
+ end
53
55
  end
54
56
  end
55
57
 
56
58
  def to_csv
57
- columns = keys + %i[solved attempts credit duration cost_usd steps tokens]
59
+ columns = keys + %i[solved attempts credit features_passed features_total duration cost_usd steps tokens]
58
60
  CSV.generate do |csv|
59
- csv << columns
60
- @groups.each { |group| csv << columns.map { group[it] } }
61
+ csv << columns + report.shown_feature_columns
62
+ @groups.each do |group|
63
+ csv << columns.map { group[it] } + report.shown_feature_columns.map { group[it] && pass_rate(group[it]) }
64
+ end
61
65
  end
62
66
  end
63
67
 
@@ -67,6 +71,16 @@ module Lemans
67
71
 
68
72
  private
69
73
 
74
+ def sort_value(group, column)
75
+ if column == :model then Report.short_model(group[:model])
76
+ elsif keys.include?(column) then group[column].to_s
77
+ elsif report.feature_columns.include?(column) then group[column] && Rational(*group[column])
78
+ elsif column == :features then group[:features_total] && Rational(group[:features_passed], group[:features_total])
79
+ elsif column == :score then [ Rational(group[:solved], group[:attempts]), group[:attempts] ]
80
+ else group[METRIC_SOURCES.fetch(column)]
81
+ end
82
+ end
83
+
70
84
  # Attempts count every run; means and the median skip runs that never
71
85
  # measured the value, so one invalid trial cannot zero out a cell.
72
86
  def build(values, group)
@@ -77,18 +91,37 @@ module Lemans
77
91
  duration: median(group.filter_map { it[:duration] }),
78
92
  cost_usd: mean(group.filter_map { it[:cost_usd] }),
79
93
  steps: mean(group.filter_map { it[:steps] }),
80
- tokens: mean(group.filter_map { it[:tokens] })
94
+ tokens: mean(group.filter_map { it[:tokens] }),
95
+ **features_sum(group),
96
+ **report.feature_columns.to_h { [ it, feature_tally(group, it) ] }
81
97
  )
82
98
  end
83
99
 
100
+ # Passed out of the runs that graded the feature; nil when none did
101
+ def feature_tally(group, column)
102
+ graded = group.map { Report.feature_of(it, column) }.reject(&:nil?)
103
+ [ graded.count(true), graded.size ] unless graded.empty?
104
+ end
105
+
106
+ def pass_rate(tally) = tally ? tally.join("/") : "-"
107
+
108
+ # Features passed out of graded, summed over the group's runs
109
+ def features_sum(group)
110
+ tallies = group.filter_map { Report.features_tally(it) }
111
+ return { features_passed: nil, features_total: nil } if tallies.empty?
112
+
113
+ { features_passed: tallies.sum(&:first), features_total: tallies.sum(&:last) }
114
+ end
115
+
84
116
  def cell(metric, group)
85
117
  case metric
86
118
  when :score then "#{group[:solved]}/#{group[:attempts]}"
87
119
  when :credit then mean_display(group[:credit], 2)
88
- when :time then time(group[:duration])
89
- when :cost then cost(group[:cost_usd])
120
+ when :features then group[:features_total] ? "#{group[:features_passed]}/#{group[:features_total]}" : "-"
121
+ when :time then Report.duration_display(group[:duration])
122
+ when :cost then Report.cost_display(group[:cost_usd])
90
123
  when :steps then mean_display(group[:steps], 1)
91
- when :tokens then mean_display(group[:tokens], 0)
124
+ when :tokens then Report.tokens_display(group[:tokens])
92
125
  end
93
126
  end
94
127
 
@@ -108,15 +141,6 @@ module Lemans
108
141
  key == :model ? Report.short_model(value) : value.to_s
109
142
  end
110
143
 
111
- def time(sec)
112
- return "-" if sec.nil?
113
-
114
- minutes, seconds = sec.round.divmod(60)
115
- minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
116
- end
117
-
118
- def cost(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
119
-
120
144
  def mean_display(value, digits) = value.nil? ? "-" : format("%g", value.round(digits))
121
145
  end
122
146
  end