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
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "time"
|
|
5
4
|
|
|
6
5
|
module LLM
|
|
7
6
|
module Evals
|
|
@@ -10,161 +9,59 @@ module LLM
|
|
|
10
9
|
# A portable snapshot of completed runs, suitable for rendering later
|
|
11
10
|
# without repeating model calls or depending on the original case files.
|
|
12
11
|
class JSON
|
|
12
|
+
# Identifies a document as one of these snapshots, so other JSON is refused.
|
|
13
13
|
FORMAT = "llm-evals-report"
|
|
14
|
+
# The document's schema version; {Decoder} refuses any other.
|
|
14
15
|
VERSION = 1
|
|
16
|
+
# A snapshot read back: what {HTML} and the terminal display are given.
|
|
17
|
+
#
|
|
18
|
+
# @!attribute [r] suite
|
|
19
|
+
# @return [String] the suite's name
|
|
20
|
+
# @!attribute [r] generated_at
|
|
21
|
+
# @return [String, nil] when the snapshot was written, ISO 8601
|
|
22
|
+
# @!attribute [r] run_options
|
|
23
|
+
# @return [Hash{String => Object}] as the run recorded them, with string keys now
|
|
24
|
+
# @!attribute [r] summaries
|
|
25
|
+
# @return [Array<Actions::Experiment::Summary>]
|
|
26
|
+
# @!attribute [r] metrics
|
|
27
|
+
# @return [Array<Scoring::Metric>]
|
|
28
|
+
# @!attribute [r] weighted_score
|
|
29
|
+
# @return [Scoring::WeightedScore, nil] nil when the lineup set no weights
|
|
15
30
|
Loaded = Data.define(:suite, :generated_at, :run_options, :summaries, :metrics, :weighted_score)
|
|
31
|
+
# Stands in for the {Actions::Experiment} on a result read back; a snapshot keeps only its model.
|
|
32
|
+
#
|
|
33
|
+
# @!attribute [r] model
|
|
34
|
+
# @return [Models::Model]
|
|
16
35
|
SavedExperiment = Data.define(:model)
|
|
17
36
|
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
# @param
|
|
37
|
+
# Serializes completed runs under the configured suite's name.
|
|
38
|
+
#
|
|
39
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
40
|
+
# @param metrics [Array<Scoring::Metric>]
|
|
41
|
+
# @param weighted_score [Scoring::WeightedScore, nil]
|
|
42
|
+
# @param run_options [Hash]
|
|
21
43
|
# @return [String]
|
|
22
44
|
def self.dump(summaries, metrics:, weighted_score: nil, run_options: {})
|
|
23
|
-
|
|
24
|
-
format: FORMAT,
|
|
25
|
-
version: VERSION,
|
|
26
|
-
suite: LLM::Evals.configuration.name,
|
|
27
|
-
generated_at: Time.now.iso8601,
|
|
28
|
-
run_options: json_value(run_options),
|
|
29
|
-
metrics: metrics.map { { name: it.name, description: it.description, aggregate: it.aggregate, primary: it.primary } },
|
|
30
|
-
weights: weighted_score&.weights,
|
|
31
|
-
summaries: summaries.map { summary(it) }
|
|
32
|
-
}
|
|
33
|
-
::JSON.pretty_generate(document)
|
|
45
|
+
Encoder.new(summaries, metrics:, weighted_score:, run_options:, suite: LLM::Evals.configuration.name).call
|
|
34
46
|
end
|
|
35
47
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
when Array then value.map { json_value(it) }
|
|
40
|
-
when Symbol then value.to_s
|
|
41
|
-
else value
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
private_class_method :json_value
|
|
45
|
-
|
|
48
|
+
# Parses a snapshot back into runs. Providers are looked up by name, so
|
|
49
|
+
# the document's providers must still be configured.
|
|
50
|
+
#
|
|
46
51
|
# @param source [String] report JSON, not a path
|
|
47
52
|
# @param providers [Providers::Registry]
|
|
48
53
|
# @return [Loaded]
|
|
54
|
+
# @raise [KeyError] when a required field is missing
|
|
49
55
|
# @raise [Error] for an unknown format/version or provider
|
|
50
|
-
def self.decode(source, providers:)
|
|
51
|
-
document = ::JSON.parse(source)
|
|
52
|
-
unless document["format"] == FORMAT && document["version"] == VERSION
|
|
53
|
-
raise Error, "unsupported report format version (expected #{FORMAT} v#{VERSION})"
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
metrics = document.fetch("metrics").map do |metric|
|
|
57
|
-
Metric.new(name: metric.fetch("name"),
|
|
58
|
-
description: metric.fetch("description", ""),
|
|
59
|
-
aggregate: metric.fetch("aggregate", "mean").to_sym,
|
|
60
|
-
primary: metric.fetch("primary", false))
|
|
61
|
-
end
|
|
62
|
-
weights = document["weights"]
|
|
63
|
-
weighted_score = WeightedScore.new(weights, metrics: metrics.map(&:name)) if weights && !weights.empty?
|
|
64
|
-
Loaded.new(suite: document.fetch("suite", "llm-evals"),
|
|
65
|
-
generated_at: document["generated_at"],
|
|
66
|
-
run_options: document.fetch("run_options", {}),
|
|
67
|
-
summaries: document.fetch("summaries").map { summary_from(it, providers) },
|
|
68
|
-
metrics:,
|
|
69
|
-
weighted_score:)
|
|
70
|
-
rescue ::JSON::ParserError => e
|
|
71
|
-
raise Error, "invalid report JSON: #{e.message}"
|
|
72
|
-
end
|
|
56
|
+
def self.decode(source, providers:) = Decoder.new(providers:).call(source)
|
|
73
57
|
|
|
58
|
+
# Alias of {.decode}.
|
|
59
|
+
#
|
|
60
|
+
# @param source [String] report JSON, not a path
|
|
61
|
+
# @param providers [Providers::Registry]
|
|
62
|
+
# @return [Loaded]
|
|
63
|
+
# @raise [Error] for an unknown format/version or provider
|
|
74
64
|
def self.load(source, providers:) = decode(source, providers:)
|
|
75
|
-
|
|
76
|
-
def self.summary(summary)
|
|
77
|
-
{
|
|
78
|
-
model: { id: summary.model.id, provider: summary.model.provider.name, settings: summary.model.settings },
|
|
79
|
-
dataset: summary.dataset,
|
|
80
|
-
name: summary.name,
|
|
81
|
-
means: summary.means,
|
|
82
|
-
errors: summary.errors,
|
|
83
|
-
duration: summary.duration,
|
|
84
|
-
permalink: summary.permalink,
|
|
85
|
-
usage: summary.usage.to_h,
|
|
86
|
-
results: summary.results.map { result(it) }
|
|
87
|
-
}
|
|
88
|
-
end
|
|
89
|
-
private_class_method :summary
|
|
90
|
-
|
|
91
|
-
def self.result(result)
|
|
92
|
-
{
|
|
93
|
-
case: {
|
|
94
|
-
id: result.case.id,
|
|
95
|
-
dataset: result.case.dataset,
|
|
96
|
-
variant: result.case.variant,
|
|
97
|
-
input: result.case.input,
|
|
98
|
-
expected: result.case.expected,
|
|
99
|
-
metadata: result.case.metadata
|
|
100
|
-
},
|
|
101
|
-
reply: result.reply && {
|
|
102
|
-
output: result.reply.output,
|
|
103
|
-
raw: result.reply.raw,
|
|
104
|
-
usage: result.reply.usage.to_h,
|
|
105
|
-
error: result.reply.error,
|
|
106
|
-
metadata: result.reply.metadata
|
|
107
|
-
},
|
|
108
|
-
score: result.score && {
|
|
109
|
-
value: result.score.value,
|
|
110
|
-
metrics: result.score.metrics,
|
|
111
|
-
outcomes: result.score.outcomes,
|
|
112
|
-
diagnostics: result.score.diagnostics
|
|
113
|
-
},
|
|
114
|
-
duration: result.duration,
|
|
115
|
-
error: result.error
|
|
116
|
-
}
|
|
117
|
-
end
|
|
118
|
-
private_class_method :result
|
|
119
|
-
|
|
120
|
-
def self.summary_from(data, providers)
|
|
121
|
-
model_data = data.fetch("model")
|
|
122
|
-
provider = providers.find { it.name == model_data.fetch("provider") }
|
|
123
|
-
raise Error, "report uses unknown provider #{model_data.fetch("provider").inspect}" unless provider
|
|
124
|
-
|
|
125
|
-
model = Model.new(id: model_data.fetch("id"), provider:, settings: model_data.fetch("settings", {}))
|
|
126
|
-
placeholder_cases = data.fetch("results").map do |result_data|
|
|
127
|
-
case_data = result_data.fetch("case")
|
|
128
|
-
kase = Case.new(id: case_data.fetch("id"),
|
|
129
|
-
dataset: case_data.fetch("dataset"),
|
|
130
|
-
variant: case_data.fetch("variant"),
|
|
131
|
-
input: case_data.fetch("input"),
|
|
132
|
-
expected: case_data["expected"],
|
|
133
|
-
path: "",
|
|
134
|
-
metadata: (case_data["metadata"] || {}).transform_keys(&:to_sym))
|
|
135
|
-
[result_data, kase]
|
|
136
|
-
end
|
|
137
|
-
experiment = SavedExperiment.new(model:)
|
|
138
|
-
results = placeholder_cases.map do |result_data, kase|
|
|
139
|
-
reply_data = result_data["reply"]
|
|
140
|
-
reply = if reply_data
|
|
141
|
-
Reply.new(output: reply_data["output"],
|
|
142
|
-
raw: reply_data["raw"],
|
|
143
|
-
usage: reply_data["usage"],
|
|
144
|
-
error: reply_data["error"],
|
|
145
|
-
metadata: reply_data["metadata"] || {})
|
|
146
|
-
end
|
|
147
|
-
score_data = result_data["score"]
|
|
148
|
-
if score_data
|
|
149
|
-
score = Score.new(value: score_data.fetch("value"),
|
|
150
|
-
metrics: score_data["metrics"] || {},
|
|
151
|
-
outcomes: score_data["outcomes"] || {},
|
|
152
|
-
diagnostics: score_data["diagnostics"] || {})
|
|
153
|
-
end
|
|
154
|
-
RunResult.new(experiment:, case: kase, reply:, score:, duration: result_data.fetch("duration", 0.0), error: result_data["error"])
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
Experiment::Summary.new(model:,
|
|
158
|
-
dataset: data.fetch("dataset"),
|
|
159
|
-
name: data.fetch("name"),
|
|
160
|
-
means: data.fetch("means"),
|
|
161
|
-
results:,
|
|
162
|
-
errors: data.fetch("errors", []),
|
|
163
|
-
duration: data.fetch("duration", 0.0),
|
|
164
|
-
permalink: data["permalink"],
|
|
165
|
-
usage: Usage.from(data["usage"]))
|
|
166
|
-
end
|
|
167
|
-
private_class_method :summary_from
|
|
168
65
|
end
|
|
169
66
|
end
|
|
170
67
|
end
|
|
@@ -11,10 +11,25 @@ module LLM
|
|
|
11
11
|
# Writes the canonical JSON snapshot and passes that snapshot to the
|
|
12
12
|
# selected display. Temporary snapshots are removed after presentation.
|
|
13
13
|
class Pipeline
|
|
14
|
+
# Where a run's reports went. A path is nil for anything not kept; `browser_opened`
|
|
15
|
+
# is nil when no attempt was made.
|
|
16
|
+
#
|
|
17
|
+
# @!attribute [r] json_path
|
|
18
|
+
# @return [String, nil] the saved snapshot; nil when it was only temporary
|
|
19
|
+
# @!attribute [r] html_path
|
|
20
|
+
# @return [String, nil]
|
|
21
|
+
# @!attribute [r] css_path
|
|
22
|
+
# @return [String, nil]
|
|
23
|
+
# @!attribute [r] js_path
|
|
24
|
+
# @return [String, nil]
|
|
25
|
+
# @!attribute [r] browser_opened
|
|
26
|
+
# @return [Boolean, nil]
|
|
14
27
|
Result = Data.define(:json_path, :html_path, :css_path, :js_path, :browser_opened)
|
|
15
28
|
|
|
29
|
+
# Reduces the project's name to a slug for use in file names.
|
|
30
|
+
#
|
|
16
31
|
# @param providers [Providers::Registry]
|
|
17
|
-
# @param runtime [Runtime]
|
|
32
|
+
# @param runtime [Runtime::Environment]
|
|
18
33
|
# @param destination [String]
|
|
19
34
|
# @param temp_dir [String]
|
|
20
35
|
# @param project [String]
|
|
@@ -26,14 +41,20 @@ module LLM
|
|
|
26
41
|
@project = project.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
|
|
27
42
|
end
|
|
28
43
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
44
|
+
# Decodes the snapshot back before showing it, so a live report and `render`
|
|
45
|
+
# display exactly the same thing. Does nothing without autosave or a display.
|
|
46
|
+
# `display` is none, tui, browser or both.
|
|
47
|
+
#
|
|
48
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
49
|
+
# @param metrics [Array<Scoring::Metric>]
|
|
50
|
+
# @param weighted_score [Scoring::WeightedScore, nil]
|
|
32
51
|
# @param run_options [Hash]
|
|
33
52
|
# @param autosave [Boolean]
|
|
34
53
|
# @param display [String]
|
|
35
54
|
# @param open [Boolean]
|
|
36
55
|
# @yield [report] display the decoded JSON in the terminal
|
|
56
|
+
# @yieldparam report [JSON::Loaded]
|
|
57
|
+
# @raise [Error] when the snapshot cannot be decoded
|
|
37
58
|
# @return [Result]
|
|
38
59
|
def call(summaries:, metrics:, weighted_score:, run_options:, autosave:, display:, open:)
|
|
39
60
|
return Result.new(nil, nil, nil, nil, nil) unless autosave || display != "none"
|
|
@@ -44,24 +65,18 @@ module LLM
|
|
|
44
65
|
return Result.new(json_path:, html_path: nil, css_path: nil, js_path: nil, browser_opened: nil) if display == "none"
|
|
45
66
|
|
|
46
67
|
report = JSON.decode(File.read(json_path), providers: @providers)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
yield report if block_given?
|
|
50
|
-
[nil, nil, nil, nil]
|
|
51
|
-
when "browser"
|
|
52
|
-
render_browser(report, open:)
|
|
53
|
-
else
|
|
54
|
-
[nil, nil, nil, nil]
|
|
55
|
-
end
|
|
56
|
-
html_path, css_path, js_path, browser_opened = assets
|
|
68
|
+
yield report if block_given? && Config::TERMINAL_DISPLAYS.include?(display)
|
|
69
|
+
html_path, css_path, js_path, browser_opened = render_browser(report, open:) if Config::BROWSER_DISPLAYS.include?(display)
|
|
57
70
|
Result.new(json_path: autosave ? json_path : nil, html_path:, css_path:, js_path:, browser_opened:)
|
|
58
71
|
ensure
|
|
59
72
|
FileUtils.rm_f(json_path) if defined?(json_path) && json_path && !autosave
|
|
60
73
|
end
|
|
61
74
|
|
|
62
|
-
#
|
|
63
|
-
#
|
|
64
|
-
# @param
|
|
75
|
+
# The snapshot's document, to a file or back to the caller.
|
|
76
|
+
#
|
|
77
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
78
|
+
# @param metrics [Array<Scoring::Metric>]
|
|
79
|
+
# @param weighted_score [Scoring::WeightedScore, nil]
|
|
65
80
|
# @param run_options [Hash]
|
|
66
81
|
# @param path [String, nil] nil returns JSON text to the caller
|
|
67
82
|
# @return [String, nil] JSON text when no path was supplied
|
|
@@ -76,6 +91,8 @@ module LLM
|
|
|
76
91
|
end
|
|
77
92
|
end
|
|
78
93
|
|
|
94
|
+
# Rebuilds the HTML from a snapshot, standing alone with its assets embedded.
|
|
95
|
+
#
|
|
79
96
|
# @param json [String] serialized run results
|
|
80
97
|
# @param path [String, nil] nil returns HTML text to the caller
|
|
81
98
|
# @return [String, nil] HTML text when no path was supplied
|
|
@@ -94,9 +111,9 @@ module LLM
|
|
|
94
111
|
# Writes an explicitly requested JSON report under the configured
|
|
95
112
|
# destination, using the same timestamped name as an autosaved result.
|
|
96
113
|
#
|
|
97
|
-
# @param summaries [Array<Experiment::Summary>]
|
|
98
|
-
# @param metrics [Array<Metric>]
|
|
99
|
-
# @param weighted_score [WeightedScore, nil]
|
|
114
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
115
|
+
# @param metrics [Array<Scoring::Metric>]
|
|
116
|
+
# @param weighted_score [Scoring::WeightedScore, nil]
|
|
100
117
|
# @param run_options [Hash]
|
|
101
118
|
# @return [String] the saved JSON path
|
|
102
119
|
def save_json(summaries:, metrics:, weighted_score:, run_options:)
|
|
@@ -118,6 +135,12 @@ module LLM
|
|
|
118
135
|
|
|
119
136
|
private
|
|
120
137
|
|
|
138
|
+
# Writes the page and its assets next to each other, and opens it when asked.
|
|
139
|
+
#
|
|
140
|
+
# @param report [JSON::Loaded]
|
|
141
|
+
# @param open [Boolean] whether to open the page in the browser
|
|
142
|
+
# @return [Array(String, String, String, Boolean, nil)] the HTML, CSS and JS paths, and
|
|
143
|
+
# whether the browser opened; nil when `open` is false
|
|
121
144
|
def render_browser(report, open:)
|
|
122
145
|
FileUtils.mkdir_p(@destination)
|
|
123
146
|
html_path = report_path(@destination, "html")
|
|
@@ -133,12 +156,24 @@ module LLM
|
|
|
133
156
|
[html_path, css_path, js_path, browser_opened]
|
|
134
157
|
end
|
|
135
158
|
|
|
159
|
+
# Timestamped, so results accumulate rather than replace each other.
|
|
160
|
+
#
|
|
161
|
+
# @param directory [String]
|
|
162
|
+
# @param stamp [String] see {#timestamp}
|
|
163
|
+
# @param extension [String]
|
|
164
|
+
# @return [String]
|
|
136
165
|
def artifact_path(directory, stamp, extension)
|
|
137
166
|
File.join(directory, "#{@project}.results.#{stamp}.#{extension}")
|
|
138
167
|
end
|
|
139
168
|
|
|
169
|
+
# Down to the microsecond, so runs started together do not overwrite each other.
|
|
170
|
+
#
|
|
171
|
+
# @return [String]
|
|
140
172
|
def timestamp = Time.now.strftime("%Y-%m-%d.%H%M%S%6N")
|
|
141
173
|
|
|
174
|
+
# Creates an empty file now to claim the name; {#call} removes it afterwards.
|
|
175
|
+
#
|
|
176
|
+
# @return [String] the path
|
|
142
177
|
def temporary_json_path
|
|
143
178
|
FileUtils.mkdir_p(@temp_dir)
|
|
144
179
|
file = Tempfile.create(["#{@project}.results.", ".json"], @temp_dir)
|
|
@@ -7,6 +7,8 @@ module LLM
|
|
|
7
7
|
# Presents a decoded report in the terminal and optionally writes the
|
|
8
8
|
# detailed view to a report file.
|
|
9
9
|
class Terminal
|
|
10
|
+
# The collaborators are callables so the command keeps its drawing methods.
|
|
11
|
+
#
|
|
10
12
|
# @param invocation [Dry::CLI::UI::Invocation]
|
|
11
13
|
# @param writing_to [#call] redirects the command's table helpers
|
|
12
14
|
# @param leaderboard [#call] draws the summary leaderboard
|
|
@@ -16,11 +18,16 @@ module LLM
|
|
|
16
18
|
@leaderboard = leaderboard
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
#
|
|
21
|
+
# Draws the detail then the leaderboard. With a report file, both also go there;
|
|
22
|
+
# the screen then gets the leaderboard alone.
|
|
23
|
+
#
|
|
24
|
+
# @param summaries [Array<Actions::Experiment::Summary>]
|
|
20
25
|
# @param output [String, nil]
|
|
21
26
|
# @param scores [Boolean]
|
|
22
27
|
# @param totals_mode [String] overall, variant or both
|
|
28
|
+
# @param details [Proc, nil] the block below
|
|
23
29
|
# @yield [summaries] draw the per-case detail
|
|
30
|
+
# @yieldparam summaries [Array<Actions::Experiment::Summary>]
|
|
24
31
|
# @return [String, nil] report file path
|
|
25
32
|
def call(summaries:, output:, scores:, totals_mode:, &details)
|
|
26
33
|
path = @invocation.report_path(output)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Results
|
|
6
|
+
# What a {Models::Model} returns from one call: the text, what it cost, and the
|
|
7
|
+
# provider's raw response for an adapter that needs more than the text.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute [r] text
|
|
10
|
+
# @return [String] the model's answer, never nil
|
|
11
|
+
# @!attribute [r] usage
|
|
12
|
+
# @return [Stats::Usage] what the call cost
|
|
13
|
+
# @!attribute [r] raw
|
|
14
|
+
# @return [RubyLLM::Message, Hash, nil] the provider's own response: OpenRouter's message, TypeSafe's answers by name
|
|
15
|
+
ProviderResponse = Data.define(:text, :usage, :raw) do
|
|
16
|
+
# Normalizes the text to a String and the usage to {Stats::Usage}.
|
|
17
|
+
#
|
|
18
|
+
# @param text [#to_s] the model's answer
|
|
19
|
+
# @param usage [Stats::Usage, Hash, nil] what the call cost; see {Stats::Usage.from}
|
|
20
|
+
# @param raw [RubyLLM::Message, Hash, nil] the provider's own response
|
|
21
|
+
def initialize(text:, usage: Stats::Usage.zero, raw: nil) = super(text: text.to_s, usage: Stats::Usage.from(usage), raw: raw)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Results
|
|
6
|
+
# What an {Actions::Adapter} returns for one case. `error` is the model's failure
|
|
7
|
+
# (it did not answer the question: no JSON, a refusal) and scores 0; a
|
|
8
|
+
# transport failure is not the model's and is raised instead, so it is
|
|
9
|
+
# counted as an error of the run rather than a wrong answer.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] output
|
|
12
|
+
# @return [Object, nil] the adapter's parsed answer, usually a Hash; nil when the model failed
|
|
13
|
+
# @!attribute [r] raw
|
|
14
|
+
# @return [String, nil] the reply as the model gave it
|
|
15
|
+
# @!attribute [r] usage
|
|
16
|
+
# @return [Stats::Usage] what the call cost
|
|
17
|
+
# @!attribute [r] error
|
|
18
|
+
# @return [String, nil] why the model failed to answer
|
|
19
|
+
# @!attribute [r] metadata
|
|
20
|
+
# @return [Hash] anything else the adapter wants kept with the reply
|
|
21
|
+
Reply = Data.define(:output, :raw, :usage, :error, :metadata) do
|
|
22
|
+
# Normalizes the usage, so a Hash read back from a snapshot is accepted.
|
|
23
|
+
#
|
|
24
|
+
# @param output [Object, nil] the adapter's parsed answer
|
|
25
|
+
# @param raw [String, nil] the reply as the model gave it
|
|
26
|
+
# @param usage [Stats::Usage, Hash, nil] what the call cost; see {Stats::Usage.from}
|
|
27
|
+
# @param error [String, nil] why the model failed to answer
|
|
28
|
+
# @param metadata [Hash] anything else the adapter wants kept with the reply
|
|
29
|
+
def initialize(output: nil, raw: nil, usage: Stats::Usage.zero, error: nil, metadata: {})
|
|
30
|
+
super(output: output, raw: raw, usage: Stats::Usage.from(usage), error: error, metadata: metadata)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Boolean] whether the model failed to answer
|
|
34
|
+
def error? = !error.nil?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Results
|
|
6
|
+
# What running a set of experiments produced, and how long it took as a whole.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute [r] summaries
|
|
9
|
+
# @return [Array<Actions::Experiment::Summary>] the experiments that finished
|
|
10
|
+
# @!attribute [r] seconds
|
|
11
|
+
# @return [Float] wall-clock time for the whole run, which experiments overlap within
|
|
12
|
+
RunOutcome = Data.define(:summaries, :seconds)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Results
|
|
6
|
+
# One case, run once: what came back and what it scored. `reply` is nil
|
|
7
|
+
# when the adapter raised, and `error` then holds the message.
|
|
8
|
+
#
|
|
9
|
+
# @!attribute [r] experiment
|
|
10
|
+
# @return [Actions::Experiment, Render::Reports::JSON::SavedExperiment] what ran it; the latter when read from a snapshot
|
|
11
|
+
# @!attribute [r] case
|
|
12
|
+
# @return [Datasets::Case] the case that ran
|
|
13
|
+
# @!attribute [r] reply
|
|
14
|
+
# @return [Reply, nil] what the adapter returned; nil when it raised
|
|
15
|
+
# @!attribute [r] score
|
|
16
|
+
# @return [Score, nil] what the reply scored; nil when there is nothing to score
|
|
17
|
+
# @!attribute [r] duration
|
|
18
|
+
# @return [Float] seconds this case took
|
|
19
|
+
# @!attribute [r] error
|
|
20
|
+
# @return [String, nil] the message of what the adapter raised
|
|
21
|
+
RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error) do
|
|
22
|
+
# Only `experiment` and `case` are required, since a failed run has little else.
|
|
23
|
+
#
|
|
24
|
+
# @param experiment [Actions::Experiment, Render::Reports::JSON::SavedExperiment] what ran it
|
|
25
|
+
# @param case [Datasets::Case] the case that ran
|
|
26
|
+
# @param reply [Reply, nil] what the adapter returned
|
|
27
|
+
# @param score [Score, nil] what the reply scored
|
|
28
|
+
# @param duration [Float] seconds this case took
|
|
29
|
+
# @param error [String, nil] the message of what the adapter raised
|
|
30
|
+
def initialize(experiment:, case:, reply: nil, score: nil, duration: 0.0, error: nil) = super
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] whether the adapter raised rather than answered
|
|
33
|
+
def failed? = !error.nil?
|
|
34
|
+
|
|
35
|
+
# @return [Boolean] whether the case went wrong, by raising or by a reply the model failed
|
|
36
|
+
def errored? = failed? || reply&.error? == true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLM
|
|
4
|
+
module Evals
|
|
5
|
+
module Results
|
|
6
|
+
# What a {Scoring::Scorer} says about one reply. `value` is the case's overall
|
|
7
|
+
# score in 0..1; `metrics` holds one entry per {Scoring::Metric} the scorer
|
|
8
|
+
# declares, nil where the case does not apply; `outcomes` and
|
|
9
|
+
# `diagnostics` are free-form, keyed by field, and only ever displayed.
|
|
10
|
+
#
|
|
11
|
+
# @!attribute [r] value
|
|
12
|
+
# @return [Float] the overall score, in 0..1
|
|
13
|
+
# @!attribute [r] metrics
|
|
14
|
+
# @return [Hash{String => Float, nil}] by metric name
|
|
15
|
+
# @!attribute [r] outcomes
|
|
16
|
+
# @return [Hash] free-form, keyed by field; only displayed
|
|
17
|
+
# @!attribute [r] diagnostics
|
|
18
|
+
# @return [Hash] free-form, keyed by field; only displayed
|
|
19
|
+
Score = Data.define(:value, :metrics, :outcomes, :diagnostics) do
|
|
20
|
+
# Refuses a value outside 0..1, and turns metric names into Strings so that
|
|
21
|
+
# a fresh score and one read back from JSON look up the same way.
|
|
22
|
+
#
|
|
23
|
+
# @param value [Numeric] the overall score, in 0..1
|
|
24
|
+
# @param metrics [Hash{String, Symbol => Float, nil}] by metric name
|
|
25
|
+
# @param outcomes [Hash] free-form, keyed by field
|
|
26
|
+
# @param diagnostics [Hash] free-form, keyed by field
|
|
27
|
+
# @raise [ArgumentError] when the value is not a number within 0..1
|
|
28
|
+
def initialize(value:, metrics: {}, outcomes: {}, diagnostics: {})
|
|
29
|
+
raise ArgumentError, "score value #{value.inspect} is not within 0..1" unless value.is_a?(Numeric) && (0..1).cover?(value)
|
|
30
|
+
|
|
31
|
+
super(value: value.to_f, metrics: metrics.transform_keys(&:to_s), outcomes: outcomes, diagnostics: diagnostics)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The score of a reply the model failed: 0 everywhere, the failure as a diagnostic.
|
|
35
|
+
#
|
|
36
|
+
# @param metrics [Array<Scoring::Metric>]
|
|
37
|
+
# @param error [String]
|
|
38
|
+
# @return [Score]
|
|
39
|
+
def self.failed(metrics, error)
|
|
40
|
+
new(value: 0.0, metrics: metrics.to_h { [it.name, 0.0] }, diagnostics: { "error" => error })
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|