activeagent 1.9.0 → 1.9.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: da954ef27a255c7c9b81c073b81a320e2f5d85bf89108d2282f23e926adefd1e
4
- data.tar.gz: f01703dbd9f5114bbba7574a594e334eb03bab619e41f74c0e65726909d68c33
3
+ metadata.gz: 2b52709687c94c3d6c989fc36bba7826b931a6d0aa6e193b1bd4482f916d945f
4
+ data.tar.gz: 1801338ff01afc21837db5382aee49eb4cde1f002b322b479cf2256c283e8668
5
5
  SHA512:
6
- metadata.gz: 1443ef8d4ef8f0ea234e8110c940f443cd02073859243489d471fd977ccb66fa4d4e3117d64a37b6062d981b109ef206158882623a94a445b36ff77712dfa66f
7
- data.tar.gz: 60a3d87ffc1d8cdd5ef5ed02a7a9c9ae3f8e7fbda08db9d1622a26a0b179815b6cfc938cf1e4c9f582399bf1a049eea1121a2caf814a35b56546744a72a7353c
6
+ metadata.gz: 4d0299374c70e7e66b5edf3b141d171e79cb2f576ec32dcb6a2b4e11e9eb5bccaabbf2a5106fb5afd05f01ece8b6dc75e9a0cd956523c39ffdc4c3b5a17a0b22
7
+ data.tar.gz: b60f377af9288f08cd5166ba7d71edc1060ccddf23c1d2912ed4b9d105e75ce6a6325a1f0e855e2141ded3c2ebdffbb7cd0e2f37796e662cc37ef833054d21d1
data/CHANGELOG.md CHANGED
@@ -5,7 +5,225 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [1.9.2] - 2026-10-20
9
+
10
+ Releases `activeagent` and `actionagent` 1.9.2, with the matching browser
11
+ sidecar: evaluations at scale (the scenario suite as a build page, model
12
+ comparison with intervals and P95 latency, opt-in parallel scenario runs,
13
+ the judge named after its model), provider streaming fixes (Anthropic
14
+ citations, OpenAI Chat refusals and logprobs, Responses built-in tools),
15
+ nested `has_many` schemas, and delegation contracts from `SchemaGenerator`
16
+ classes.
17
+
18
+ Upgrading: structured-output property names are now sent to the provider,
19
+ and answered, as declared. A `response_format` schema with multiword
20
+ property names (`first_name`) used to reach the provider camelized
21
+ (`firstName`) while `required` stayed snake_case, which strict mode
22
+ rejected; both are now as declared. `parsed_json` is unaffected; code that
23
+ reads `message.content` itself, or matches on request payloads, sees
24
+ `first_name` where it saw `firstName`. In a Rails app, `has_many` is
25
+ available on every `ActiveModel::Model` class, since the railtie includes
26
+ `SchemaGenerator` there, not only on classes that include it themselves.
27
+ Scenario fan-out is off by default: `config.scenario_evaluation_fan_out =
28
+ true` turns it on, and its jobs run on the `:agents` queue. No new
29
+ migration; a host still on 1.9.0 needs 1.9.1's migration 027.
30
+
31
+ ### Added
32
+
33
+ - **P50 and P95 latency per model in evaluation reports** (`activeagent`).
34
+ `Report#summary_by_model` adds `p50_duration_ms` and `p95_duration_ms`
35
+ beside `avg_duration_ms`, by nearest rank as the dashboard's Metrics view
36
+ computes them, and the Markdown and HTML reports show them next to the
37
+ mean, which hides the tail (#428).
38
+
39
+ - **Cost per 1,000 scenario runs in the Markdown report** (`activeagent`).
40
+ The summary table gains `Cost / 1,000`, each model's mean cost per priced
41
+ replay on a common denominator, marked `~` when it is estimated or
42
+ covers only some replays (#428).
43
+
44
+ - **`ActiveAgent::Evals::Comparison`: is the gap between models real?**
45
+ (`activeagent`). A 95% interval for each model's pass rate and for the
46
+ difference between every pair of models, from 5,000 seeded resamples of
47
+ whole scenarios (a paired bootstrap for the pairs, over the scenarios both
48
+ ran). A pair is decisive when its interval excludes zero, and
49
+ `#conclusion` says in one sentence which pairs are, or that the ranking
50
+ is not decisive evidence of superiority (#428).
51
+
52
+ - **The model comparison in evaluation reports** (`activeagent`).
53
+ `Report#comparison` and `to_h["comparison"]` carry it for a report that
54
+ compares models, the Markdown report gains a Comparison section, and
55
+ `Report.new(comparison:)` renders one already recorded for the results
56
+ instead of resampling, as `verdict:` does (#428).
57
+
58
+ - **A ranked interval plot in the HTML evaluation report** (`activeagent`).
59
+ Under the comparison table, each model's pass rate is a dot inside its
60
+ 95% interval on a fixed 0 to 100% axis, best first, with the figures
61
+ beside it, the conclusion, and how the intervals were computed. Plain
62
+ CSS on the design tokens: no script, light and dark (#428).
63
+
64
+ - **Each comparison run keeps its model comparison** (`actionagent`). A
65
+ scenario run that compares models records `Report#comparison` under
66
+ `scores["_comparison"]` when it completes; a published report's is
67
+ worked out from the stored results rather than taken from the payload;
68
+ and the run's report page renders the recorded intervals instead of
69
+ resampling on every view. A run recorded before gets one from its
70
+ results (#428).
71
+
72
+ - **P50 and P95 latency in the dashboard's model comparison**
73
+ (`actionagent`). The comparison table gains a P50 / P95 column and each
74
+ model's scorecard a p50 / p95 figure, from the percentiles the run
75
+ recorded; a run recorded before shows neither (#428).
76
+
77
+ - **The model comparison plotted in the dashboard** (`actionagent`). A
78
+ run that compared models shows, under its comparison table, each model's
79
+ pass rate inside its 95% interval, best first, with the run's
80
+ conclusion and how the intervals were computed, from the comparison the
81
+ run recorded (#428).
82
+
83
+ - Parallel scenario evaluations in `actionagent`: with
84
+ `config.scenario_evaluation_fan_out = true`, a run enqueues one
85
+ `EvaluationReplayJob` per scenario × model, each stored replay is scored by
86
+ its own `EvaluationJudgeJob`, and the last result to be scored completes the
87
+ run with the scores a sequential run writes. Runs as wide as the host's
88
+ `:agents` queue. Off by default; browser and host-adapter runs stay
89
+ sequential (#422).
90
+ - `ActiveAgent::Evals::Runner.new(refine_gate:)`, a callable asked before each
91
+ recommendation call so per-scenario runners share one run-wide judge limit.
92
+
93
+ - **Nested arrays of objects in ActiveModel schemas** (`activeagent`).
94
+ `has_many :addresses, class_name: "Address"` on an ActiveModel class that
95
+ includes `ActiveAgent::SchemaGenerator` makes `to_json_schema` emit the
96
+ attribute as an array of `$ref`s to the item class's schema, which goes
97
+ under the root `$defs`. `strict:` and `additional_properties:` reach the
98
+ nested schema, a nested class's own definitions move up to the root, and
99
+ a class that nests itself raises `ArgumentError`. A length validation on
100
+ the array sets its item count (`min_items` / `max_items`) rather than a
101
+ string's length. `has_many` also declares the attribute with an
102
+ empty-array default, and is added only to a class without one, so
103
+ ActiveRecord's is untouched (#260).
104
+
105
+ - **Evaluation runs record their judge** (`actionagent`), in
106
+ `scores["_judge"]`: the `EvaluationJudgeAgent` it runs as, its provider,
107
+ the model it asked for and where that setting came from (the evaluation,
108
+ the provider key, the host's config, or the provider's default), and the
109
+ models that answered, with call counts. The suite's Models panel shows
110
+ "judged by <model>" with an `auto` badge when no model is pinned, and
111
+ opens the judge's configuration, with a field that pins `judge_model` on
112
+ the evaluation through `PATCH /api/evaluations/:id` (#605).
113
+
114
+
115
+ ### Changed
116
+
117
+ - **A scenario suite's page reads like a build page** (`actionagent`). The
118
+ page opened on a summary, every run, the costs, the models and every
119
+ scenario stacked down the page, so on a suite of 100 scenarios and 12 runs
120
+ the selected run's results started three screens down. It now opens on the
121
+ selected run: a run header with the run's passed count, models, agent and
122
+ judge spend, a to-fix count and a progress bar; beside it a rail of runs,
123
+ each with a pass bar per model, and a box of the run's scenarios that
124
+ scrolls on its own, one line per step (`[+]` passed, `[!]` failed, `[~]`
125
+ running, `[ ]` queued, `[?]` errored, `[-]` skipped) under a sticky header
126
+ and foldable group rows, with a step's detail opening in place. A toolbar
127
+ filters the steps to failed only, puts failed first, shows the tool cells
128
+ and follows the running step until the person scrolls. Models, what to fix
129
+ and the footer follow below; a failed run shows its error in the header
130
+ instead of models. Selecting a run keeps the page where it is, polling
131
+ backs off and stops in a hidden tab, and below 940px the rail becomes a
132
+ strip of the latest runs. The same suite is now 2,786px tall where it was
133
+ 11,354px (#602).
134
+
135
+ ### Fixed
136
+
137
+ - **`max_retries` sets the Anthropic `json_object` retries** (`activeagent`).
138
+ `AnthropicProvider` read the count from its constructor's arguments after
139
+ `BaseProvider` had already moved `max_retries` into the provider's options,
140
+ so an unparseable `json_object` answer was always retried twice, whatever
141
+ `max_retries` said, on Anthropic and on Bedrock, which inherits it. The
142
+ count now comes from the options: `max_retries: 0` sends the request once,
143
+ `max_retries: 5` up to six times, and the default is still two retries
144
+ (#531).
145
+ - **A streamed Anthropic answer with citations no longer raises.** Claude
146
+ streams the citations of a text block as `citations_delta` deltas when it
147
+ cites a document or a web search result, and the Anthropic provider raised
148
+ `Unexpected delta type: citations_delta` on the first one. The delta is now
149
+ skipped like `input_json_delta`: the full block, citations included, arrives
150
+ at `content_block_stop`.
151
+ - **A streamed OpenAI Chat refusal, or a stream with logprobs, no longer
152
+ raises.** The openai gem's chat stream emits `refusal.delta` and
153
+ `refusal.done` when the model refuses, and `logprobs.*` events when the
154
+ request sets `logprobs`, and the Chat provider failed on them with
155
+ `Unexpected Response Event Type`. They are now handled, and `refusal.done`
156
+ ends the generation, since a refused message never gets a `content.done`.
157
+ - **A streamed Responses answer that used a built-in tool no longer raises.**
158
+ With `tools: [{ type: "web_search_preview" }]` (or file search, image
159
+ generation, code interpreter, remote MCP), the stream carries the tool's
160
+ call as an output item (`web_search_call`, ...), its progress events
161
+ (`response.web_search_call.searching`, ...), and
162
+ `response.output_text.annotation.added` for the citations, and the
163
+ Responses provider raised `Unexpected Item Type: web_search_call` on the
164
+ first of them. The tool calls are now kept like in a non-streamed response,
165
+ and their events are skipped.
166
+ - **A streamed Responses answer cut short no longer raises.** When a response
167
+ hits `max_output_tokens`, the stream ends with `response.incomplete` instead
168
+ of `response.completed`, and the Responses provider raised `Unexpected
169
+ Response Chunk Type: response.incomplete`. It now ends the generation with
170
+ the text streamed so far, like `response.completed`.
171
+ - **A streamed Responses refusal no longer raises.** The provider raised
172
+ `Unexpected Response Chunk Type: response.refusal.delta` when the model
173
+ refused; the refusal events are now skipped and the response completes.
174
+ - **A streamed Responses generation reports its token usage.** The usage of a
175
+ streamed response is only on its final event, and the Responses provider
176
+ never read it, so `response.usage` was `nil`. It is now recorded from
177
+ `response.completed` or `response.incomplete`, as the Chat provider does
178
+ from its usage chunk.
179
+ - **Delegation contracts built from a `SchemaGenerator` class**
180
+ (`activeagent`). `delegation ..., schema: TicketForm` and
181
+ `returns TicketForm` produced a contract with no properties, because
182
+ `to_json_schema` wraps the schema in `{ name:, schema:, strict: }` and
183
+ the contract read only a top-level `properties`. The wrapper is now
184
+ unwrapped, and `$defs` are kept so the `$ref`s that nested schemas use
185
+ still resolve. A contract built from a class is sent with the strict
186
+ setting the class declared: `Model.to_json_schema` is not strict unless
187
+ asked, and its `required` then names only the presence-validated
188
+ attributes, which OpenAI's strict mode refuses, so `returns Model` goes
189
+ out with `strict: false` and `returns Model.to_json_schema(strict: true)`
190
+ strict with every attribute required.
191
+
192
+ - **Structured-output schema normalization preserves field names**
193
+ (`activeagent`). `prompt` converts JSON Schema keywords to camel case
194
+ without renaming properties or definitions, keeping multiword attributes
195
+ and `has_many` associations consistent with `required` and `$ref` in
196
+ strict schemas. Literal data in `default`, `const`, `enum` and `examples`
197
+ is preserved too (#260).
198
+
199
+ Property names now reach the provider as declared, so a `first_name`
200
+ attribute is sent as `first_name` rather than `firstName`, and the model
201
+ answers with that key. `parsed_json` underscores keys either way, so code
202
+ reading it is unaffected; code reading the raw `message.content`, or
203
+ matching on request payloads, sees snake_case where it saw camelCase.
204
+
205
+ A delegation's `returns` schema follows suit: its `required` entries are
206
+ no longer camelized, so a multiword required key such as `max_points`
207
+ matches its property in strict mode instead of being sent as
208
+ `maxPoints`.
209
+ - **A judge with no `judge_model` is labelled with the model that
210
+ answered** (`actionagent`), not its provider ("judged by openrouter").
211
+ OpenRouter's default, `openrouter/auto`, routes each call, so a run judged
212
+ by more than one model is labelled "<provider> default" (#605).
213
+
214
+ ## [1.9.1] - 2026-10-16
215
+
216
+ Releases `activeagent` and `actionagent` 1.9.1, with the matching browser
217
+ sidecar version. Adds user feedback to the dashboard. Upgrade by running
218
+ `bin/rails generate action_agent:install --skip` and `bin/rails db:migrate`
219
+ before deploying the updated dashboard (migration 027).
220
+
221
+ ### Added
222
+
223
+ - Interaction feedback in `actionagent`: submit thumbs up/down and optional
224
+ comments through the authenticated feedback API, then review and filter votes
225
+ in an agent’s Feedback tab. Includes response span attribution, retry-safe vote
226
+ updates, workspace scoping, an upgrade migration and user feedback docs (#591).
9
227
 
10
228
  ## [1.9.0] - 2026-10-14
11
229
 
@@ -397,13 +397,38 @@ module ActiveAgent
397
397
 
398
398
  if response_format[:type].to_sym == :json_schema
399
399
  response_format = nest_flat_json_schema(response_format)
400
- response_format[:json_schema] = prompt_view_schema(response_format[:json_schema])
401
- response_format[:json_schema].deep_transform_keys! { _1.to_s.camelize(:lower) }
400
+ response_format[:json_schema] = normalize_json_schema(prompt_view_schema(response_format[:json_schema]))
402
401
  end
403
402
 
404
403
  response_format
405
404
  end
406
405
 
406
+ # Convert Ruby-style schema keywords without changing property names,
407
+ # definition names, references or literal data (default, const, enum,
408
+ # examples). Only schema-valued keywords are traversed. Named maps keep
409
+ # their keys, so required entries and $ref paths still address them.
410
+ #
411
+ # @api private
412
+ def normalize_json_schema(schema)
413
+ return schema.map { |item| normalize_json_schema(item) } if schema.is_a?(Array)
414
+ return schema unless schema.is_a?(Hash)
415
+
416
+ schema.to_h do |key, value|
417
+ keyword = key.to_s.camelize(:lower)
418
+ normalized = case keyword
419
+ when "properties", "patternProperties", "$defs", "definitions", "dependentSchemas", "dependencies"
420
+ value.is_a?(Hash) ? value.transform_values { |definition| normalize_json_schema(definition) } : value
421
+ when "schema", "items", "prefixItems", "additionalItems", "contains", "additionalProperties",
422
+ "unevaluatedItems", "unevaluatedProperties", "propertyNames", "contentSchema",
423
+ "allOf", "anyOf", "oneOf", "not", "if", "then", "else"
424
+ normalize_json_schema(value)
425
+ else
426
+ value
427
+ end
428
+ [ keyword, normalized ]
429
+ end
430
+ end
431
+
407
432
  # Moves a json_schema given flat, with its name, schema and strict flag
408
433
  # beside +type+, under +json_schema+, so it is not mistaken for a
409
434
  # json_schema that names a view. Without a top-level schema, or with a
@@ -56,15 +56,25 @@ module ActiveAgent
56
56
  schema
57
57
  end
58
58
 
59
- # @param hash [Hash] JSON Schema object
59
+ # @param hash [Hash] JSON Schema object, or the +{ name:, schema:, strict: }+
60
+ # wrapper {ActiveAgent::SchemaGenerator} returns around one
60
61
  # @return [Schema]
61
62
  def from_hash(hash)
62
63
  hash = hash.deep_symbolize_keys
64
+ strict = nil
65
+ if !hash.key?(:properties) && hash[:schema].is_a?(Hash)
66
+ # SchemaGenerator's wrapper carries +strict: true+ only when the
67
+ # schema was built for strict mode; otherwise it is not strict.
68
+ strict = hash[:strict] == true
69
+ hash = hash[:schema]
70
+ end
63
71
 
64
72
  new.tap do |schema|
65
73
  (hash[:properties] || {}).each { |name, definition| schema.property(name, definition) }
66
74
  schema.required(*Array(hash[:required]))
67
75
  schema.additional_properties(hash.fetch(:additionalProperties, hash.fetch(:additional_properties, false)))
76
+ schema.definitions(hash[:$defs]) if hash[:$defs]
77
+ schema.strict(strict) unless strict.nil?
68
78
  end
69
79
  end
70
80
  end
@@ -73,6 +83,8 @@ module ActiveAgent
73
83
  @properties = {}
74
84
  @required = []
75
85
  @additional_properties = false
86
+ @definitions = {}
87
+ @strict = nil
76
88
  end
77
89
 
78
90
  # @return [Boolean] true when nothing has been declared
@@ -158,6 +170,29 @@ module ActiveAgent
158
170
  @additional_properties = value
159
171
  end
160
172
 
173
+ # Declares whether the schema was built for a provider's strict mode,
174
+ # as the +strict:+ of the wrapper {ActiveAgent::SchemaGenerator} returns
175
+ # says. Strict mode wants every property in +required+; a
176
+ # SchemaGenerator schema names only the attributes a presence
177
+ # validation covers unless it was built with +strict: true+, so a
178
+ # class is sent as it declared itself (see #to_response_format).
179
+ #
180
+ # @param value [Boolean]
181
+ # @return [void]
182
+ def strict(value = true)
183
+ @strict = value
184
+ end
185
+
186
+ # Declares the subschemas that properties reference with
187
+ # +"$ref": "#/$defs/name"+, such as the nested schemas
188
+ # {ActiveAgent::SchemaGenerator} emits for associations.
189
+ #
190
+ # @param definitions [Hash{Symbol => Hash}]
191
+ # @return [void]
192
+ def definitions(definitions)
193
+ @definitions = @definitions.merge(definitions.deep_symbolize_keys)
194
+ end
195
+
161
196
  # @return [Array<Symbol>] declared property names
162
197
  def keys
163
198
  @properties.keys
@@ -177,27 +212,30 @@ module ActiveAgent
177
212
  type: "object",
178
213
  properties: @properties.deep_dup,
179
214
  required: @required.map(&:to_s),
180
- additionalProperties: @additional_properties
181
- }
215
+ additionalProperties: @additional_properties,
216
+ "$defs": (@definitions.deep_dup if @definitions.any?)
217
+ }.compact
182
218
  end
183
219
  alias_method :to_h, :to_json_schema
184
220
 
185
221
  # JSON Schema for a +response_format+ payload.
186
222
  #
187
- # ActiveAgent camelizes response-format schema *keys* on the way to the
188
- # provider, but the +required+ array holds string *values* — so they are
189
- # camelized here to keep the emitted schema internally consistent.
190
- # {Assistant#parsed_json} underscores the keys again on the way back, so
191
- # agent code only ever sees snake_case.
223
+ # ActiveAgent sends property names to the provider as declared, so the
224
+ # +required+ array names them as declared too.
225
+ # {Assistant#parsed_json} underscores the keys of the answer, so agent
226
+ # code only ever sees snake_case.
192
227
  #
193
228
  # @param name [String] schema name reported to the provider
194
- # @param strict [Boolean]
229
+ # @param strict [Boolean, nil] nil follows the source: what a
230
+ # SchemaGenerator class declared (not strict unless built with
231
+ # +strict: true+, since its +required+ otherwise lists only the
232
+ # presence-validated attributes, which strict mode refuses), else true
195
233
  # @return [Hash]
196
- def to_response_format(name:, strict: true)
197
- schema = to_json_schema
198
- schema[:required] = schema[:required].map { |key| key.camelize(:lower) }
234
+ def to_response_format(name:, strict: nil)
235
+ strict = @strict if strict.nil?
236
+ strict = true if strict.nil?
199
237
 
200
- { name: name, schema: schema, strict: strict }
238
+ { name: name, schema: to_json_schema, strict: strict }
201
239
  end
202
240
 
203
241
  # Validates a parsed payload against the declared required properties.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveAgent
4
+ module Evals
5
+ # Whether the gap between the models of a run is larger than the noise of
6
+ # its scenario sample: each model's pass rate with a confidence interval,
7
+ # and an interval for the difference between every pair of models.
8
+ #
9
+ # The scenario is the unit. A model's pass rate is the mean over its
10
+ # scenarios of the share of its results there that passed (1.0 or 0.0
11
+ # for one result per scenario), and the intervals come from resampling
12
+ # whole scenarios with replacement, +resamples+ times: the percentile
13
+ # bootstrap. A pair is compared on the scenarios both models ran, each
14
+ # resample drawing the same scenarios for both (a paired bootstrap of
15
+ # the per-scenario differences). A pair is decisive when its interval
16
+ # excludes zero.
17
+ #
18
+ # The resampling is seeded, so the same results always give the same
19
+ # intervals and a report renders the same page each time.
20
+ #
21
+ # comparison = Comparison.new(results: report.results, models: report.models)
22
+ # comparison.to_h["pairs"].first
23
+ # # => { "models" => ["gpt-5-mini", "qwen3:8b"], "scenarios" => 42, "difference" => 7.1,
24
+ # # "interval" => [0.0, 16.7], "decisive" => false }
25
+ # comparison.conclusion
26
+ # # => "The pairwise difference interval includes zero: the ranking is not decisive evidence of superiority."
27
+ class Comparison
28
+ RESAMPLES = 5_000
29
+ CONFIDENCE = 0.95
30
+ SEED = 0
31
+ # Fewer scenarios than this give no interval: resampling one scenario
32
+ # only ever draws that scenario.
33
+ MINIMUM_SCENARIOS = 2
34
+
35
+ attr_reader :resamples, :confidence, :seed
36
+
37
+ # @param results [Array<Result>]
38
+ # @param models [Array<ModelSpec>] in the run's order, which breaks ties in the ranking
39
+ # @param resamples [Integer]
40
+ # @param confidence [Float] 0..1
41
+ # @param seed [Integer]
42
+ def initialize(results:, models:, resamples: RESAMPLES, confidence: CONFIDENCE, seed: SEED)
43
+ @labels = models.map(&:label)
44
+ @resamples = resamples
45
+ @confidence = confidence
46
+ @seed = seed
47
+ @outcomes = @labels.to_h do |label|
48
+ by_scenario = results.select { |result| result.label == label }.group_by { |result| result.scenario.key }
49
+ [ label, by_scenario.transform_values { |cohort| cohort.count(&:passed?).fdiv(cohort.size) } ]
50
+ end
51
+ end
52
+
53
+ # Per model label, in the run's order: `{ "scenarios", "pass_rate",
54
+ # "interval" }`, the pass rate and its interval in percent. The interval
55
+ # is nil below MINIMUM_SCENARIOS, and the pass rate nil for none.
56
+ def models
57
+ @models ||= @labels.to_h do |label|
58
+ values = @outcomes[label].values
59
+ [ label, {
60
+ "scenarios" => values.size,
61
+ "pass_rate" => percent(mean(values)),
62
+ "interval" => interval(values)
63
+ } ]
64
+ end
65
+ end
66
+
67
+ # The labels by pass rate, best first; ties keep the run's order.
68
+ def ranking
69
+ @ranking ||= @labels.sort_by.with_index { |label, index| [ -(models[label]["pass_rate"] || -1), index ] }
70
+ end
71
+
72
+ # One entry per pair of models, the better ranked first: `{ "models" =>
73
+ # [a, b], "scenarios", "difference", "interval", "decisive" }`, where
74
+ # "difference" is a's pass rate minus b's over the scenarios both ran,
75
+ # in percentage points.
76
+ def pairs
77
+ @pairs ||= ranking.combination(2).map do |a, b|
78
+ shared = @outcomes[a].keys & @outcomes[b].keys
79
+ differences = shared.map { |key| @outcomes[a][key] - @outcomes[b][key] }
80
+ range = interval(differences)
81
+
82
+ {
83
+ "models" => [ a, b ],
84
+ "scenarios" => shared.size,
85
+ "difference" => percent(mean(differences)),
86
+ "interval" => range,
87
+ "decisive" => !range.nil? && (range.first.positive? || range.last.negative?)
88
+ }
89
+ end
90
+ end
91
+
92
+ # The honest read of the ranking, in a sentence: which pairs differ by
93
+ # more than the noise, or that none does. nil for a single model.
94
+ def conclusion
95
+ return nil if pairs.empty?
96
+
97
+ compared, too_few = pairs.partition { |pair| pair["interval"] }
98
+ decisive, inconclusive = compared.partition { |pair| pair["decisive"] }
99
+ sentences =
100
+ if compared.empty?
101
+ [ "The models share too few scenarios to compare." ]
102
+ elsif decisive.empty?
103
+ [ "#{all_include_zero(compared.size)}: the ranking is not decisive evidence of superiority." ]
104
+ else
105
+ decisive.map { |pair| decisive_sentence(pair) } + (inconclusive.any? ? [ others_include_zero(inconclusive.size) ] : [])
106
+ end
107
+ sentences << "#{plural(too_few.size, 'pair')} share#{'s' if too_few.one?} too few scenarios to compare." if compared.any? && too_few.any?
108
+ sentences.join(" ")
109
+ end
110
+
111
+ # How the intervals were computed, for a caption.
112
+ def method_summary
113
+ { "statistic" => "pass rate", "interval" => "percentile bootstrap", "unit" => "scenario",
114
+ "resamples" => @resamples, "confidence" => @confidence, "seed" => @seed }
115
+ end
116
+
117
+ def to_h
118
+ { "method" => method_summary, "models" => models, "ranking" => ranking, "pairs" => pairs, "conclusion" => conclusion }
119
+ end
120
+
121
+ private
122
+
123
+ # The bootstrap interval of the mean of +values+, in percent, or nil
124
+ # below MINIMUM_SCENARIOS.
125
+ def interval(values)
126
+ return nil if values.size < MINIMUM_SCENARIOS
127
+
128
+ random = Random.new(@seed)
129
+ size = values.size
130
+ means = Array.new(@resamples) do
131
+ sum = 0.0
132
+ size.times { sum += values[random.rand(size)] }
133
+ sum / size
134
+ end
135
+ # A rational tail, so 95% leaves exactly 2.5: in floating point it
136
+ # is 2.5000000000000022, whose nearest rank is one too high.
137
+ tail = (1 - @confidence.rationalize) / 2 * 100
138
+ [ percent(Statistics.percentile(means, tail)), percent(Statistics.percentile(means, 100 - tail)) ]
139
+ end
140
+
141
+ def mean(values)
142
+ values.sum.fdiv(values.size) if values.any?
143
+ end
144
+
145
+ def percent(fraction)
146
+ (fraction * 100).round(1) if fraction
147
+ end
148
+
149
+ def all_include_zero(count)
150
+ count == 1 ? "The pairwise difference interval includes zero" : "All #{count} pairwise difference intervals include zero"
151
+ end
152
+
153
+ def others_include_zero(count)
154
+ count == 1 ? "The other pairwise interval includes zero." : "The other #{count} pairwise intervals include zero."
155
+ end
156
+
157
+ def decisive_sentence(pair)
158
+ better, worse = pair["difference"].positive? ? pair["models"] : pair["models"].reverse
159
+ low, high = pair["interval"].map(&:abs).sort
160
+ "#{better} passes more scenarios than #{worse}, by #{pair['difference'].abs} points " \
161
+ "(#{(@confidence * 100).round}% CI #{low} to #{high})."
162
+ end
163
+
164
+ def plural(count, word)
165
+ "#{count} #{word}#{'s' unless count == 1}"
166
+ end
167
+ end
168
+ end
169
+ end