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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +163 -87
  4. data/exe/llm-evals +60 -5
  5. data/lib/.DS_Store +0 -0
  6. data/lib/llm/.DS_Store +0 -0
  7. data/lib/llm/evals/actions/adapter.rb +50 -0
  8. data/lib/llm/evals/actions/error_budget.rb +63 -0
  9. data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
  10. data/lib/llm/evals/actions/error_streak.rb +83 -0
  11. data/lib/llm/evals/actions/experiment.rb +251 -0
  12. data/lib/llm/evals/actions/verify.rb +61 -0
  13. data/lib/llm/evals/actions.rb +9 -0
  14. data/lib/llm/evals/cli/cases.rb +10 -0
  15. data/lib/llm/evals/cli/command.rb +32 -99
  16. data/lib/llm/evals/cli/compare.rb +0 -20
  17. data/lib/llm/evals/cli/contender_selector.rb +61 -0
  18. data/lib/llm/evals/cli/error_pause.rb +81 -0
  19. data/lib/llm/evals/cli/experiment_pool.rb +97 -0
  20. data/lib/llm/evals/cli/formatting.rb +8 -0
  21. data/lib/llm/evals/cli/leaderboard.rb +66 -12
  22. data/lib/llm/evals/cli/model_colors.rb +3 -0
  23. data/lib/llm/evals/cli/model_command.rb +152 -315
  24. data/lib/llm/evals/cli/model_options.rb +80 -0
  25. data/lib/llm/evals/cli/option_groups.rb +32 -0
  26. data/lib/llm/evals/cli/progress_bars.rb +92 -5
  27. data/lib/llm/evals/cli/render.rb +6 -6
  28. data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
  29. data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
  30. data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
  31. data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
  32. data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
  33. data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
  34. data/lib/llm/evals/cli/run.rb +13 -22
  35. data/lib/llm/evals/cli/run_planner.rb +97 -0
  36. data/lib/llm/evals/cli/run_record.rb +68 -0
  37. data/lib/llm/evals/cli/standings.rb +39 -17
  38. data/lib/llm/evals/cli/status_box.rb +51 -0
  39. data/lib/llm/evals/cli/table.rb +103 -0
  40. data/lib/llm/evals/cli/text.rb +22 -0
  41. data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
  42. data/lib/llm/evals/cli/upgrade_config.rb +60 -0
  43. data/lib/llm/evals/cli/verify.rb +5 -1
  44. data/lib/llm/evals/cli/version.rb +4 -0
  45. data/lib/llm/evals/cli/weights.rb +48 -0
  46. data/lib/llm/evals/cli.rb +39 -6
  47. data/lib/llm/evals/config/contender_builder.rb +30 -0
  48. data/lib/llm/evals/config/error_limits.rb +41 -0
  49. data/lib/llm/evals/config/listing.rb +153 -0
  50. data/lib/llm/evals/config/results.rb +42 -0
  51. data/lib/llm/evals/config/score_weights.rb +25 -0
  52. data/lib/llm/evals/config/upgrade.rb +107 -0
  53. data/lib/llm/evals/config.rb +90 -79
  54. data/lib/llm/evals/datasets/case.rb +46 -0
  55. data/lib/llm/evals/datasets/discovery.rb +143 -0
  56. data/lib/llm/evals/datasets.rb +9 -0
  57. data/lib/llm/evals/launcher.rb +57 -3
  58. data/lib/llm/evals/models/contender.rb +51 -0
  59. data/lib/llm/evals/models/model.rb +87 -0
  60. data/lib/llm/evals/models/throttle.rb +170 -0
  61. data/lib/llm/evals/models.rb +9 -0
  62. data/lib/llm/evals/providers/base.rb +61 -16
  63. data/lib/llm/evals/providers/catalog.rb +5 -2
  64. data/lib/llm/evals/providers/open_router.rb +54 -7
  65. data/lib/llm/evals/providers/registry.rb +49 -16
  66. data/lib/llm/evals/providers/type_safe/client.rb +67 -0
  67. data/lib/llm/evals/providers/type_safe.rb +113 -12
  68. data/lib/llm/evals/providers.rb +9 -0
  69. data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
  70. data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
  71. data/lib/llm/evals/render/reports/html/format.rb +38 -0
  72. data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
  73. data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
  74. data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
  75. data/lib/llm/evals/render/reports/html.rb +60 -181
  76. data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
  77. data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
  78. data/lib/llm/evals/render/reports/json.rb +38 -141
  79. data/lib/llm/evals/render/reports/pipeline.rb +55 -20
  80. data/lib/llm/evals/render/reports/terminal.rb +8 -1
  81. data/lib/llm/evals/render.rb +9 -0
  82. data/lib/llm/evals/results/provider_response.rb +25 -0
  83. data/lib/llm/evals/results/reply.rb +38 -0
  84. data/lib/llm/evals/results/run_outcome.rb +15 -0
  85. data/lib/llm/evals/results/run_result.rb +40 -0
  86. data/lib/llm/evals/results/score.rb +45 -0
  87. data/lib/llm/evals/results/warnings.rb +149 -0
  88. data/lib/llm/evals/results.rb +9 -0
  89. data/lib/llm/evals/runtime/configuration.rb +295 -0
  90. data/lib/llm/evals/runtime/environment.rb +90 -0
  91. data/lib/llm/evals/runtime/helper.rb +153 -0
  92. data/lib/llm/evals/runtime.rb +2 -67
  93. data/lib/llm/evals/scoring/metric.rb +47 -0
  94. data/lib/llm/evals/scoring/schema_validator.rb +102 -0
  95. data/lib/llm/evals/scoring/scorer.rb +49 -0
  96. data/lib/llm/evals/scoring/weighted_score.rb +148 -0
  97. data/lib/llm/evals/scoring.rb +9 -0
  98. data/lib/llm/evals/stats/call_stats.rb +91 -0
  99. data/lib/llm/evals/stats/usage.rb +46 -0
  100. data/lib/llm/evals/stats.rb +29 -0
  101. data/lib/llm/evals/testing/echo_adapter.rb +14 -9
  102. data/lib/llm/evals/testing/fake_model.rb +8 -6
  103. data/lib/llm/evals/testing/fake_provider.rb +39 -8
  104. data/lib/llm/evals/testing.rb +9 -0
  105. data/lib/llm/evals/trackers/base.rb +110 -0
  106. data/lib/llm/evals/trackers/braintrust.rb +40 -3
  107. data/lib/llm/evals/trackers/local.rb +38 -2
  108. data/lib/llm/evals/trackers/logfire.rb +56 -3
  109. data/lib/llm/evals/trackers/registry.rb +29 -7
  110. data/lib/llm/evals/trackers.rb +9 -0
  111. data/lib/llm/evals/version.rb +3 -1
  112. data/lib/llm/evals.rb +17 -7
  113. metadata +91 -24
  114. data/lib/llm/evals/adapter.rb +0 -47
  115. data/lib/llm/evals/case.rb +0 -36
  116. data/lib/llm/evals/completion.rb +0 -11
  117. data/lib/llm/evals/configuration.rb +0 -173
  118. data/lib/llm/evals/contender.rb +0 -31
  119. data/lib/llm/evals/discovery.rb +0 -95
  120. data/lib/llm/evals/experiment.rb +0 -158
  121. data/lib/llm/evals/helper.rb +0 -98
  122. data/lib/llm/evals/metric.rb +0 -26
  123. data/lib/llm/evals/model.rb +0 -55
  124. data/lib/llm/evals/reply.rb +0 -18
  125. data/lib/llm/evals/run_result.rb +0 -14
  126. data/lib/llm/evals/schema_validator.rb +0 -84
  127. data/lib/llm/evals/score.rb +0 -26
  128. data/lib/llm/evals/scorer.rb +0 -45
  129. data/lib/llm/evals/tracker.rb +0 -80
  130. data/lib/llm/evals/usage.rb +0 -33
  131. data/lib/llm/evals/verify.rb +0 -49
  132. data/lib/llm/evals/warnings.rb +0 -98
  133. data/lib/llm/evals/weighted_score.rb +0 -116
  134. /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
  135. /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
  136. /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
- # @param metrics [Array<Metric>]
15
- # @param primary [Metric] the metric the ranking reads
16
- # @param weighted [WeightedScore, nil] nil when the file sets no weights
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
- # @param summaries [Array<Experiment::Summary>]
60
- # @return [Hash{Experiment::Summary => Float, nil}] by identity, each compared within its dataset
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" => summary.score,
87
- "errors" => summary.errors.size,
88
- "time" => summary.duration,
89
- "cost" => summary.usage.cost_usd.to_f
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, ["errors", *WeightedScore::MAGNITUDES].include?(name) ? total : total / values.size]
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
@@ -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