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
data/lib/llm/evals/cli.rb CHANGED
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module LLM
4
2
  module Evals
5
3
  # Every command the executable answers to. The help's title and
@@ -8,17 +6,50 @@ module LLM
8
6
  module CLI
9
7
  extend Dry::CLI::Registry
10
8
 
9
+ class << self
10
+ # @return [String] the help's title
11
+ def header
12
+ p = Pastel.new
13
+ p.green.inverse("\n #{Runtime::Configuration::DEFAULT_NAME} ") +
14
+ p.yellow.inverse(" v#{VERSION} ") +
15
+ p.cyan.inverse.bold(" © 2026 Konstantin Gredeskoul, MIT License ") +
16
+ p.cyan("") + p.reset("\n")
17
+ end
18
+
19
+ # Confirms which evals are running: a green box naming the suite and its
20
+ # file, or a yellow one saying there is no file.
21
+ #
22
+ # @param configuration [LLM::Evals::Runtime::Configuration]
23
+ # @param color [Boolean]
24
+ # @param width [Integer] the box's whole width; a console reporting no size, as CI does, gets the narrowest
25
+ # @return [String]
26
+ def dataset_conf_label(configuration:, color: false, width: IO.console&.winsize&.last.to_i.clamp(60..))
27
+ box = StatusBox.new(width:, color:)
28
+ return box.render(:warning, configuration.config_missing_message) unless configuration.config_found?
29
+
30
+ box.render(:found, "Evals configuration: #{configuration.name}\nFile: #{configuration.relative_config}")
31
+ end
32
+ end
33
+
11
34
  # What users type, so what the help and shell completion are written for.
12
35
  # exe/llm-evals exists only for a system where `evals` is taken.
13
- PROGRAM = "evals"
36
+ PROGRAM = "evals".freeze
37
+
38
+ Dry::CLI::Help::Screens::Command.prepend(OptionGroups)
14
39
 
15
40
  Dry::CLI::Help.configure do
16
41
  configuration = LLM::Evals.configuration
17
- title configuration.name == Configuration::DEFAULT_NAME ? PROGRAM : "#{PROGRAM} (#{configuration.name})"
18
- types = configuration.providers.uniq(&:request_type).map { "#{it.request_type} (#{it.request_type_description})" }
42
+
43
+ title ::LLM::Evals::CLI.header
44
+
45
+ types = configuration.providers.uniq(&:request_type).map do |provider|
46
+ "#{provider.request_type} (#{provider.request_type_description})"
47
+ end
48
+
19
49
  description "#{configuration.description} Request types: #{types.join("; ")}."
20
50
 
21
- columns = IO.console&.winsize&.last || 80
51
+ columns = (IO.console&.winsize&.last || 80).clamp(60, 90)
52
+
22
53
  width columns.clamp(60, 120)
23
54
  exit_code_without_arguments 0
24
55
 
@@ -36,6 +67,8 @@ module LLM
36
67
  register "render", Render
37
68
  register "cases", Cases
38
69
  register "verify", Verify
70
+ register "upgrade-config", UpgradeConfig
71
+ register "weights", Weights
39
72
  register "version", Version, aliases: ["-V", "--version"]
40
73
  register "completion", Dry::CLI::Autocomplete::Command[self, program_name: PROGRAM]
41
74
  end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ class Config
6
+ # One contender per effort and temperature a model's settings list.
7
+ class ContenderBuilder
8
+ # @param defaults [Hash{String => Object}] under the entry's own settings
9
+ # @param overrides [Hash{String => Object}] over them; nil entries are ignored
10
+ def initialize(defaults:, overrides:)
11
+ @defaults = defaults
12
+ @overrides = overrides.compact
13
+ end
14
+
15
+ # Expands one model's lists of efforts and temperatures into their product.
16
+ #
17
+ # @param model [String] the model id
18
+ # @param entry [Hash{String => Object}] the model's settings in the file
19
+ # @param provider [Symbol, nil] a direct provider's name; nil when the id picks the provider
20
+ # @return [Array<Models::Contender>]
21
+ def call(model, entry = {}, provider: nil)
22
+ settings = @defaults.merge(entry).merge(@overrides)
23
+ Array(settings["effort"]).product(Array(settings["temperature"])).map do |effort, temperature|
24
+ Models::Contender.new(model:, effort: effort.to_s, temperature:, max_tokens: Integer(settings["max_tokens"]), provider:)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ class Config
6
+ # The `errors:` section: when to give up on a run. Both limits must be met,
7
+ # and `percent` needs `total`, since one failed first call is already 100%.
8
+ #
9
+ # @!attribute [r] total
10
+ # @return [Integer] errors before the run may abort
11
+ # @!attribute [r] percent
12
+ # @return [Numeric, nil] the share of finished calls that must have failed
13
+ ErrorLimits = Data.define(:total, :percent) do
14
+ # Checks the section as the file wrote it.
15
+ #
16
+ # @param section [Object] the section as the file has it, not nil
17
+ # @param path [String] names the file in an error
18
+ # @return [ErrorLimits]
19
+ # @raise [Error] on a limit that is missing or out of range
20
+ def self.from_hash(section, path:)
21
+ limits = section["abort_after"] if section.is_a?(Hash)
22
+ raise Error, "#{path}: errors needs an abort_after: mapping of total and percent" unless limits.is_a?(Hash)
23
+
24
+ total = limits["total"]
25
+ percent = limits["percent"]
26
+ raise Error, "#{path}: errors.abort_after.total must be a whole number above 0" unless total.is_a?(Integer) && total.positive?
27
+ unless percent.nil? || (percent.is_a?(Numeric) && percent.positive? && percent <= 100)
28
+ raise Error, "#{path}: errors.abort_after.percent must be above 0 and at most 100"
29
+ end
30
+
31
+ new(total:, percent:)
32
+ end
33
+
34
+ # A new one each time, since a budget counts one run's calls.
35
+ #
36
+ # @return [Actions::ErrorBudget]
37
+ def budget = Actions::ErrorBudget.new(total:, percent:)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ class Config
6
+ # What a lineup's provider sections list: the models and the `rate_limit`s,
7
+ # checked and read once, so that a file wrong in either way is refused
8
+ # whichever of the two a command asks for first.
9
+ class Listing
10
+ # The keys a provider section may have.
11
+ SECTION_KEYS = %w[rate_limit vendors models].freeze
12
+ # The keys an entry under `vendors:` may have.
13
+ VENDOR_KEYS = %w[rate_limit models].freeze
14
+ # The keys one line under `models:` may have.
15
+ ENTRY_KEYS = %w[model effort temperature max_tokens rate_limit].freeze
16
+
17
+ # What marks a mapping as meant for a provider, under any name. Other
18
+ # mappings belong to trackers and suites, so they are left alone.
19
+ MARKERS = %w[vendors models model rate_limit].freeze
20
+
21
+ # One line under `models:`.
22
+ #
23
+ # @!attribute [r] provider
24
+ # @return [Providers::Base] the section it was listed under
25
+ # @!attribute [r] id
26
+ # @return [String] the id a provider recognises
27
+ # @!attribute [r] settings
28
+ # @return [Hash{String => Object}] the line as the file has it, with `model` as a key
29
+ Entry = Data.define(:provider, :id, :settings)
30
+
31
+ # @return [Array<Entry>] in the file's order
32
+ attr_reader :entries
33
+
34
+ # @return [Hash{String => Models::Throttle::Limit}] by scope
35
+ attr_reader :limits
36
+
37
+ # @param data [Hash] the whole file
38
+ # @param path [String] named in every error
39
+ # @param providers [Providers::Registry] whose names the sections may use
40
+ # @raise [Error] on a section, entry or limit the file format does not have
41
+ def initialize(data, path:, providers:)
42
+ @path = path
43
+ @providers = providers
44
+ @entries = []
45
+ @limits = {}
46
+ sections(data).each { |provider, section| read(provider, section) }
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :path, :providers
52
+
53
+ # @!attribute [r] path
54
+ # The lineup file, named in every error.
55
+ # @return [String]
56
+
57
+ # @!attribute [r] providers
58
+ # Whose names the sections may use.
59
+ # @return [Providers::Registry]
60
+
61
+ # @param data [Hash] the whole file
62
+ # @return [Array<Array(Providers::Base, Hash)>] the file's provider sections
63
+ # @raise [Error] on a retired key, an unknown provider or an unknown key
64
+ def sections(data)
65
+ retired = %w[vendors direct] & data.keys
66
+ raise Error, "#{path}: #{retired.join(" and ")} no longer exist; run `evals upgrade-config` to move them under provider sections" if retired.any?
67
+
68
+ names = providers.map(&:name)
69
+ data.filter_map do |name, section|
70
+ next unless section.is_a?(Hash) && (names.include?(name.to_s) || section.keys.intersect?(MARKERS))
71
+ raise Error, "#{path}: unknown provider #{name.inspect}; known: #{names.join(", ")}" unless names.include?(name.to_s)
72
+
73
+ known!(name, section, SECTION_KEYS)
74
+ [providers.find { it.name == name.to_s }, section]
75
+ end
76
+ end
77
+
78
+ # Collects one provider section's limits and models, those under its vendors first.
79
+ #
80
+ # @param provider [Providers::Base] the section's provider
81
+ # @param section [Hash] the section as the file has it
82
+ # @return [void]
83
+ # @raise [Error] on a section the file format does not have
84
+ def read(provider, section)
85
+ limit(provider.name, section)
86
+ vendors = section["vendors"] || {}
87
+ raise Error, "#{path}: vendors must be a mapping of vendor to models" unless vendors.is_a?(Hash)
88
+
89
+ vendors.each do |vendor, listing|
90
+ raise Error, "#{path}: vendor #{vendor} needs a models: list" unless listing.is_a?(Hash)
91
+
92
+ known!(vendor, listing, VENDOR_KEYS)
93
+ limit("#{provider.name}/#{vendor}", listing)
94
+ list(provider, vendor, listing, "#{provider.name}/#{vendor}/", "#{provider.prefix}#{vendor}/")
95
+ end
96
+ list(provider, provider.name, section, "#{provider.name}/", provider.prefix.to_s)
97
+ end
98
+
99
+ # Adds each line of a `models:` list to {#entries}, and any limit it sets to {#limits}.
100
+ #
101
+ # @param provider [Providers::Base] the section's provider
102
+ # @param owner [String] the vendor or provider that lists them, for errors
103
+ # @param listing [Hash] the mapping holding `models:`
104
+ # @param scope [String] prepended to the model name to key its limit
105
+ # @param prefix [String] prepended to the model name to make its id
106
+ # @return [void]
107
+ # @raise [Error] on an entry without a model, or with an unknown key
108
+ def list(provider, owner, listing, scope, prefix)
109
+ Array(listing["models"]).each do |entry|
110
+ entry = { "model" => entry } if entry.is_a?(String)
111
+ raise Error, "#{path}: every entry under #{owner} needs a model" unless entry.is_a?(Hash) && entry["model"]
112
+
113
+ known!(entry["model"], entry, ENTRY_KEYS)
114
+ limit("#{scope}#{entry["model"]}", entry)
115
+ @entries << Entry.new(provider:, id: "#{prefix}#{entry["model"]}", settings: entry)
116
+ end
117
+ end
118
+
119
+ # Refuses keys the format does not have, so a typo is not ignored.
120
+ #
121
+ # @param owner [String] names the mapping in the error
122
+ # @param mapping [Hash] the mapping to check
123
+ # @param keys [Array<String>] the keys it may have
124
+ # @return [void]
125
+ # @raise [Error] on any other key
126
+ def known!(owner, mapping, keys)
127
+ unknown = mapping.keys - keys
128
+ raise Error, "#{path}: unknown keys under #{owner}: #{unknown.join(", ")}; known: #{keys.join(", ")}" if unknown.any?
129
+ end
130
+
131
+ # A scope has one limit, so a model listed twice, say at two efforts, may
132
+ # set it once: the second would otherwise replace the first in silence.
133
+ #
134
+ # @param scope [String] the key {Models::Throttle} looks the limit up by
135
+ # @param owner [Hash] the mapping that may hold `rate_limit:`
136
+ # @return [void]
137
+ # @raise [Error] on a limit that is malformed or set twice
138
+ def limit(scope, owner)
139
+ given = owner["rate_limit"]
140
+ return if given.nil?
141
+
142
+ burst, per_minute = given.values_at("burst", "per_minute") if given.is_a?(Hash)
143
+ unless [burst, per_minute].all? { it.is_a?(Integer) && it.positive? } && given.size == 2
144
+ raise Error, "#{path}: rate_limit of #{scope} needs burst and per_minute, whole numbers above 0"
145
+ end
146
+ raise Error, "#{path}: rate_limit of #{scope} is set twice; a model listed twice shares one limit, so set it once" if @limits.key?(scope)
147
+
148
+ @limits[scope] = Models::Throttle::Limit.new(burst:, per_minute:)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ class Config
6
+ # The `results:` section: where completed runs are saved and how they are
7
+ # presented. Paths are rooted at the caller's working directory, so the same
8
+ # config works from a project root or when invoked by a task runner.
9
+ #
10
+ # @!attribute [r] autosave
11
+ # @return [Boolean] whether completed runs are saved under {#destination}
12
+ # @!attribute [r] destination
13
+ # @return [String] absolute directory for saved runs
14
+ # @!attribute [r] temp_dir
15
+ # @return [String] absolute directory for the temporary JSON used when autosave is off
16
+ # @!attribute [r] display_mode
17
+ # @return [String] one of {RESULT_DISPLAYS}
18
+ Results = Data.define(:autosave, :destination, :temp_dir, :display_mode) do
19
+ # Applies the defaults, checks the types, and roots the paths at the working directory.
20
+ #
21
+ # @param section [Object] the section as the file has it
22
+ # @param path [String] names the file in an error
23
+ # @return [Results]
24
+ # @raise [Error] when a setting has the wrong type or an unknown display
25
+ def self.from_hash(section, path:)
26
+ raise Error, "#{path}: results must be a mapping" unless section.is_a?(Hash)
27
+
28
+ autosave = section.fetch("autosave", true)
29
+ raise Error, "#{path}: results.autosave must be true or false" unless [true, false].include?(autosave)
30
+
31
+ display = section.fetch("display", DEFAULT_DISPLAY).to_s
32
+ raise Error, "#{path}: results.display must be one of #{RESULT_DISPLAYS.join(", ")}" unless RESULT_DISPLAYS.include?(display)
33
+
34
+ new(autosave:,
35
+ destination: File.expand_path(section.fetch("destination", "./evals/results").to_s, Dir.pwd),
36
+ temp_dir: File.expand_path(section.fetch("temp_dir", "./tmp").to_s, Dir.pwd),
37
+ display_mode: display)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ class Config
6
+ # The `weighted_score:` section; see {Scoring::WeightedScore}.
7
+ module ScoreWeights
8
+ # Only checks the shape; {Scoring::WeightedScore} judges the columns and numbers.
9
+ #
10
+ # @param section [Object, nil] the section as the file has it
11
+ # @param path [String] names the file in an error
12
+ # @return [Hash{String => Object}] by column; empty when the file sets none
13
+ # @raise [Error] when either level is not a mapping
14
+ def self.from_hash(section, path:)
15
+ return {} if section.nil?
16
+
17
+ weights = section["weights"] if section.is_a?(Hash)
18
+ raise Error, "#{path}: weighted_score needs a weights: mapping of column to number" unless weights.is_a?(Hash)
19
+
20
+ weights.transform_keys(&:to_s)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module LLM
6
+ module Evals
7
+ class Config
8
+ # Rewrites a lineup from top-level `vendors:` and `direct:` to provider
9
+ # sections. It works on the text, since loading YAML would drop the comments
10
+ # that explain a lineup, then proves the result means the same before
11
+ # anything is written.
12
+ class Upgrade
13
+ # A line that opens a new top-level key, which ends any section being moved.
14
+ TOP_KEY = /\A[\w-]+:/
15
+ # The old `vendors:` line, keeping whatever follows the colon.
16
+ VENDORS = /\Avendors:(?<rest>.*)/m
17
+ # The old `direct:` line, with at most a comment after it.
18
+ DIRECT = /\Adirect:\s*(?<comment>#.*)?\n?\z/
19
+ # A key indented by two spaces: a vendor, or a direct provider.
20
+ GROUP = /\A ([\w-]+):([^\n]*)\n?\z/
21
+
22
+ # @param text [String] the lineup as it is
23
+ # @param path [String] named in every error
24
+ # @raise [Error] when the text is not YAML, or not a mapping
25
+ def initialize(text, path:)
26
+ @text = text
27
+ @path = path
28
+ @data = YAML.safe_load(text) || {}
29
+ raise Error, "#{path}: a lineup must be a mapping, not a #{@data.class.name.downcase}" unless @data.is_a?(Hash)
30
+ rescue Psych::SyntaxError => e
31
+ raise Error, "#{path}: not valid YAML: #{e.message}"
32
+ end
33
+
34
+ # @return [Boolean] whether the lineup still has the old keys
35
+ def needed? = @data.key?("vendors") || @data.key?("direct")
36
+
37
+ # @return [String] the lineup in the new shape
38
+ # @raise [Error] when the rewrite would change what the file means
39
+ def call
40
+ taken = [("openrouter" if @data.key?("vendors")), *(@data["direct"] || {}).keys].compact & @data.keys
41
+ raise Error, "#{@path}: cannot upgrade: the file has both the old keys and a section named #{taken.join(", ")}" if taken.any?
42
+
43
+ rewritten = rewrite
44
+ return rewritten if same_meaning?(rewritten)
45
+
46
+ raise Error, "#{@path}: cannot upgrade: vendors: or direct: is not in the plain list form; move them by hand"
47
+ end
48
+
49
+ private
50
+
51
+ # The proof that a textual rewrite is safe to write.
52
+ #
53
+ # @param rewritten [String] the lineup in the new shape
54
+ # @return [Boolean] whether it loads to what {#expected} says; false when it is not YAML
55
+ def same_meaning?(rewritten)
56
+ YAML.safe_load(rewritten) == expected
57
+ rescue Psych::SyntaxError
58
+ false
59
+ end
60
+
61
+ # Rewrites the text line by line, tracking which old section the line falls in.
62
+ #
63
+ # @return [String] the lineup in the new shape
64
+ def rewrite
65
+ section = nil
66
+ @text.lines.flat_map do |line|
67
+ section = nil if line.match?(TOP_KEY)
68
+ if (vendors = VENDORS.match(line))
69
+ section = :vendors
70
+ ["openrouter:#{vendors[:rest]}", " vendors:\n"]
71
+ elsif (direct = DIRECT.match(line))
72
+ section = :direct
73
+ direct[:comment] ? ["#{direct[:comment]}\n"] : []
74
+ else
75
+ moved(section, line)
76
+ end
77
+ end.join
78
+ end
79
+
80
+ # Reindents one line of an old section; comments and blank lines stay as they are.
81
+ #
82
+ # @param section [Symbol, nil] `:vendors`, `:direct`, or nil outside both
83
+ # @param line [String] the line, with its newline
84
+ # @return [Array<String>] the line or lines that replace it
85
+ def moved(section, line)
86
+ return [line] if section.nil? || line.strip.empty? || line.start_with?("#")
87
+
88
+ group = GROUP.match(line)
89
+ case section
90
+ when :vendors then group ? [" #{group[1]}:#{group[2]}\n", " models:\n"] : [" #{line}"]
91
+ else group ? ["#{group[1]}:#{group[2]}\n", " models:\n"] : [line]
92
+ end
93
+ end
94
+
95
+ # What the file means in the new shape, built from the parsed data rather than the text.
96
+ #
97
+ # @return [Hash{String => Object}] the lineup as {#same_meaning?} expects it to load
98
+ def expected
99
+ rest = @data.except("vendors", "direct")
100
+ vendors = @data["vendors"]&.transform_values { { "models" => it } }
101
+ direct = (@data["direct"] || {}).transform_values { { "models" => it } }
102
+ rest.merge(vendors ? { "openrouter" => { "vendors" => vendors } } : {}).merge(direct)
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end