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,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module LLM
|
|
6
|
+
module Evals
|
|
7
|
+
module CLI
|
|
8
|
+
# What a run will do: which datasets, how many cases and experiments at
|
|
9
|
+
# once, and one experiment per dataset and contender.
|
|
10
|
+
class RunPlanner
|
|
11
|
+
# Holds only collaborators; nothing is decided until it is asked.
|
|
12
|
+
#
|
|
13
|
+
# @param configuration [Runtime::Configuration]
|
|
14
|
+
# @param config [Config]
|
|
15
|
+
# @param options [Hash] the parsed flags
|
|
16
|
+
# @param trackers [TrackerResolver]
|
|
17
|
+
def initialize(configuration:, config:, options:, trackers:)
|
|
18
|
+
@configuration = configuration
|
|
19
|
+
@config = config
|
|
20
|
+
@options = options
|
|
21
|
+
@trackers = trackers
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Named datasets are checked, so a typo fails before the first call.
|
|
25
|
+
#
|
|
26
|
+
# @param names [Array<String>] as given to -d, possibly comma-separated; empty means every dataset
|
|
27
|
+
# @return [Array<String>]
|
|
28
|
+
# @raise [Error] on an unknown dataset, before anything is spent
|
|
29
|
+
def datasets(names)
|
|
30
|
+
names = names.flat_map { it.split(",") }.uniq
|
|
31
|
+
return every_dataset if names.empty?
|
|
32
|
+
|
|
33
|
+
names.each { discovery.cases(dataset: it) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Every dataset crossed with every contender, datasets first.
|
|
37
|
+
#
|
|
38
|
+
# @param datasets [Array<String>]
|
|
39
|
+
# @param contenders [Array<Models::Contender>]
|
|
40
|
+
# @return [Array<Actions::Experiment>]
|
|
41
|
+
def experiments(datasets, contenders)
|
|
42
|
+
datasets.flat_map { |dataset| contenders.map { experiment(it, dataset) } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# -p, else the lineup's defaults.parallelism.
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer] cases in flight at once, per model
|
|
48
|
+
def parallelism = Integer(@options[:parallelism] || @config.defaults["parallelism"])
|
|
49
|
+
|
|
50
|
+
# -j, else one experiment per CPU. Uncapped, a full lineup started
|
|
51
|
+
# every model x dataset pair at once: 140 experiments, each with its
|
|
52
|
+
# own pool of case threads, all fighting for the network and the screen.
|
|
53
|
+
#
|
|
54
|
+
# @return [Integer] experiments at once; 0 means all of them
|
|
55
|
+
# @raise [Error] on a negative or non-numeric -j
|
|
56
|
+
def concurrency
|
|
57
|
+
value = @options[:concurrency] || Etc.nprocessors
|
|
58
|
+
Integer(value).tap { raise Error, "--concurrency must be 0 or more, got #{value}" if it.negative? }
|
|
59
|
+
rescue ArgumentError, TypeError
|
|
60
|
+
raise Error, "--concurrency must be a whole number, got #{value.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Read through the configuration on each call.
|
|
66
|
+
#
|
|
67
|
+
# @return [Datasets::Discovery]
|
|
68
|
+
def discovery = @configuration.discovery
|
|
69
|
+
|
|
70
|
+
# Used when -d names none.
|
|
71
|
+
#
|
|
72
|
+
# @return [Array<String>]
|
|
73
|
+
# @raise [Error] when the suite has no datasets
|
|
74
|
+
def every_dataset
|
|
75
|
+
discovery.datasets.tap { raise Error, "no datasets under #{discovery.root}" if it.empty? }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Trackers are made new for each experiment; the adapter and the scorer are shared by all.
|
|
79
|
+
#
|
|
80
|
+
# @param contender [Models::Contender]
|
|
81
|
+
# @param dataset [String]
|
|
82
|
+
# @return [Actions::Experiment]
|
|
83
|
+
# @raise [Error] when no case in the dataset matches -c
|
|
84
|
+
def experiment(contender, dataset)
|
|
85
|
+
Actions::Experiment.new(
|
|
86
|
+
model: @configuration.providers.model_for(contender, throttle: @config.throttle),
|
|
87
|
+
cases: discovery.cases!(dataset:, only: @options[:case]),
|
|
88
|
+
adapter: @configuration.adapter,
|
|
89
|
+
scorer: @configuration.scorer,
|
|
90
|
+
parallelism:,
|
|
91
|
+
trackers: @trackers.for_experiment
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# What a saved report says about its run: the parsed flags, which preserve
|
|
7
|
+
# the invocation, and the settings that actually reached the experiments,
|
|
8
|
+
# which record the defaults and expanded selections that were implicit.
|
|
9
|
+
#
|
|
10
|
+
# @!attribute [r] command
|
|
11
|
+
# @return [String] the action that ran, `run` or `compare`
|
|
12
|
+
# @!attribute [r] cli
|
|
13
|
+
# @return [Hash{Symbol => Object}] the parsed flags
|
|
14
|
+
# @!attribute [r] config
|
|
15
|
+
# @return [Config]
|
|
16
|
+
# @!attribute [r] planner
|
|
17
|
+
# @return [RunPlanner]
|
|
18
|
+
# @!attribute [r] trackers
|
|
19
|
+
# @return [TrackerResolver]
|
|
20
|
+
# @!attribute [r] providers
|
|
21
|
+
# @return [Providers::Registry]
|
|
22
|
+
# @!attribute [r] contenders
|
|
23
|
+
# @return [Array<Models::Contender>]
|
|
24
|
+
# @!attribute [r] datasets
|
|
25
|
+
# @return [Array<String>]
|
|
26
|
+
# @!attribute [r] runs
|
|
27
|
+
# @return [Array<Actions::Experiment>]
|
|
28
|
+
# @!attribute [r] seconds
|
|
29
|
+
# @return [Float] how long the experiments took, by the wall clock
|
|
30
|
+
RunRecord = Data.define(:command, :cli, :config, :planner, :trackers, :providers, :contenders, :datasets, :runs, :seconds) do
|
|
31
|
+
# Replaces `Data#to_h`, so the collaborators stay out of what gets saved.
|
|
32
|
+
#
|
|
33
|
+
# @return [Hash]
|
|
34
|
+
def to_h
|
|
35
|
+
{ command:, cli:, effective: effective }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
# What reached the experiments once every default and selection was expanded.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash{Symbol => Object}]
|
|
43
|
+
def effective
|
|
44
|
+
{ project: trackers.project,
|
|
45
|
+
request_types: config.request_types.map(&:to_s),
|
|
46
|
+
datasets:,
|
|
47
|
+
cases: runs.group_by(&:dataset).transform_values { |group| group.flat_map { it.cases.map(&:id) }.uniq },
|
|
48
|
+
contenders: contenders.map { contender(it) },
|
|
49
|
+
defaults: config.defaults,
|
|
50
|
+
parallelism: planner.parallelism,
|
|
51
|
+
concurrency: planner.concurrency,
|
|
52
|
+
tracker: cli[:local] ? "none" : trackers.remote&.tracker_name,
|
|
53
|
+
seconds: }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Names the provider too, which a model id alone does not settle.
|
|
57
|
+
#
|
|
58
|
+
# @param entry [Models::Contender]
|
|
59
|
+
# @return [Hash{Symbol => Object}]
|
|
60
|
+
def contender(entry)
|
|
61
|
+
settings = entry.settings
|
|
62
|
+
{ model: entry.model, provider: providers.for(entry).name, effort: settings[:effort],
|
|
63
|
+
temperature: settings[:temperature], max_tokens: settings[:max_tokens] }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -11,9 +11,23 @@ module LLM
|
|
|
11
11
|
|
|
12
12
|
attr_reader :metrics, :primary, :weighted
|
|
13
13
|
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# @!attribute [r] metrics
|
|
15
|
+
# The columns the suite scores.
|
|
16
|
+
# @return [Array<Scoring::Metric>]
|
|
17
|
+
|
|
18
|
+
# @!attribute [r] primary
|
|
19
|
+
# The metric the ranking reads.
|
|
20
|
+
# @return [Scoring::Metric]
|
|
21
|
+
|
|
22
|
+
# @!attribute [r] weighted
|
|
23
|
+
# Doubles as the question of whether the file sets weights at all.
|
|
24
|
+
# @return [Scoring::WeightedScore, nil]
|
|
25
|
+
|
|
26
|
+
# Holds no results; every method answers from what it is given.
|
|
27
|
+
#
|
|
28
|
+
# @param metrics [Array<Scoring::Metric>]
|
|
29
|
+
# @param primary [Scoring::Metric] the metric the ranking reads
|
|
30
|
+
# @param weighted [Scoring::WeightedScore, nil] nil when the file sets no weights
|
|
17
31
|
def initialize(metrics:, primary:, weighted: nil)
|
|
18
32
|
@metrics = metrics
|
|
19
33
|
@primary = primary
|
|
@@ -25,7 +39,7 @@ module LLM
|
|
|
25
39
|
# plain score-per-dollar ratio would crown whatever is nearly free,
|
|
26
40
|
# however wrong it is.
|
|
27
41
|
#
|
|
28
|
-
# @param entries [Array<Array(Float, Float, Integer)>] primary metric, cost, errors
|
|
42
|
+
# @param entries [Array<Array(Float, Float, Integer)>] primary metric (nil when unscored), cost, errors
|
|
29
43
|
# @return [Integer, nil] the winner's index
|
|
30
44
|
def best_value(entries)
|
|
31
45
|
eligible = entries.each_index.select { entries[it][0] && entries[it][2].zero? }
|
|
@@ -37,14 +51,14 @@ module LLM
|
|
|
37
51
|
|
|
38
52
|
# Each contender's numbers over the given experiments, best first.
|
|
39
53
|
#
|
|
40
|
-
# @param summaries [Array<Experiment::Summary>]
|
|
54
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
41
55
|
# @return [Array<Hash>]
|
|
42
56
|
def contender_groups(summaries)
|
|
43
57
|
groups = summaries.group_by { [it.model.key, it.model.effort, it.model.temperature] }.values.map do |group|
|
|
44
58
|
values = group.filter_map { it.mean(primary) }
|
|
45
59
|
{ first: group.first, errors: group.sum { it.errors.size },
|
|
46
60
|
value: values.size == group.size ? values.sum / values.size : nil,
|
|
47
|
-
usage: group.map(&:usage).reduce(Usage.zero, :+),
|
|
61
|
+
usage: group.map(&:usage).reduce(Stats::Usage.zero, :+),
|
|
48
62
|
row: overall_row(group) }
|
|
49
63
|
end
|
|
50
64
|
scores = weighted_by(groups.map { it[:row] })
|
|
@@ -52,12 +66,16 @@ module LLM
|
|
|
52
66
|
groups.sort_by { -((weighted ? it[:weighted] : it[:value]) || -1) }
|
|
53
67
|
end
|
|
54
68
|
|
|
69
|
+
# The same rule as {#best_value}, over contenders rather than experiments.
|
|
70
|
+
#
|
|
55
71
|
# @param groups [Array<Hash>] from {#contender_groups}
|
|
56
72
|
# @return [Integer, nil] the index of the best value
|
|
57
73
|
def best_contender(groups) = best_value(groups.map { [it[:value], it[:usage].cost_usd, it[:errors]] })
|
|
58
74
|
|
|
59
|
-
#
|
|
60
|
-
#
|
|
75
|
+
# Weighted scores are relative, so an experiment is compared only within its dataset.
|
|
76
|
+
#
|
|
77
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
78
|
+
# @return [Hash{Actions::Experiment::Summary => Float, nil}] by identity, each compared within its dataset
|
|
61
79
|
def weighted_scores(summaries)
|
|
62
80
|
summaries.group_by(&:dataset).values.each_with_object({}.compare_by_identity) do |group, scores|
|
|
63
81
|
group.zip(weighted_by(group.map { weighted_row(it) })).each { |summary, value| scores[summary] = value }
|
|
@@ -66,7 +84,7 @@ module LLM
|
|
|
66
84
|
|
|
67
85
|
# The best value in each dataset's block of the leaderboard.
|
|
68
86
|
#
|
|
69
|
-
# @param ranked [Array<Experiment::Summary>]
|
|
87
|
+
# @param ranked [Array<Actions::Experiment::Summary>]
|
|
70
88
|
# @return [Array<Integer>] indices into ranked
|
|
71
89
|
def best_per_dataset(ranked)
|
|
72
90
|
ranked.each_index.group_by { ranked[it].dataset }.values.filter_map do |indices|
|
|
@@ -77,22 +95,24 @@ module LLM
|
|
|
77
95
|
|
|
78
96
|
private
|
|
79
97
|
|
|
80
|
-
# What {WeightedScore} reads from one experiment.
|
|
98
|
+
# What {Scoring::WeightedScore} reads from one experiment.
|
|
81
99
|
#
|
|
82
|
-
# @param summary [Experiment::Summary]
|
|
100
|
+
# @param summary [Actions::Experiment::Summary]
|
|
83
101
|
# @return [Hash{String => Numeric, nil}]
|
|
84
102
|
def weighted_row(summary)
|
|
85
103
|
metrics.to_h { [it.name, summary.mean(it)] }.merge(
|
|
86
|
-
"score"
|
|
87
|
-
"errors"
|
|
88
|
-
"time"
|
|
89
|
-
"cost"
|
|
104
|
+
"score" => summary.score,
|
|
105
|
+
"errors" => summary.errors.size,
|
|
106
|
+
"time" => summary.duration,
|
|
107
|
+
"cost" => summary.usage.cost_usd.to_f,
|
|
108
|
+
"latency" => summary.latency,
|
|
109
|
+
"p95" => summary.latency_p95
|
|
90
110
|
)
|
|
91
111
|
end
|
|
92
112
|
|
|
93
113
|
# One contender over every dataset: errors and magnitudes add up, the rest average.
|
|
94
114
|
#
|
|
95
|
-
# @param group [Array<Experiment::Summary>]
|
|
115
|
+
# @param group [Array<Actions::Experiment::Summary>]
|
|
96
116
|
# @return [Hash{String => Numeric, nil}]
|
|
97
117
|
def overall_row(group)
|
|
98
118
|
rows = group.map { weighted_row(it) }
|
|
@@ -101,10 +121,12 @@ module LLM
|
|
|
101
121
|
total = values.sum(&:to_f)
|
|
102
122
|
next [name, nil] if values.any?(&:nil?)
|
|
103
123
|
|
|
104
|
-
[name,
|
|
124
|
+
[name, Scoring::WeightedScore::SUMMED.include?(name) ? total : total / values.size]
|
|
105
125
|
end
|
|
106
126
|
end
|
|
107
127
|
|
|
128
|
+
# Scores rows together, since a weighted score depends on the rows beside it.
|
|
129
|
+
#
|
|
108
130
|
# @param rows [Array<Hash>]
|
|
109
131
|
# @return [Array<Float, nil>] nils when the file sets no weights
|
|
110
132
|
def weighted_by(rows) = weighted ? weighted.call(rows) : Array.new(rows.size)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-box"
|
|
4
|
+
|
|
5
|
+
module LLM
|
|
6
|
+
module Evals
|
|
7
|
+
module CLI
|
|
8
|
+
# ui.info, ui.success, ui.warn and ui.error, painted on their color across
|
|
9
|
+
# the full width of the terminal. dry-cli-ui colors only a box's title, so
|
|
10
|
+
# this draws the box with tty-box, which it uses too. Without color it is a
|
|
11
|
+
# plain framed box.
|
|
12
|
+
class StatusBox
|
|
13
|
+
# Each level's title and colors.
|
|
14
|
+
LEVELS = {
|
|
15
|
+
info: { title: " Info ", fg: :bright_white, bg: :blue },
|
|
16
|
+
success: { title: " Success ", fg: :black, bg: :green },
|
|
17
|
+
found: { title: " Info ", fg: :black, bg: :green },
|
|
18
|
+
warning: { title: " Warning ", fg: :black, bg: :yellow },
|
|
19
|
+
error: { title: " Error ", fg: :bright_white, bg: :red }
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Told its width and color, rather than asking the terminal itself.
|
|
23
|
+
#
|
|
24
|
+
# @param width [Integer] the box's whole width
|
|
25
|
+
# @param color [Boolean]
|
|
26
|
+
def initialize(width:, color:)
|
|
27
|
+
@width = width
|
|
28
|
+
@color = color
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Cuts words too wide for the box, before tty-box can drop them.
|
|
32
|
+
#
|
|
33
|
+
# @param level [Symbol] a key of {LEVELS}
|
|
34
|
+
# @param text [String]
|
|
35
|
+
# @return [String]
|
|
36
|
+
# @raise [KeyError] on an unknown level
|
|
37
|
+
def render(level, text)
|
|
38
|
+
look = LEVELS.fetch(level)
|
|
39
|
+
style = @color ? { fg: look[:fg], bg: look[:bg], border: { fg: look[:fg], bg: look[:bg] } } : {}
|
|
40
|
+
# tty-box drops a word wider than the box, such as a long path; cut it first
|
|
41
|
+
TTY::Box.frame(Text.wrap(text, @width - 4),
|
|
42
|
+
width: @width,
|
|
43
|
+
padding: [0, 1],
|
|
44
|
+
title: { top_left: look[:title] },
|
|
45
|
+
style: style,
|
|
46
|
+
enable_color: @color)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tty-table"
|
|
4
|
+
|
|
5
|
+
module LLM
|
|
6
|
+
module Evals
|
|
7
|
+
module CLI
|
|
8
|
+
# ui.table, as wide as the terminal (and so as the status boxes), with every
|
|
9
|
+
# all-numeric column right-aligned and a line between rows once any cell
|
|
10
|
+
# spans several. dry-cli-ui does none of that, so this renders through
|
|
11
|
+
# tty-table, as it does.
|
|
12
|
+
#
|
|
13
|
+
# The fill column takes whatever width the others leave. With wrap, its text
|
|
14
|
+
# is word-wrapped to fit; without, it is only ever widened, so an id is never
|
|
15
|
+
# broken and a table too wide for the screen stays so.
|
|
16
|
+
class Table
|
|
17
|
+
# A cell that reads as a number: 42, 1,200, 97.1%, 3.5s, $0.0042.
|
|
18
|
+
NUMBER = /\A-?\$?\d[\d,]*(?:\.\d+)?[%s]?\z/
|
|
19
|
+
|
|
20
|
+
# Cells that stand in for a number without being one; they do not stop
|
|
21
|
+
# a column from right-aligning.
|
|
22
|
+
PLACEHOLDERS = ["", "-", "default"].freeze
|
|
23
|
+
|
|
24
|
+
# The narrowest a wrapped column gets, however full the screen.
|
|
25
|
+
MIN_WRAP = 30
|
|
26
|
+
|
|
27
|
+
# Holds no rows, so one instance can draw any number of tables.
|
|
28
|
+
#
|
|
29
|
+
# @param pastel [Pastel]
|
|
30
|
+
# @param width [Integer] the columns available
|
|
31
|
+
def initialize(pastel:, width:)
|
|
32
|
+
@pastel = pastel
|
|
33
|
+
@width = width
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Draws one table, with widths measured before any highlight is painted on.
|
|
37
|
+
#
|
|
38
|
+
# @param rows [Array<Array<#to_s>>]
|
|
39
|
+
# @param header [Array<String>]
|
|
40
|
+
# @param highlight [Array<Integer>] rows drawn in bold yellow (when color is on)
|
|
41
|
+
# @param fill [Integer] the column that absorbs the spare width
|
|
42
|
+
# @param wrap [Boolean] word-wrap the fill column to fit
|
|
43
|
+
# @return [String]
|
|
44
|
+
def render(rows, header:, highlight: [], fill: 1, wrap: false)
|
|
45
|
+
cells = rows.map { |row| row.map(&:to_s) }
|
|
46
|
+
widths = fitted_widths(header, cells, fill, wrap)
|
|
47
|
+
alignments = alignments(header, cells)
|
|
48
|
+
multiline = cells.flatten.any? { it.include?("\n") }
|
|
49
|
+
cells = cells.each_with_index.map { |row, index| highlight.include?(index) ? row.map { @pastel.bold.yellow(it) } : row }
|
|
50
|
+
TTY::Table.new(header: header.map { @pastel.bold(it) }, rows: cells).render(
|
|
51
|
+
:unicode,
|
|
52
|
+
padding: [0, 1],
|
|
53
|
+
width: Dry::CLI::UI::Widgets::Table::UNLIMITED,
|
|
54
|
+
alignments: alignments,
|
|
55
|
+
multiline: multiline,
|
|
56
|
+
column_widths: widths
|
|
57
|
+
) { |renderer| renderer.border.separator = :each_row if multiline }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Wraps the fill column's cells in place when asked to.
|
|
63
|
+
#
|
|
64
|
+
# @param header [Array<String>]
|
|
65
|
+
# @param cells [Array<Array<String>>] modified when wrapping
|
|
66
|
+
# @param fill [Integer] the column that absorbs the spare width
|
|
67
|
+
# @param wrap [Boolean] word-wrap the fill column to fit
|
|
68
|
+
# @return [Array<Integer>] each column's width
|
|
69
|
+
def fitted_widths(header, cells, fill, wrap)
|
|
70
|
+
widths = column_widths([header] + cells)
|
|
71
|
+
spare = @width - (widths.sum - widths[fill]) - ((3 * widths.size) + 1)
|
|
72
|
+
if wrap
|
|
73
|
+
widths[fill] = [spare, MIN_WRAP].max
|
|
74
|
+
cells.each { it[fill] = Text.wrap(it[fill], widths[fill]) }
|
|
75
|
+
else
|
|
76
|
+
widths[fill] = [spare, widths[fill]].max
|
|
77
|
+
end
|
|
78
|
+
widths
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A column right-aligns when all it holds reads as a number. One holding only placeholders stays left.
|
|
82
|
+
#
|
|
83
|
+
# @param header [Array<String>]
|
|
84
|
+
# @param cells [Array<Array<String>>]
|
|
85
|
+
# @return [Array<Symbol>] `:left` or `:right`, one per column
|
|
86
|
+
def alignments(header, cells)
|
|
87
|
+
header.each_index.map do |index|
|
|
88
|
+
column = cells.map { it[index] }.reject { PLACEHOLDERS.include?(it) }
|
|
89
|
+
!column.empty? && column.all? { NUMBER.match?(it) } ? :right : :left
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Measures past any color, which takes up no columns.
|
|
94
|
+
#
|
|
95
|
+
# @param rows [Array<Array<#to_s>>]
|
|
96
|
+
# @return [Array<Integer>] each column's widest line
|
|
97
|
+
def column_widths(rows)
|
|
98
|
+
rows.transpose.map { |column| column.flat_map { @pastel.strip(it.to_s).split("\n") }.map(&:length).max.to_i }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Text fitted to a width, for tables and boxes alike.
|
|
7
|
+
module Text
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Word-wraps text to a width, keeping its own line breaks. A word
|
|
11
|
+
# longer than the width is cut.
|
|
12
|
+
#
|
|
13
|
+
# @param text [String]
|
|
14
|
+
# @param width [Integer]
|
|
15
|
+
# @return [String]
|
|
16
|
+
def wrap(text, width)
|
|
17
|
+
text.split("\n").flat_map { |line| line.scan(/\S.{0,#{width - 1}}(?=\s|\z)|\S{#{width}}/).map(&:strip) }.join("\n")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Where a run's results go: the local JSON log, always, then one remote
|
|
7
|
+
# tracker if any, chosen once per run.
|
|
8
|
+
class TrackerResolver
|
|
9
|
+
# Holds only collaborators; nothing is chosen until {#remote} is asked.
|
|
10
|
+
#
|
|
11
|
+
# @param configuration [Runtime::Configuration]
|
|
12
|
+
# @param config [Config]
|
|
13
|
+
# @param options [Hash] the parsed flags
|
|
14
|
+
# @param err [IO] says which tracker was chosen, under --verbose
|
|
15
|
+
# @param env [#[]]
|
|
16
|
+
def initialize(configuration:, config:, options:, err:, env: ENV)
|
|
17
|
+
@configuration = configuration
|
|
18
|
+
@config = config
|
|
19
|
+
@options = options
|
|
20
|
+
@err = err
|
|
21
|
+
@env = env
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# --tracker, then tracker: in the config, then the environment. --local is none.
|
|
25
|
+
#
|
|
26
|
+
# @return [Class<Trackers::Base>, nil]
|
|
27
|
+
def remote
|
|
28
|
+
return @remote if defined?(@remote)
|
|
29
|
+
|
|
30
|
+
chosen = @configuration.trackers.choose(flag: @options[:tracker], config: @config.tracker, env: @env, local: @options[:local])
|
|
31
|
+
@err.puts "tracker: #{chosen ? "#{chosen.first.tracker_name}, because #{chosen.last}" : "none"}" if @options[:verbose]
|
|
32
|
+
@remote = chosen&.first
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# --project, the lineup's, the suite's, and last the suite's name.
|
|
36
|
+
#
|
|
37
|
+
# @return [String]
|
|
38
|
+
def project = @options[:project] || @config.project || @configuration.project || @configuration.name
|
|
39
|
+
|
|
40
|
+
# Each experiment gets instances of its own.
|
|
41
|
+
#
|
|
42
|
+
# @return [Array<Trackers::Base>]
|
|
43
|
+
def for_experiment
|
|
44
|
+
context = { suite: @configuration.name, project:, project_id: @options[:project] ? nil : Trackers::Braintrust.project_id(@env) }
|
|
45
|
+
local = Trackers::Local.new(dir: File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "llm-evals"), **context)
|
|
46
|
+
klass = remote
|
|
47
|
+
[local, klass&.new(**context, options: @config.tracker_options(klass.tracker_name))].compact
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# Moves a lineup file to the current format, keeping its comments and
|
|
7
|
+
# leaving the old file beside it as <file>.bak.
|
|
8
|
+
class UpgradeConfig < Command
|
|
9
|
+
desc "Rewrite the config file in the current format, keeping its comments"
|
|
10
|
+
|
|
11
|
+
example [
|
|
12
|
+
" # the suite's .llm-evals.config.yml",
|
|
13
|
+
"-C evals/.llm-evals.config.yml # another lineup file",
|
|
14
|
+
"-n # print the result, change nothing"
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
option :config, aliases: ["-C"], desc: "The lineup file (default: the suite's .llm-evals.config.yml)"
|
|
18
|
+
option :dry_run, type: :boolean, default: false, aliases: ["-n"], desc: "Print the result, change nothing"
|
|
19
|
+
|
|
20
|
+
# Leaves a file already in the current format alone, and refuses to overwrite an earlier backup.
|
|
21
|
+
#
|
|
22
|
+
# @param config [String, nil] the lineup file; nil for the suite's
|
|
23
|
+
# @param dry_run [Boolean] print the result, change nothing
|
|
24
|
+
# @return [void]
|
|
25
|
+
# @raise [Error] when the file is missing, or its backup already exists
|
|
26
|
+
def call(config: nil, dry_run: false, **)
|
|
27
|
+
path = config ? File.expand_path(config) : configuration.lineup.path
|
|
28
|
+
raise Error, "no config file at #{path}" unless File.exist?(path)
|
|
29
|
+
|
|
30
|
+
text = File.read(path)
|
|
31
|
+
upgrade = Config::Upgrade.new(text, path:)
|
|
32
|
+
return status_box(:info, "#{path} is already in the current format") unless upgrade.needed?
|
|
33
|
+
|
|
34
|
+
upgraded = upgrade.call
|
|
35
|
+
return out.write(upgraded) if dry_run
|
|
36
|
+
|
|
37
|
+
backup = "#{path}.bak"
|
|
38
|
+
raise Error, "#{backup} already exists; move it away first" if File.exist?(backup)
|
|
39
|
+
|
|
40
|
+
File.write(backup, text)
|
|
41
|
+
replace(path, upgraded)
|
|
42
|
+
status_box(:success, "#{path} upgraded; the old file is #{backup}")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A rename, so that a failed write cannot leave half a lineup.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String]
|
|
50
|
+
# @param text [String]
|
|
51
|
+
# @return [void]
|
|
52
|
+
def replace(path, text)
|
|
53
|
+
temporary = "#{path}.tmp"
|
|
54
|
+
File.write(temporary, text)
|
|
55
|
+
File.rename(temporary, path)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/llm/evals/cli/verify.rb
CHANGED
|
@@ -7,9 +7,13 @@ module LLM
|
|
|
7
7
|
class Verify < Command
|
|
8
8
|
desc "Check every case's input and expected answer, through the adapter and the scorer (no API calls)"
|
|
9
9
|
|
|
10
|
+
# Boxes every problem on STDERR, then fails, so a script sees the exit status.
|
|
11
|
+
#
|
|
12
|
+
# @return [void]
|
|
13
|
+
# @raise [Error] when any case has a problem
|
|
10
14
|
def call(**)
|
|
11
15
|
all = discovery.all
|
|
12
|
-
failures = LLM::Evals::Verify.new(configuration).problems
|
|
16
|
+
failures = LLM::Evals::Actions::Verify.new(configuration).problems
|
|
13
17
|
if failures.empty?
|
|
14
18
|
status_box :success, "#{all.size} cases verified across #{discovery.datasets.join(", ")}"
|
|
15
19
|
return
|
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
module LLM
|
|
4
4
|
module Evals
|
|
5
5
|
module CLI
|
|
6
|
+
# `evals version`, also reachable as -V and --version.
|
|
6
7
|
class Version < Command
|
|
7
8
|
desc "Print the version"
|
|
8
9
|
|
|
10
|
+
# Prints the bare number, so a script can read it.
|
|
11
|
+
#
|
|
12
|
+
# @return [void]
|
|
9
13
|
def call(**)
|
|
10
14
|
out.puts LLM::Evals::VERSION
|
|
11
15
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module CLI
|
|
6
|
+
# How the lineup's weights divide the weighted score, without calling
|
|
7
|
+
# anything: a weight is relative, so its share is what it really counts for.
|
|
8
|
+
class Weights < Command
|
|
9
|
+
desc "Print how the config file's weights divide the weighted score"
|
|
10
|
+
|
|
11
|
+
example [
|
|
12
|
+
" # the suite's .llm-evals.config.yml",
|
|
13
|
+
"-C evals/.llm-evals.config.yml # another lineup file"
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
option :config, aliases: ["-C"], desc: "The lineup file (default: the suite's .llm-evals.config.yml)"
|
|
17
|
+
|
|
18
|
+
# Prints each weighted column's share, and in red the metrics no weight covers.
|
|
19
|
+
#
|
|
20
|
+
# @param config [String, nil] the lineup file; nil for the suite's
|
|
21
|
+
# @return [void]
|
|
22
|
+
# @raise [Error] when the file sets no weights
|
|
23
|
+
def call(config: nil, **)
|
|
24
|
+
lineup = config ? Config.new(File.expand_path(config)) : configuration.lineup
|
|
25
|
+
weights = lineup.score_weights
|
|
26
|
+
raise Error, "#{lineup.path}: no weighted_score.weights to show" if weights.empty?
|
|
27
|
+
|
|
28
|
+
metrics = configuration.scorer.metrics.map(&:name)
|
|
29
|
+
weighted = Scoring::WeightedScore.new(weights, metrics:)
|
|
30
|
+
rows = weighted.shares.map { |name, share| [direction(name, weighted.weights[name]), weighted.weights[name], format("%.1f%%", share * 100)] }
|
|
31
|
+
unweighted = (metrics - weighted.weights.keys).map { [pastel.red(it), "", ""] }
|
|
32
|
+
table(rows + unweighted, header: %w[Column Weight Share], fill: 0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# A negative weight means less is better.
|
|
38
|
+
#
|
|
39
|
+
# @param name [String]
|
|
40
|
+
# @param weight [Numeric]
|
|
41
|
+
# @return [String] the column, marked with the direction that is good for it
|
|
42
|
+
def direction(name, weight)
|
|
43
|
+
weight.negative? ? pastel.bold.blue("↓ #{name}") : pastel.bold.yellow("↑ #{name}")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|