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
|
@@ -11,16 +11,24 @@ module LLM
|
|
|
11
11
|
# `wrong` maps a case to fields to answer wrongly. `answers`, a callable
|
|
12
12
|
# taking the user prompt, replaces the lookup altogether.
|
|
13
13
|
class FakeProvider < Providers::Base
|
|
14
|
+
# The namespace of the ids this provider claims.
|
|
14
15
|
PREFIX = "fake/"
|
|
15
|
-
|
|
16
|
+
# What every call reports having cost, so totals are predictable in a spec.
|
|
17
|
+
USAGE = Stats::Usage.new(input_tokens: 100, output_tokens: 10, cost_usd: 0.001)
|
|
18
|
+
# The reply a `junk` case gets.
|
|
16
19
|
JUNK = "Sure! Here is my answer, in prose rather than JSON."
|
|
17
20
|
|
|
21
|
+
# Every {#complete} call so far, with `model:`, `system:`, `user:` and `json:`, for a spec to inspect.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array<Hash{Symbol => Object}>]
|
|
18
24
|
attr_reader :calls
|
|
19
25
|
|
|
26
|
+
# With no arguments, every case gets its expected JSON.
|
|
27
|
+
#
|
|
20
28
|
# @param junk [Array<String>] case ids or keys to answer with prose
|
|
21
29
|
# @param wrong [Hash{String => Hash}] case id or key => fields to override
|
|
22
30
|
# @param answers [#call, nil] user prompt => reply text; replaces the lookup
|
|
23
|
-
# @param discovery [Discovery, nil] where to look cases up; the configured suite's when nil
|
|
31
|
+
# @param discovery [Datasets::Discovery, nil] where to look cases up; the configured suite's when nil
|
|
24
32
|
def initialize(junk: [], wrong: {}, answers: nil, discovery: nil)
|
|
25
33
|
super()
|
|
26
34
|
@junk = junk
|
|
@@ -31,26 +39,46 @@ module LLM
|
|
|
31
39
|
@lock = Mutex.new
|
|
32
40
|
end
|
|
33
41
|
|
|
42
|
+
# Overrides {Base#name}.
|
|
43
|
+
#
|
|
44
|
+
# @return [String] "fake"
|
|
34
45
|
def name = "fake"
|
|
46
|
+
# Overrides {Base#request_type}.
|
|
47
|
+
#
|
|
48
|
+
# @return [Symbol] `:prompt`
|
|
35
49
|
def request_type = :prompt
|
|
50
|
+
# Overrides {Base#prefix}.
|
|
51
|
+
#
|
|
52
|
+
# @return [String] {PREFIX}
|
|
36
53
|
def prefix = PREFIX
|
|
54
|
+
# Overrides {Base#env_keys}: nothing is called, so nothing is needed.
|
|
55
|
+
#
|
|
56
|
+
# @return [Array<String>] always empty
|
|
37
57
|
def env_keys = []
|
|
38
58
|
|
|
59
|
+
# Overrides {Base#catalog} to say so outright.
|
|
60
|
+
#
|
|
39
61
|
# @return [nil] any id passes verification
|
|
40
62
|
def catalog = nil
|
|
41
63
|
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
# @param
|
|
45
|
-
# @param
|
|
46
|
-
# @
|
|
64
|
+
# Records the call under a lock, then answers it. Overrides {Base#complete}.
|
|
65
|
+
#
|
|
66
|
+
# @param model [Models::Model] the model called
|
|
67
|
+
# @param system [String, nil] the system prompt, recorded only
|
|
68
|
+
# @param user [String] the user prompt, which selects the case
|
|
69
|
+
# @param json [Boolean] recorded only
|
|
70
|
+
# @return [Results::ProviderResponse]
|
|
47
71
|
def complete(model, system:, user:, json: true)
|
|
48
72
|
@lock.synchronize { @calls << { model: model.id, system: system, user: user, json: json } }
|
|
49
|
-
|
|
73
|
+
Results::ProviderResponse.new(text: @answers ? @answers.call(user) : answer(user), usage: USAGE)
|
|
50
74
|
end
|
|
51
75
|
|
|
52
76
|
private
|
|
53
77
|
|
|
78
|
+
# Looks the case up by its input. A prompt matching no case gets "{}".
|
|
79
|
+
#
|
|
80
|
+
# @param user [String] the user prompt
|
|
81
|
+
# @return [String] prose for a `junk` case; otherwise JSON, with `wrong` merged in
|
|
54
82
|
def answer(user)
|
|
55
83
|
kase = discovery.all.find { it.input == user }
|
|
56
84
|
return "{}" if kase.nil?
|
|
@@ -60,6 +88,9 @@ module LLM
|
|
|
60
88
|
JSON.generate(kase.expected.is_a?(Hash) ? kase.expected.merge(wrong) : kase.expected)
|
|
61
89
|
end
|
|
62
90
|
|
|
91
|
+
# The injected discovery, or the configured suite's when none was given.
|
|
92
|
+
#
|
|
93
|
+
# @return [Datasets::Discovery]
|
|
63
94
|
def discovery = @discovery || LLM::Evals.configuration.discovery
|
|
64
95
|
end
|
|
65
96
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Trackers
|
|
6
|
+
# Where a run's results go besides the terminal. The gem always owns the
|
|
7
|
+
# run loop; a tracker only receives callbacks: {#start} once per
|
|
8
|
+
# experiment, {#record} once per finished case, {#finish} once with the
|
|
9
|
+
# summary. The local JSON log ({Trackers::Local}) always runs; at most
|
|
10
|
+
# one remote tracker runs beside it, chosen by `--tracker`, the
|
|
11
|
+
# `tracker:` key, or the environment (see {Trackers::Registry#choose}).
|
|
12
|
+
#
|
|
13
|
+
# A tracker for another framework (W&B Weave, an OTLP backend) subclasses
|
|
14
|
+
# this in a consumer or a plugin gem and is registered with `c.tracker`.
|
|
15
|
+
#
|
|
16
|
+
# @abstract Override the three callbacks; declare {.env_key} and {.gems}.
|
|
17
|
+
class Base
|
|
18
|
+
class << self
|
|
19
|
+
# The class name is the default, so a subclass needs no declaration.
|
|
20
|
+
#
|
|
21
|
+
# @return [String] the name `--tracker` and `tracker:` use; the class name, dasherized, by default
|
|
22
|
+
def tracker_name = (name || "Tracker").split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
|
|
23
|
+
|
|
24
|
+
# Nil means the environment can never select this tracker by itself.
|
|
25
|
+
#
|
|
26
|
+
# @return [String, nil] the environment variable whose presence selects this tracker when nothing else does
|
|
27
|
+
def env_key = nil
|
|
28
|
+
|
|
29
|
+
# Loaded by {.requirements!} rather than at require time.
|
|
30
|
+
#
|
|
31
|
+
# @return [Array<String>] the optional gems this tracker requires when chosen
|
|
32
|
+
def gems = []
|
|
33
|
+
|
|
34
|
+
# A blank value counts as absent.
|
|
35
|
+
#
|
|
36
|
+
# @param env [#[]] the environment, ENV by default
|
|
37
|
+
# @return [Boolean] whether the environment names this tracker
|
|
38
|
+
def detect?(env = ENV) = !env_key.nil? && !env[env_key].to_s.strip.empty?
|
|
39
|
+
|
|
40
|
+
# Loads the gems and checks the key, with an error that names both, so
|
|
41
|
+
# a chosen tracker that cannot run says so before any model is called.
|
|
42
|
+
#
|
|
43
|
+
# @param env [#[]] the environment, ENV by default
|
|
44
|
+
# @return [void]
|
|
45
|
+
# @raise [MissingDependency] when a gem is absent
|
|
46
|
+
# @raise [Error] when the key is not set
|
|
47
|
+
def requirements!(env = ENV)
|
|
48
|
+
gems.each do |gem_name|
|
|
49
|
+
LLM::Evals.optional!(gem_name.tr("-", "/"), gem_name, needed_for: "the #{tracker_name} tracker")
|
|
50
|
+
end
|
|
51
|
+
return if env_key.nil? || detect?(env)
|
|
52
|
+
|
|
53
|
+
raise Error, "the #{tracker_name} tracker needs #{env_key} in the environment (gems: #{gems.join(", ")})"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What {#initialize} was given, for subclasses to read.
|
|
58
|
+
#
|
|
59
|
+
# @return [Hash{Symbol => Object}]
|
|
60
|
+
attr_reader :context
|
|
61
|
+
|
|
62
|
+
# Keeps the context whole, so a subclass reads only what it wants.
|
|
63
|
+
#
|
|
64
|
+
# @param context [Hash] `suite:`, `project:`, `project_id:` and `options:` (the tracker's own key in the config file)
|
|
65
|
+
def initialize(**context)
|
|
66
|
+
@context = context
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Delegates to {.tracker_name}, so an instance and its class agree.
|
|
70
|
+
#
|
|
71
|
+
# @return [String]
|
|
72
|
+
def name = self.class.tracker_name
|
|
73
|
+
|
|
74
|
+
# Nothing in this class turns itself off; a subclass may.
|
|
75
|
+
#
|
|
76
|
+
# @return [Boolean] false turns every callback into a no-op
|
|
77
|
+
def enabled? = true
|
|
78
|
+
|
|
79
|
+
# Overridden by each tracker to name its destination.
|
|
80
|
+
#
|
|
81
|
+
# @return [String] one line for `--verbose`: where this tracker sends things, and why
|
|
82
|
+
def describe = name
|
|
83
|
+
|
|
84
|
+
# Called once per experiment, before any case runs.
|
|
85
|
+
#
|
|
86
|
+
# @param _experiment [Actions::Experiment] the experiment starting
|
|
87
|
+
# @return [void]
|
|
88
|
+
def start(_experiment) = nil
|
|
89
|
+
|
|
90
|
+
# Called once per finished case, one at a time under the experiment's lock.
|
|
91
|
+
#
|
|
92
|
+
# @param _result [Results::RunResult] the finished case
|
|
93
|
+
# @return [void]
|
|
94
|
+
def record(_result) = nil
|
|
95
|
+
|
|
96
|
+
# Called once per experiment, after the last case.
|
|
97
|
+
#
|
|
98
|
+
# @param _experiment [Actions::Experiment] the experiment finishing
|
|
99
|
+
# @param _summary [Actions::Experiment::Summary] its totals
|
|
100
|
+
# @return [Hash] `url:` to the experiment when one is known, else nil
|
|
101
|
+
def finish(_experiment, _summary) = { url: nil }
|
|
102
|
+
|
|
103
|
+
# Empty when the config file has no section for this tracker.
|
|
104
|
+
#
|
|
105
|
+
# @return [Hash] the tracker's own section of the config file
|
|
106
|
+
def options = context.fetch(:options, nil) || {}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -7,17 +7,38 @@ module LLM
|
|
|
7
7
|
# suite's project, through the braintrust gem's own OpenTelemetry
|
|
8
8
|
# exporter, the way `Braintrust::Eval.run` would have, except that the
|
|
9
9
|
# loop stays ours. The gem is required only when this tracker is chosen.
|
|
10
|
-
class Braintrust <
|
|
10
|
+
class Braintrust < Base
|
|
11
|
+
# A set id skips the project lookup in {#start}.
|
|
12
|
+
#
|
|
13
|
+
# @param env [#[]]
|
|
14
|
+
# @return [String, nil] the project BRAINTRUST_PROJECT_ID names
|
|
15
|
+
def self.project_id(env = ENV) = env["BRAINTRUST_PROJECT_ID"].to_s[/\S+/]
|
|
16
|
+
|
|
17
|
+
# Overrides {Base.env_key}.
|
|
18
|
+
#
|
|
19
|
+
# @return [String] "BRAINTRUST_API_KEY"
|
|
11
20
|
def self.env_key = "BRAINTRUST_API_KEY"
|
|
21
|
+
# Overrides {Base.gems}.
|
|
22
|
+
#
|
|
23
|
+
# @return [Array<String>] just the braintrust gem
|
|
12
24
|
def self.gems = ["braintrust"]
|
|
13
25
|
|
|
14
26
|
attr_reader :experiment_id, :permalink
|
|
15
27
|
|
|
28
|
+
# @!attribute [r] experiment_id
|
|
29
|
+
# @return [String, nil] the Braintrust experiment; nil until {#start}
|
|
30
|
+
|
|
31
|
+
# @!attribute [r] permalink
|
|
32
|
+
# @return [String, nil] the experiment's page; nil until {#start}
|
|
33
|
+
|
|
34
|
+
# Overrides {Base#describe}.
|
|
35
|
+
#
|
|
36
|
+
# @return [String] the destination project, by id when there is one
|
|
16
37
|
def describe = "braintrust: project #{context[:project_id] || context[:project].inspect}"
|
|
17
38
|
|
|
18
39
|
# Logs in, finds or creates the project, creates the experiment.
|
|
19
40
|
#
|
|
20
|
-
# @param experiment [Experiment]
|
|
41
|
+
# @param experiment [Actions::Experiment]
|
|
21
42
|
# @return [void]
|
|
22
43
|
def start(experiment)
|
|
23
44
|
::Braintrust.init(blocking_login: true, auto_instrument: false)
|
|
@@ -41,7 +62,7 @@ module LLM
|
|
|
41
62
|
# runner emits: input, expected, output, scores and metadata as JSON
|
|
42
63
|
# attributes, parented to the experiment.
|
|
43
64
|
#
|
|
44
|
-
# @param result [RunResult]
|
|
65
|
+
# @param result [Results::RunResult]
|
|
45
66
|
# @return [void]
|
|
46
67
|
def record(result)
|
|
47
68
|
span = @tracer.start_root_span("eval")
|
|
@@ -63,6 +84,8 @@ module LLM
|
|
|
63
84
|
|
|
64
85
|
# Flushes the spans and hands back the experiment's page.
|
|
65
86
|
#
|
|
87
|
+
# @param _experiment [Actions::Experiment] unused
|
|
88
|
+
# @param _summary [Actions::Experiment::Summary] unused
|
|
66
89
|
# @return [Hash] `url:` the experiment permalink
|
|
67
90
|
def finish(_experiment, _summary)
|
|
68
91
|
provider = OpenTelemetry.tracer_provider
|
|
@@ -72,13 +95,27 @@ module LLM
|
|
|
72
95
|
|
|
73
96
|
private
|
|
74
97
|
|
|
98
|
+
# Sets one span attribute as JSON, the form Braintrust reads.
|
|
99
|
+
#
|
|
100
|
+
# @param span [OpenTelemetry::Trace::Span] the span to annotate
|
|
101
|
+
# @param key [String] the attribute name
|
|
102
|
+
# @param value [Object] any JSON-encodable value
|
|
103
|
+
# @return [void]
|
|
75
104
|
def json(span, key, value) = span.set_attribute(key, JSON.dump(value))
|
|
76
105
|
|
|
106
|
+
# What the experiment was run with, as Braintrust stores it.
|
|
107
|
+
#
|
|
108
|
+
# @param experiment [Actions::Experiment] the experiment starting
|
|
109
|
+
# @return [Hash{Symbol => Object}]
|
|
77
110
|
def metadata(experiment)
|
|
78
111
|
{ model: experiment.model.id, provider: experiment.model.provider.name, dataset: experiment.dataset,
|
|
79
112
|
**experiment.model.settings, **experiment.adapter.metadata }
|
|
80
113
|
end
|
|
81
114
|
|
|
115
|
+
# What a case span carries beyond its scores; a failed case has no score or reply to report.
|
|
116
|
+
#
|
|
117
|
+
# @param result [Results::RunResult] the finished case
|
|
118
|
+
# @return [Hash{Symbol => Object}]
|
|
82
119
|
def case_metadata(result)
|
|
83
120
|
base = { case: result.case.key, dataset: result.case.dataset, variant: result.case.variant, difficulty: result.case.difficulty }
|
|
84
121
|
return base.merge(error: result.error) if result.failed?
|
|
@@ -8,11 +8,19 @@ module LLM
|
|
|
8
8
|
# Always on: every experiment's results and summary land in one JSON
|
|
9
9
|
# file under `log/`, so a run is never lost to a closed terminal or a
|
|
10
10
|
# remote tracker that was never configured.
|
|
11
|
-
class Local <
|
|
11
|
+
class Local < Base
|
|
12
12
|
attr_reader :dir, :path
|
|
13
13
|
|
|
14
|
+
# @!attribute [r] dir
|
|
15
|
+
# @return [String] where the files go
|
|
16
|
+
|
|
17
|
+
# @!attribute [r] path
|
|
18
|
+
# @return [String, nil] the file for this experiment; nil until {#start}
|
|
19
|
+
|
|
20
|
+
# Holds results in memory and writes nothing until {#finish}.
|
|
21
|
+
#
|
|
14
22
|
# @param dir [String] where the files go; `log/llm-evals` at the repository root by default
|
|
15
|
-
# @param context [Hash] see {
|
|
23
|
+
# @param context [Hash] see {Base#initialize}
|
|
16
24
|
def initialize(dir: File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "llm-evals"), **context)
|
|
17
25
|
super(**context)
|
|
18
26
|
@dir = dir
|
|
@@ -20,17 +28,32 @@ module LLM
|
|
|
20
28
|
@lock = Mutex.new
|
|
21
29
|
end
|
|
22
30
|
|
|
31
|
+
# Overrides {Base#describe}.
|
|
32
|
+
#
|
|
33
|
+
# @return [String] the directory
|
|
23
34
|
def describe = "local: #{dir}"
|
|
24
35
|
|
|
36
|
+
# Fixes the file name from the start time, so {#finish} needs no clock.
|
|
37
|
+
#
|
|
38
|
+
# @param experiment [Actions::Experiment] the experiment starting
|
|
39
|
+
# @return [void]
|
|
25
40
|
def start(experiment)
|
|
26
41
|
@started_at = Time.now
|
|
27
42
|
@path = File.join(dir, "#{slug(experiment.name)}.#{@started_at.strftime("%Y-%m-%d.%H%M%S")}.json")
|
|
28
43
|
end
|
|
29
44
|
|
|
45
|
+
# Keeps the result for {#finish}'s document.
|
|
46
|
+
#
|
|
47
|
+
# @param result [Results::RunResult] the finished case
|
|
48
|
+
# @return [void]
|
|
30
49
|
def record(result)
|
|
31
50
|
@lock.synchronize { @results << result }
|
|
32
51
|
end
|
|
33
52
|
|
|
53
|
+
# Creates the directory and writes the whole document in one go.
|
|
54
|
+
#
|
|
55
|
+
# @param experiment [Actions::Experiment] the experiment finishing
|
|
56
|
+
# @param summary [Actions::Experiment::Summary] its totals
|
|
34
57
|
# @return [Hash] `url:` nil, `path:` the file written
|
|
35
58
|
def finish(experiment, summary)
|
|
36
59
|
FileUtils.mkdir_p(dir)
|
|
@@ -40,8 +63,17 @@ module LLM
|
|
|
40
63
|
|
|
41
64
|
private
|
|
42
65
|
|
|
66
|
+
# Makes a name safe for a file name.
|
|
67
|
+
#
|
|
68
|
+
# @param text [String] the experiment's name
|
|
69
|
+
# @return [String]
|
|
43
70
|
def slug(text) = text.gsub(/[^A-Za-z0-9.-]+/, "_").squeeze("_").delete_prefix("_").delete_suffix("_")
|
|
44
71
|
|
|
72
|
+
# The file's whole contents, with results back in case order rather than finishing order.
|
|
73
|
+
#
|
|
74
|
+
# @param experiment [Actions::Experiment] the experiment finishing
|
|
75
|
+
# @param summary [Actions::Experiment::Summary] its totals
|
|
76
|
+
# @return [Hash{Symbol => Object}]
|
|
45
77
|
def document(experiment, summary)
|
|
46
78
|
{
|
|
47
79
|
suite: context[:suite],
|
|
@@ -59,6 +91,10 @@ module LLM
|
|
|
59
91
|
}
|
|
60
92
|
end
|
|
61
93
|
|
|
94
|
+
# One case's entry; the score and reply are nil when the case raised.
|
|
95
|
+
#
|
|
96
|
+
# @param result [Results::RunResult] the finished case
|
|
97
|
+
# @return [Hash{Symbol => Object}]
|
|
62
98
|
def row(result)
|
|
63
99
|
{
|
|
64
100
|
case: result.case.key,
|
|
@@ -14,27 +14,45 @@ module LLM
|
|
|
14
14
|
# already set, the SDK's own reading of it wins and {#describe} says so.
|
|
15
15
|
# The raw reply is not sent unless the config file opts in with
|
|
16
16
|
# `logfire: { include_replies: true }`.
|
|
17
|
-
class Logfire <
|
|
17
|
+
class Logfire < Base
|
|
18
18
|
# Logfire's US region, per their docs on 2026-10-15; other regions
|
|
19
19
|
# exist, so LOGFIRE_BASE_URL overrides it.
|
|
20
20
|
DEFAULT_BASE_URL = "https://logfire-us.pydantic.dev"
|
|
21
|
+
# The environment variable holding the write token; also what selects this tracker.
|
|
21
22
|
TOKEN = "LOGFIRE_TOKEN"
|
|
23
|
+
# The environment variable that overrides {DEFAULT_BASE_URL}.
|
|
22
24
|
BASE_URL = "LOGFIRE_BASE_URL"
|
|
25
|
+
# The standard OTLP endpoint variable, which beats ours when set.
|
|
23
26
|
OTEL_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
|
|
27
|
+
# The standard OTLP headers variable, which beats ours when set.
|
|
24
28
|
OTEL_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"
|
|
25
29
|
|
|
26
30
|
# Bytes of the diagnostics JSON kept on a case span; the rest is cut,
|
|
27
31
|
# since an attribute is not the place for a whole transcript.
|
|
28
32
|
DIAGNOSTICS_LIMIT = 4096
|
|
29
33
|
|
|
34
|
+
# Overrides {Base.env_key}.
|
|
35
|
+
#
|
|
36
|
+
# @return [String] {TOKEN}
|
|
30
37
|
def self.env_key = TOKEN
|
|
38
|
+
# Overrides {Base.gems}.
|
|
39
|
+
#
|
|
40
|
+
# @return [Array<String>] the OpenTelemetry SDK and its OTLP exporter
|
|
31
41
|
def self.gems = %w[opentelemetry-sdk opentelemetry-exporter-otlp]
|
|
32
42
|
|
|
33
43
|
attr_reader :endpoint, :source
|
|
34
44
|
|
|
45
|
+
# @!attribute [r] endpoint
|
|
46
|
+
# @return [String, nil] where traces go; nil leaves the SDK's default
|
|
47
|
+
|
|
48
|
+
# @!attribute [r] source
|
|
49
|
+
# @return [String] which variables the credentials came from, for {#describe}
|
|
50
|
+
|
|
51
|
+
# Reads the settings now; the OpenTelemetry objects wait for {#start}.
|
|
52
|
+
#
|
|
35
53
|
# @param exporter [#export, nil] an exporter to use instead of OTLP, for tests
|
|
36
|
-
# @param env [
|
|
37
|
-
# @param context [Hash] see {
|
|
54
|
+
# @param env [#[]] the environment, ENV by default
|
|
55
|
+
# @param context [Hash] see {Base#initialize}
|
|
38
56
|
def initialize(exporter: nil, env: ENV, **context)
|
|
39
57
|
super(**context)
|
|
40
58
|
@env = env
|
|
@@ -43,17 +61,31 @@ module LLM
|
|
|
43
61
|
@cases = []
|
|
44
62
|
end
|
|
45
63
|
|
|
64
|
+
# Overrides {Base#describe}.
|
|
65
|
+
#
|
|
66
|
+
# @return [String] the endpoint and where the credentials came from
|
|
46
67
|
def describe = "logfire: endpoint #{endpoint || "(SDK default)"}, credentials from #{source}"
|
|
47
68
|
|
|
69
|
+
# Accepts the config's key as a string or a symbol.
|
|
70
|
+
#
|
|
48
71
|
# @return [Boolean] whether raw replies ride along on the case spans
|
|
49
72
|
def include_replies? = options["include_replies"] == true || options[:include_replies] == true
|
|
50
73
|
|
|
74
|
+
# Builds the provider and opens the experiment span.
|
|
75
|
+
#
|
|
76
|
+
# @param experiment [Actions::Experiment] the experiment starting
|
|
77
|
+
# @return [void]
|
|
51
78
|
def start(experiment)
|
|
52
79
|
@provider = provider
|
|
53
80
|
@tracer = @provider.tracer("llm-evals", LLM::Evals::VERSION)
|
|
54
81
|
@span = @tracer.start_span("experiment #{experiment.name}", attributes: experiment_attributes(experiment))
|
|
55
82
|
end
|
|
56
83
|
|
|
84
|
+
# Adds a finished child span, back-dated by the case's duration, since the
|
|
85
|
+
# callback arrives only once the case is over.
|
|
86
|
+
#
|
|
87
|
+
# @param result [Results::RunResult] the finished case
|
|
88
|
+
# @return [void]
|
|
57
89
|
def record(result)
|
|
58
90
|
now = Time.now
|
|
59
91
|
span = @tracer.start_span("case #{result.case.key}",
|
|
@@ -70,6 +102,8 @@ module LLM
|
|
|
70
102
|
# Closes the experiment span with the summary and flushes. No URL:
|
|
71
103
|
# Logfire's trace pages are not something the token lets us build.
|
|
72
104
|
#
|
|
105
|
+
# @param _experiment [Actions::Experiment] unused
|
|
106
|
+
# @param summary [Actions::Experiment::Summary] its totals
|
|
73
107
|
# @return [Hash] `url:` nil
|
|
74
108
|
def finish(_experiment, summary)
|
|
75
109
|
summary.means.each { |metric, mean| @span.set_attribute("evals.mean.#{metric}", mean) unless mean.nil? }
|
|
@@ -87,6 +121,10 @@ module LLM
|
|
|
87
121
|
# Which credentials win: the standard OTEL_* variables when either is
|
|
88
122
|
# set, else our LOGFIRE_* pair. Returns the endpoint and headers to
|
|
89
123
|
# hand the exporter (nil leaves the SDK's own reading in place).
|
|
124
|
+
#
|
|
125
|
+
# @param env [#[]] the environment
|
|
126
|
+
# @return [Array(String, Hash{String => String}, String)] endpoint, headers and a
|
|
127
|
+
# description of the source; endpoint and headers may be nil
|
|
90
128
|
def settings(env)
|
|
91
129
|
if [OTEL_ENDPOINT, OTEL_HEADERS].any? { !env[it].to_s.empty? }
|
|
92
130
|
[env[OTEL_ENDPOINT], nil, "#{OTEL_ENDPOINT}/#{OTEL_HEADERS} (set in the environment; #{TOKEN} ignored)"]
|
|
@@ -98,6 +136,8 @@ module LLM
|
|
|
98
136
|
|
|
99
137
|
# A tracer provider of our own, so the global one, which a consumer
|
|
100
138
|
# may have configured for its application, is left alone.
|
|
139
|
+
#
|
|
140
|
+
# @return [OpenTelemetry::SDK::Trace::TracerProvider]
|
|
101
141
|
def provider
|
|
102
142
|
exporter = @exporter || OpenTelemetry::Exporter::OTLP::Exporter.new(**{ endpoint: endpoint, headers: @headers }.compact)
|
|
103
143
|
processor = if @exporter
|
|
@@ -109,6 +149,10 @@ module LLM
|
|
|
109
149
|
OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource).tap { it.add_span_processor(processor) }
|
|
110
150
|
end
|
|
111
151
|
|
|
152
|
+
# The attributes of the experiment span.
|
|
153
|
+
#
|
|
154
|
+
# @param experiment [Actions::Experiment] the experiment starting
|
|
155
|
+
# @return [Hash{String => String, Numeric}]
|
|
112
156
|
def experiment_attributes(experiment)
|
|
113
157
|
model = experiment.model
|
|
114
158
|
{ "evals.suite" => context[:suite].to_s, "evals.project" => context[:project].to_s, "evals.experiment" => experiment.name,
|
|
@@ -117,6 +161,11 @@ module LLM
|
|
|
117
161
|
"evals.cases" => experiment.cases.size }
|
|
118
162
|
end
|
|
119
163
|
|
|
164
|
+
# The attributes of a case span. A failed case has no score or reply, so it
|
|
165
|
+
# stops at the error.
|
|
166
|
+
#
|
|
167
|
+
# @param result [Results::RunResult] the finished case
|
|
168
|
+
# @return [Hash{String => String, Numeric}]
|
|
120
169
|
def case_attributes(result)
|
|
121
170
|
kase = result.case
|
|
122
171
|
attributes = { "evals.case" => kase.id, "evals.dataset" => kase.dataset, "evals.variant" => kase.variant,
|
|
@@ -136,6 +185,10 @@ module LLM
|
|
|
136
185
|
attributes
|
|
137
186
|
end
|
|
138
187
|
|
|
188
|
+
# Caps an attribute at {DIAGNOSTICS_LIMIT} bytes.
|
|
189
|
+
#
|
|
190
|
+
# @param text [String] the JSON or reply text
|
|
191
|
+
# @return [String] the text, cut and marked with "..." when over the limit
|
|
139
192
|
def truncate(text) = text.bytesize > DIAGNOSTICS_LIMIT ? "#{text.byteslice(0, DIAGNOSTICS_LIMIT)}..." : text
|
|
140
193
|
end
|
|
141
194
|
end
|
|
@@ -15,26 +15,38 @@ module LLM
|
|
|
15
15
|
# @return [Registry] Braintrust and Logfire
|
|
16
16
|
def self.default = new([Braintrust, Logfire])
|
|
17
17
|
|
|
18
|
-
#
|
|
18
|
+
# The array is copied, so registering later does not touch the caller's.
|
|
19
|
+
#
|
|
20
|
+
# @param classes [Array<Class<Base>>]
|
|
19
21
|
def initialize(classes = [])
|
|
20
22
|
@classes = classes.dup
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
+
# Later registration wins, which is how a suite swaps a built-in tracker.
|
|
26
|
+
#
|
|
27
|
+
# @param klass [Class<Base>] replaces one registered under the same name
|
|
28
|
+
# @return [Class<Base>] the class given
|
|
25
29
|
def register(klass)
|
|
26
30
|
@classes.reject! { it.tracker_name == klass.tracker_name }
|
|
27
31
|
@classes << klass
|
|
28
32
|
klass
|
|
29
33
|
end
|
|
30
34
|
|
|
35
|
+
# Walks the classes in registration order.
|
|
36
|
+
#
|
|
37
|
+
# @yieldparam klass [Class<Base>] each tracker class
|
|
38
|
+
# @return [Array<Class<Base>>, Enumerator] the list; an Enumerator without a block
|
|
31
39
|
def each(&) = @classes.each(&)
|
|
32
40
|
|
|
41
|
+
# For {#named}'s error message.
|
|
42
|
+
#
|
|
33
43
|
# @return [Array<String>]
|
|
34
44
|
def names = map(&:tracker_name)
|
|
35
45
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
46
|
+
# Finds a tracker class by its {Base.tracker_name}.
|
|
47
|
+
#
|
|
48
|
+
# @param name [String, Symbol] the name as given
|
|
49
|
+
# @return [Class<Base>]
|
|
38
50
|
# @raise [Error] naming the known trackers
|
|
39
51
|
def named(name)
|
|
40
52
|
find { it.tracker_name == name.to_s } || raise(Error, "no tracker named #{name.to_s.inspect}; known: #{names.join(", ")}")
|
|
@@ -47,9 +59,9 @@ module LLM
|
|
|
47
59
|
#
|
|
48
60
|
# @param flag [String, nil] what `--tracker` was given
|
|
49
61
|
# @param config [String, nil] the config file's `tracker:`
|
|
50
|
-
# @param env [
|
|
62
|
+
# @param env [#[]] the environment, ENV by default
|
|
51
63
|
# @param local [Boolean] `--local`
|
|
52
|
-
# @return [Array(Class<
|
|
64
|
+
# @return [Array(Class<Base>, String), nil] the class and why it was chosen; nil for none
|
|
53
65
|
# @raise [Error] on an unknown name or an ambiguous environment
|
|
54
66
|
def choose(flag: nil, config: nil, env: ENV, local: false)
|
|
55
67
|
return nil if local
|
|
@@ -69,8 +81,18 @@ module LLM
|
|
|
69
81
|
|
|
70
82
|
private
|
|
71
83
|
|
|
84
|
+
# Whether a flag or config value actually says something; blank counts as unset.
|
|
85
|
+
#
|
|
86
|
+
# @param value [String, nil] the value given
|
|
87
|
+
# @return [Boolean]
|
|
72
88
|
def present?(value) = !value.nil? && !value.to_s.strip.empty?
|
|
73
89
|
|
|
90
|
+
# Resolves a named choice, where the {NONE} spellings mean no remote tracker.
|
|
91
|
+
#
|
|
92
|
+
# @param name [String] the name as given
|
|
93
|
+
# @param from [String] where it came from, for `--verbose`
|
|
94
|
+
# @return [Array(Class<Base>, String), nil] the class and the reason; nil for none
|
|
95
|
+
# @raise [Error] on an unknown name
|
|
74
96
|
def choice(name, from)
|
|
75
97
|
return nil if NONE.include?(name.to_s)
|
|
76
98
|
|
data/lib/llm/evals/version.rb
CHANGED
data/lib/llm/evals.rb
CHANGED
|
@@ -8,14 +8,16 @@ require "dry/cli/ui"
|
|
|
8
8
|
require "dry/cli/autocomplete/command"
|
|
9
9
|
require "zeitwerk"
|
|
10
10
|
|
|
11
|
+
# Home of the gem; everything it defines sits under {Evals}.
|
|
11
12
|
module LLM
|
|
12
13
|
# A generic evaluation runner with a one-way boundary. The consumer suite
|
|
13
|
-
# registers an {Adapter}, which runs one {Case} against a {Model} and
|
|
14
|
-
# returns a {Reply} without scoring it, and a {Scorer}, which turns a reply
|
|
15
|
-
# into a {Score} without calling anything. Everything else, from discovery
|
|
14
|
+
# registers an {Actions::Adapter}, which runs one {Datasets::Case} against a {Models::Model} and
|
|
15
|
+
# returns a {Results::Reply} without scoring it, and a {Scoring::Scorer}, which turns a reply
|
|
16
|
+
# into a {Results::Score} without calling anything. Everything else, from discovery
|
|
16
17
|
# to the leaderboard, is this gem's, and none of it knows what is being
|
|
17
18
|
# evaluated.
|
|
18
19
|
module Evals
|
|
20
|
+
# The base of every error the gem raises on purpose.
|
|
19
21
|
class Error < StandardError; end
|
|
20
22
|
|
|
21
23
|
# Raised by a command that needs an optional gem the bundle does not hold.
|
|
@@ -24,9 +26,15 @@ module LLM
|
|
|
24
26
|
class << self
|
|
25
27
|
# The suite's settings, filled in by {.configure} from `evals/eval_helper.rb`.
|
|
26
28
|
#
|
|
27
|
-
# @return [Configuration]
|
|
29
|
+
# @return [Runtime::Configuration]
|
|
28
30
|
def configuration
|
|
29
|
-
@configuration ||= Configuration.new
|
|
31
|
+
@configuration ||= Runtime::Configuration.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The root of the gem, which holds the default config and the CLI.
|
|
35
|
+
# @return [String]
|
|
36
|
+
def root
|
|
37
|
+
@root ||= File.expand_path("../..", __dir__)
|
|
30
38
|
end
|
|
31
39
|
|
|
32
40
|
# Builds the suite's configuration. Called once from the eval helper.
|
|
@@ -40,8 +48,8 @@ module LLM
|
|
|
40
48
|
# c.before_run { MyGem.reset_configuration! }
|
|
41
49
|
# end
|
|
42
50
|
#
|
|
43
|
-
# @yieldparam configuration [Configuration]
|
|
44
|
-
# @return [Configuration]
|
|
51
|
+
# @yieldparam configuration [Runtime::Configuration]
|
|
52
|
+
# @return [Runtime::Configuration]
|
|
45
53
|
def configure
|
|
46
54
|
yield configuration
|
|
47
55
|
configuration
|
|
@@ -54,6 +62,8 @@ module LLM
|
|
|
54
62
|
@configuration = nil
|
|
55
63
|
end
|
|
56
64
|
|
|
65
|
+
# A suite needs both halves of the boundary, so a helper that set only one does not count.
|
|
66
|
+
#
|
|
57
67
|
# @return [Boolean] whether a helper has registered an adapter and a scorer
|
|
58
68
|
def configured? = configuration.complete?
|
|
59
69
|
|