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 +4 -4
- data/CHANGELOG.md +219 -1
- data/lib/active_agent/base.rb +27 -2
- data/lib/active_agent/delegation/schema.rb +51 -13
- data/lib/active_agent/evals/comparison.rb +169 -0
- data/lib/active_agent/evals/report.rb +73 -7
- data/lib/active_agent/evals/report_html.rb +64 -2
- data/lib/active_agent/evals/runner.rb +7 -1
- data/lib/active_agent/evals/statistics.rb +26 -0
- data/lib/active_agent/evals.rb +2 -0
- data/lib/active_agent/providers/anthropic_provider.rb +3 -1
- data/lib/active_agent/providers/open_ai/chat_provider.rb +7 -0
- data/lib/active_agent/providers/open_ai/responses_provider.rb +37 -4
- data/lib/active_agent/schema_generator.rb +146 -22
- data/lib/active_agent/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2b52709687c94c3d6c989fc36bba7826b931a6d0aa6e193b1bd4482f916d945f
|
|
4
|
+
data.tar.gz: 1801338ff01afc21837db5382aee49eb4cde1f002b322b479cf2256c283e8668
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
|
data/lib/active_agent/base.rb
CHANGED
|
@@ -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
|
|
188
|
-
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
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:
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
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
|