activeagent 1.9.6 → 1.9.7
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 +436 -28
- data/README.md +6 -0
- data/lib/active_agent/base.rb +1 -0
- data/lib/active_agent/cli/eval_commands.rb +3 -3
- data/lib/active_agent/concerns/tooling.rb +9 -0
- data/lib/active_agent/evals/capture.rb +275 -0
- data/lib/active_agent/evals/catalog.rb +150 -19
- data/lib/active_agent/evals/gate_check.rb +79 -0
- data/lib/active_agent/evals/pass_thresholds.rb +137 -0
- data/lib/active_agent/evals/patterns.rb +80 -0
- data/lib/active_agent/evals/publisher.rb +124 -8
- data/lib/active_agent/evals/report.rb +165 -20
- data/lib/active_agent/evals/report_html.rb +91 -17
- data/lib/active_agent/evals/report_markdown.rb +63 -6
- data/lib/active_agent/evals/result.rb +10 -2
- data/lib/active_agent/evals/rspec.rb +87 -0
- data/lib/active_agent/evals/ruby_llm.rb +105 -3
- data/lib/active_agent/evals/runner.rb +59 -16
- data/lib/active_agent/evals/scenario.rb +12 -4
- data/lib/active_agent/evals/scenario_parser.rb +9 -1
- data/lib/active_agent/evals/scorer.rb +53 -23
- data/lib/active_agent/evals/suite.rb +117 -6
- data/lib/active_agent/evals/test_helper.rb +286 -0
- data/lib/active_agent/evals.rb +9 -1
- data/lib/active_agent/providers/_base_provider.rb +17 -1
- data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
- data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
- data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
- data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
- data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
- data/lib/active_agent/providers/ollama/_types.rb +1 -0
- data/lib/active_agent/providers/ollama/catalog.rb +221 -0
- data/lib/active_agent/providers/ollama/cloud.rb +109 -0
- data/lib/active_agent/providers/ollama/options.rb +66 -2
- data/lib/active_agent/providers/ollama_provider.rb +55 -3
- data/lib/active_agent/railtie.rb +4 -0
- data/lib/active_agent/version.rb +1 -1
- data/lib/tasks/active_agent/evals.rake +28 -0
- metadata +16 -3
- data/lib/tasks/activeagent_tasks.rake +0 -4
|
@@ -14,7 +14,33 @@ module ActiveAgent
|
|
|
14
14
|
# pass rate itself, no judge having been available to rule on them.
|
|
15
15
|
PASS_RATE_JUDGE = "pass rate"
|
|
16
16
|
|
|
17
|
-
attr_reader :results, :models, :judge, :judge_label, :instructions, :metadata, :agent_name, :links, :tool_resolver
|
|
17
|
+
attr_reader :results, :models, :judge, :judge_label, :instructions, :metadata, :agent_name, :links, :tool_resolver,
|
|
18
|
+
:pass_thresholds
|
|
19
|
+
|
|
20
|
+
# One group's (or the whole report's, with group nil and name "All")
|
|
21
|
+
# pass rate under one model against the bar the suite set for it.
|
|
22
|
+
# `pass_rate` and `min_pass_rate` are 0..1 fractions, so they compare
|
|
23
|
+
# directly and render with Format.percent; an ungated row is always met.
|
|
24
|
+
Gate = Struct.new(:group, :name, :label, :passed, :total, :pass_rate, :min_pass_rate, :gated, keyword_init: true) do
|
|
25
|
+
def met?
|
|
26
|
+
!gated || pass_rate >= min_pass_rate
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
super.transform_keys(&:to_s).merge("met" => met?)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# "Order support · gpt-5-mini: 1/2 (50%) passed, below 100%" — one
|
|
34
|
+
# sentence a failing assertion can print.
|
|
35
|
+
def to_s
|
|
36
|
+
bar =
|
|
37
|
+
if !gated then "not gated"
|
|
38
|
+
elsif met? then "at or above #{Format.percent(min_pass_rate)}"
|
|
39
|
+
else "below #{Format.percent(min_pass_rate)}"
|
|
40
|
+
end
|
|
41
|
+
"#{name} · #{label}: #{Format.passes(passed, total, style: :markdown)} passed, #{bar}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
18
44
|
|
|
19
45
|
# @param tool_resolver [#call, nil] maps a tool name to the MCP server
|
|
20
46
|
# that provides it — `{ "key", "name", "status" }` with status
|
|
@@ -41,15 +67,22 @@ module ActiveAgent
|
|
|
41
67
|
# @param comparison [Hash, nil] a comparison already recorded for these
|
|
42
68
|
# results (Comparison#to_h). A report rebuilt from a persisted run
|
|
43
69
|
# renders the intervals that run recorded instead of resampling them.
|
|
44
|
-
|
|
70
|
+
# @param threshold [Float, nil] the score an answer must reach to pass
|
|
71
|
+
# a criterion; an explicit value wins over the `pass_thresholds` bar,
|
|
72
|
+
# and without either it is PASS_THRESHOLD
|
|
73
|
+
# @param pass_thresholds [PassThresholds, nil] the bars the suite
|
|
74
|
+
# declared: its threshold applies when none is given here, #gates
|
|
75
|
+
# and #passed? read them, and `to_h` carries them with the per-group
|
|
76
|
+
# summary and the gates.
|
|
77
|
+
def initialize(results:, models:, judge: nil, instructions: nil, threshold: nil, metadata: {},
|
|
45
78
|
tool_resolver: nil, agent_name: nil, links: {}, verdict: nil, judge_label: nil,
|
|
46
|
-
judge_usage: nil, release: nil, comparison: nil)
|
|
79
|
+
judge_usage: nil, release: nil, comparison: nil, pass_thresholds: nil)
|
|
47
80
|
@results = results
|
|
48
81
|
@models = models
|
|
49
82
|
@judge = judge
|
|
50
83
|
@judge_label = judge_label.presence
|
|
51
84
|
@instructions = instructions
|
|
52
|
-
@threshold = threshold
|
|
85
|
+
@threshold = threshold || pass_thresholds&.threshold || PASS_THRESHOLD
|
|
53
86
|
@metadata = metadata
|
|
54
87
|
@tool_resolver = tool_resolver
|
|
55
88
|
@agent_name = agent_name.presence || "the agent"
|
|
@@ -58,6 +91,7 @@ module ActiveAgent
|
|
|
58
91
|
@run_judge_usage = judge_usage.is_a?(Hash) ? judge_usage.to_h.stringify_keys.presence : nil
|
|
59
92
|
@release = release.is_a?(Hash) ? release.to_h.stringify_keys.compact.presence : nil
|
|
60
93
|
@recorded_comparison = comparison.is_a?(Hash) ? comparison.to_h.deep_stringify_keys.presence : nil
|
|
94
|
+
@pass_thresholds = pass_thresholds
|
|
61
95
|
end
|
|
62
96
|
|
|
63
97
|
# The release of the agent this report scored, `{ "digest",
|
|
@@ -68,12 +102,20 @@ module ActiveAgent
|
|
|
68
102
|
@models.size > 1
|
|
69
103
|
end
|
|
70
104
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
105
|
+
# How many times each scenario was replayed under each model: the
|
|
106
|
+
# highest trial number any result carries, 1 for a run with none.
|
|
107
|
+
def trials
|
|
108
|
+
@results.map(&:trial).max || 1
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Per model, keyed by label: replay count ("scenarios", every trial of
|
|
112
|
+
# every scenario), distinct scenarios ("unique_scenarios"), passes,
|
|
113
|
+
# errors, pass rate over every replay, mean score, latency (mean, and
|
|
114
|
+
# the P50 and P95 by nearest rank, Statistics.percentile), tokens,
|
|
115
|
+
# cost and fault counts. "cost" sums the replays that carried a cost
|
|
116
|
+
# — "priced" of the "scenarios", of which "reported" came from the
|
|
117
|
+
# caller and "estimated" from tokens × a model rate
|
|
118
|
+
# (Result#cost_source) — and is nil when none did.
|
|
77
119
|
# "judge_cost" and "judge_calls" sum what the judge spent on the
|
|
78
120
|
# cohort's results (Result#judge_usage); the judge's cost never joins
|
|
79
121
|
# the agent's.
|
|
@@ -90,6 +132,7 @@ module ActiveAgent
|
|
|
90
132
|
"provider" => spec.provider,
|
|
91
133
|
"model" => spec.model,
|
|
92
134
|
"scenarios" => cohort.size,
|
|
135
|
+
"unique_scenarios" => cohort.map { |result| result.scenario.key }.uniq.size,
|
|
93
136
|
"passed" => cohort.count(&:passed?),
|
|
94
137
|
"errored" => cohort.count(&:errored?),
|
|
95
138
|
"pass_rate" => cohort.any? ? (cohort.count(&:passed?) * 100.0 / cohort.size).round(1) : 0.0,
|
|
@@ -111,6 +154,58 @@ module ActiveAgent
|
|
|
111
154
|
end
|
|
112
155
|
end
|
|
113
156
|
|
|
157
|
+
# Per group key, in run order, then per model under "models" (keyed by
|
|
158
|
+
# label, only the models with a result in the group): scenario count,
|
|
159
|
+
# passes, errors, pass rate as a percent, mean score and fault counts,
|
|
160
|
+
# counted as summary_by_model counts them. "name" is the group's
|
|
161
|
+
# display name, or its key. Scenarios with no group are left out.
|
|
162
|
+
def summary_by_group
|
|
163
|
+
@summary_by_group ||= begin
|
|
164
|
+
grouped = @results.select { |result| result.scenario.group.present? }.group_by { |result| result.scenario.group }
|
|
165
|
+
grouped.to_h do |group, cohort|
|
|
166
|
+
models = @models.filter_map do |spec|
|
|
167
|
+
rows = cohort.select { |result| result.label == spec.label }
|
|
168
|
+
[ spec.label, cohort_summary(rows) ] if rows.any?
|
|
169
|
+
end
|
|
170
|
+
[ group, { "name" => group_name(cohort.first.scenario), "models" => models.to_h } ]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# One Gate per group and model with results there, in summary_by_group
|
|
176
|
+
# order, then one per model for the whole report (group nil, name
|
|
177
|
+
# "All") over every gated result of that model, ungrouped results
|
|
178
|
+
# included; a model with no gated result has no "All" row. Each row
|
|
179
|
+
# carries the bar `thresholds` sets for its group.
|
|
180
|
+
def gates(thresholds = pass_thresholds || PassThresholds.default)
|
|
181
|
+
rows = summary_by_group.flat_map do |group, summary|
|
|
182
|
+
summary["models"].map do |label, stats|
|
|
183
|
+
gate(group, summary["name"], label, stats["passed"], stats["scenarios"],
|
|
184
|
+
thresholds.min_pass_rate(group), thresholds.gated?(group))
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
gated = @results.select { |result| thresholds.gated?(result.scenario.group) }
|
|
188
|
+
rows + @models.filter_map do |spec|
|
|
189
|
+
cohort = gated.select { |result| result.label == spec.label }
|
|
190
|
+
gate(nil, "All", spec.label, cohort.count(&:passed?), cohort.size, thresholds.min_pass_rate(nil), true) if cohort.any?
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Whether every gate is met. A report with no gate, because no result
|
|
195
|
+
# reached it (a selection that matched nothing, every scenario left
|
|
196
|
+
# out as production_only), has shown nothing and does not pass.
|
|
197
|
+
def passed?(thresholds = pass_thresholds || PassThresholds.default)
|
|
198
|
+
rows = gates(thresholds)
|
|
199
|
+
rows.any? && rows.all?(&:met?)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The results that did not pass — failed and errored — in run order,
|
|
203
|
+
# narrowed to one group when given.
|
|
204
|
+
def failures(group: nil)
|
|
205
|
+
failed = @results.reject(&:passed?)
|
|
206
|
+
group.nil? ? failed : failed.select { |result| result.scenario.group.to_s == group.to_s }
|
|
207
|
+
end
|
|
208
|
+
|
|
114
209
|
# Per scenario key, in run order: what every model's answer cost
|
|
115
210
|
# together — the agent's cost summed over the models, the judge's, and
|
|
116
211
|
# the two as "total" — plus the same per model under "models", keyed
|
|
@@ -258,7 +353,7 @@ module ActiveAgent
|
|
|
258
353
|
cost = " at #{Format.money(stats['cost'], estimated: estimated_cost?(stats))}"
|
|
259
354
|
cost += " (estimated)" if estimated_cost?(stats)
|
|
260
355
|
end
|
|
261
|
-
rationale = "Passed #{stats['passed']} of #{stats
|
|
356
|
+
rationale = "Passed #{stats['passed']} of #{counted_scenarios(stats)}" \
|
|
262
357
|
" (#{Format.percent(stats['scenarios'].to_i.positive? ? stats['passed'].to_f / stats['scenarios'] : nil)})" \
|
|
263
358
|
"#{" with a mean score of #{Format.score(stats['avg_score'])}" if stats['avg_score']}#{cost}."
|
|
264
359
|
judged = @judge&.verdict(summary_by_model, instructions: @instructions)
|
|
@@ -286,21 +381,28 @@ module ActiveAgent
|
|
|
286
381
|
@comparison ||= @recorded_comparison || Comparison.new(results: @results, models: @models).to_h
|
|
287
382
|
end
|
|
288
383
|
|
|
289
|
-
# Adds "judge_usage" and "release" only when there is one to add,
|
|
290
|
-
# "comparison" only when comparing models,
|
|
291
|
-
#
|
|
384
|
+
# Adds "judge_usage" and "release" only when there is one to add,
|
|
385
|
+
# "comparison" only when comparing models, "trials" only when a scenario
|
|
386
|
+
# was replayed more than once, and "pass_thresholds" with the per-group
|
|
387
|
+
# summary ("groups") and the "gates" only when the suite declared its
|
|
388
|
+
# bars, so a single-model, single-trial report built the way it always
|
|
389
|
+
# was serializes exactly as before.
|
|
292
390
|
def to_h
|
|
293
391
|
{
|
|
294
392
|
"models" => summary_by_model,
|
|
393
|
+
"trials" => (trials if trials > 1),
|
|
295
394
|
"criteria" => criterion_scores,
|
|
296
395
|
"recommendations" => recommendations,
|
|
297
396
|
"verdict" => verdict,
|
|
298
397
|
"comparison" => comparison,
|
|
398
|
+
"pass_thresholds" => pass_thresholds&.to_h&.merge("threshold" => @threshold),
|
|
399
|
+
"groups" => (summary_by_group if pass_thresholds),
|
|
400
|
+
"gates" => (gates.map(&:to_h) if pass_thresholds),
|
|
299
401
|
"judge" => @judge_label || @judge&.label,
|
|
300
402
|
"judge_usage" => judge_usage,
|
|
301
403
|
"release" => release,
|
|
302
404
|
"metadata" => @metadata.presence,
|
|
303
|
-
"results" =>
|
|
405
|
+
"results" => serialized_results
|
|
304
406
|
}.compact
|
|
305
407
|
end
|
|
306
408
|
|
|
@@ -310,6 +412,14 @@ module ActiveAgent
|
|
|
310
412
|
|
|
311
413
|
private
|
|
312
414
|
|
|
415
|
+
# Each result's hash; a single-trial report leaves out every "trial",
|
|
416
|
+
# so it is the JSON an earlier gem wrote and a re-delivery of it under
|
|
417
|
+
# the same run_id matches the stored digest.
|
|
418
|
+
def serialized_results
|
|
419
|
+
hashes = @results.map(&:to_h)
|
|
420
|
+
trials > 1 ? hashes : hashes.map { |hash| hash.except("trial") }
|
|
421
|
+
end
|
|
422
|
+
|
|
313
423
|
# Whether a model summary's cost is an estimate — any replay priced
|
|
314
424
|
# from tokens, or some replay unpriced, which leaves the sum a lower
|
|
315
425
|
# bound — and so reads with a "~".
|
|
@@ -339,10 +449,37 @@ module ActiveAgent
|
|
|
339
449
|
stats["cost"].to_f / priced if stats["cost"] && priced.positive?
|
|
340
450
|
end
|
|
341
451
|
|
|
452
|
+
# The denominator a verdict names: "6 scenarios", or "18 replays of 6
|
|
453
|
+
# scenarios" when the model replayed each more than once.
|
|
454
|
+
def counted_scenarios(stats)
|
|
455
|
+
replays = stats["scenarios"].to_i
|
|
456
|
+
unique = stats["unique_scenarios"].to_i
|
|
457
|
+
replays > unique ? "#{replays} replays of #{unique} scenarios" : "#{replays} scenarios"
|
|
458
|
+
end
|
|
459
|
+
|
|
342
460
|
def criterion_keys
|
|
343
461
|
@results.flat_map { |result| result.scores.keys }.uniq
|
|
344
462
|
end
|
|
345
463
|
|
|
464
|
+
# One model's counts over the results of a group, the way
|
|
465
|
+
# summary_by_model counts a model's whole cohort.
|
|
466
|
+
def cohort_summary(cohort)
|
|
467
|
+
scored = cohort.filter_map(&:score)
|
|
468
|
+
{
|
|
469
|
+
"scenarios" => cohort.size,
|
|
470
|
+
"passed" => cohort.count(&:passed?),
|
|
471
|
+
"errored" => cohort.count(&:errored?),
|
|
472
|
+
"pass_rate" => cohort.any? ? (cohort.count(&:passed?) * 100.0 / cohort.size).round(1) : 0.0,
|
|
473
|
+
"avg_score" => scored.any? ? (scored.sum / scored.size).round(3) : nil,
|
|
474
|
+
"faults" => cohort.filter_map(&:fault).tally
|
|
475
|
+
}
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def gate(group, name, label, passed, total, min_pass_rate, gated)
|
|
479
|
+
Gate.new(group: group, name: name, label: label, passed: passed, total: total,
|
|
480
|
+
pass_rate: passed.to_f / total, min_pass_rate: min_pass_rate, gated: gated)
|
|
481
|
+
end
|
|
482
|
+
|
|
346
483
|
def stats_for(values)
|
|
347
484
|
scored = values.compact
|
|
348
485
|
return { "skipped" => true, "reason" => "No scorable answers" } if scored.empty?
|
|
@@ -628,17 +765,25 @@ module ActiveAgent
|
|
|
628
765
|
header = [ "| Scenario | #{labels.map { |label| cell("`#{label}`") }.join(' | ')} | Cost |", "|---|#{labels.map { '---' }.join('|')}|---|" ]
|
|
629
766
|
rows = @results.group_by { |result| result.scenario.key }.map do |key, cohort|
|
|
630
767
|
cells = labels.map do |label|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
mark = result.passed? ? "✅" : (result.errored? ? "⚠️" : "❌")
|
|
635
|
-
cell([ mark, (Format.score(result.score) if result.score), result.fault&.tr("_", " ") ].compact.join(" "))
|
|
768
|
+
mine = cohort.select { |candidate| candidate.label == label }.sort_by(&:trial)
|
|
769
|
+
mine.empty? ? "—" : cell(matrix_cell_text(mine))
|
|
636
770
|
end
|
|
637
771
|
"| #{cell("`#{key}` #{cohort.first.scenario.prompt.truncate(70)}")} | #{cells.join(' | ')} | #{scenario_cost_cell(key)} |"
|
|
638
772
|
end
|
|
639
773
|
header + rows
|
|
640
774
|
end
|
|
641
775
|
|
|
776
|
+
# "✅ 100%" or "❌ 50% missing capability" for one result; for a
|
|
777
|
+
# scenario replayed more than once, every trial's mark run together
|
|
778
|
+
# ("✅✅❌"), the mean of the trials' scores, and the fault seen most
|
|
779
|
+
# often among them (a tie goes to the first seen).
|
|
780
|
+
def matrix_cell_text(results)
|
|
781
|
+
marks = results.map { |result| status_mark(result) }.join
|
|
782
|
+
scored = results.filter_map(&:score)
|
|
783
|
+
fault = results.filter_map(&:fault).tally.max_by(&:last)&.first
|
|
784
|
+
[ marks, (Format.score(scored.sum.fdiv(scored.size)) if scored.any?), fault&.tr("_", " ") ].compact.join(" ")
|
|
785
|
+
end
|
|
786
|
+
|
|
642
787
|
# "~$0.0243 (judge ~$0.0015)" for a scenario's total across models.
|
|
643
788
|
def scenario_cost_cell(key)
|
|
644
789
|
costs = scenario_costs[key] || {}
|
|
@@ -215,7 +215,13 @@ module ActiveAgent
|
|
|
215
215
|
# --- page ------------------------------------------------------------
|
|
216
216
|
|
|
217
217
|
def html_title
|
|
218
|
-
"Evaluation — #{plural(scenario_cohorts.size, 'scenario')} × #{plural(@models.size, 'model')}"
|
|
218
|
+
with_trials("Evaluation — #{plural(scenario_cohorts.size, 'scenario')} × #{plural(@models.size, 'model')}")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# +text+ with "3 trials per scenario" beside it when each scenario was
|
|
222
|
+
# replayed more than once; +text+ alone otherwise.
|
|
223
|
+
def with_trials(text)
|
|
224
|
+
trials > 1 ? "#{text} · #{trials} trials per scenario" : text
|
|
219
225
|
end
|
|
220
226
|
|
|
221
227
|
def html_styles
|
|
@@ -282,7 +288,7 @@ module ActiveAgent
|
|
|
282
288
|
passed = @results.count(&:passed?)
|
|
283
289
|
ratio = total.positive? ? passed.to_f / total : 0.0
|
|
284
290
|
tiles = [
|
|
285
|
-
html_tile("Scenario runs", total, "#{plural(scenario_cohorts.size, 'scenario')} × #{plural(@models.size, 'model')}"),
|
|
291
|
+
html_tile("Scenario runs", total, with_trials("#{plural(scenario_cohorts.size, 'scenario')} × #{plural(@models.size, 'model')}")),
|
|
286
292
|
html_tile("Pass rate", Format.percent(total.positive? ? ratio : nil), "#{passed}/#{total} passed", tone: tone_for(ratio)),
|
|
287
293
|
html_tile("Open faults", total - passed, plural(fix_items.size, "fix item")),
|
|
288
294
|
html_cost_tile,
|
|
@@ -496,29 +502,49 @@ module ActiveAgent
|
|
|
496
502
|
|
|
497
503
|
<<~MATRIX
|
|
498
504
|
<section class="section">
|
|
499
|
-
<div class="section-head"><span class="micro">Scenarios</span><span class="meta">#{h(meta)}</span></div>
|
|
505
|
+
<div class="section-head"><span class="micro">Scenarios</span><span class="meta">#{h(with_trials(meta))}</span></div>
|
|
500
506
|
<div class="matrix"><div class="inner">#{rows.join}</div></div>
|
|
501
507
|
</section>
|
|
502
508
|
MATRIX
|
|
503
509
|
end
|
|
504
510
|
|
|
511
|
+
# A group's row: each model's passes over every replay of the row's
|
|
512
|
+
# scenarios, toned against the group's bar when the suite declared one —
|
|
513
|
+
# the row's own pass rate at or above it green, below it red, an ungated
|
|
514
|
+
# group plain, with the bar as the tooltip — and otherwise green when
|
|
515
|
+
# every replay passed and red when none did. A row with a group name but
|
|
516
|
+
# no group key has no bar to read.
|
|
505
517
|
def html_group_row(cohorts)
|
|
506
|
-
|
|
518
|
+
scenario = cohorts.first.first.scenario
|
|
519
|
+
name = group_name(scenario)
|
|
520
|
+
gated = pass_thresholds && scenario.group.present?
|
|
521
|
+
gates_by_label = gated ? gates.select { |gate| gate.group == scenario.group }.index_by(&:label) : {}
|
|
507
522
|
passes = @models.map do |spec|
|
|
508
|
-
results = cohorts.
|
|
523
|
+
results = cohorts.flat_map { |cohort| cohort.select { |result| result.label == spec.label } }
|
|
524
|
+
next %(<span class="group-pass">—</span>) if results.empty?
|
|
525
|
+
|
|
509
526
|
passed = results.count(&:passed?)
|
|
510
|
-
tone =
|
|
511
|
-
|
|
512
|
-
elsif passed == results.size then " text-success"
|
|
513
|
-
elsif passed.zero? then " text-error"
|
|
514
|
-
else ""
|
|
515
|
-
end
|
|
516
|
-
%(<span class="group-pass#{tone}">#{results.empty? ? '—' : "#{h(fmt_passes(passed, results.size))} passed"}</span>)
|
|
527
|
+
tone, title = html_group_tone(passed, results.size, gates_by_label[spec.label])
|
|
528
|
+
%(<span class="group-pass#{tone}"#{title}>#{h(fmt_passes(passed, results.size))} passed</span>)
|
|
517
529
|
end
|
|
518
530
|
%(<div class="mx group"><span class="group-name">#{h(name)}</span>) +
|
|
519
531
|
%(<span class="count">#{h(plural(cohorts.size, 'scenario'))}</span>#{passes.join}#{html_group_cost(cohorts)}</div>)
|
|
520
532
|
end
|
|
521
533
|
|
|
534
|
+
# [tone class, title attribute] for a group-pass span: the row's own
|
|
535
|
+
# counts against its gate's bar when there is one, so a group split
|
|
536
|
+
# across the run tones each row by what that row shows; else from the
|
|
537
|
+
# counts alone, with no title.
|
|
538
|
+
def html_group_tone(passed, total, gate)
|
|
539
|
+
if gate
|
|
540
|
+
tone = gate.gated ? (passed.to_f / total >= gate.min_pass_rate ? " text-success" : " text-error") : ""
|
|
541
|
+
[ tone, %( title="#{h(gate.gated ? "pass rate ≥ #{Format.percent(gate.min_pass_rate)}" : 'not gated')}") ]
|
|
542
|
+
elsif passed == total then [ " text-success", "" ]
|
|
543
|
+
elsif passed.zero? then [ " text-error", "" ]
|
|
544
|
+
else [ "", "" ]
|
|
545
|
+
end
|
|
546
|
+
end
|
|
547
|
+
|
|
522
548
|
# The group's subtotal — its scenarios' costs summed, judge apart — in
|
|
523
549
|
# the trailing column, so the grid stays aligned under a group row.
|
|
524
550
|
def html_group_cost(cohorts)
|
|
@@ -542,8 +568,11 @@ module ActiveAgent
|
|
|
542
568
|
scenario = cohort.first.scenario
|
|
543
569
|
expects = scenario.expected_tools.map { |tool| %(<span class="expect">#{h(tool)}</span>) }.join
|
|
544
570
|
cells = @models.map do |spec|
|
|
545
|
-
|
|
546
|
-
|
|
571
|
+
mine = cohort.select { |candidate| candidate.label == spec.label }.sort_by(&:trial)
|
|
572
|
+
if mine.empty? then %(<div class="cell"><div class="top"><span class="muted">—</span></div></div>)
|
|
573
|
+
elsif mine.one? then html_result_cell(mine.first)
|
|
574
|
+
else html_trials_cell(mine)
|
|
575
|
+
end
|
|
547
576
|
end
|
|
548
577
|
costs = scenario_costs[scenario.key] || {}
|
|
549
578
|
total_cell = if costs["cost"].nil? && costs["judge_cost"].nil?
|
|
@@ -564,6 +593,26 @@ module ActiveAgent
|
|
|
564
593
|
%(<div class="calls">#{html_calls(result, empty: 'no tools called')}</div>#{html_cell_cost(result)}</div>)
|
|
565
594
|
end
|
|
566
595
|
|
|
596
|
+
# The cell of a scenario replayed more than once under one model: how
|
|
597
|
+
# many replays passed, one line per trial (its number, the glyph, the
|
|
598
|
+
# score and the fault), and what the replays cost together.
|
|
599
|
+
def html_trials_cell(results)
|
|
600
|
+
passed = results.count(&:passed?)
|
|
601
|
+
tone =
|
|
602
|
+
if passed == results.size then " tone-success"
|
|
603
|
+
elsif passed.zero? then " tone-error"
|
|
604
|
+
else ""
|
|
605
|
+
end
|
|
606
|
+
lines = results.map do |result|
|
|
607
|
+
glyph_tone = result.passed? ? "success" : "error"
|
|
608
|
+
fault = result.fault ? %(<span class="f">#{h(fault_name(result.fault))}</span>) : ""
|
|
609
|
+
%(<div class="top trial"><span class="muted">#{result.trial}</span><span class="g tone-#{glyph_tone}">#{result.passed? ? '[+]' : '[!]'}</span>) +
|
|
610
|
+
%(<span class="s tone-#{glyph_tone}">#{h(fmt_score(result.score))}</span>#{fault}</div>)
|
|
611
|
+
end
|
|
612
|
+
%(<div class="cell"><div class="top"><span class="s#{tone}">#{h(fmt_passes(passed, results.size))} passed</span></div>) +
|
|
613
|
+
%(#{lines.join}#{html_trials_cost(results)}</div>)
|
|
614
|
+
end
|
|
615
|
+
|
|
567
616
|
# "~$0.0243 · judge ~$0.0015" under a cell: what this answer cost and
|
|
568
617
|
# what judging it cost. A result with no cost at all shows no line.
|
|
569
618
|
def html_cell_cost(result)
|
|
@@ -573,6 +622,25 @@ module ActiveAgent
|
|
|
573
622
|
%(<div class="cost-line"#{cost_title_attr([ result ], estimated: result.estimated_cost?)}>#{h(text)}</div>)
|
|
574
623
|
end
|
|
575
624
|
|
|
625
|
+
# The cost line under a trials cell: the replays' costs summed, the
|
|
626
|
+
# judge's apart. The sum reads as an estimate when any replay's cost
|
|
627
|
+
# is one, or when a replay went unpriced (a lower bound), and shows its
|
|
628
|
+
# working in a tooltip only when a replay was priced from tokens.
|
|
629
|
+
# Nothing when no replay carried a cost.
|
|
630
|
+
def html_trials_cost(results)
|
|
631
|
+
costs = results.filter_map { |result| result.replay.cost }
|
|
632
|
+
judge_costs = results.filter_map { |result| result.judge_usage&.dig("cost") }
|
|
633
|
+
return "" if costs.empty? && judge_costs.empty?
|
|
634
|
+
|
|
635
|
+
priced_from_tokens = results.any?(&:estimated_cost?)
|
|
636
|
+
estimated = priced_from_tokens || (costs.any? && costs.size < results.size)
|
|
637
|
+
parts = [ fmt_cost(costs.any? ? costs.sum : nil, estimated: estimated) ]
|
|
638
|
+
if judge_costs.any?
|
|
639
|
+
parts << "judge #{fmt_cost(judge_costs.sum, estimated: results.any? { |result| estimated_usage?(result.judge_usage) })}"
|
|
640
|
+
end
|
|
641
|
+
%(<div class="cost-line"#{cost_title_attr(results, estimated: priced_from_tokens)}>#{h(parts.join(' · '))}</div>)
|
|
642
|
+
end
|
|
643
|
+
|
|
576
644
|
def result_cost_text(result)
|
|
577
645
|
judge_cost = result.judge_usage&.dig("cost")
|
|
578
646
|
return nil if result.replay.cost.nil? && judge_cost.nil?
|
|
@@ -619,7 +687,10 @@ module ActiveAgent
|
|
|
619
687
|
scenario = cohort.first.scenario
|
|
620
688
|
expects = scenario.expected_tools.any? ? %(<span class="exp">expects <b>#{h(scenario.expected_tools.join(' or '))}</b></span>) : ""
|
|
621
689
|
labels = @models.map(&:label)
|
|
622
|
-
|
|
690
|
+
# A model that replayed the scenario more than once numbers its cards.
|
|
691
|
+
repeated = cohort.map(&:label).tally.select { |_label, count| count > 1 }
|
|
692
|
+
cards = cohort.sort_by { |result| [ labels.index(result.label) || labels.size, result.trial ] }
|
|
693
|
+
.map { |result| html_result_card(result, numbered: repeated.key?(result.label)) }
|
|
623
694
|
md_id = "md-#{anchor(scenario)}"
|
|
624
695
|
|
|
625
696
|
<<~BLOCK
|
|
@@ -632,8 +703,11 @@ module ActiveAgent
|
|
|
632
703
|
BLOCK
|
|
633
704
|
end
|
|
634
705
|
|
|
635
|
-
|
|
706
|
+
# +numbered+ names the card's trial, for a model with several cards on
|
|
707
|
+
# one scenario.
|
|
708
|
+
def html_result_card(result, numbered: false)
|
|
636
709
|
short, = split_label(result.spec)
|
|
710
|
+
trial = numbered ? %(<span class="micro sm">trial #{result.trial}</span>) : ""
|
|
637
711
|
status =
|
|
638
712
|
if result.passed? then %(<span class="badge success">passed · #{h(fmt_score(result.score))}</span>)
|
|
639
713
|
elsif result.errored? then %(<span class="badge error">errored</span>)
|
|
@@ -650,7 +724,7 @@ module ActiveAgent
|
|
|
650
724
|
|
|
651
725
|
<<~CARD
|
|
652
726
|
<div class="result">
|
|
653
|
-
<div class="head"><span class="name">#{h(short)}</span>#{status}<span class="meta">#{h(result_meta(result))}</span></div>
|
|
727
|
+
<div class="head"><span class="name">#{h(short)}</span>#{trial}#{status}<span class="meta">#{h(result_meta(result))}</span></div>
|
|
654
728
|
<div class="body"><div class="tools-line"><span class="micro sm">Tools</span>#{html_calls(result, empty: 'none called')}</div>#{fault}#{error}#{answer}</div>
|
|
655
729
|
</div>
|
|
656
730
|
CARD
|
|
@@ -11,8 +11,9 @@ module ActiveAgent
|
|
|
11
11
|
#
|
|
12
12
|
# Each piece is public on its own, for the pieces people paste one at a
|
|
13
13
|
# time: #fix_item_markdown, #fix_items_markdown (optionally for one
|
|
14
|
-
# model), #verdict_markdown
|
|
15
|
-
#
|
|
14
|
+
# model), #verdict_markdown, #scenario_markdown, #gates_markdown,
|
|
15
|
+
# #failures_markdown and #failure_markdown. The whole report is built
|
|
16
|
+
# from them, so a piece copied alone reads as it does in the report.
|
|
16
17
|
#
|
|
17
18
|
# Table cells escape `|` and fold line breaks; text set inside the HTML
|
|
18
19
|
# of a `<details>` block escapes `<`, `>` and `&`.
|
|
@@ -25,9 +26,11 @@ module ActiveAgent
|
|
|
25
26
|
lines = [ "# #{markdown_title}", "" ]
|
|
26
27
|
lines << judged_by_line
|
|
27
28
|
lines << ""
|
|
29
|
+
lines << trials_line << "" if trials > 1
|
|
28
30
|
lines.concat(summary_table)
|
|
29
31
|
lines << ""
|
|
30
32
|
lines.concat(total_lines)
|
|
33
|
+
lines.concat(gates_markdown).push("") if pass_thresholds && gates.any?
|
|
31
34
|
lines.concat(verdict_lines)
|
|
32
35
|
lines.concat(comparison_lines)
|
|
33
36
|
lines.concat(matrix_table)
|
|
@@ -84,6 +87,47 @@ module ActiveAgent
|
|
|
84
87
|
lines.join("\n")
|
|
85
88
|
end
|
|
86
89
|
|
|
90
|
+
# Pass rate by group: one row per Report#gates row, in that order —
|
|
91
|
+
# the group, the model, the passes, the pass rate with its mark (✅
|
|
92
|
+
# met, ❌ not met, — not gated), the bar and the group's mean score
|
|
93
|
+
# for that model (the whole report's for an "All" row).
|
|
94
|
+
def gates_markdown(thresholds = pass_thresholds || PassThresholds.default)
|
|
95
|
+
lines = [ "## Pass rate by group", "", "| Group | Model | Passed | Pass rate | Bar | Mean score |", "|---|---|---|---|---|---|" ]
|
|
96
|
+
gates(thresholds).each do |gate|
|
|
97
|
+
stats = gate.group ? summary_by_group.dig(gate.group, "models", gate.label) : summary_by_model[gate.label]
|
|
98
|
+
mark = gate.gated ? (gate.met? ? "✅" : "❌") : "—"
|
|
99
|
+
cells = [ cell(gate.name), cell("`#{gate.label}`"), "#{gate.passed} of #{gate.total}", "#{Format.percent(gate.pass_rate)} #{mark}",
|
|
100
|
+
gate.gated ? Format.percent(gate.min_pass_rate) : "not gated", Format.score(stats&.dig("avg_score")) ]
|
|
101
|
+
lines << "| #{cells.join(' | ')} |"
|
|
102
|
+
end
|
|
103
|
+
lines
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Every result that did not pass (Report#failures), one
|
|
107
|
+
# #failure_markdown line each, narrowed to one group when given.
|
|
108
|
+
def failures_markdown(group: nil)
|
|
109
|
+
failed = failures(group: group)
|
|
110
|
+
return "_Nothing failed._" if failed.empty?
|
|
111
|
+
|
|
112
|
+
failed.map { |result| failure_markdown(result) }.join("\n")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# One result that did not pass, as a line naming its scenario and
|
|
116
|
+
# model with its fault and summary — without a diagnosis, the replay's
|
|
117
|
+
# error for an errored result and the status alone otherwise. When the
|
|
118
|
+
# report holds more than one replay of that scenario × model, the line
|
|
119
|
+
# names the trial as #scenario_markdown does ("`gpt-5-mini` · trial 2"),
|
|
120
|
+
# so each line points at one answer.
|
|
121
|
+
def failure_markdown(result)
|
|
122
|
+
detail =
|
|
123
|
+
if result.fault then [ result.fault.tr("_", " "), result.summary.presence ].compact.join(" — ")
|
|
124
|
+
elsif result.errored? then [ "errored", result.replay.error.presence ].compact.join(" — ")
|
|
125
|
+
else result.status
|
|
126
|
+
end
|
|
127
|
+
trial = " · trial #{result.trial}" if replay_counts[[ result.scenario.key, result.label ]].to_i > 1
|
|
128
|
+
"- `#{result.scenario.key}` on `#{result.label}`#{trial}: #{detail}"
|
|
129
|
+
end
|
|
130
|
+
|
|
87
131
|
# The verdict — the best model, why, and who picked it — then the
|
|
88
132
|
# per-model table and, comparing models, the comparison with its
|
|
89
133
|
# intervals.
|
|
@@ -108,7 +152,10 @@ module ActiveAgent
|
|
|
108
152
|
|
|
109
153
|
scenario = cohort.first.scenario
|
|
110
154
|
labels = @models.map(&:label)
|
|
111
|
-
cohort = cohort.sort_by { |result| labels.index(result.label) || labels.size }
|
|
155
|
+
cohort = cohort.sort_by { |result| [ labels.index(result.label) || labels.size, result.trial ] }
|
|
156
|
+
# A model that replayed the scenario more than once names each trial.
|
|
157
|
+
repeated = cohort.map(&:label).tally.select { |_label, count| count > 1 }
|
|
158
|
+
name = ->(result) { repeated.key?(result.label) ? "`#{result.label}` · trial #{result.trial}" : "`#{result.label}`" }
|
|
112
159
|
body = []
|
|
113
160
|
body << "**Prompt**" << "" << quoted(scenario.prompt).join("\n") << ""
|
|
114
161
|
expectations = expectation_lines(scenario)
|
|
@@ -116,12 +163,12 @@ module ActiveAgent
|
|
|
116
163
|
body << "| Model | Status | Score | Tools | Fault | Cost |" << "|---|---|---|---|---|---|"
|
|
117
164
|
cohort.each do |result|
|
|
118
165
|
tools = result.replay.tool_calls.map { |call| "#{call['name']}#{' ✗' if call['error']}" }.join(", ")
|
|
119
|
-
body << "| #{[
|
|
166
|
+
body << "| #{[ name.call(result), status_text(result), (result.score ? Format.score(result.score) : '—'), tools.presence || '—',
|
|
120
167
|
result.fault&.tr('_', ' ') || '—', (result.replay.cost ? answer_cost(result) : '—') ].map { |text| cell(text) }.join(' | ')} |"
|
|
121
168
|
end
|
|
122
169
|
body << ""
|
|
123
170
|
cohort.each do |result|
|
|
124
|
-
body << "
|
|
171
|
+
body << "**#{name.call(result)}** — #{status_text(result)}"
|
|
125
172
|
body << ""
|
|
126
173
|
body << "Fault: #{result.fault.tr('_', ' ')} — #{[ result.summary, result.recommendation ].compact.join(' ')}" << "" if result.fault
|
|
127
174
|
body << "Error: #{result.replay.error}" << "" if result.replay.error
|
|
@@ -132,7 +179,8 @@ module ActiveAgent
|
|
|
132
179
|
text = body.join("\n").rstrip
|
|
133
180
|
return "### Scenario `#{scenario.key}`\n\n#{text}" unless details
|
|
134
181
|
|
|
135
|
-
|
|
182
|
+
# A model that replayed the scenario shows one mark per trial, run together.
|
|
183
|
+
marks = cohort.group_by(&:label).map { |label, replays| "#{replays.map { |result| status_mark(result) }.join} #{label}" }.join(" · ")
|
|
136
184
|
summary = "<code>#{html_text(scenario.key)}</code> #{html_text(scenario.prompt.truncate(80))} — #{html_text(marks)}"
|
|
137
185
|
"<details>\n<summary>#{summary}</summary>\n\n#{text}\n\n</details>"
|
|
138
186
|
end
|
|
@@ -148,11 +196,20 @@ module ActiveAgent
|
|
|
148
196
|
@results.map { |result| result.scenario.key }.uniq
|
|
149
197
|
end
|
|
150
198
|
|
|
199
|
+
# How many results the report holds per scenario key × model label.
|
|
200
|
+
def replay_counts
|
|
201
|
+
@replay_counts ||= @results.map { |result| [ result.scenario.key, result.label ] }.tally
|
|
202
|
+
end
|
|
203
|
+
|
|
151
204
|
def judged_by_line
|
|
152
205
|
label = @judge_label || @judge&.label
|
|
153
206
|
label ? "Judged by `#{label}`." : "No judge; scored on rules and expectations alone."
|
|
154
207
|
end
|
|
155
208
|
|
|
209
|
+
def trials_line
|
|
210
|
+
"Each scenario was replayed #{trials} times; a model's pass rate counts every replay."
|
|
211
|
+
end
|
|
212
|
+
|
|
156
213
|
# "**Best model: gpt-5-mini**" with the verdict's rationale under it
|
|
157
214
|
# and, when a judge (not the pass-rate ranking) wrote it, who did.
|
|
158
215
|
def verdict_lines
|
|
@@ -7,12 +7,19 @@ module ActiveAgent
|
|
|
7
7
|
#
|
|
8
8
|
# `status` is "errored" when the run raised, "failed" when a fault was
|
|
9
9
|
# assigned, and "passed" otherwise. `diagnosis` is the Diagnosis::Result
|
|
10
|
-
# hash, with a `"judge"` sub-hash when a Judge refined it.
|
|
11
|
-
|
|
10
|
+
# hash, with a `"judge"` sub-hash when a Judge refined it. `trial` numbers
|
|
11
|
+
# the replays of one scenario under one model from 1 when the Runner
|
|
12
|
+
# replays each more than once (Runner.new trials:).
|
|
13
|
+
Result = Struct.new(:scenario, :spec, :replay, :scores, :score, :status, :diagnosis, :trial, keyword_init: true) do
|
|
12
14
|
def passed?
|
|
13
15
|
status == "passed"
|
|
14
16
|
end
|
|
15
17
|
|
|
18
|
+
# A Result built without a trial is the first.
|
|
19
|
+
def trial
|
|
20
|
+
self[:trial] || 1
|
|
21
|
+
end
|
|
22
|
+
|
|
16
23
|
def failed?
|
|
17
24
|
status == "failed"
|
|
18
25
|
end
|
|
@@ -88,6 +95,7 @@ module ActiveAgent
|
|
|
88
95
|
"label" => label,
|
|
89
96
|
"provider" => provider,
|
|
90
97
|
"model" => model,
|
|
98
|
+
"trial" => trial,
|
|
91
99
|
"status" => status,
|
|
92
100
|
"score" => score,
|
|
93
101
|
"scores" => scores,
|