activeagent 1.9.1 → 1.9.3
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 +407 -1
- data/README.md +3 -0
- data/lib/active_agent/base.rb +29 -4
- data/lib/active_agent/delegation/schema.rb +51 -13
- data/lib/active_agent/evals/comparison.rb +169 -0
- data/lib/active_agent/evals/publisher.rb +19 -12
- data/lib/active_agent/evals/report.rb +82 -66
- data/lib/active_agent/evals/report_html.rb +149 -12
- data/lib/active_agent/evals/report_markdown.rb +202 -0
- data/lib/active_agent/evals/runner.rb +7 -1
- data/lib/active_agent/evals/statistics.rb +26 -0
- data/lib/active_agent/evals.rb +3 -0
- data/lib/active_agent/providers/_base_provider.rb +2 -0
- data/lib/active_agent/providers/anthropic_provider.rb +4 -1
- data/lib/active_agent/providers/common/responses/base.rb +15 -2
- data/lib/active_agent/providers/common/usage.rb +4 -2
- data/lib/active_agent/providers/open_ai/chat_provider.rb +46 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +40 -4
- data/lib/active_agent/providers/opper/_types.rb +16 -0
- data/lib/active_agent/providers/opper/options.rb +38 -0
- data/lib/active_agent/providers/opper_provider.rb +72 -0
- data/lib/active_agent/schema_generator.rb +146 -22
- data/lib/active_agent/version.rb +1 -1
- metadata +8 -2
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveAgent
|
|
4
|
+
module Evals
|
|
5
|
+
# Whether the gap between the models of a run is larger than the noise of
|
|
6
|
+
# its scenario sample: each model's pass rate with a confidence interval,
|
|
7
|
+
# and an interval for the difference between every pair of models.
|
|
8
|
+
#
|
|
9
|
+
# The scenario is the unit. A model's pass rate is the mean over its
|
|
10
|
+
# scenarios of the share of its results there that passed (1.0 or 0.0
|
|
11
|
+
# for one result per scenario), and the intervals come from resampling
|
|
12
|
+
# whole scenarios with replacement, +resamples+ times: the percentile
|
|
13
|
+
# bootstrap. A pair is compared on the scenarios both models ran, each
|
|
14
|
+
# resample drawing the same scenarios for both (a paired bootstrap of
|
|
15
|
+
# the per-scenario differences). A pair is decisive when its interval
|
|
16
|
+
# excludes zero.
|
|
17
|
+
#
|
|
18
|
+
# The resampling is seeded, so the same results always give the same
|
|
19
|
+
# intervals and a report renders the same page each time.
|
|
20
|
+
#
|
|
21
|
+
# comparison = Comparison.new(results: report.results, models: report.models)
|
|
22
|
+
# comparison.to_h["pairs"].first
|
|
23
|
+
# # => { "models" => ["gpt-5-mini", "qwen3:8b"], "scenarios" => 42, "difference" => 7.1,
|
|
24
|
+
# # "interval" => [0.0, 16.7], "decisive" => false }
|
|
25
|
+
# comparison.conclusion
|
|
26
|
+
# # => "The pairwise difference interval includes zero: the ranking is not decisive evidence of superiority."
|
|
27
|
+
class Comparison
|
|
28
|
+
RESAMPLES = 5_000
|
|
29
|
+
CONFIDENCE = 0.95
|
|
30
|
+
SEED = 0
|
|
31
|
+
# Fewer scenarios than this give no interval: resampling one scenario
|
|
32
|
+
# only ever draws that scenario.
|
|
33
|
+
MINIMUM_SCENARIOS = 2
|
|
34
|
+
|
|
35
|
+
attr_reader :resamples, :confidence, :seed
|
|
36
|
+
|
|
37
|
+
# @param results [Array<Result>]
|
|
38
|
+
# @param models [Array<ModelSpec>] in the run's order, which breaks ties in the ranking
|
|
39
|
+
# @param resamples [Integer]
|
|
40
|
+
# @param confidence [Float] 0..1
|
|
41
|
+
# @param seed [Integer]
|
|
42
|
+
def initialize(results:, models:, resamples: RESAMPLES, confidence: CONFIDENCE, seed: SEED)
|
|
43
|
+
@labels = models.map(&:label)
|
|
44
|
+
@resamples = resamples
|
|
45
|
+
@confidence = confidence
|
|
46
|
+
@seed = seed
|
|
47
|
+
@outcomes = @labels.to_h do |label|
|
|
48
|
+
by_scenario = results.select { |result| result.label == label }.group_by { |result| result.scenario.key }
|
|
49
|
+
[ label, by_scenario.transform_values { |cohort| cohort.count(&:passed?).fdiv(cohort.size) } ]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Per model label, in the run's order: `{ "scenarios", "pass_rate",
|
|
54
|
+
# "interval" }`, the pass rate and its interval in percent. The interval
|
|
55
|
+
# is nil below MINIMUM_SCENARIOS, and the pass rate nil for none.
|
|
56
|
+
def models
|
|
57
|
+
@models ||= @labels.to_h do |label|
|
|
58
|
+
values = @outcomes[label].values
|
|
59
|
+
[ label, {
|
|
60
|
+
"scenarios" => values.size,
|
|
61
|
+
"pass_rate" => percent(mean(values)),
|
|
62
|
+
"interval" => interval(values)
|
|
63
|
+
} ]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The labels by pass rate, best first; ties keep the run's order.
|
|
68
|
+
def ranking
|
|
69
|
+
@ranking ||= @labels.sort_by.with_index { |label, index| [ -(models[label]["pass_rate"] || -1), index ] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# One entry per pair of models, the better ranked first: `{ "models" =>
|
|
73
|
+
# [a, b], "scenarios", "difference", "interval", "decisive" }`, where
|
|
74
|
+
# "difference" is a's pass rate minus b's over the scenarios both ran,
|
|
75
|
+
# in percentage points.
|
|
76
|
+
def pairs
|
|
77
|
+
@pairs ||= ranking.combination(2).map do |a, b|
|
|
78
|
+
shared = @outcomes[a].keys & @outcomes[b].keys
|
|
79
|
+
differences = shared.map { |key| @outcomes[a][key] - @outcomes[b][key] }
|
|
80
|
+
range = interval(differences)
|
|
81
|
+
|
|
82
|
+
{
|
|
83
|
+
"models" => [ a, b ],
|
|
84
|
+
"scenarios" => shared.size,
|
|
85
|
+
"difference" => percent(mean(differences)),
|
|
86
|
+
"interval" => range,
|
|
87
|
+
"decisive" => !range.nil? && (range.first.positive? || range.last.negative?)
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The honest read of the ranking, in a sentence: which pairs differ by
|
|
93
|
+
# more than the noise, or that none does. nil for a single model.
|
|
94
|
+
def conclusion
|
|
95
|
+
return nil if pairs.empty?
|
|
96
|
+
|
|
97
|
+
compared, too_few = pairs.partition { |pair| pair["interval"] }
|
|
98
|
+
decisive, inconclusive = compared.partition { |pair| pair["decisive"] }
|
|
99
|
+
sentences =
|
|
100
|
+
if compared.empty?
|
|
101
|
+
[ "The models share too few scenarios to compare." ]
|
|
102
|
+
elsif decisive.empty?
|
|
103
|
+
[ "#{all_include_zero(compared.size)}: the ranking is not decisive evidence of superiority." ]
|
|
104
|
+
else
|
|
105
|
+
decisive.map { |pair| decisive_sentence(pair) } + (inconclusive.any? ? [ others_include_zero(inconclusive.size) ] : [])
|
|
106
|
+
end
|
|
107
|
+
sentences << "#{plural(too_few.size, 'pair')} share#{'s' if too_few.one?} too few scenarios to compare." if compared.any? && too_few.any?
|
|
108
|
+
sentences.join(" ")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# How the intervals were computed, for a caption.
|
|
112
|
+
def method_summary
|
|
113
|
+
{ "statistic" => "pass rate", "interval" => "percentile bootstrap", "unit" => "scenario",
|
|
114
|
+
"resamples" => @resamples, "confidence" => @confidence, "seed" => @seed }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def to_h
|
|
118
|
+
{ "method" => method_summary, "models" => models, "ranking" => ranking, "pairs" => pairs, "conclusion" => conclusion }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# The bootstrap interval of the mean of +values+, in percent, or nil
|
|
124
|
+
# below MINIMUM_SCENARIOS.
|
|
125
|
+
def interval(values)
|
|
126
|
+
return nil if values.size < MINIMUM_SCENARIOS
|
|
127
|
+
|
|
128
|
+
random = Random.new(@seed)
|
|
129
|
+
size = values.size
|
|
130
|
+
means = Array.new(@resamples) do
|
|
131
|
+
sum = 0.0
|
|
132
|
+
size.times { sum += values[random.rand(size)] }
|
|
133
|
+
sum / size
|
|
134
|
+
end
|
|
135
|
+
# A rational tail, so 95% leaves exactly 2.5: in floating point it
|
|
136
|
+
# is 2.5000000000000022, whose nearest rank is one too high.
|
|
137
|
+
tail = (1 - @confidence.rationalize) / 2 * 100
|
|
138
|
+
[ percent(Statistics.percentile(means, tail)), percent(Statistics.percentile(means, 100 - tail)) ]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def mean(values)
|
|
142
|
+
values.sum.fdiv(values.size) if values.any?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def percent(fraction)
|
|
146
|
+
(fraction * 100).round(1) if fraction
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def all_include_zero(count)
|
|
150
|
+
count == 1 ? "The pairwise difference interval includes zero" : "All #{count} pairwise difference intervals include zero"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def others_include_zero(count)
|
|
154
|
+
count == 1 ? "The other pairwise interval includes zero." : "The other #{count} pairwise intervals include zero."
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def decisive_sentence(pair)
|
|
158
|
+
better, worse = pair["difference"].positive? ? pair["models"] : pair["models"].reverse
|
|
159
|
+
low, high = pair["interval"].map(&:abs).sort
|
|
160
|
+
"#{better} passes more scenarios than #{worse}, by #{pair['difference'].abs} points " \
|
|
161
|
+
"(#{(@confidence * 100).round}% CI #{low} to #{high})."
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def plural(count, word)
|
|
165
|
+
"#{count} #{word}#{'s' unless count == 1}"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -94,11 +94,7 @@ module ActiveAgent
|
|
|
94
94
|
# report may be a Report or its saved JSON hash. Full prompts, answers and
|
|
95
95
|
# tool results are included; applications should make publication opt-in.
|
|
96
96
|
def call(report:, run_id:, source:, agent_name:, suite:)
|
|
97
|
-
|
|
98
|
-
identities.each do |key, value|
|
|
99
|
-
raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
|
|
100
|
-
end
|
|
101
|
-
body = encode(identities.merge("version" => 1, "report" => report_hash(report)))
|
|
97
|
+
body = encode(self.class.envelope(report: report, run_id: run_id, source: source, agent_name: agent_name, suite: suite))
|
|
102
98
|
raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
|
|
103
99
|
|
|
104
100
|
deliver("retain the report and run_id for retry") do
|
|
@@ -143,6 +139,24 @@ module ActiveAgent
|
|
|
143
139
|
end
|
|
144
140
|
end
|
|
145
141
|
|
|
142
|
+
# The version-1 envelope a collector stores: the four identities, the
|
|
143
|
+
# version and the report's hash. What #call sends, and what a
|
|
144
|
+
# collector's export of a stored run returns, so the export can be
|
|
145
|
+
# posted again. report may be a Report or its saved JSON hash.
|
|
146
|
+
#
|
|
147
|
+
# Raises ArgumentError for an identity that is not a nonempty string,
|
|
148
|
+
# or a report that is neither.
|
|
149
|
+
def self.envelope(report:, run_id:, source:, agent_name:, suite:)
|
|
150
|
+
identities = { "run_id" => run_id, "source" => source, "agent_name" => agent_name, "suite" => suite }
|
|
151
|
+
identities.each do |key, value|
|
|
152
|
+
raise ArgumentError, "#{key} must be a nonempty string" unless value.is_a?(String) && !value.strip.empty?
|
|
153
|
+
end
|
|
154
|
+
hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
|
|
155
|
+
raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
|
|
156
|
+
|
|
157
|
+
identities.merge("version" => 1, "report" => hash)
|
|
158
|
+
end
|
|
159
|
+
|
|
146
160
|
private
|
|
147
161
|
|
|
148
162
|
# Posts +body+ to the endpoint with the bearer key and the delivery timeouts.
|
|
@@ -176,13 +190,6 @@ module ActiveAgent
|
|
|
176
190
|
raise Error.new("Evaluation delivery failed (#{e.class}); #{guidance}", retryable: true)
|
|
177
191
|
end
|
|
178
192
|
|
|
179
|
-
def report_hash(report)
|
|
180
|
-
hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
|
|
181
|
-
raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
|
|
182
|
-
|
|
183
|
-
hash
|
|
184
|
-
end
|
|
185
|
-
|
|
186
193
|
# Returns the envelope as JSON. A report holding invalid UTF-8, NaN,
|
|
187
194
|
# Infinity or nesting over JSON's depth limit raises a non-retryable
|
|
188
195
|
# Error, without the generator's message, which can quote the report.
|
|
@@ -8,6 +8,7 @@ module ActiveAgent
|
|
|
8
8
|
# Markdown, or a self-contained HTML page (ReportHtml).
|
|
9
9
|
class Report
|
|
10
10
|
include ReportHtml
|
|
11
|
+
include ReportMarkdown
|
|
11
12
|
|
|
12
13
|
# The judge a verdict names when the framework ranked the models by
|
|
13
14
|
# pass rate itself, no judge having been available to rule on them.
|
|
@@ -37,9 +38,12 @@ module ActiveAgent
|
|
|
37
38
|
# @param release [Hash, nil] the release of the agent under evaluation,
|
|
38
39
|
# `{ "digest", "revision", "label" }`, so the report names the code
|
|
39
40
|
# it scored: in `to_h["release"]` and as a header chip.
|
|
41
|
+
# @param comparison [Hash, nil] a comparison already recorded for these
|
|
42
|
+
# results (Comparison#to_h). A report rebuilt from a persisted run
|
|
43
|
+
# renders the intervals that run recorded instead of resampling them.
|
|
40
44
|
def initialize(results:, models:, judge: nil, instructions: nil, threshold: PASS_THRESHOLD, metadata: {},
|
|
41
45
|
tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil,
|
|
42
|
-
judge_usage: nil, release: nil)
|
|
46
|
+
judge_usage: nil, release: nil, comparison: nil)
|
|
43
47
|
@results = results
|
|
44
48
|
@models = models
|
|
45
49
|
@judge = judge
|
|
@@ -53,6 +57,7 @@ module ActiveAgent
|
|
|
53
57
|
@recorded_verdict = verdict.is_a?(Hash) ? verdict.to_h.stringify_keys.presence : nil
|
|
54
58
|
@run_judge_usage = judge_usage.is_a?(Hash) ? judge_usage.to_h.stringify_keys.presence : nil
|
|
55
59
|
@release = release.is_a?(Hash) ? release.to_h.stringify_keys.compact.presence : nil
|
|
60
|
+
@recorded_comparison = comparison.is_a?(Hash) ? comparison.to_h.deep_stringify_keys.presence : nil
|
|
56
61
|
end
|
|
57
62
|
|
|
58
63
|
# The release of the agent this report scored, `{ "digest",
|
|
@@ -64,7 +69,8 @@ module ActiveAgent
|
|
|
64
69
|
end
|
|
65
70
|
|
|
66
71
|
# Per model, keyed by label: scenario count, passes, errors, pass rate,
|
|
67
|
-
# mean score,
|
|
72
|
+
# mean score, latency (mean, and the P50 and P95 by nearest rank,
|
|
73
|
+
# Statistics.percentile), tokens, cost and fault counts. "cost" sums
|
|
68
74
|
# the replays that carried a cost — "priced" of the "scenarios", of
|
|
69
75
|
# which "reported" came from the caller and "estimated" from tokens ×
|
|
70
76
|
# a model rate (Result#cost_source) — and is nil when none did.
|
|
@@ -90,6 +96,8 @@ module ActiveAgent
|
|
|
90
96
|
"avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
|
|
91
97
|
"avg_task_completion" => task_scores.any? ? (task_scores.sum / task_scores.size).round(3) : nil,
|
|
92
98
|
"avg_duration_ms" => durations.any? ? (durations.sum.to_f / durations.size).round : nil,
|
|
99
|
+
"p50_duration_ms" => Statistics.percentile(durations, 50)&.round,
|
|
100
|
+
"p95_duration_ms" => Statistics.percentile(durations, 95)&.round,
|
|
93
101
|
"input_tokens" => cohort.sum { |result| result.replay.input_tokens.to_i },
|
|
94
102
|
"output_tokens" => cohort.sum { |result| result.replay.output_tokens.to_i },
|
|
95
103
|
"cost" => costs.any? ? costs.sum.to_f.round(6) : nil,
|
|
@@ -267,14 +275,27 @@ module ActiveAgent
|
|
|
267
275
|
verdict&.dig("winner")
|
|
268
276
|
end
|
|
269
277
|
|
|
270
|
-
#
|
|
271
|
-
#
|
|
278
|
+
# Whether the gap between the models is real (Comparison#to_h): each
|
|
279
|
+
# model's pass rate with its interval, every pair's difference with
|
|
280
|
+
# its interval and whether it is decisive, and the conclusion. The
|
|
281
|
+
# comparison recorded for these results when one was handed in. nil
|
|
282
|
+
# for a single model.
|
|
283
|
+
def comparison
|
|
284
|
+
return nil unless comparing?
|
|
285
|
+
|
|
286
|
+
@comparison ||= @recorded_comparison || Comparison.new(results: @results, models: @models).to_h
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Adds "judge_usage" and "release" only when there is one to add, and
|
|
290
|
+
# "comparison" only when comparing models, so a single-model report
|
|
291
|
+
# built the way it always was serializes exactly as before.
|
|
272
292
|
def to_h
|
|
273
293
|
{
|
|
274
294
|
"models" => summary_by_model,
|
|
275
295
|
"criteria" => criterion_scores,
|
|
276
296
|
"recommendations" => recommendations,
|
|
277
297
|
"verdict" => verdict,
|
|
298
|
+
"comparison" => comparison,
|
|
278
299
|
"judge" => @judge_label || @judge&.label,
|
|
279
300
|
"judge_usage" => judge_usage,
|
|
280
301
|
"release" => release,
|
|
@@ -287,23 +308,6 @@ module ActiveAgent
|
|
|
287
308
|
JSON.pretty_generate(to_h, *args)
|
|
288
309
|
end
|
|
289
310
|
|
|
290
|
-
def to_markdown
|
|
291
|
-
scenario_count = @results.map { |result| result.scenario.key }.uniq.size
|
|
292
|
-
lines = [ "# Evaluation — #{scenario_count} scenario#{'s' unless scenario_count == 1} × #{@models.size} model#{'s' unless @models.size == 1}", "" ]
|
|
293
|
-
label = @judge_label || @judge&.label
|
|
294
|
-
lines << (label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone.")
|
|
295
|
-
lines << ""
|
|
296
|
-
lines.concat(summary_table)
|
|
297
|
-
lines << ""
|
|
298
|
-
lines.concat(total_lines)
|
|
299
|
-
lines << "**Best model: #{winner}**" if winner
|
|
300
|
-
lines << ""
|
|
301
|
-
lines.concat(matrix_table)
|
|
302
|
-
lines.concat(detail_lines)
|
|
303
|
-
lines.concat(recommendation_lines)
|
|
304
|
-
lines.join("\n")
|
|
305
|
-
end
|
|
306
|
-
|
|
307
311
|
private
|
|
308
312
|
|
|
309
313
|
# Whether a model summary's cost is an estimate — any replay priced
|
|
@@ -525,30 +529,43 @@ module ActiveAgent
|
|
|
525
529
|
|
|
526
530
|
# --- Markdown ----------------------------------------------------------
|
|
527
531
|
|
|
528
|
-
# The per-model table.
|
|
529
|
-
#
|
|
532
|
+
# The per-model table. Cost / 1,000 puts every model on one
|
|
533
|
+
# denominator however many of its scenarios ran. A Judge column joins
|
|
534
|
+
# it when a judge spent anything, so the agent's cost and the
|
|
535
|
+
# evaluation's stay two numbers.
|
|
530
536
|
def summary_table
|
|
531
537
|
judged = judge_usage.present?
|
|
532
|
-
columns = [ "Model", "Passed", "Mean score", "Mean latency", "Tokens in/out", "Cost", ("Judge" if judged), "Faults" ].compact
|
|
538
|
+
columns = [ "Model", "Passed", "Mean score", "Mean latency", "Median / P95", "Tokens in/out", "Cost", "Cost / 1,000", ("Judge" if judged), "Faults" ].compact
|
|
533
539
|
header = [ "| #{columns.join(' | ')} |", "|#{columns.map { '---' }.join('|')}|" ]
|
|
534
540
|
rows = summary_by_model.map do |label, stats|
|
|
535
541
|
faults = stats["faults"].map { |fault, count| "#{fault.tr('_', ' ')} ×#{count}" }.join(", ")
|
|
536
542
|
latency = stats["avg_duration_ms"] ? "#{stats['avg_duration_ms']} ms" : "—"
|
|
543
|
+
percentiles = stats["p50_duration_ms"] ? "#{stats['p50_duration_ms']} / #{stats['p95_duration_ms']} ms" : "—"
|
|
537
544
|
cells = [
|
|
538
|
-
"`#{label}`",
|
|
545
|
+
cell("`#{label}`"),
|
|
539
546
|
Format.passes(stats["passed"], stats["scenarios"], style: :markdown),
|
|
540
547
|
Format.score(stats["avg_score"]),
|
|
541
548
|
latency,
|
|
549
|
+
percentiles,
|
|
542
550
|
"#{stats['input_tokens']}/#{stats['output_tokens']}",
|
|
543
551
|
Format.money(stats["cost"], estimated: estimated_cost?(stats)),
|
|
552
|
+
per_thousand_cell(stats),
|
|
544
553
|
(judge_cell(stats) if judged),
|
|
545
|
-
faults.presence || "—"
|
|
554
|
+
cell(faults.presence || "—")
|
|
546
555
|
].compact
|
|
547
556
|
"| #{cells.join(' | ')} |"
|
|
548
557
|
end
|
|
549
558
|
header + rows
|
|
550
559
|
end
|
|
551
560
|
|
|
561
|
+
# "$3.6300": what 1,000 scenario runs cost at the model's mean cost
|
|
562
|
+
# per priced replay, marked "~" when that mean is estimated or covers
|
|
563
|
+
# only some of the replays. "—" when none was priced.
|
|
564
|
+
def per_thousand_cell(stats)
|
|
565
|
+
per = cost_per_priced(stats)
|
|
566
|
+
per ? Format.money(per * 1_000, estimated: estimated_cost?(stats)) : "—"
|
|
567
|
+
end
|
|
568
|
+
|
|
552
569
|
# "~$0.0030 (3 calls)" for a model's judge spend, "—" when the judge
|
|
553
570
|
# was not asked about its results.
|
|
554
571
|
def judge_cell(stats)
|
|
@@ -572,20 +589,52 @@ module ActiveAgent
|
|
|
572
589
|
lines << ""
|
|
573
590
|
end
|
|
574
591
|
|
|
592
|
+
# Each model's pass rate with its interval, best first, then every
|
|
593
|
+
# pair's difference with its interval, the conclusion, and how the
|
|
594
|
+
# intervals were computed. Nothing for a single model.
|
|
595
|
+
def comparison_lines
|
|
596
|
+
return [] unless comparison
|
|
597
|
+
|
|
598
|
+
method = comparison["method"]
|
|
599
|
+
confidence = "#{(method['confidence'].to_f * 100).round}% CI"
|
|
600
|
+
lines = [ "## Comparison", "", "| Model | Pass rate | #{confidence} | Scenarios |", "|---|---|---|---|" ]
|
|
601
|
+
comparison["ranking"].each do |label|
|
|
602
|
+
stats = comparison["models"][label]
|
|
603
|
+
lines << "| #{cell("`#{label}`")} | #{rate_text(stats['pass_rate'])} | #{interval_text(stats['interval'])} | #{stats['scenarios']} |"
|
|
604
|
+
end
|
|
605
|
+
lines += [ "", "| Pair | Difference | #{confidence} | Shared scenarios | Decisive |", "|---|---|---|---|---|" ]
|
|
606
|
+
comparison["pairs"].each do |pair|
|
|
607
|
+
a, b = pair["models"]
|
|
608
|
+
difference = pair["difference"] ? format("%+.1f points", pair["difference"]) : "—"
|
|
609
|
+
lines << "| #{cell("`#{a}` vs `#{b}`")} | #{difference} | #{interval_text(pair['interval'])} | #{pair['scenarios']} | #{pair['decisive'] ? 'yes' : 'no'} |"
|
|
610
|
+
end
|
|
611
|
+
lines += [ "", comparison["conclusion"].to_s, "",
|
|
612
|
+
"_#{confidence} from #{Format.delimited(method['resamples'])} resamples of whole scenarios (seed #{method['seed']})._", "" ]
|
|
613
|
+
end
|
|
614
|
+
|
|
615
|
+
def rate_text(rate)
|
|
616
|
+
rate ? "#{rate}%" : "—"
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# "73.8 to 95.2", or "—" when there were too few scenarios.
|
|
620
|
+
def interval_text(interval)
|
|
621
|
+
interval ? "#{interval.first} to #{interval.last}" : "—"
|
|
622
|
+
end
|
|
623
|
+
|
|
575
624
|
# The scenario matrix, with a trailing Cost column: what the scenario
|
|
576
625
|
# cost across every model, and in brackets what the judge spent on it.
|
|
577
626
|
def matrix_table
|
|
578
627
|
labels = @models.map(&:label)
|
|
579
|
-
header = [ "| Scenario | #{labels.map { |label| "`#{label}`" }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
|
|
628
|
+
header = [ "| Scenario | #{labels.map { |label| cell("`#{label}`") }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
|
|
580
629
|
rows = @results.group_by { |result| result.scenario.key }.map do |key, cohort|
|
|
581
630
|
cells = labels.map do |label|
|
|
582
631
|
result = cohort.find { |candidate| candidate.label == label }
|
|
583
632
|
next "—" unless result
|
|
584
633
|
|
|
585
634
|
mark = result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
|
|
586
|
-
[ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" ")
|
|
635
|
+
cell([ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" "))
|
|
587
636
|
end
|
|
588
|
-
"| `#{key}` #{
|
|
637
|
+
"| #{cell("`#{key}` #{cohort.first.scenario.prompt.truncate(70)}")} | #{cells.join(' | ')} | #{scenario_cost_cell(key)} |"
|
|
589
638
|
end
|
|
590
639
|
header + rows
|
|
591
640
|
end
|
|
@@ -598,30 +647,11 @@ module ActiveAgent
|
|
|
598
647
|
text
|
|
599
648
|
end
|
|
600
649
|
|
|
601
|
-
# A prompt may contain " | " (ScenarioParser keeps it),
|
|
602
|
-
# otherwise
|
|
650
|
+
# A prompt may contain " | " (ScenarioParser keeps it), and an answer
|
|
651
|
+
# or a label a line break, either of which would otherwise break the
|
|
652
|
+
# table row.
|
|
603
653
|
def cell(text)
|
|
604
|
-
text.to_s.gsub("|") { "\\|" }
|
|
605
|
-
end
|
|
606
|
-
|
|
607
|
-
def detail_lines
|
|
608
|
-
lines = [ "", "## Answers", "" ]
|
|
609
|
-
@results.each do |result|
|
|
610
|
-
lines << "### `#{result.scenario.key}` · `#{result.label}` · #{result.status}" \
|
|
611
|
-
"#{" · score #{Format.score(result.score)}" if result.score}#{" · #{answer_cost(result)}" if result.replay.cost}"
|
|
612
|
-
lines << ""
|
|
613
|
-
lines << "> #{result.scenario.prompt}"
|
|
614
|
-
lines << ""
|
|
615
|
-
if result.replay.tool_calls.any?
|
|
616
|
-
lines << "Tools: #{result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(', ')}"
|
|
617
|
-
end
|
|
618
|
-
lines << "Fault: #{result.fault.tr('_', ' ')} — #{result.summary} #{result.recommendation}" if result.fault
|
|
619
|
-
lines << "Error: #{result.replay.error}" if result.replay.error
|
|
620
|
-
lines << ""
|
|
621
|
-
lines << (result.replay.answer.presence || "_(no answer)_").to_s.truncate(1_500)
|
|
622
|
-
lines << ""
|
|
623
|
-
end
|
|
624
|
-
lines
|
|
654
|
+
text.to_s.gsub(/\s*\r?\n\s*/, " ").gsub("|") { "\\|" }
|
|
625
655
|
end
|
|
626
656
|
|
|
627
657
|
# "~$0.0243 · judge ~$0.0015": what one answer cost, and what judging
|
|
@@ -632,20 +662,6 @@ module ActiveAgent
|
|
|
632
662
|
text += " · judge #{Format.money(judge_cost, estimated: estimated_usage?(result.judge_usage))}" if judge_cost
|
|
633
663
|
text
|
|
634
664
|
end
|
|
635
|
-
|
|
636
|
-
# Renders after `detail_lines`, whose trailing blank line separates the two sections.
|
|
637
|
-
def recommendation_lines
|
|
638
|
-
return [] if recommendations.empty?
|
|
639
|
-
|
|
640
|
-
lines = [ "## Recommendations", "" ]
|
|
641
|
-
recommendations.each do |entry|
|
|
642
|
-
lines << "- **#{entry['fault'].tr('_', ' ')}** ×#{entry['count']} (#{entry['scenario_keys'].join(', ')}): #{entry['recommendation']}"
|
|
643
|
-
entry["suggested_tools"].each do |tool|
|
|
644
|
-
lines << " - suggested tool `#{tool['name']}`: #{tool['description']}"
|
|
645
|
-
end
|
|
646
|
-
end
|
|
647
|
-
lines << ""
|
|
648
|
-
end
|
|
649
665
|
end
|
|
650
666
|
end
|
|
651
667
|
end
|