activeagent 1.9.6 → 1.9.8

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +612 -28
  3. data/README.md +9 -0
  4. data/lib/active_agent/base.rb +1 -0
  5. data/lib/active_agent/cli/eval_commands.rb +50 -7
  6. data/lib/active_agent/concerns/tooling.rb +11 -0
  7. data/lib/active_agent/evals/capture.rb +286 -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/model_capabilities.rb +1 -1
  27. data/lib/active_agent/providers/_base_provider.rb +18 -2
  28. data/lib/active_agent/providers/anthropic_provider.rb +10 -0
  29. data/lib/active_agent/providers/atlas_cloud/options.rb +35 -0
  30. data/lib/active_agent/providers/atlas_cloud_provider.rb +45 -0
  31. data/lib/active_agent/providers/bedrock/bearer_client.rb +2 -1
  32. data/lib/active_agent/providers/cheaper_inference/_types.rb +17 -0
  33. data/lib/active_agent/providers/cheaper_inference/options.rb +39 -0
  34. data/lib/active_agent/providers/cheaper_inference_provider.rb +72 -0
  35. data/lib/active_agent/providers/common/usage.rb +6 -1
  36. data/lib/active_agent/providers/ollama/_types.rb +1 -0
  37. data/lib/active_agent/providers/ollama/catalog.rb +221 -0
  38. data/lib/active_agent/providers/ollama/cloud.rb +109 -0
  39. data/lib/active_agent/providers/ollama/options.rb +66 -2
  40. data/lib/active_agent/providers/ollama_provider.rb +55 -3
  41. data/lib/active_agent/providers/open_ai/responses_provider.rb +7 -0
  42. data/lib/active_agent/providers/requesty_provider.rb +8 -0
  43. data/lib/active_agent/providers/ruby_llm/tool_proxy.rb +15 -0
  44. data/lib/active_agent/providers/ruby_llm_provider.rb +41 -5
  45. data/lib/active_agent/providers/runcrate/_types.rb +17 -0
  46. data/lib/active_agent/providers/runcrate/options.rb +39 -0
  47. data/lib/active_agent/providers/runcrate_provider.rb +72 -0
  48. data/lib/active_agent/railtie.rb +4 -0
  49. data/lib/active_agent/version.rb +1 -1
  50. data/lib/tasks/active_agent/evals.rake +28 -0
  51. metadata +27 -5
  52. data/lib/tasks/activeagent_tasks.rake +0 -4
@@ -20,12 +20,21 @@ module ActiveAgent
20
20
  # ArgumentError before anything is sent.
21
21
  class Publisher
22
22
  DEFAULT_ENDPOINT = "https://api.activeagents.ai/v1/evaluations"
23
+ # The hosted dashboard people sign in to; the hosted collector answers
24
+ # on the API host, where a dashboard session does not reach.
25
+ HOSTED_DASHBOARD_URL = "https://activeagents.ai"
23
26
  MAX_BYTES = 2 * 1024 * 1024
24
27
  DETAIL_LIMIT = 200
25
28
 
26
29
  # @return [String] the collector URL reports go to
27
30
  attr_reader :endpoint
28
31
 
32
+ # @return [String] the address a receipt's "url" path resolves against: the
33
+ # dashboard_url given, or by default the endpoint's origin (its scheme,
34
+ # host and any non-default port, with no path), except that the hosted
35
+ # API host resolves to HOSTED_DASHBOARD_URL
36
+ attr_reader :dashboard_url
37
+
29
38
  # Raised for every failed delivery. Only a network failure keeps the
30
39
  # underlying error as its +cause+, so neither the response nor the
31
40
  # report reaches a log through the exception chain.
@@ -68,7 +77,14 @@ module ActiveAgent
68
77
  # The key is sent as a bearer token and filtered from the collector's
69
78
  # explanation, so it is limited to visible ASCII: the sanitizer in
70
79
  # +collector_detail+ never alters it, and an echo of it always matches.
71
- def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, timeout: 10, open_timeout: 10)
80
+ #
81
+ # @param dashboard_url [String, nil] the address people open the dashboard at,
82
+ # where a receipt's "url" path resolves; by default the endpoint's origin,
83
+ # or HOSTED_DASHBOARD_URL for the hosted collector.
84
+ # The receipt's path, which carries the engine's mount, replaces any path on it
85
+ # @raise [ArgumentError] "Evaluation dashboard_url is not an http(s) URL" when it is anything else, or
86
+ # "Evaluation dashboard_url must be an http(s) URL without credentials, query or fragment" when it carries one of those
87
+ def initialize(api_key:, endpoint: DEFAULT_ENDPOINT, dashboard_url: nil, timeout: 10, open_timeout: 10)
72
88
  @uri = URI.parse(endpoint.to_s)
73
89
  unless @uri.is_a?(URI::HTTP) && @uri.host && !@uri.userinfo && !@uri.query && !@uri.fragment
74
90
  raise ArgumentError, "Evaluation endpoint must be an HTTP(S) URL without credentials, query or fragment"
@@ -78,6 +94,8 @@ module ActiveAgent
78
94
  end
79
95
  @endpoint = @uri.to_s
80
96
 
97
+ @dashboard_url = dashboard_url.nil? ? default_dashboard_url(@uri) : checked_dashboard_url(dashboard_url)
98
+
81
99
  @api_key = api_key.to_s.strip
82
100
  raise ArgumentError, "Evaluation API key is required" if @api_key.empty?
83
101
  unless @api_key.match?(/\A[\x21-\x7E]+\z/)
@@ -93,9 +111,42 @@ module ActiveAgent
93
111
  raise ArgumentError, "Evaluation endpoint is not a valid URL"
94
112
  end
95
113
 
96
- # report may be a Report or its saved JSON hash. Full prompts, answers and
97
- # tool results are included; applications should make publication opt-in.
98
- def call(report:, run_id:, source:, agent_name:, suite:)
114
+ # Returns a publisher built from +env+: ACTIVEAGENTS_API_KEY is the key,
115
+ # ACTIVEAGENTS_EVALUATIONS_ENDPOINT the collector (DEFAULT_ENDPOINT when
116
+ # blank) and ACTIVEAGENTS_DASHBOARD_URL the dashboard a receipt's "url"
117
+ # path resolves against (the endpoint's origin when blank), so
118
+ # publishing is one line:
119
+ #
120
+ # Publisher.from_env.call(report: report, source: "support-app", agent_name: "SupportBot", suite: "support_desk")
121
+ #
122
+ # @param env [#[]] the environment to read, ENV by default
123
+ # @raise [ArgumentError] "ACTIVEAGENTS_API_KEY is not set" for a blank key, and what +new+ raises for the other two
124
+ # @return [Publisher]
125
+ def self.from_env(env = ENV)
126
+ api_key = env["ACTIVEAGENTS_API_KEY"].to_s.strip
127
+ raise ArgumentError, "ACTIVEAGENTS_API_KEY is not set" if api_key.empty?
128
+
129
+ endpoint = env["ACTIVEAGENTS_EVALUATIONS_ENDPOINT"].to_s.strip
130
+ dashboard_url = env["ACTIVEAGENTS_DASHBOARD_URL"].to_s.strip
131
+ new(api_key: api_key, endpoint: endpoint.empty? ? DEFAULT_ENDPOINT : endpoint, dashboard_url: dashboard_url.empty? ? nil : dashboard_url)
132
+ end
133
+
134
+ # Returns the collector's receipt for the completed +report+, a Report
135
+ # or its saved JSON hash. Full prompts, answers and tool results are
136
+ # included; applications should make publication opt-in.
137
+ #
138
+ # The receipt's "url" is the run's dashboard page, made absolute: a path
139
+ # beginning with "/" is joined onto +dashboard_url+. An absolute URL, a
140
+ # url that is neither (a relative reference without a leading slash, a
141
+ # network-path reference, or a path that is not a valid URI) and a
142
+ # receipt without one are left as the collector sent them.
143
+ #
144
+ # @param run_id [String, nil] the run's identity, kept across retries; by default
145
+ # the one the report's metadata records under "run_id" or :run_id, which
146
+ # Correlation#with_run puts there
147
+ # @raise [ArgumentError] when no run_id is given and the report's metadata records none as a nonempty string
148
+ def call(report:, run_id: nil, source:, agent_name:, suite:)
149
+ run_id = resolve_run_id(report, run_id)
99
150
  envelope = self.class.envelope(report: report, run_id: run_id, source: source, agent_name: agent_name, suite: suite)
100
151
  publish(envelope, accepted: %w[complete], receipt: "completion receipt")
101
152
  end
@@ -104,13 +155,18 @@ module ActiveAgent
104
155
  # which the collector shows as a running run under +run_id+. Each call
105
156
  # replaces the results the previous one sent; +call+ with the finished
106
157
  # report under the same run_id completes the run. The receipt's status
107
- # is "running", or "complete" when the run already finished.
158
+ # is "running", or "complete" when the run already finished, and its
159
+ # "url" is made absolute as +call+ makes it.
108
160
  #
109
161
  # Raises Error, without sending the report, when the collector does not
110
162
  # list "progress" among its capabilities (see +supports_progress?+): a
111
163
  # collector that predates progress would store the partial report as
112
164
  # complete.
113
- def progress(report:, run_id:, source:, agent_name:, suite:)
165
+ #
166
+ # @param run_id [String, nil] as for +call+: by default the one the report's metadata records
167
+ # @raise [ArgumentError] when no run_id is given and the report's metadata records none as a nonempty string
168
+ def progress(report:, run_id: nil, source:, agent_name:, suite:)
169
+ run_id = resolve_run_id(report, run_id)
114
170
  unless supports_progress?
115
171
  raise Error, "Evaluation collector does not accept progress; publish the finished report with call"
116
172
  end
@@ -186,8 +242,54 @@ module ActiveAgent
186
242
 
187
243
  private
188
244
 
245
+ # The run_id a delivery goes under: +run_id+ when given, otherwise the
246
+ # one the report's metadata records under a String or Symbol key, which
247
+ # Correlation#with_run puts there. Raises ArgumentError for a report
248
+ # that is neither a Report nor a hash, as +envelope+ does, and when
249
+ # neither +run_id+ nor the metadata names a nonempty string.
250
+ def resolve_run_id(report, run_id)
251
+ return run_id unless run_id.nil?
252
+
253
+ hash = report.to_h if report.respond_to?(:to_h) && !report.nil? && !report.is_a?(Array)
254
+ raise ArgumentError, "report must be a Report or its saved JSON hash" unless hash.is_a?(Hash)
255
+
256
+ metadata = hash["metadata"]
257
+ recorded = metadata["run_id"] || metadata[:run_id] if metadata.is_a?(Hash)
258
+ return recorded if recorded.is_a?(String) && !recorded.strip.empty?
259
+
260
+ raise ArgumentError, "run_id must be given, or the report's metadata must carry one (Correlation#with_run records it)"
261
+ end
262
+
263
+ # The hosted collector's API host serves no dashboard session, so its
264
+ # receipts resolve against the hosted dashboard; any other collector is
265
+ # its own dashboard.
266
+ def default_dashboard_url(uri)
267
+ uri.hostname == URI.parse(DEFAULT_ENDPOINT).hostname ? HOSTED_DASHBOARD_URL : origin(uri)
268
+ end
269
+
270
+ # Returns the dashboard_url given as a String, raising ArgumentError
271
+ # unless it is an http(s) URL with a host and without credentials, a
272
+ # query or a fragment.
273
+ def checked_dashboard_url(value)
274
+ uri = URI.parse(value.to_s)
275
+ raise ArgumentError, "Evaluation dashboard_url is not an http(s) URL" unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
276
+ if uri.userinfo || uri.query || uri.fragment
277
+ raise ArgumentError, "Evaluation dashboard_url must be an http(s) URL without credentials, query or fragment"
278
+ end
279
+
280
+ value.to_s
281
+ rescue URI::InvalidURIError
282
+ raise ArgumentError, "Evaluation dashboard_url is not an http(s) URL"
283
+ end
284
+
285
+ # The origin of +uri+: its scheme, host and any non-default port, with no path.
286
+ def origin(uri)
287
+ uri.dup.tap { |without_path| without_path.path = "" }.to_s
288
+ end
289
+
189
290
  # Delivers +envelope+ and returns the collector's receipt, which must name
190
- # the envelope's run_id, a stored run, and one of the +accepted+ statuses.
291
+ # the envelope's run_id, a stored run, and one of the +accepted+ statuses,
292
+ # with a "url" path made absolute on the dashboard.
191
293
  def publish(envelope, accepted:, receipt:)
192
294
  body = encode(envelope)
193
295
  raise Error, "Evaluation report exceeds the 2 MiB delivery limit; publish a smaller selection" if body.bytesize > MAX_BYTES
@@ -201,10 +303,24 @@ module ActiveAgent
201
303
  answer["id"] && answer["evaluation_id"]
202
304
  raise Error.new("Evaluation collector returned an invalid #{receipt}; retain the report and run_id for retry", retryable: true)
203
305
  end
204
- answer
306
+ resolve_url(answer)
205
307
  end
206
308
  end
207
309
 
310
+ # Returns +receipt+ with a "url" that is a path beginning with "/" joined
311
+ # onto +dashboard_url+. Any other url (an absolute one, a relative
312
+ # reference without a leading slash, a network-path reference, a path
313
+ # that is not a valid URI) and a receipt without one are left as the
314
+ # collector sent them.
315
+ def resolve_url(receipt)
316
+ url = receipt["url"]
317
+ return receipt unless url.is_a?(String) && url.start_with?("/") && !url.start_with?("//")
318
+
319
+ receipt.merge("url" => URI.join(@dashboard_url, url).to_s)
320
+ rescue URI::InvalidURIError
321
+ receipt
322
+ end
323
+
208
324
  # The string entries of a JSON object body's +capabilities+ array, or none.
209
325
  def collector_capabilities(body)
210
326
  parsed = JSON.parse(body.to_s)
@@ -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] || {}