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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +436 -28
  3. data/README.md +6 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +3 -3
  6. data/lib/active_agent/concerns/tooling.rb +9 -0
  7. data/lib/active_agent/evals/capture.rb +275 -0
  8. data/lib/active_agent/evals/catalog.rb +150 -19
  9. data/lib/active_agent/evals/gate_check.rb +79 -0
  10. data/lib/active_agent/evals/pass_thresholds.rb +137 -0
  11. data/lib/active_agent/evals/patterns.rb +80 -0
  12. data/lib/active_agent/evals/publisher.rb +124 -8
  13. data/lib/active_agent/evals/report.rb +165 -20
  14. data/lib/active_agent/evals/report_html.rb +91 -17
  15. data/lib/active_agent/evals/report_markdown.rb +63 -6
  16. data/lib/active_agent/evals/result.rb +10 -2
  17. data/lib/active_agent/evals/rspec.rb +87 -0
  18. data/lib/active_agent/evals/ruby_llm.rb +105 -3
  19. data/lib/active_agent/evals/runner.rb +59 -16
  20. data/lib/active_agent/evals/scenario.rb +12 -4
  21. data/lib/active_agent/evals/scenario_parser.rb +9 -1
  22. data/lib/active_agent/evals/scorer.rb +53 -23
  23. data/lib/active_agent/evals/suite.rb +117 -6
  24. data/lib/active_agent/evals/test_helper.rb +286 -0
  25. data/lib/active_agent/evals.rb +9 -1
  26. data/lib/active_agent/providers/_base_provider.rb +17 -1
  27. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  28. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  29. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  30. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  31. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  32. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  33. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  34. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  35. data/lib/active_agent/providers/ollama/options.rb +66 -2
  36. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  37. data/lib/active_agent/railtie.rb +4 -0
  38. data/lib/active_agent/version.rb +1 -1
  39. data/lib/tasks/active_agent/evals.rake +28 -0
  40. metadata +16 -3
  41. 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
- def initialize(results:, models:, judge: nil, instructions: nil, threshold: PASS_THRESHOLD, metadata: {},
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
- # Per model, keyed by label: scenario count, passes, errors, pass rate,
72
- # mean score, latency (mean, and the P50 and P95 by nearest rank,
73
- # Statistics.percentile), tokens, cost and fault counts. "cost" sums
74
- # the replays that carried a cost — "priced" of the "scenarios", of
75
- # which "reported" came from the caller and "estimated" from tokens ×
76
- # a model rate (Result#cost_source) — and is nil when none did.
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['scenarios']} scenarios" \
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, and
290
- # "comparison" only when comparing models, so a single-model report
291
- # built the way it always was serializes exactly as before.
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" => @results.map(&:to_h)
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
- result = cohort.find { |candidate| candidate.label == label }
632
- next "—" unless result
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
- name = group_name(cohorts.first.first.scenario)
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.filter_map { |cohort| cohort.find { |result| result.label == spec.label } }
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
- if results.empty? then ""
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
- result = cohort.find { |candidate| candidate.label == spec.label }
546
- result ? html_result_cell(result) : %(<div class="cell"><div class="top"><span class="muted">—</span></div></div>)
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
- cards = cohort.sort_by { |result| labels.index(result.label) || labels.size }.map { |result| html_result_card(result) }
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
- def html_result_card(result)
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 and #scenario_markdown. The whole report is
15
- # built from them, so a piece copied alone reads as it does in the report.
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 << "| #{[ "`#{result.label}`", status_text(result), (result.score ? Format.score(result.score) : '—'), tools.presence || '—',
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 << "**`#{result.label}`** — #{status_text(result)}"
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
- marks = cohort.map { |result| "#{status_mark(result)} #{result.label}" }.join(" · ")
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
- Result = Struct.new(:scenario, :spec, :replay, :scores, :score, :status, :diagnosis, keyword_init: true) do
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,