llm-evals 0.4.0 → 0.5.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/CHANGELOG.md +38 -0
- data/README.md +163 -87
- data/exe/llm-evals +60 -5
- data/lib/.DS_Store +0 -0
- data/lib/llm/.DS_Store +0 -0
- data/lib/llm/evals/actions/adapter.rb +50 -0
- data/lib/llm/evals/actions/error_budget.rb +63 -0
- data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
- data/lib/llm/evals/actions/error_streak.rb +83 -0
- data/lib/llm/evals/actions/experiment.rb +251 -0
- data/lib/llm/evals/actions/verify.rb +61 -0
- data/lib/llm/evals/actions.rb +9 -0
- data/lib/llm/evals/cli/cases.rb +10 -0
- data/lib/llm/evals/cli/command.rb +32 -99
- data/lib/llm/evals/cli/compare.rb +0 -20
- data/lib/llm/evals/cli/contender_selector.rb +61 -0
- data/lib/llm/evals/cli/error_pause.rb +81 -0
- data/lib/llm/evals/cli/experiment_pool.rb +97 -0
- data/lib/llm/evals/cli/formatting.rb +8 -0
- data/lib/llm/evals/cli/leaderboard.rb +66 -12
- data/lib/llm/evals/cli/model_colors.rb +3 -0
- data/lib/llm/evals/cli/model_command.rb +152 -315
- data/lib/llm/evals/cli/model_options.rb +80 -0
- data/lib/llm/evals/cli/option_groups.rb +32 -0
- data/lib/llm/evals/cli/progress_bars.rb +92 -5
- data/lib/llm/evals/cli/render.rb +6 -6
- data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
- data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
- data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
- data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
- data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
- data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
- data/lib/llm/evals/cli/run.rb +13 -22
- data/lib/llm/evals/cli/run_planner.rb +97 -0
- data/lib/llm/evals/cli/run_record.rb +68 -0
- data/lib/llm/evals/cli/standings.rb +39 -17
- data/lib/llm/evals/cli/status_box.rb +51 -0
- data/lib/llm/evals/cli/table.rb +103 -0
- data/lib/llm/evals/cli/text.rb +22 -0
- data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
- data/lib/llm/evals/cli/upgrade_config.rb +60 -0
- data/lib/llm/evals/cli/verify.rb +5 -1
- data/lib/llm/evals/cli/version.rb +4 -0
- data/lib/llm/evals/cli/weights.rb +48 -0
- data/lib/llm/evals/cli.rb +39 -6
- data/lib/llm/evals/config/contender_builder.rb +30 -0
- data/lib/llm/evals/config/error_limits.rb +41 -0
- data/lib/llm/evals/config/listing.rb +153 -0
- data/lib/llm/evals/config/results.rb +42 -0
- data/lib/llm/evals/config/score_weights.rb +25 -0
- data/lib/llm/evals/config/upgrade.rb +107 -0
- data/lib/llm/evals/config.rb +90 -79
- data/lib/llm/evals/datasets/case.rb +46 -0
- data/lib/llm/evals/datasets/discovery.rb +143 -0
- data/lib/llm/evals/datasets.rb +9 -0
- data/lib/llm/evals/launcher.rb +57 -3
- data/lib/llm/evals/models/contender.rb +51 -0
- data/lib/llm/evals/models/model.rb +87 -0
- data/lib/llm/evals/models/throttle.rb +170 -0
- data/lib/llm/evals/models.rb +9 -0
- data/lib/llm/evals/providers/base.rb +61 -16
- data/lib/llm/evals/providers/catalog.rb +5 -2
- data/lib/llm/evals/providers/open_router.rb +54 -7
- data/lib/llm/evals/providers/registry.rb +49 -16
- data/lib/llm/evals/providers/type_safe/client.rb +67 -0
- data/lib/llm/evals/providers/type_safe.rb +113 -12
- data/lib/llm/evals/providers.rb +9 -0
- data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
- data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
- data/lib/llm/evals/render/reports/html/format.rb +38 -0
- data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
- data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
- data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
- data/lib/llm/evals/render/reports/html.rb +60 -181
- data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
- data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
- data/lib/llm/evals/render/reports/json.rb +38 -141
- data/lib/llm/evals/render/reports/pipeline.rb +55 -20
- data/lib/llm/evals/render/reports/terminal.rb +8 -1
- data/lib/llm/evals/render.rb +9 -0
- data/lib/llm/evals/results/provider_response.rb +25 -0
- data/lib/llm/evals/results/reply.rb +38 -0
- data/lib/llm/evals/results/run_outcome.rb +15 -0
- data/lib/llm/evals/results/run_result.rb +40 -0
- data/lib/llm/evals/results/score.rb +45 -0
- data/lib/llm/evals/results/warnings.rb +149 -0
- data/lib/llm/evals/results.rb +9 -0
- data/lib/llm/evals/runtime/configuration.rb +295 -0
- data/lib/llm/evals/runtime/environment.rb +90 -0
- data/lib/llm/evals/runtime/helper.rb +153 -0
- data/lib/llm/evals/runtime.rb +2 -67
- data/lib/llm/evals/scoring/metric.rb +47 -0
- data/lib/llm/evals/scoring/schema_validator.rb +102 -0
- data/lib/llm/evals/scoring/scorer.rb +49 -0
- data/lib/llm/evals/scoring/weighted_score.rb +148 -0
- data/lib/llm/evals/scoring.rb +9 -0
- data/lib/llm/evals/stats/call_stats.rb +91 -0
- data/lib/llm/evals/stats/usage.rb +46 -0
- data/lib/llm/evals/stats.rb +29 -0
- data/lib/llm/evals/testing/echo_adapter.rb +14 -9
- data/lib/llm/evals/testing/fake_model.rb +8 -6
- data/lib/llm/evals/testing/fake_provider.rb +39 -8
- data/lib/llm/evals/testing.rb +9 -0
- data/lib/llm/evals/trackers/base.rb +110 -0
- data/lib/llm/evals/trackers/braintrust.rb +40 -3
- data/lib/llm/evals/trackers/local.rb +38 -2
- data/lib/llm/evals/trackers/logfire.rb +56 -3
- data/lib/llm/evals/trackers/registry.rb +29 -7
- data/lib/llm/evals/trackers.rb +9 -0
- data/lib/llm/evals/version.rb +3 -1
- data/lib/llm/evals.rb +17 -7
- metadata +91 -24
- data/lib/llm/evals/adapter.rb +0 -47
- data/lib/llm/evals/case.rb +0 -36
- data/lib/llm/evals/completion.rb +0 -11
- data/lib/llm/evals/configuration.rb +0 -173
- data/lib/llm/evals/contender.rb +0 -31
- data/lib/llm/evals/discovery.rb +0 -95
- data/lib/llm/evals/experiment.rb +0 -158
- data/lib/llm/evals/helper.rb +0 -98
- data/lib/llm/evals/metric.rb +0 -26
- data/lib/llm/evals/model.rb +0 -55
- data/lib/llm/evals/reply.rb +0 -18
- data/lib/llm/evals/run_result.rb +0 -14
- data/lib/llm/evals/schema_validator.rb +0 -84
- data/lib/llm/evals/score.rb +0 -26
- data/lib/llm/evals/scorer.rb +0 -45
- data/lib/llm/evals/tracker.rb +0 -80
- data/lib/llm/evals/usage.rb +0 -33
- data/lib/llm/evals/verify.rb +0 -49
- data/lib/llm/evals/warnings.rb +0 -98
- data/lib/llm/evals/weighted_score.rb +0 -116
- /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
- /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Actions
|
|
6
|
+
# How many failed calls a run tolerates, from `errors.abort_after`: at least
|
|
7
|
+
# `total` errors, and, when `percent` is set, at least that share of the
|
|
8
|
+
# calls finished so far. Shared by every experiment's threads, and once
|
|
9
|
+
# spent it stays spent, so a late success cannot restart an aborted run.
|
|
10
|
+
class ErrorBudget
|
|
11
|
+
attr_reader :total, :percent, :errors, :calls
|
|
12
|
+
|
|
13
|
+
# @!attribute [r] total
|
|
14
|
+
# The fewest errors at which the run may abort.
|
|
15
|
+
# @return [Integer]
|
|
16
|
+
|
|
17
|
+
# @!attribute [r] percent
|
|
18
|
+
# The share of finished calls that must also have failed, from 0 to 100.
|
|
19
|
+
# @return [Numeric, nil]
|
|
20
|
+
|
|
21
|
+
# @!attribute [r] errors
|
|
22
|
+
# Failed calls so far, counted across every experiment sharing this budget.
|
|
23
|
+
# @return [Integer]
|
|
24
|
+
|
|
25
|
+
# @!attribute [r] calls
|
|
26
|
+
# Finished calls so far, whether or not they failed.
|
|
27
|
+
# @return [Integer]
|
|
28
|
+
|
|
29
|
+
# @param total [Integer] errors before the run may abort
|
|
30
|
+
# @param percent [Numeric, nil] the share of finished calls that must have failed
|
|
31
|
+
def initialize(total:, percent: nil)
|
|
32
|
+
@total = total
|
|
33
|
+
@percent = percent
|
|
34
|
+
@errors = 0
|
|
35
|
+
@calls = 0
|
|
36
|
+
@spent = false
|
|
37
|
+
@lock = Mutex.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Counts one finished call, and marks the budget spent once both limits are met.
|
|
41
|
+
#
|
|
42
|
+
# @param result [Results::RunResult] the finished call
|
|
43
|
+
# @return [void]
|
|
44
|
+
def record(result)
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
@calls += 1
|
|
47
|
+
@errors += 1 if result.errored?
|
|
48
|
+
@spent ||= @errors >= total && (percent.nil? || @errors * 100.0 / @calls >= percent)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @return [Boolean] whether the run must stop
|
|
53
|
+
def spent? = @lock.synchronize { @spent }
|
|
54
|
+
|
|
55
|
+
# @return [String] why the run stopped
|
|
56
|
+
def reason
|
|
57
|
+
limit = percent ? "#{total} errors at #{percent}%" : "#{total} errors"
|
|
58
|
+
"run aborted: #{errors} of #{calls} finished calls failed (errors.abort_after: #{limit}); partial results were kept"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Actions
|
|
6
|
+
# What a streak of failed calls has in common: the messages, and how much of
|
|
7
|
+
# the run they touch. One failing model and a failing key call for different
|
|
8
|
+
# answers, so this names which it is.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] failures
|
|
11
|
+
# @return [Array<Results::RunResult>] the failed calls
|
|
12
|
+
# @!attribute [r] runs
|
|
13
|
+
# @return [Array<Experiment>] the whole run, which "all" is measured against
|
|
14
|
+
ErrorDiagnosis = Data.define(:failures, :runs) do
|
|
15
|
+
# The distinct messages, most frequent first.
|
|
16
|
+
#
|
|
17
|
+
# @return [Array<Array(String, Integer)>] each message with its count
|
|
18
|
+
def messages
|
|
19
|
+
failures.map { it.error || it.reply.error }.map(&:to_s).tally.sort_by { |message, count| [-count, message] }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Scope] the models that failed
|
|
23
|
+
def models = scope(failures.map { it.experiment.model.id }, runs.map { it.model.id })
|
|
24
|
+
|
|
25
|
+
# @return [Scope] the datasets that failed
|
|
26
|
+
def datasets = scope(failures.map { it.case.dataset }, runs.map(&:dataset))
|
|
27
|
+
|
|
28
|
+
# @return [Scope] the cases that failed
|
|
29
|
+
def cases = scope(failures.map { it.case.key }, runs.flat_map(&:cases).map(&:key))
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# @param failed [Array<String>] one name per failure
|
|
34
|
+
# @param planned [Array<String>] one name per planned item
|
|
35
|
+
# @return [Scope]
|
|
36
|
+
def scope(failed, planned) = ErrorDiagnosis::Scope.new(failed: failed.uniq.sort, total: planned.uniq.size)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# How much of one dimension of the run has failed.
|
|
40
|
+
#
|
|
41
|
+
# @!attribute [r] failed
|
|
42
|
+
# @return [Array<String>] the names that failed, sorted
|
|
43
|
+
# @!attribute [r] total
|
|
44
|
+
# @return [Integer] how many the run planned
|
|
45
|
+
ErrorDiagnosis::Scope = Data.define(:failed, :total) do
|
|
46
|
+
# @return [Boolean] whether every planned one failed
|
|
47
|
+
def all? = failed.size >= total
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Actions
|
|
6
|
+
# Notices when every call has been failing, and holds the run while someone
|
|
7
|
+
# decides what to do. A bad key or a dead endpoint fails everything, and
|
|
8
|
+
# without this the run spends its whole matrix finding that out. Unlike
|
|
9
|
+
# {ErrorBudget}, which the lineup opts into and which only stops, this is
|
|
10
|
+
# always on and can resume. Shared by every experiment's threads.
|
|
11
|
+
class ErrorStreak
|
|
12
|
+
# Failures in a row, with no success between them, that pause the run.
|
|
13
|
+
LIMIT = 100
|
|
14
|
+
|
|
15
|
+
# @param limit [Integer] failures in a row that pause the run
|
|
16
|
+
def initialize(limit: LIMIT)
|
|
17
|
+
@limit = limit
|
|
18
|
+
@failures = []
|
|
19
|
+
@paused = false
|
|
20
|
+
@stopped = false
|
|
21
|
+
@lock = Mutex.new
|
|
22
|
+
@resumed = ConditionVariable.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Counts one finished call. The call that completes a streak is the one
|
|
26
|
+
# that asks; the other workers wait in {#hold}. Calls still in flight
|
|
27
|
+
# while it asks do not count, so one question cannot lead straight to another.
|
|
28
|
+
#
|
|
29
|
+
# @param result [Results::RunResult] the finished call
|
|
30
|
+
# @yieldparam failures [Array<Results::RunResult>] the streak, oldest first
|
|
31
|
+
# @yieldreturn [Boolean] whether the run should carry on
|
|
32
|
+
# @return [void]
|
|
33
|
+
def record(result)
|
|
34
|
+
failures = @lock.synchronize { tally(result) }
|
|
35
|
+
settle { yield failures } if failures
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Blocks while the run is paused. Workers call it before starting a case.
|
|
39
|
+
#
|
|
40
|
+
# @return [void]
|
|
41
|
+
def hold
|
|
42
|
+
@lock.synchronize { @resumed.wait(@lock) while @paused }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Boolean] whether the answer was to stop
|
|
46
|
+
def stopped? = @lock.synchronize { @stopped }
|
|
47
|
+
|
|
48
|
+
# @return [String] why the run stopped
|
|
49
|
+
def reason = "run stopped after #{@limit} failed calls in a row; partial results were kept"
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# @param result [Results::RunResult]
|
|
54
|
+
# @return [Array<Results::RunResult>, nil] the streak, once it reaches the limit
|
|
55
|
+
def tally(result)
|
|
56
|
+
return if @paused || @stopped
|
|
57
|
+
|
|
58
|
+
result.errored? ? @failures << result : @failures.clear
|
|
59
|
+
return if @failures.size < @limit
|
|
60
|
+
|
|
61
|
+
@paused = true
|
|
62
|
+
@failures.dup.tap { @failures.clear }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Releases the workers whatever happens: an exception or an interrupt
|
|
66
|
+
# while asking counts as a stop, since nobody said to carry on.
|
|
67
|
+
#
|
|
68
|
+
# @yieldreturn [Boolean] whether the run should carry on
|
|
69
|
+
# @return [void]
|
|
70
|
+
def settle
|
|
71
|
+
carry_on = false
|
|
72
|
+
carry_on = yield
|
|
73
|
+
ensure
|
|
74
|
+
@lock.synchronize do
|
|
75
|
+
@paused = false
|
|
76
|
+
@stopped = !carry_on
|
|
77
|
+
@resumed.broadcast
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Actions
|
|
6
|
+
# One model at one setting over every case of one dataset, run on a
|
|
7
|
+
# thread pool the gem owns, plus the per-case detail the terminal report
|
|
8
|
+
# needs. Trackers only watch: {Trackers::Local} always, and at most one
|
|
9
|
+
# remote tracker, each told when the experiment starts, when a case
|
|
10
|
+
# finishes, and what the summary was.
|
|
11
|
+
class Experiment
|
|
12
|
+
# What one finished experiment reports back. `means` holds one entry
|
|
13
|
+
# per metric plus "score", the mean of each case's overall value;
|
|
14
|
+
# `usage` is summed over the cases; `permalink` is the remote tracker's
|
|
15
|
+
# page, when it has one.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] model
|
|
18
|
+
# @return [Models::Model] what ran
|
|
19
|
+
# @!attribute [r] dataset
|
|
20
|
+
# @return [String] the dataset's name
|
|
21
|
+
# @!attribute [r] name
|
|
22
|
+
# @return [String] the experiment's {Experiment#name}
|
|
23
|
+
# @!attribute [r] means
|
|
24
|
+
# @return [Hash{String => Float, nil}] one mean per metric, plus "score"
|
|
25
|
+
# @!attribute [r] results
|
|
26
|
+
# @return [Array<Results::RunResult>] in the experiment's case order
|
|
27
|
+
# @!attribute [r] errors
|
|
28
|
+
# @return [Array<String>] one "case: message" per call that raised
|
|
29
|
+
# @!attribute [r] duration
|
|
30
|
+
# @return [Float] seconds, less an estimate of the time spent on rate limits
|
|
31
|
+
# @!attribute [r] permalink
|
|
32
|
+
# @return [String, nil] the remote tracker's page
|
|
33
|
+
# @!attribute [r] usage
|
|
34
|
+
# @return [Stats::Usage] summed over the cases
|
|
35
|
+
Summary = Data.define(:model, :dataset, :name, :means, :results, :errors, :duration, :permalink, :usage) do
|
|
36
|
+
# @return [Float, nil] the mean overall score
|
|
37
|
+
def score = means["score"]
|
|
38
|
+
|
|
39
|
+
# @param metric [Scoring::Metric, String]
|
|
40
|
+
# @return [Float, nil]
|
|
41
|
+
def mean(metric) = means[metric.respond_to?(:name) ? metric.name : metric.to_s]
|
|
42
|
+
|
|
43
|
+
# @return [Float, nil] the mean seconds of the calls that worked
|
|
44
|
+
def latency = Stats.mean(worked_durations)
|
|
45
|
+
|
|
46
|
+
# @return [Float, nil] the 95th percentile seconds of the calls that worked
|
|
47
|
+
def latency_p95 = Stats.percentile(worked_durations, Stats::CallStats::P95)
|
|
48
|
+
|
|
49
|
+
# Each working call's own seconds, which {#latency} and {#latency_p95} read.
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<Float>] errored calls are left out
|
|
52
|
+
def worked_durations = results.reject(&:errored?).map(&:duration)
|
|
53
|
+
|
|
54
|
+
# The same experiment over one variant's cases. Its duration is the sum
|
|
55
|
+
# of those cases' own, since they overlapped with the others.
|
|
56
|
+
#
|
|
57
|
+
# @param variant [String]
|
|
58
|
+
# @param metrics [Array<Scoring::Metric>] the scorer's
|
|
59
|
+
# @return [Summary]
|
|
60
|
+
def for_variant(variant, metrics)
|
|
61
|
+
chosen = results.select { it.case.variant == variant }
|
|
62
|
+
with(means: Experiment.means(metrics, chosen),
|
|
63
|
+
results: chosen,
|
|
64
|
+
errors: chosen.select(&:failed?).map { "#{it.case.key}: #{it.error}" },
|
|
65
|
+
duration: chosen.sum(&:duration),
|
|
66
|
+
usage: Experiment.usage(chosen))
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Averages what the results scored; a failed case has no score and is skipped.
|
|
71
|
+
#
|
|
72
|
+
# @param metrics [Array<Scoring::Metric>]
|
|
73
|
+
# @param results [Array<Results::RunResult>]
|
|
74
|
+
# @return [Hash{String => Float, nil}] one mean per metric, plus "score"
|
|
75
|
+
def self.means(metrics, results)
|
|
76
|
+
scored = results.filter_map(&:score)
|
|
77
|
+
means = metrics.to_h { |metric| [metric.name, metric.combine(scored.map { it.metrics[metric.name] })] }
|
|
78
|
+
means.merge("score" => scored.empty? ? nil : scored.sum(&:value).fdiv(scored.size))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Tokens and cost across the results; a raised case has no reply and adds nothing.
|
|
82
|
+
#
|
|
83
|
+
# @param results [Array<Results::RunResult>]
|
|
84
|
+
# @return [Stats::Usage] summed over the results
|
|
85
|
+
def self.usage(results) = results.filter_map(&:reply).map(&:usage).reduce(Stats::Usage.zero, :+)
|
|
86
|
+
|
|
87
|
+
attr_reader :model, :adapter, :scorer, :cases, :dataset, :parallelism, :trackers
|
|
88
|
+
|
|
89
|
+
# @!attribute [r] model
|
|
90
|
+
# The model at the setting under test.
|
|
91
|
+
# @return [Models::Model]
|
|
92
|
+
|
|
93
|
+
# @!attribute [r] adapter
|
|
94
|
+
# Runs each case.
|
|
95
|
+
# @return [Adapter]
|
|
96
|
+
|
|
97
|
+
# @!attribute [r] scorer
|
|
98
|
+
# Scores each reply.
|
|
99
|
+
# @return [Scoring::Scorer]
|
|
100
|
+
|
|
101
|
+
# @!attribute [r] cases
|
|
102
|
+
# Sorted by variant, number and id.
|
|
103
|
+
# @return [Array<Datasets::Case>]
|
|
104
|
+
|
|
105
|
+
# @!attribute [r] dataset
|
|
106
|
+
# The name of the dataset the first case came from.
|
|
107
|
+
# @return [String]
|
|
108
|
+
|
|
109
|
+
# @!attribute [r] parallelism
|
|
110
|
+
# Cases in flight at once, never below 1.
|
|
111
|
+
# @return [Integer]
|
|
112
|
+
|
|
113
|
+
# @!attribute [r] trackers
|
|
114
|
+
# Only the enabled ones of those given, in the order given.
|
|
115
|
+
# @return [Array<Trackers::Base>]
|
|
116
|
+
|
|
117
|
+
# @param model [Models::Model]
|
|
118
|
+
# @param cases [Array<Datasets::Case>] all of one dataset
|
|
119
|
+
# @param adapter [Adapter]
|
|
120
|
+
# @param scorer [Scoring::Scorer]
|
|
121
|
+
# @param parallelism [Integer] cases in flight at once
|
|
122
|
+
# @param trackers [Array<Trackers::Base>] told about the run, in this order
|
|
123
|
+
# @raise [ArgumentError] when there are no cases
|
|
124
|
+
def initialize(model:, cases:, adapter:, scorer:, parallelism: 4, trackers: [])
|
|
125
|
+
raise ArgumentError, "an experiment needs at least one case" if cases.empty?
|
|
126
|
+
|
|
127
|
+
@model = model
|
|
128
|
+
@cases = cases.sort_by { [it.variant, it.number || 0, it.id] }
|
|
129
|
+
@dataset = cases.first.dataset
|
|
130
|
+
@adapter = adapter
|
|
131
|
+
@scorer = scorer
|
|
132
|
+
@parallelism = [parallelism.to_i, 1].max
|
|
133
|
+
@trackers = trackers.select(&:enabled?)
|
|
134
|
+
@results = {}
|
|
135
|
+
@lock = Mutex.new
|
|
136
|
+
@waited = 0.0
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# How reports and trackers tell this experiment from the others of a run.
|
|
140
|
+
#
|
|
141
|
+
# @return [String] e.g. "anthropic/claude-haiku-5.5 effort=low t=0.0 [clear]"
|
|
142
|
+
def name = "#{model} [#{dataset}]"
|
|
143
|
+
|
|
144
|
+
# Runs every case, then asks the trackers for a page to link the summary to.
|
|
145
|
+
#
|
|
146
|
+
# @param on_case [#call, nil] called with each finished case's {Results::RunResult}
|
|
147
|
+
# @param stop [#call, nil] asked before each case; truthy leaves the rest unrun
|
|
148
|
+
# @return [Summary]
|
|
149
|
+
def run(on_case: nil, stop: nil)
|
|
150
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
151
|
+
adapter.prepare(dataset)
|
|
152
|
+
trackers.each { it.start(self) }
|
|
153
|
+
errors = run_locally(on_case, stop)
|
|
154
|
+
# An approximation: it assumes waits fell evenly across the workers. Workers
|
|
155
|
+
# that wait while others run did not hold up the run for that long.
|
|
156
|
+
summary = summarize(errors, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - (@waited / workers))
|
|
157
|
+
urls = trackers.map { it.finish(self, summary)&.fetch(:url, nil) }
|
|
158
|
+
summary.with(permalink: urls.compact.first)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Runs one case: the adapter, then the scorer. A reply the model failed
|
|
162
|
+
# (`Reply#error`) scores 0 without troubling the scorer; an exception is
|
|
163
|
+
# the transport's and is left to the caller, which counts it as an error.
|
|
164
|
+
#
|
|
165
|
+
# @param kase [Datasets::Case]
|
|
166
|
+
# @return [Results::RunResult]
|
|
167
|
+
# @raise [Error] when the adapter returns anything but a {Results::Reply}
|
|
168
|
+
def evaluate(kase)
|
|
169
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
170
|
+
reply, waited = Models::Throttle.measure { adapter.call(kase, model: model) }
|
|
171
|
+
@lock.synchronize { @waited += waited }
|
|
172
|
+
raise Error, "#{adapter.class}#call returned #{reply.class}, not an LLM::Evals::Results::Reply" unless reply.is_a?(Results::Reply)
|
|
173
|
+
|
|
174
|
+
score = reply.error? ? Results::Score.failed(scorer.metrics, reply.error) : scorer.call(kase, reply, context: adapter.context(kase))
|
|
175
|
+
Results::RunResult.new(experiment: self,
|
|
176
|
+
case: kase,
|
|
177
|
+
reply: reply,
|
|
178
|
+
score: score,
|
|
179
|
+
duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - waited)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# Threads worth starting: more than there are cases would only sit idle.
|
|
185
|
+
#
|
|
186
|
+
# @return [Integer]
|
|
187
|
+
def workers = [parallelism, cases.size].min
|
|
188
|
+
|
|
189
|
+
# Remembers a result and tells the trackers, under one lock, so a
|
|
190
|
+
# tracker sees results one at a time and in the order they are kept.
|
|
191
|
+
#
|
|
192
|
+
# @param result [Results::RunResult] the finished case
|
|
193
|
+
# @return [Results::RunResult] the same result
|
|
194
|
+
def remember(result)
|
|
195
|
+
@lock.synchronize do
|
|
196
|
+
@results[result.case.key] = result
|
|
197
|
+
trackers.each { it.record(result) }
|
|
198
|
+
end
|
|
199
|
+
result
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A fixed pool of threads over a queue: `parallelism` cases in flight.
|
|
203
|
+
#
|
|
204
|
+
# @param on_case [#call, nil] called with each finished case's {Results::RunResult}
|
|
205
|
+
# @param stop [#call, nil] asked before each case; truthy leaves the rest unrun
|
|
206
|
+
# @return [Array<String>] one "case: message" per case that raised
|
|
207
|
+
def run_locally(on_case, stop)
|
|
208
|
+
queue = Queue.new
|
|
209
|
+
cases.each { queue << it }
|
|
210
|
+
queue.close
|
|
211
|
+
errors = Queue.new
|
|
212
|
+
Array.new(workers) do
|
|
213
|
+
Thread.new do
|
|
214
|
+
while !stop&.call && (kase = queue.pop)
|
|
215
|
+
result = begin
|
|
216
|
+
remember evaluate(kase)
|
|
217
|
+
rescue StandardError => e
|
|
218
|
+
errors << "#{kase.key}: #{e.class}: #{e.message}"
|
|
219
|
+
remember Results::RunResult.new(experiment: self, case: kase, error: "#{e.class}: #{e.message}")
|
|
220
|
+
end
|
|
221
|
+
on_case&.call(result)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end.each(&:join)
|
|
225
|
+
Array.new(errors.size) { errors.pop }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Freezes what has been remembered so far into a {Summary}, in case order.
|
|
229
|
+
#
|
|
230
|
+
# @param errors [Array<String>, nil] the messages {#run_locally} collected
|
|
231
|
+
# @param permalink [String, nil] the remote tracker's page, when already known
|
|
232
|
+
# @param duration [Float] seconds to report
|
|
233
|
+
# @return [Summary]
|
|
234
|
+
def summarize(errors, permalink, duration)
|
|
235
|
+
results = @lock.synchronize { @results.values }.sort_by { cases.index(it.case) }
|
|
236
|
+
Summary.new(
|
|
237
|
+
model: model,
|
|
238
|
+
dataset: dataset,
|
|
239
|
+
name: name,
|
|
240
|
+
means: self.class.means(scorer.metrics, results),
|
|
241
|
+
results: results,
|
|
242
|
+
errors: Array(errors),
|
|
243
|
+
duration: duration,
|
|
244
|
+
permalink: permalink,
|
|
245
|
+
usage: self.class.usage(results)
|
|
246
|
+
)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Actions
|
|
6
|
+
# Offline consistency checks over every case, so a typo in the suite is
|
|
7
|
+
# caught before it is billed as a model's mistake. The gem checks what
|
|
8
|
+
# every suite has: an input and a parsable expected answer. The adapter
|
|
9
|
+
# checks each dataset's shared files, and the scorer each case against
|
|
10
|
+
# what it will demand of it.
|
|
11
|
+
class Verify
|
|
12
|
+
# @param configuration [Runtime::Configuration] the suite to check
|
|
13
|
+
def initialize(configuration = LLM::Evals.configuration)
|
|
14
|
+
@configuration = configuration
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Asks the adapter about each dataset, then checks every case in it.
|
|
18
|
+
#
|
|
19
|
+
# @return [Hash{String => Array<String>}] problems by "dataset/id"; empty when all is sound
|
|
20
|
+
def problems
|
|
21
|
+
discovery = @configuration.discovery
|
|
22
|
+
failures = {}
|
|
23
|
+
discovery.datasets.each do |dataset|
|
|
24
|
+
Array(adapter.verify(dataset)).each { (failures[dataset.to_s] ||= []) << it }
|
|
25
|
+
discovery.cases(dataset: dataset).each do |kase|
|
|
26
|
+
list = case_problems(kase)
|
|
27
|
+
failures[kase.key] = list unless list.empty?
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
failures
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Checks one case; an exception from the scorer or adapter is reported as a problem.
|
|
34
|
+
#
|
|
35
|
+
# @param kase [Datasets::Case] the case to check
|
|
36
|
+
# @return [Array<String>] problems; empty when the case is sound
|
|
37
|
+
def case_problems(kase)
|
|
38
|
+
problems = []
|
|
39
|
+
problems << "input is empty" if kase.input.empty?
|
|
40
|
+
problems << kase.metadata[:expected_error] if kase.metadata[:expected_error]
|
|
41
|
+
return problems unless kase.expected?
|
|
42
|
+
|
|
43
|
+
problems.concat(Array(scorer.verify(kase, context: adapter.context(kase))))
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
problems << "#{e.class}: #{e.message}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# The suite's adapter.
|
|
51
|
+
#
|
|
52
|
+
# @return [Actions::Adapter, nil] nil when the suite has not registered one
|
|
53
|
+
def adapter = @configuration.adapter
|
|
54
|
+
# The suite's scorer.
|
|
55
|
+
#
|
|
56
|
+
# @return [Scoring::Scorer, nil] nil when the suite has not registered one
|
|
57
|
+
def scorer = @configuration.scorer
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/llm/evals/cli/cases.rb
CHANGED
|
@@ -9,6 +9,11 @@ module LLM
|
|
|
9
9
|
|
|
10
10
|
option :dataset, type: :array, aliases: ["-d"], default: [], desc: "Only these datasets, comma-separated"
|
|
11
11
|
|
|
12
|
+
# Prints one row per case, then whatever the lineup cannot ask.
|
|
13
|
+
#
|
|
14
|
+
# @param dataset [Array<String>] as given to -d, possibly comma-separated; empty means every dataset
|
|
15
|
+
# @return [void]
|
|
16
|
+
# @raise [Error] when no case is found
|
|
12
17
|
def call(dataset: [], **)
|
|
13
18
|
scorer = configuration.scorer
|
|
14
19
|
names = dataset.flat_map { it.split(",") }
|
|
@@ -26,6 +31,8 @@ module LLM
|
|
|
26
31
|
|
|
27
32
|
# Names, on STDERR, every lineup entry the suite cannot ask, so the
|
|
28
33
|
# mismatch is visible before anyone reaches for `run`.
|
|
34
|
+
#
|
|
35
|
+
# @return [void]
|
|
29
36
|
def refusals
|
|
30
37
|
config = configuration.lineup
|
|
31
38
|
_, refused = configuration.providers.admit(config.contenders, request_types: config.request_types)
|
|
@@ -34,6 +41,9 @@ module LLM
|
|
|
34
41
|
|
|
35
42
|
# The generic cell when the scorer has nothing to say: how many fields
|
|
36
43
|
# the expected answer holds, or what kind of thing it is.
|
|
44
|
+
#
|
|
45
|
+
# @param kase [Datasets::Case]
|
|
46
|
+
# @return [String]
|
|
37
47
|
def summary(kase)
|
|
38
48
|
case kase.expected
|
|
39
49
|
when Hash then "#{kase.expected.size} fields"
|