ruby_llm-contract 1.4.0 → 1.4.1

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: b836138600866650ba9df05fce0a0ce02d148dc1819b9b215e5e47aa124e93cb
4
- data.tar.gz: 237df37acef3aba9320acc723156dfdf33a928729c58b6e79ad54ea1ac33cf68
3
+ metadata.gz: 58ba70123cbc8bc4c7d3c9dc85ce430f1f1492ceb031df35ae0ea3f18f534dd8
4
+ data.tar.gz: 24fed341a96267536d66532926d0e19cb4eb59e2ffd95467be1ca861d9dbe4f9
5
5
  SHA512:
6
- metadata.gz: 79ff71eea63a6ce371b0d183137d8b0c498b3dcb8f21fa2410cbd9793334120be140a15716080688569e96a0d7b543e2083e986ccb2c199153722a17ae0adffb
7
- data.tar.gz: c0547247d8d84e2e4bfc03fc398ddee89d1b77330924487a68f606f363365df800dc2be0016a255227cf4bb004c2b3510d1bf3f5ee7f19a1d5d00a2896103a28
6
+ metadata.gz: 2cc04307951beb543adb0aae5876013767256e90501ed65be68cc9b4918e547568860bd377cf94651777faaa3159b5be47d3b054445223ee5d7f1a86a5aea697
7
+ data.tar.gz: 8c06562bc7093a78d50c3e42294875884e6537d326bf7a474dc69985f7a5798209cedbe2691b6acd83050857ef715d3ef963f5a08358bc3c79c2984e51a76014
data/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.1 (2026-10-11)
4
+
5
+ ### Fixed
6
+
7
+ - **`compare_models` runs each candidate on a Step with a retry chain.** The
8
+ Step's `retry_policy` chain picked the model of every attempt, so without
9
+ `production_mode:` each candidate ran the chain's first model (and escalated
10
+ through the chain) under its own label: the scores and costs were all the
11
+ same model's, and `best_for` and `recommend` could name a model never run.
12
+ A candidate now runs without the chain, on its own model. This also holds
13
+ for the Steps of a Pipeline compared with `compare_models`. Without
14
+ `production_mode:`, a policy that only sets `attempts` picks no model, so
15
+ it stays: the candidate keeps its same-model retries, as before; and a
16
+ `retry_policy_override` passed in `context:` is still used as given. With
17
+ `production_mode:`, the candidate-and-fallback policy replaces both, as
18
+ before.
19
+ - **An `evaluator:` that returns an `EvaluationResult` keeps its score.** The
20
+ result was taken as a truthy value, so every case passed with 1.0 - also one
21
+ the evaluator scored 0.0 with `passed: false`, as a judge built on another gem
22
+ does (the RubyLLM::Tribunal example in the docs). It is now used as returned.
23
+ - **The `production_mode:` table draws no chain for a candidate run alone.**
24
+ Since 1.4.0 a candidate whose fallback would send the same request runs
25
+ single-shot, but the table still showed `candidate → fallback` with `0%`
26
+ when the two labels differed (`gpt-5-mini (effort: low)` with fallback
27
+ `gpt-5-mini`). It now shows the candidate alone with `—`, as for a fallback
28
+ equal to the candidate.
29
+
3
30
  ## 1.4.0 (2026-10-11)
4
31
 
5
32
  ### Added
data/README.md CHANGED
@@ -15,12 +15,13 @@ gem "ruby_llm-contract"
15
15
  ```ruby
16
16
  RubyLLM.configure do |c|
17
17
  c.openai_api_key = ENV["OPENAI_API_KEY"]
18
- c.default_model = "gpt-4.1-mini" # used when a Step has no explicit model
19
18
  end
20
19
 
21
20
  # Required: boots the gem so `Step.run` knows how to talk to your LLM.
22
- # Empty block is fine. Pass options here if you need them (e.g. `c.logger`).
23
- RubyLLM::Contract.configure { }
21
+ # An empty block is fine; options such as `c.logger` go here too.
22
+ RubyLLM::Contract.configure do |c|
23
+ c.default_model = "gpt-4.1-mini" # used when a Step has no explicit model
24
+ end
24
25
  ```
25
26
 
26
27
  Works with any `ruby_llm` provider (OpenAI, Anthropic, Gemini, etc). Requires `ruby_llm ~> 2.0` and Ruby ≥ 3.2.
@@ -60,7 +61,7 @@ end
60
61
  Three things to notice in the snippet above:
61
62
 
62
63
  - **`{input}` is a gem-specific prompt placeholder** — at call time `SummarizeArticle.run(article_text)` interpolates `article_text` into that slot. For `input_type Hash`, each top-level key becomes its own placeholder (e.g. `{title}`, `{body}`). Plain text in, plain text out — no ERB, no `String#%`.
63
- - **`validate("...") { |o, _| ... }`** — each block receives `(parsed_output, context)`; the convention `|o, _|` reads "output, ignore context". Use `o[:key]` to assert on any field your schema declared. Return false → status `:validation_failed`, retry fires.
64
+ - **`validate("...") { |o, _| ... }`** — each block receives `(parsed_output, input)`; the convention `|o, _|` reads "output, ignore input". Use `o[:key]` to assert on any field your schema declared. Return false → status `:validation_failed`, retry fires.
64
65
  - **`retry_policy do escalate(...) end`** — block form for mixed configs (strings + hashes with per-attempt options). For uniform model lists you can use the shorthand `retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1]` (it's the same DSL — `models` is an alias for `escalate`).
65
66
 
66
67
  ```ruby
@@ -68,7 +69,7 @@ result = SummarizeArticle.run(article_text)
68
69
  result.status # => :ok (or :validation_failed if all steps fail)
69
70
  result.parsed_output # => { tldr: "...", takeaways: [...], tone: "..." }
70
71
  result.trace[:model] # => "gpt-4.1-mini" (winning step)
71
- result.trace[:cost] # => 0.000520 (total across all attempts)
72
+ result.trace[:cost] # => 0.000309 (total across all attempts)
72
73
 
73
74
  result.trace[:attempts]
74
75
  # => [
@@ -78,7 +79,7 @@ result.trace[:attempts]
78
79
  # status: :validation_failed,
79
80
  # usage: { input_tokens: 256, output_tokens: 84 },
80
81
  # latency_ms: 45,
81
- # cost: 0.000100
82
+ # cost: 0.000059
82
83
  # },
83
84
  # {
84
85
  # attempt: 2,
@@ -86,7 +87,7 @@ result.trace[:attempts]
86
87
  # status: :ok,
87
88
  # usage: { input_tokens: 256, output_tokens: 92 },
88
89
  # latency_ms: 92,
89
- # cost: 0.000420
90
+ # cost: 0.000250
90
91
  # }
91
92
  # ]
92
93
  ```
@@ -114,7 +115,7 @@ end
114
115
  expect(SummarizeArticle).to pass_eval("regression").without_regressions
115
116
  ```
116
117
 
117
- A bad prompt edit or model swap that drops accuracy on the frozen dataset → red CI, blocked merge. The first CI run records a baseline; subsequent runs compare against it. Every production miss should become the next `add_case`. See [Prevent silent prompt regressions](docs/guide/eval_first.md) for the full flywheel.
118
+ A bad prompt edit or model swap that drops accuracy on the frozen dataset → red CI, blocked merge. Save a baseline once (`report.save_baseline!`, or `t.save_baseline = true` in the rake task); until one exists, `without_regressions` has nothing to compare against and passes. Every production miss should become the next `add_case`. See [Prevent silent prompt regressions](docs/guide/eval_first.md) for the full flywheel.
118
119
 
119
120
  ## Do I need this?
120
121
 
@@ -84,7 +84,8 @@ Compare two prompt versions on the same eval:
84
84
  diff = SummarizeArticleV2.compare_with(
85
85
  SummarizeArticleV1,
86
86
  eval: "regression",
87
- model: "gpt-4.1-mini"
87
+ model: "gpt-4.1-mini",
88
+ context: { retry_policy_override: nil } # run mini, not the step's retry chain
88
89
  )
89
90
 
90
91
  diff.safe_to_switch? # => true if no cases regressed
@@ -103,7 +104,7 @@ end
103
104
 
104
105
  # Prompt iteration happens afterward
105
106
  diff = SummarizeArticleV2.compare_with(
106
- SummarizeArticleV1, eval: "regression", model: "gpt-4.1-mini"
107
+ SummarizeArticleV1, eval: "regression", model: "gpt-4.1-mini", context: { retry_policy_override: nil }
107
108
  )
108
109
  ```
109
110
 
@@ -124,7 +125,7 @@ Good for: offline smoke tests, local development, testing evaluator wiring, chec
124
125
 
125
126
  Not enough for real prompt decisions. For those:
126
127
 
127
- - `run_eval(..., context: { model: "..." })` with a real model, or pass an explicit adapter via `context: { adapter: ... }`. An *adapter* here is the layer that actually executes the LLM call — `RubyLLM::Contract::Adapters::Test` returns canned data, the default RubyLlm adapter hits the real provider. `model:` chooses the model name; `adapter:` chooses whether the call goes live or stays canned.
128
+ - `run_eval(..., context: { adapter: RubyLLM::Contract::Adapters::RubyLLM.new, model: "..." })` with a real model. An *adapter* here is the layer that actually executes the LLM call — `RubyLLM::Contract::Adapters::Test` returns canned data, the RubyLLM adapter hits the real provider. `model:` chooses the model name; `adapter:` chooses whether the call goes live or stays canned: an eval with `sample_response` stays canned until you pass an adapter, whatever `model:` says. On a step with a `retry_policy` chain, the chain picks each attempt's model, so add `retry_policy_override: nil` to measure the one you name.
128
129
  - `compare_with(...)` for prompt A/B.
129
130
 
130
131
  `compare_with` intentionally ignores `sample_response` — canned data would make both sides look the same.
@@ -135,11 +136,11 @@ For larger datasets, `run_eval` accepts a `concurrency:` argument — cases run
135
136
 
136
137
  ```ruby
137
138
  report = SummarizeArticle.run_eval("regression",
138
- context: { model: "gpt-4.1-mini" },
139
+ context: { model: "gpt-4.1-mini", retry_policy_override: nil },
139
140
  concurrency: 8)
140
141
  ```
141
142
 
142
- Same accepted by `compare_models` and `optimize_retry_policy`. Thread count is a ceiling — dataset order of results is preserved. Keep it low enough to respect the provider's rate limits.
143
+ `compare_models` and `optimize_retry_policy` do not take `concurrency:`; they run cases one at a time. Thread count is a ceiling — dataset order of results is preserved. Keep it low enough to respect the provider's rate limits.
143
144
 
144
145
  ## Budgeting an eval before you run it
145
146
 
@@ -148,7 +149,7 @@ Same accepted by `compare_models` and `optimize_retry_policy`. Thread count is a
148
149
  ```ruby
149
150
  SummarizeArticle.estimate_eval_cost("regression",
150
151
  models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1])
151
- # => { "gpt-4.1-nano" => 0.00041, "gpt-4.1-mini" => 0.0018, "gpt-4.1" => 0.0092 }
152
+ # => { "gpt-4.1-nano" => 0.003207, "gpt-4.1-mini" => 0.012829, "gpt-4.1" => 0.064142 }
152
153
  ```
153
154
 
154
155
  Use it in CI to decide which models are worth running regression on, or to cap worst-case spend per build.
@@ -37,11 +37,11 @@ class SummarizeArticle < RubyLLM::Contract::Step::Base
37
37
  # 5. Refuse before calling the LLM if input is too large or estimated cost exceeds the cap
38
38
  max_input 2_000
39
39
  max_output 4_000
40
- max_cost 0.01
40
+ max_cost 0.05
41
41
  end
42
42
  ```
43
43
 
44
- Each `validate` block receives `(parsed_output, context)` — the convention `|o, _|` reads "output, ignore context". Use `o[:key]` to assert on any field your `output_schema` declared. The block returns truthy/falsy: `false` → status `:validation_failed`, retry fires if `retry_policy` was set.
44
+ Each `validate` block receives `(parsed_output, input)` — the convention `|o, _|` reads "output, ignore input". Use `o[:key]` to assert on any field your `output_schema` declared. The block returns truthy/falsy: `false` → status `:validation_failed`, retry fires if `retry_policy` was set.
45
45
 
46
46
  ## Validation and retry behavior
47
47
 
@@ -53,13 +53,13 @@ result = SummarizeArticle.run(article_text)
53
53
  result.status # => :ok
54
54
  result.parsed_output # => { tldr: "...", takeaways: [...], tone: "analytical" }
55
55
  result.trace[:model] # => "gpt-4.1-mini" (first model that passed)
56
- result.trace[:cost] # => 0.00052 (sum of all attempts)
56
+ result.trace[:cost] # => 0.000309 (sum of all attempts)
57
57
  result.trace[:attempts]
58
58
  # => [
59
59
  # { attempt: 1, model: "gpt-4.1-nano", status: :validation_failed,
60
- # cost: 0.00010, latency_ms: 45, ... },
60
+ # cost: 0.000059, latency_ms: 45, ... },
61
61
  # { attempt: 2, model: "gpt-4.1-mini", status: :ok,
62
- # cost: 0.00042, latency_ms: 92, ... }
62
+ # cost: 0.000250, latency_ms: 92, ... }
63
63
  # ]
64
64
  ```
65
65
 
@@ -104,6 +104,8 @@ retry_policy models: [
104
104
 
105
105
  `reasoning_effort` is a gpt-5 family feature (gpt-4.1 is not a reasoning model). The per-attempt value is forwarded via `with_thinking` (provider-agnostic — OpenAI `reasoning_effort` and Anthropic extended-thinking budget both supported). Passing it alongside a non-reasoning model is forwarded unchanged to the provider, which will either ignore it or reject the request — the gem does not guard against this. An element without `reasoning_effort` inherits the call's or the step's; `{ model: "gpt-4.1-mini", reasoning_effort: :default }` sends none (see [thinking DSL note](optimizing_retry_policy.md#thinking-dsl-note)).
106
106
 
107
+ On a step with a `retry_policy` chain, the chain picks the model of every attempt, so a `model:` in the context (of `run`, `run_eval`, `compare_with` or `pass_eval(...).with_context`) is not used. To measure or run one model, add `retry_policy_override: nil` to the context. `compare_models` does this for each candidate.
108
+
107
109
  ## Evals and CI gates
108
110
 
109
111
  An eval is a named scenario you can run to verify the step still works. `sample_response` makes it offline — zero API calls — so CI can run it on every merge without burning budget.
@@ -178,7 +180,7 @@ expect(SummarizeArticle).to pass_eval("regression").without_regressions
178
180
  `max_input`, `max_output`, and `max_cost` are preflight checks — the LLM is never called if an estimate exceeds the limit. Zero tokens spent, zero cost.
179
181
 
180
182
  ```ruby
181
- result = SummarizeArticle.run(giant_10mb_document)
183
+ result = SummarizeArticle.run(forty_page_report) # ~128k characters
182
184
  result.status # => :limit_exceeded
183
185
  result.validation_errors
184
186
  # => ["Input token limit exceeded: estimated 32000 tokens (heuristic ±30%), max 2000"]
@@ -251,13 +253,13 @@ SummarizeArticle.estimate_cost(input: article_text)
251
253
  # => {
252
254
  # model: "gpt-4.1-mini",
253
255
  # input_tokens: 812, output_tokens_estimate: 4000,
254
- # estimated_cost: 0.00243
256
+ # estimated_cost: 0.00672
255
257
  # }
256
258
 
257
259
  # Estimate what a full eval would cost across candidate models
258
260
  SummarizeArticle.estimate_eval_cost("regression",
259
261
  models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1])
260
- # => { "gpt-4.1-nano" => 0.00041, "gpt-4.1-mini" => 0.0018, "gpt-4.1" => 0.0092 }
262
+ # => { "gpt-4.1-nano" => 0.003207, "gpt-4.1-mini" => 0.012829, "gpt-4.1" => 0.064142 }
261
263
  ```
262
264
 
263
265
  `estimate_cost` returns `nil` when pricing isn't registered. `estimate_eval_cost` silently treats unknown-pricing cases as `$0.00` and sums the rest — it does **not** fail closed the way `max_cost` does. Treat its output as a floor, not a guarantee; register pricing via `CostCalculator.register_model` before relying on it for budget decisions.
@@ -34,7 +34,7 @@ The judge is just another Step. The benefit of using `ruby_llm-contract`: you ge
34
34
 
35
35
  The judge takes **two** inputs in a single Hash: the SOURCE you want the answer to be faithful to (here: the article), and the ANSWER under review (here: the summary). Both are required — without the source, the judge has nothing to verify against.
36
36
 
37
- The `prompt do` DSL uses a small template syntax: `{key}` placeholders are interpolated from the input. With `input_type Hash`, every top-level key of the hash becomes available as `{key}` — here `{article}` and `{summary}` map to `input[:article]` and `input[:summary]` when you later call `AccuracyJudge.run(article: ..., summary: ...)`. With `input_type String`, you use the special `{input}` placeholder (one input, one slot). It is the gem's own syntax — not ERB, not `String#%`, not string interpolation — and it is what makes the judge inputs visible inside the prompt.
37
+ The `prompt do` DSL uses a small template syntax: `{key}` placeholders are interpolated from the input. With `input_type Hash`, every top-level key of the hash becomes available as `{key}` — here `{article}` and `{summary}` map to `input[:article]` and `input[:summary]` when you later call `AccuracyJudge.run({ article: ..., summary: ... })` (one Hash argument, so the braces are needed). With `input_type String`, you use the special `{input}` placeholder (one input, one slot). It is the gem's own syntax — not ERB, not `String#%`, not string interpolation — and it is what makes the judge inputs visible inside the prompt.
38
38
 
39
39
  ```ruby
40
40
  # A cheap model reads the summary against its source and decides if the
@@ -66,11 +66,12 @@ class AccuracyJudge < RubyLLM::Contract::Step::Base
66
66
  end
67
67
 
68
68
  retry_policy models: %w[gpt-5-nano gpt-5-mini]
69
- max_cost 0.001
69
+ max_output 2_000 # gpt-5 reasoning tokens count as output
70
+ max_cost 0.01
70
71
  end
71
72
  ```
72
73
 
73
- The `retry_policy` escalates to `gpt-5-mini` if the cheaper `gpt-5-nano` fails schema/validate (e.g., returns malformed JSON) — the judge stays reliable without paying for the bigger model on every call. `max_cost 0.001` bounds each call: the judge runs N× per eval suite (one call per case), so capping per-call cost prevents calibration runs from blowing the budget.
74
+ The `retry_policy` escalates to `gpt-5-mini` if the cheaper `gpt-5-nano` fails schema/validate (e.g., returns malformed JSON) — the judge stays reliable without paying for the bigger model on every call. `max_cost 0.01` bounds each attempt (with `max_output 2_000`, up to roughly 24k input tokens on `gpt-5-mini`), so a case that falls back from nano to mini costs at most about $0.012: the judge runs N× per eval suite (one call per case), so capping per-call cost prevents calibration runs from blowing the budget.
74
75
 
75
76
  This is small on purpose: one boolean, one reason. Easy to read, easy to disagree with.
76
77
 
@@ -148,7 +149,7 @@ Only now, after the judge has earned trust on real data, do you use it as the or
148
149
 
149
150
  The lambda must return a `Float` in `[0.0, 1.0]` — the eval framework averages per-case scores into the suite score. Map `true → 1.0`, `false → 0.0`, or use weighted partial scores if your judge returns gradations.
150
151
 
151
- > ⚠️ **Heads-up before you copy the snippet below.** Smoke evals on `SummarizeArticle` (per [Getting Started](getting_started.md)) typically run with `RubyLLM::Contract::Adapters::Test` for determinism — canned summaries, no API calls. **The `AccuracyJudge.run(...)` inside the evaluator does NOT inherit that Test adapter.** Each Step picks its adapter independently (from `RubyLLM::Contract.configure`, `context: { adapter: }`, or `LIVE=1` env). Run accuracy-regression evals as live (`LIVE=1` or explicit `context:`), and rely on `max_cost` on the judge to bound the bill.
152
+ > ⚠️ **Heads-up before you copy the snippet below.** Smoke evals on `SummarizeArticle` (per [Getting Started](getting_started.md)) typically run with `RubyLLM::Contract::Adapters::Test` for determinism — canned summaries, no API calls. **The `AccuracyJudge.run(...)` inside the evaluator does NOT inherit that Test adapter.** Each Step picks its adapter independently (from `context: { adapter: }`, or the `default_adapter` set in `RubyLLM::Contract.configure`). Run accuracy-regression evals live (an explicit `context:` or a configured default adapter), and rely on `max_cost` on the judge to bound the bill.
152
153
  >
153
154
  > This is intentional: the judge IS the oracle, so faking its verdicts would defeat the purpose. Keep the Test-adapter smoke evals for schema/wiring checks only.
154
155
 
@@ -161,7 +162,7 @@ SummarizeArticle.define_eval("accuracy_regression") do
161
162
  summary: output[:tldr],
162
163
  article: input
163
164
  })
164
- verdict.parsed_output[:accurate] ? 1.0 : 0.0
165
+ verdict.ok? && verdict.parsed_output[:accurate] ? 1.0 : 0.0
165
166
  }
166
167
 
167
168
  # ... one case per real production article you want to defend
@@ -153,6 +153,12 @@ The gem handles single calls. You handle parallelism:
153
153
 
154
154
  ```ruby
155
155
  class SummarizeBatch < RubyLLM::Contract::Step::Base
156
+ prompt <<~PROMPT
157
+ Give a one-line TL;DR for each article below, keyed by its id.
158
+
159
+ {input}
160
+ PROMPT
161
+
156
162
  output_schema do
157
163
  array :summaries do
158
164
  object do
@@ -9,7 +9,7 @@
9
9
  ```ruby
10
10
  # app/contracts/extract_invoice_data.rb
11
11
  class ExtractInvoiceData < RubyLLM::Contract::Step::Base
12
- prompt "Extract invoice fields from the attached PDF. Return JSON."
12
+ prompt "Extract invoice fields from the attached PDF. Return JSON. {input}"
13
13
 
14
14
  output_schema do
15
15
  string :vendor
@@ -24,7 +24,9 @@ class ExtractInvoiceData < RubyLLM::Contract::Step::Base
24
24
  # Conservative estimate of attachment input tokens (provider/model-specific).
25
25
  attachment_token_estimate 15_000 # ~12 PDF pages at ~1250 tokens/page
26
26
 
27
- max_cost 0.10
27
+ max_output 4_000 # room for gpt-5's reasoning tokens, which count as output;
28
+ # without it the cost check assumes an output as long as the input
29
+ max_cost 0.10
28
30
 
29
31
  retry_policy do
30
32
  escalate "gpt-4.1-mini",
@@ -64,7 +66,7 @@ end
64
66
  The same estimate applies to:
65
67
 
66
68
  - **Runtime** (`limit_checker`) — adds the estimate to `input_tokens` before checking `max_cost`/`max_input`. Refuses pre-flight if budget exceeded.
67
- - **Pre-flight** (`estimate_cost`) — accepts `attachment:` kwarg; same accounting. No drift between estimate and runtime decisions.
69
+ - **Pre-flight** (`estimate_cost`) — accepts `attachment:` kwarg; same input accounting. The output side matches only when `max_output` is set: without it `estimate_cost` assumes 256 output tokens, while the runtime check assumes an output as long as the input.
68
70
 
69
71
  ### Fail-closed without estimate
70
72
 
@@ -78,7 +80,7 @@ end
78
80
 
79
81
  result = MyContract.run("text", context: { attachment: "doc.pdf" })
80
82
  result.status # => :limit_exceeded
81
- result.validation_errors # => ["attachment present but attachment_token_estimate not set; ..."]
83
+ result.validation_errors # => ["attachment present but attachment_token_estimate not declared; cost cannot be bounded. ..."]
82
84
  ```
83
85
 
84
86
  ### Opting out per-step
@@ -104,12 +106,12 @@ ExtractInvoiceData.estimate_cost(
104
106
  attachment: "tmp/invoice.pdf"
105
107
  )
106
108
  # => { model: "gpt-4.1-mini",
107
- # input_tokens: 15_320,
108
- # output_tokens_estimate: 256,
109
- # estimated_cost: 0.0123 }
109
+ # input_tokens: 15_022,
110
+ # output_tokens_estimate: 4000,
111
+ # estimated_cost: 0.012409 }
110
112
  ```
111
113
 
112
- The `input_tokens` figure includes both the text estimate (chars/4 heuristic) AND the declared `attachment_token_estimate`. Pre-flight refusal mirrors runtime: if `attachment` is passed and `attachment_token_estimate` is not declared, `estimate_cost` returns nil and emits the same fail-closed reason.
114
+ The `input_tokens` figure includes both the text estimate (chars/4 heuristic) AND the declared `attachment_token_estimate`. Pre-flight refusal mirrors runtime: if `attachment` is passed and `attachment_token_estimate` is not declared, `estimate_cost` returns nil (with `on_unknown_attachment_size :warn` it warns and estimates without the attachment instead).
113
115
 
114
116
  **Note on output tokens.** `attachment_token_estimate` adds to `input_tokens` only — not to `output_tokens_estimate`. Vision-heavy responses (long image descriptions, transcribed paragraphs) may exceed the conservative `output_tokens_estimate` default. Treat `estimated_cost` as a floor for budget planning, not a precise predictor; inflate `max_output` or `max_cost` accordingly if your prompt routinely produces verbose descriptions.
115
117
 
@@ -130,7 +132,7 @@ Pick a value at or above the provider's worst-case. The estimate is a **floor fo
130
132
 
131
133
  ## Multi-turn caveat
132
134
 
133
- If your contract uses history (`add_history`), attachments from prior turns are **not** replayed (still true in 1.0.x). Single-turn multimodal works; follow-up questions on the same document require additional work that is deferred to a later release. The rationale is recorded in the project's internal ADR-0022, which is not shipped with the gem.
135
+ A Step is single-turn: each `run` sends its prompt with the attachment and keeps no conversation history. A follow-up question on the same document is another `run` that passes the attachment again.
134
136
 
135
137
  ## Provider notes
136
138
 
@@ -149,8 +151,9 @@ RSpec.describe ExtractInvoiceData do
149
151
  it "forwards attachment to chat.ask" do
150
152
  expect_any_instance_of(RubyLLM::Chat).to receive(:ask)
151
153
  .with(anything, with: "fixtures/invoice.pdf")
152
- .and_return(double(content: '{"vendor":"X",...}',
153
- tokens: RubyLLM::Tokens.new(input: 200, output: 50)))
154
+ .and_return(RubyLLM::Message.new(role: :assistant, content: '{"vendor":"X","invoice_number":"1","total_amount":10,"currency":"EUR"}',
155
+ model_id: "gpt-4.1-mini",
156
+ input_tokens: 200, output_tokens: 50))
154
157
 
155
158
  result = described_class.run("extract", context: { attachment: "fixtures/invoice.pdf" })
156
159
  expect(result.status).to eq(:ok)
@@ -2,13 +2,13 @@
2
2
 
3
3
  > Read this when you have 2+ evals and want to know empirically — not by guessing — which models belong in `retry_policy` and in what order.
4
4
 
5
- You defined `SummarizeArticle` in the [README](../../README.md) with `retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1]`. That list was a guess. `optimize_retry_policy` tells you which models your evals actually need, so you stop paying for the strong model when `nano` was enough — or stop shipping `nano` when the hardest eval proves it isn't.
5
+ You defined `SummarizeArticle` in [Getting Started](getting_started.md) with `retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1]`. That list was a guess. `optimize_retry_policy` tells you which models your evals actually need, so you stop paying for the strong model when `nano` was enough — or stop shipping `nano` when the hardest eval proves it isn't.
6
6
 
7
7
  ## Requirements
8
8
 
9
9
  - **`SummarizeArticle` already has `retry_policy`.** If your step has none, add one first ([getting started](getting_started.md)).
10
10
  - **2–3 evals per step.** One eval optimizes for one scenario; with only `smoke`, you get a recommendation that passes smoke but may miss production edge cases. See [eval-first](eval_first.md).
11
- - **Rake task `ruby_llm_contract:optimize` is registered when `lib/ruby_llm/contract/rake_task.rb` is loaded.** In Rails apps on **0.10.4+** the railtie auto-loads it lazily during `rake` invocation — no manual setup needed. On older Rails versions or in non-Rails projects, add `require "ruby_llm/contract/rake_task"` to your `Rakefile` (or any file under `lib/tasks/`) and set `EVAL_DIRS=...` on the command line.
11
+ - **Rake task `ruby_llm_contract:optimize` is registered when `lib/ruby_llm/contract/rake_task.rb` is loaded.** In Rails apps the railtie auto-loads it lazily during `rake` invocation — no manual setup needed. In non-Rails projects, add `require "ruby_llm/contract/rake_task"` to your `Rakefile` (or any file under `lib/tasks/`) and set `EVAL_DIRS=...` on the command line.
12
12
 
13
13
  > **Two orthogonal dimensions to a retry chain.** A chain element is `{ model:, reasoning_effort: }` — model identity AND thinking budget. `optimize_retry_policy` explores both. You can pin the default thinking config at class level too — see the `thinking` DSL note at the end.
14
14
 
@@ -27,10 +27,12 @@ Run once offline to verify the wiring:
27
27
  ```bash
28
28
  rake ruby_llm_contract:optimize \
29
29
  STEP=SummarizeArticle \
30
- CANDIDATES=gpt-4.1-nano,gpt-4.1-mini@low,gpt-4.1-mini,gpt-4.1
30
+ CANDIDATES=gpt-5-nano,gpt-5-mini@low,gpt-5-mini,gpt-5
31
31
  ```
32
32
 
33
- The `@low` (or `@medium`, `@high`) suffix on a candidate is CLI shorthand for `{ model: "gpt-4.1-mini", reasoning_effort: :low }` — saves you constructing hash strings on the command line. Plain model names without `@` use the Step's default thinking config (or none). `@default` runs the candidate with no effort at all, which is what a model without reasoning needs when the Step sets one: `CANDIDATES=gpt-4.1-mini@default,gpt-6-luna@none`.
33
+ The `@low` (or `@medium`, `@high`) suffix on a candidate is CLI shorthand for `{ model: "gpt-5-mini", reasoning_effort: "low" }` — saves you constructing hash strings on the command line. Plain model names without `@` use the Step's default thinking config (or none). `@default` runs the candidate with no effort at all, which is what a model without reasoning needs when the Step sets one: `CANDIDATES=gpt-6-luna@none,gpt-4.1-mini@default`.
34
+
35
+ List candidates from the cheapest to the most expensive. The optimizer takes that order as the cost order: the suggested chain tries them in it, and the first candidate that passes every eval is the safe fallback. It does not sort by price.
34
36
 
35
37
  Offline uses each eval's `sample_response` — zero API calls. **Every candidate gets the same score** because they all receive the canned response. That's fine for a smoke test (verifying evals load, candidates parse, output renders) but it doesn't compare model quality. For real optimization, go live.
36
38
 
@@ -39,7 +41,7 @@ Offline uses each eval's `sample_response` — zero API calls. **Every candidate
39
41
  ```bash
40
42
  LIVE=1 RUNS=3 rake ruby_llm_contract:optimize \
41
43
  STEP=SummarizeArticle \
42
- CANDIDATES=gpt-4.1-nano,gpt-4.1-mini@low,gpt-4.1-mini,gpt-4.1
44
+ CANDIDATES=gpt-5-nano,gpt-5-mini@low,gpt-5-mini,gpt-5
43
45
  ```
44
46
 
45
47
  `LIVE=1` makes real API calls. `RUNS=3` averages each `(candidate, eval)` pair over three runs — necessary because OpenAI forces `temperature=1.0` on gpt-5 / o-series and the same pair can score `0.00` on one run and `1.00` on the next.
@@ -49,28 +51,35 @@ Output (illustrative):
49
51
  ```
50
52
  SummarizeArticle — fallback list optimization
51
53
 
52
- eval 4.1-nano 4.1-mini@low 4.1-mini 4.1
53
- ---------------------------------------------------------
54
- smoke 1.00 1.00 1.00 1.00
55
- dense_article 0.67 ← 1.00 1.00 1.00
56
- critical_tone 0.50 ← 0.67 ← 1.00 1.00
54
+ eval nano mini@low mini gpt-5
55
+ -----------------------------------------------------
56
+ smoke 1.00 1.00 1.00 1.00
57
+ dense_article 0.67 1.00 1.00 1.00
58
+ critical_tone 0.50 ← 0.67 ← 1.00 1.00
57
59
 
58
60
  Hardest eval: critical_tone
59
61
 
60
62
  Suggested fallback list:
61
- gpt-4.1-nano — covers 1 eval(s)
62
- gpt-4.1-mini — passes all 3 evals
63
+ gpt-5-nano — covers 1 eval(s)
64
+ gpt-5-mini (effort: low) — covers 1 eval(s)
65
+ gpt-5-mini — passes all 3 evals
63
66
 
64
67
  DSL:
65
- retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini]
68
+ retry_policy do
69
+ escalate(
70
+ { model: "gpt-5-nano" },
71
+ { model: "gpt-5-mini", reasoning_effort: "low" },
72
+ { model: "gpt-5-mini" }
73
+ )
74
+ end
66
75
  ```
67
76
 
68
77
  Reading the table:
69
78
  - **`←` marks scores below threshold in the hardest eval.** Not a selection hint — just "this candidate fails the row that matters most".
70
- - **Hardest eval** = the one that forces the strong fallback. Here, `critical_tone` demands `gpt-4.1-mini`.
71
- - **Suggested fallback list** = the shortest chain where each step covers more evals, built greedy-cheapest-first. Stops when all evals pass. Order matters: `gpt-4.1-nano` is tried first; on validation failure, the gem falls back to `gpt-4.1-mini`.
79
+ - **Hardest eval** = the one that forces the strong fallback. Here, `critical_tone` demands `gpt-5-mini` at its default effort.
80
+ - **Suggested fallback list** = the shortest chain where each step covers more evals, built greedy-cheapest-first. Stops when all evals pass. Order matters: `gpt-5-nano` is tried first; on validation failure, the gem falls back to `gpt-5-mini` at low effort, then at its default.
72
81
 
73
- Copy the DSL, paste into your step, verify with `rake ruby_llm_contract:eval`. You just dropped `gpt-4.1` from the chain — most requests finish on nano, mini handles what nano misses, and the strong model was never needed.
82
+ Copy the DSL, paste into your step, verify with `rake ruby_llm_contract:eval` (defined by `RubyLLM::Contract::RakeTask.new`, see [Rails integration](rails_integration.md#7-ci-gate--block-regressions-before-merge)). You just dropped `gpt-5` from the chain — most requests finish on nano, mini handles what nano misses, and the strong model was never needed.
74
83
 
75
84
  ## Measure effective cost before shipping
76
85
 
@@ -81,8 +90,8 @@ Copy the DSL, paste into your step, verify with `rake ruby_llm_contract:eval`. Y
81
90
  ```ruby
82
91
  SummarizeArticle.compare_models(
83
92
  "dense_article",
84
- candidates: [{ model: "gpt-5-nano" }, { model: "gpt-5-mini", reasoning_effort: "low" }],
85
- production_mode: { fallback: "gpt-5-mini" }
93
+ candidates: [{ model: "gpt-5-nano" }, { model: "gpt-5-mini", reasoning_effort: "low" }, { model: "gpt-5" }],
94
+ production_mode: { fallback: "gpt-5" }
86
95
  ).print_summary
87
96
  ```
88
97
 
@@ -91,17 +100,20 @@ Output (live mode, illustrative):
91
100
  ```
92
101
  dense_article — model comparison
93
102
 
94
- Chain first-attempt fallback % effective cost latency score
95
- ---------------------------------------------------------------------------------------------------
96
- gpt-5-nano → gpt-5-mini $0.0010 33% $0.0018 164ms 1.00
97
- gpt-5-mini (effort: low) → gpt-5-mini $0.0015 5% $0.0016 210ms 1.00
98
- gpt-5-mini $0.0030 — $0.0030 220ms 1.00
103
+ Chain first-attempt fallback % effective cost latency score
104
+ ----------------------------------------------------------------------------------------------
105
+ gpt-5-nano → gpt-5 $0.0010 33% $0.0030 164ms 1.00
106
+ gpt-5-mini (effort: low) → gpt-5 $0.0015 5% $0.0018 210ms 1.00
107
+ gpt-5 $0.0060 — $0.0060 450ms 1.00
108
+
109
+ Best overall: gpt-5-mini (effort: low)
110
+ Cheapest at 100%: gpt-5-mini (effort: low)
99
111
  ```
100
112
 
101
113
  - **first-attempt** — cost of the first run alone.
102
114
  - **fallback %** — fraction of cases where the validator rejected and the fallback ran.
103
115
  - **effective cost** — total per successful output including retries.
104
- - **`—`** — candidate equals fallback, no chain to observe.
116
+ - **`—`** — the fallback would send the candidate's own request, so the candidate runs alone and there is no chain to observe.
105
117
 
106
118
  Run this before finalizing: a candidate saving 3× on first-attempt but falling back 60% of the time may save only 1.2× in production.
107
119
 
@@ -135,10 +147,10 @@ Forwarded to `Chat#with_thinking(**)` through the adapter — works provider-agn
135
147
  `reasoning_effort: :default` - the same word as in the DSL - sends no effort for one call, candidate or chain element, and keeps a `thinking budget:`. Use it to run a Step set up for a reasoning model on one that takes no effort, which a provider otherwise rejects (OpenAI: "Unsupported parameter: 'reasoning.effort'"):
136
148
 
137
149
  ```ruby
138
- SummarizeArticle.run(text, context: { model: "gpt-4.1-mini", reasoning_effort: :default })
139
- SummarizeArticle.compare_models("regression", candidates: [{ model: "gpt-4.1-mini", reasoning_effort: :default },
140
- { model: "gpt-6-luna" }])
141
- retry_policy { escalate({ model: "gpt-4.1-mini", reasoning_effort: :default }, { model: "gpt-6-luna" }) }
150
+ SummarizeArticle.run(text, context: { model: "gpt-4.1-mini", reasoning_effort: :default, retry_policy_override: nil })
151
+ SummarizeArticle.compare_models("regression", candidates: [{ model: "gpt-6-luna" },
152
+ { model: "gpt-4.1-mini", reasoning_effort: :default }])
153
+ retry_policy { escalate({ model: "gpt-6-luna" }, { model: "gpt-4.1-mini", reasoning_effort: :default }) }
142
154
  ```
143
155
 
144
156
  `nil` is not `:default`: a nil effort is ignored and the class one is sent. The gem does not drop an effort by itself based on the model. A chain element without an effort runs with the call's (or the class's), and `trace.attempts[n][:config]` shows the effort each attempt was sent.
@@ -29,7 +29,7 @@ The AST is immutable, diffable, and hashable. Useful for snapshot testing and au
29
29
 
30
30
  When input is a Hash, each key becomes a template variable. Concrete scenario: a multi-language newsletter product where the same article has to be summarised in Polish for EU subscribers, English for US, with different audiences per tier (Rails developers vs engineering managers). Hash inputs let one step cover all of these without forking the class.
31
31
 
32
- The `Types::Hash.schema(...)` form below declares a **typed contract on the input** — `run()` raises `TypeError` if a required key is missing or its value violates the declared type. Use it when the input shape is part of the Step's interface (multi-key inputs, multi-tenant variants); plain `input_type Hash` accepts any hash and is fine for prototypes or when the schema is enforced upstream.
32
+ The `Types::Hash.schema(...)` form below declares a **typed contract on the input** — `run()` returns `status: :input_error` (with the reason in `validation_errors`, e.g. `":article is missing in Hash input"`) if a required key is missing or its value violates the declared type, and no model is called. Use it when the input shape is part of the Step's interface (multi-key inputs, multi-tenant variants); plain `input_type Hash` accepts any hash and is fine for prototypes or when the schema is enforced upstream.
33
33
 
34
34
  ```ruby
35
35
  class SummarizeArticle < RubyLLM::Contract::Step::Base
@@ -54,9 +54,9 @@ class SummarizeArticle < RubyLLM::Contract::Step::Base
54
54
  end
55
55
  ```
56
56
 
57
- Every `{key}` in a prompt node is pulled from the input hash at run time. Missing keys raise — making wire-up bugs loud, not silent.
57
+ Every `{key}` in a prompt node is pulled from the input hash at run time. A key the input does not have is left in the prompt as literal `{key}` text, so declare the keys in a `Types::Hash.schema(...)` input type when a missing one should stop the call.
58
58
 
59
- > **Not the same as `RubyLLM::Agent.inputs`.** `Step.input_type` is a *runtime type check* on the positional argument passed to `run(input)` — it raises `TypeError` if the input violates the declared shape. `RubyLLM::Agent.inputs` is a list of *named template locals* injected into ERB instructions. They solve different problems (validation vs templating) and can coexist on the same project.
59
+ > **Not the same as `RubyLLM::Agent.inputs`.** `Step.input_type` is a *runtime type check* on the positional argument passed to `run(input)` — a violating input returns `status: :input_error` instead of calling the model. `RubyLLM::Agent.inputs` is a list of *named template locals* injected into ERB instructions. They solve different problems (validation vs templating) and can coexist on the same project.
60
60
 
61
61
  > **Not the same as `RubyLLM::Agent` ERB templates either.** The `prompt do ... end` DSL above builds a multi-role message list (`system` / `user` / `assistant` / `example` nodes) into a node-AST. `Agent.instructions :name` loads a single-string ERB file from `app/prompts/<agent_path>/<name>.txt.erb` for the system prompt only. Different output shape, different scope. Variable interpolation here uses `{key}` substitution; ERB uses full `<%= ruby %>`.
62
62
 
@@ -141,16 +141,22 @@ Add to `spec/spec_helper.rb` (or `test_helper.rb`):
141
141
  require "ruby_llm/contract/rspec" # or ruby_llm/contract/minitest
142
142
  ```
143
143
 
144
+ With Minitest, also `include RubyLLM::Contract::MinitestHelpers` in `ActiveSupport::TestCase`.
145
+
144
146
  Then in specs:
145
147
 
146
148
  ```ruby
147
149
  RSpec.describe ArticlesController do
150
+ include ActiveJob::TestHelper
151
+
148
152
  it "saves the summary when the step passes" do
149
153
  stub_step(SummarizeArticle, response: {
150
154
  tldr: "...", takeaways: %w[a b c], tone: "analytical"
151
155
  })
152
156
 
153
- post :summarize, params: { id: article.id }
157
+ # `summarize` enqueues SummarizeArticleJob; run it here, on this thread,
158
+ # where the stub applies (stub_step is thread-local).
159
+ perform_enqueued_jobs { post :summarize, params: { id: article.id } }
154
160
 
155
161
  # NOTE: jsonb/json column round-trips as string keys on reload.
156
162
  expect(article.reload.summary["tldr"]).to eq("...")
@@ -8,7 +8,8 @@
8
8
 
9
9
  | What you write | Where it lives |
10
10
  |---|---|
11
- | `model`, `temperature`, `schema`, `instructions`, `tools`, `thinking` | covered by both — same idea, different DSL surface |
11
+ | `model`, `temperature`, `schema`, `instructions`, `thinking` | covered by both — same idea, different DSL surface |
12
+ | `tools` | only in `RubyLLM::Agent` — a Step makes a single call without tools |
12
13
  | `validate :rule do ... end` business invariants on output | only in `ruby_llm-contract` |
13
14
  | `retry_policy escalate(...)` model escalation on validation failure | only here (different from RubyLLM's network-level retry) |
14
15
  | `max_cost` / `max_input` / `max_output` pre-flight refusal | only here |
@@ -13,7 +13,7 @@ Both gems sit on top of `ruby_llm`. The space they cover overlaps in vocabulary
13
13
  | Where the output lives at evaluation time | Already in your variable, returned to caller | Still inside the gem's runner, not yet released |
14
14
  | What "fail" means | Red test in CI | Trigger retry/escalate on a stronger model, or fail closed |
15
15
  | Strongest features | Rich LLM-as-judge (faithful, relevant, hallucination, refusal, bias, toxicity, jailbreak, PII), red-team adversarial prompts, deterministic helpers (`assert_contains`, `assert_levenshtein`, …), RSpec/Minitest matchers, HTML/JUnit/GH reporters | Schema DSL with constraints, `validate` business rules, `retry_policy escalate(...)` model escalation, `max_cost` pre-flight refusal, regression-eval framework (frozen dataset + baseline + min_score gate), pipeline composition |
16
- | What it does NOT cover | No retry, no model escalation, no pre-flight cost cap, no contract layer between LLM and your code | No 10-judge LLM-as-judge catalog, no red-team generation, no rich deterministic assertion library, no test-framework matchers |
16
+ | What it does NOT cover | No retry, no model escalation, no pre-flight cost cap, no contract layer between LLM and your code | No 10-judge LLM-as-judge catalog, no red-team generation, no rich deterministic assertion library; its test-framework matchers (`satisfy_contract`, `pass_eval`) gate contracts and evals, not individual outputs |
17
17
 
18
18
  ## Visual: where each gem sits in your call
19
19
 
@@ -38,10 +38,10 @@ your code ──► Step.run ──► LLM ──► [schema + validate]
38
38
  │
39
39
  all attempts fail ─┘
40
40
  ▼
41
- Result(:validation_error)
41
+ status: :validation_failed
42
42
  ```
43
43
 
44
- The output **never reaches your code** until the contract passes. A failed validation either fixes itself (the retry policy escalates to a stronger model) or fails closed with `Result(:validation_error)` — your call site sees a deterministic failure status, never schema-invalid data.
44
+ The output **never reaches your code** until the contract passes. A failed validation either fixes itself (the retry policy escalates to a stronger model) or fails closed with `status: :validation_failed` — your call site sees a deterministic failure status, never schema-invalid data.
45
45
 
46
46
  ### Both together (Tribunal in CI → then Contract in prod)
47
47
 
@@ -85,7 +85,7 @@ If you specifically need an **LLM-as-judge** (a second LLM grading the first one
85
85
  - **You need to iterate the judge prompt** because it over-flags on your data — Tribunal's prompts are fixed per assertion.
86
86
  - **Use both** for the same project even when your check is in Tribunal's catalog: Tribunal's `assert_faithful` for spec-time grade on individual responses, plus a calibrated custom judge wired as `evaluator:` in a regression `define_eval` over a frozen dataset for CI merge-gating. They cover different lifecycle stages.
87
87
 
88
- Either way, the **methodology** in [`llm_judge.md`](llm_judge.md) — calibrate the judge against human-labeled production samples before trusting any score, watch for the six anti-patterns, refine the prompt when it over-flags — applies equally to Tribunal's built-ins, Tribunal's custom registered judges, and Contract `Step::Base` judges. Tribunal's `default_threshold = 0.8` is a starting point, not a calibrated bar for your data.
88
+ Either way, the **methodology** in [`llm_judge.md`](llm_judge.md) — calibrate the judge against human-labeled production samples before trusting any score, watch for the five anti-patterns, refine the prompt when it over-flags — applies equally to Tribunal's built-ins, Tribunal's custom registered judges, and Contract `Step::Base` judges. Tribunal's `default_threshold = 0.8` is a starting point, not a calibrated bar for your data.
89
89
 
90
90
  ## What Tribunal documents — and what it doesn't
91
91
 
@@ -111,7 +111,7 @@ These work today without any code changes in either gem — both use plain Ruby
111
111
  class ChatReply < RubyLLM::Contract::Step::Base
112
112
  prompt "Answer this question grounded in the docs:\n{input}"
113
113
 
114
- validate("no PII in answer") do |output, _ctx|
114
+ validate("no PII in answer") do |output, _input|
115
115
  test_case = RubyLLM::Tribunal::TestCase.new(actual_output: output[:answer])
116
116
  RubyLLM::Tribunal::Assertions.evaluate(:pii, test_case, {}).first == :pass
117
117
  end
@@ -33,7 +33,7 @@ result = SummarizeArticle.run("article text", context: { adapter: adapter })
33
33
  result.ok? # => true
34
34
  ```
35
35
 
36
- Multi-step pipeline testing with per-step named responses (using `ArticleCardPipeline` from [Pipeline](pipeline.md)). The keys in `responses:` must match the step identifiers declared with `add_step :name, ...` in your pipeline definition — `:summarize`, `:tag`, `:card` here map to the three steps in that order.
36
+ Multi-step pipeline testing with per-step named responses (using `ArticleCardPipeline` from [Pipeline](pipeline.md)). The keys in `responses:` must match the step identifiers declared with `step Klass, as: :name` in your pipeline definition — `:summarize`, `:tag`, `:card` here map to the three steps in that order.
37
37
 
38
38
  ```ruby
39
39
  result = ArticleCardPipeline.test("article text",
@@ -56,7 +56,7 @@ result.parsed_output[:tldr] # => "..." ✓
56
56
  result.parsed_output["tldr"] # => nil ✗
57
57
  ```
58
58
 
59
- The gem warns if a `validate` block (Step-level invariant check) or a `verify` block (eval-case evaluator declared via `verify(name) { |output| ... }` inside `define_eval`) returns `nil` — usually a sign of string-key access on symbol-keyed data.
59
+ The gem warns if a `validate` block (Step-level invariant check) or a `verify` proc (eval-case evaluator declared via `verify "name", ->(output) { ... }` inside `define_eval`) returns `nil` — usually a sign of string-key access on symbol-keyed data.
60
60
 
61
61
  ## RSpec setup
62
62
 
@@ -128,7 +128,7 @@ In RSpec, non-block stubs auto-clean after each example. In Minitest, `teardown`
128
128
 
129
129
  ## Minitest
130
130
 
131
- Require `ruby_llm/contract/minitest` in your `test_helper.rb`. You get the same `satisfy_contract` / `pass_eval` assertions and `stub_step` helper, adapted for Minitest syntax.
131
+ Require `ruby_llm/contract/minitest` in your `test_helper.rb` and `include RubyLLM::Contract::MinitestHelpers` in your test class (or `ActiveSupport::TestCase`). You get `assert_satisfies_contract` / `refute_satisfies_contract` and `assert_eval_passes` (the Minitest counterparts of `satisfy_contract` and `pass_eval`) and the same `stub_step` helper.
132
132
 
133
133
  ## RSpec matchers
134
134
 
@@ -158,7 +158,7 @@ end
158
158
 
159
159
  `pass_eval` supports a matcher chain — full reference lives in [Getting Started](getting_started.md) under Evals and CI gates. Quick summary:
160
160
 
161
- - `.with_context(model: "gpt-4.1-mini")` — pick model / pass adapter
161
+ - `.with_context(model: "gpt-4.1-mini")` — pick model / pass adapter (on a step with a `retry_policy` chain, also pass `retry_policy_override: nil`, or the chain picks the model)
162
162
  - `.with_minimum_score(0.8)` — gate on average score
163
163
  - `.with_maximum_cost(0.01)` — gate on total cost; fails when a case's model has no pricing data unless you add `.on_unknown_pricing(:warn)`
164
164
  - `.without_regressions` — block any previously-passing case that now fails (reads the baseline)
@@ -168,18 +168,19 @@ end
168
168
 
169
169
  Evals run in one of two modes depending on how they're defined and what context is passed:
170
170
 
171
- | Has `sample_response`? | Context has adapter/model? | Mode | API calls |
171
+ | Has `sample_response`? | `adapter:` in context? | Mode | API calls |
172
172
  |---|---|---|---|
173
- | Yes | No | **Offline** — uses `sample_response` as canned answer | Zero |
173
+ | Yes | No | **Offline** — uses `sample_response` as canned answer (a `model:` alone does not change that) | Zero |
174
174
  | Yes | Yes | **Online** — ignores `sample_response`, calls real LLM | Real |
175
175
  | No | Yes | **Online** — calls real LLM | Real |
176
- | No | No | **Skipped** — returns `:skipped`, excluded from score | Zero |
176
+ | No | No | **Online** with the default adapter, which `RubyLLM::Contract.configure` sets up; **Skipped** (`:skipped`, excluded from score) only when no adapter is configured | Real / Zero |
177
177
 
178
- Default is offline. To force online, pass adapter or model in context:
178
+ Default is offline. To force online, pass an adapter in context:
179
179
 
180
180
  ```ruby
181
181
  # Online — real LLM call
182
- report = SummarizeArticle.run_eval("regression", context: { model: "gpt-4.1-nano" })
182
+ report = SummarizeArticle.run_eval("regression",
183
+ context: { adapter: RubyLLM::Contract::Adapters::RubyLLM.new, model: "gpt-4.1-nano" })
183
184
 
184
185
  # Offline — uses sample_response
185
186
  report = SummarizeArticle.run_eval("smoke")
@@ -232,7 +233,7 @@ result.ok? # => true (observe never fails the contract)
232
233
  result.observations # => [{ description: "scores should differ", passed: false }]
233
234
  ```
234
235
 
235
- `observe` runs only after validation passes. Failed observations are logged via `RubyLLM::Contract.logger` — useful for "I want to know this happened without blocking the response".
236
+ `observe` runs only after validation passes. Failed observations are logged via the logger set in `RubyLLM::Contract.configure` (`c.logger`) — useful for "I want to know this happened without blocking the response".
236
237
 
237
238
  > **Not the same as `Chat#on_end_message` / `on_tool_call`.** RubyLLM exposes anonymous, global callbacks attached to a chat instance — they receive the raw `Message` and have no inherent pass/fail concept. `Step.observe` is per-step, named with a description, runs against `parsed_output` (already JSON-parsed and schema-validated), and the pass/fail outcome is recorded in `result.observations`. The two can coexist: use Chat callbacks for transport/tool tracing, use `observe` for domain assertions you want captured in the step trace.
238
239
 
@@ -269,8 +270,8 @@ Baselines are JSON files in `.eval_baselines/` — commit them to git:
269
270
  ```
270
271
  .eval_baselines/
271
272
  SummarizeArticle/
272
- regression_gpt-4_1-nano.json
273
- regression_gpt-4_1-mini.json
273
+ regression.json # report.save_baseline!, the rake task, without_regressions
274
+ regression_gpt-4_1-mini.json # report.save_baseline!(model: "gpt-4.1-mini"), one per model
274
275
  ```
275
276
 
276
277
  Each file contains dataset name, step name, score, and per-case results. No timestamps — re-saving an identical baseline produces no git diff. Baseline semantics (what counts as a regression, how `compare_with_baseline` works) are covered in [Getting Started](getting_started.md#evals-and-ci-gates) and [Eval-First](eval_first.md).
data/docs/guide/why.md CHANGED
@@ -62,18 +62,18 @@ Nano misclassifies the tone on the first attempt → contract rejects → mini g
62
62
 
63
63
  **See it in 30 seconds:** `ruby examples/01_fallback_showcase.rb` — zero API keys required. The Test adapter simulates a tone/takeaways mismatch on the first attempt and a consistent sample on the retry, then prints the per-attempt trace.
64
64
 
65
- `retry_policy` has three other shapes beyond cross-model escalation — same-model `attempts: 3` (absorbs sampling variance without paying for a stronger tier), `reasoning_effort` escalation (low → medium → high on one model), and cross-provider fallback (Ollama → Anthropic → OpenAI — local first because it costs nothing, hosted last because it is the most accurate). `examples/06_retry_variants.rb` runs all three through the Test adapter with the trace printed.
65
+ `retry_policy` has three other shapes beyond cross-model escalation — same-model `attempts: 3` (absorbs sampling variance without paying for a stronger tier), `reasoning_effort` escalation (low → medium → high on one model), and cross-provider fallback (OpenAI → Anthropic — ruby_llm picks the provider from the model name; a local Ollama model needs `provider: :ollama` for the whole call, so it cannot be one tier of such a chain). `examples/06_retry_variants.rb` runs all three through the Test adapter with the trace printed.
66
66
 
67
67
  ## Failure 4 — Runaway cost and no fallback policy
68
68
 
69
- Someone pastes a 40-page PDF into the endpoint that calls `SummarizeArticle`. The prompt expands to 80k tokens. Your provider bill jumps. Meanwhile, a separate team uses `gpt-4.1` for every single call because "quality matters" — even though 80% of their traffic is trivially handled by `gpt-4.1-nano` at 1/30th the cost. Neither situation is visible until the invoice arrives.
69
+ Someone pastes a 40-page PDF into the endpoint that calls `SummarizeArticle`. The prompt expands to 80k tokens. Your provider bill jumps. Meanwhile, a separate team uses `gpt-4.1` for every single call because "quality matters" — even though 80% of their traffic is trivially handled by `gpt-4.1-nano` at 1/20th the cost. Neither situation is visible until the invoice arrives.
70
70
 
71
71
  Contracts make cost a first-class concern:
72
72
 
73
73
  ```ruby
74
74
  max_input 2_000 # refuses before calling the API if tokens exceed budget
75
75
  max_output 4_000
76
- max_cost 0.01 # refuses if estimated cost exceeds cap
76
+ max_cost 0.05 # refuses if estimated cost exceeds cap (checked per attempt)
77
77
  retry_policy models: %w[gpt-4.1-nano gpt-4.1-mini gpt-4.1] # cheap first, escalate only on failure
78
78
  ```
79
79
 
@@ -166,9 +166,15 @@ r.trace[:cost] # => sum of per-attempt costs
166
166
  # adapter = RubyLLM::Contract::Adapters::RubyLLM.new
167
167
  # result = SummarizeArticleWithRetry.run(article_text, context: { adapter: adapter })
168
168
  #
169
- # Switch provider per call — ruby_llm resolves the provider from the model name:
170
- # SummarizeArticleWithRetry.run(article_text, context: { adapter: adapter, model: "claude-sonnet-4-6" })
171
- # SummarizeArticleWithRetry.run(article_text, context: { adapter: adapter, model: "gemma3:4b" }) # local Ollama
169
+ # Switch provider per call — ruby_llm resolves the provider from the model name.
170
+ # The retry_policy chain picks the model of every attempt, so turn it off for
171
+ # the call (retry_policy_override: nil) to run a model of your choice:
172
+ # (configure c.anthropic_api_key, or c.ollama_api_base for Ollama, first)
173
+ # SummarizeArticleWithRetry.run(article_text, context: { adapter: adapter, model: "claude-sonnet-4-6",
174
+ # retry_policy_override: nil })
175
+ # SummarizeArticleWithRetry.run(article_text, context: { adapter: adapter, model: "gemma3:4b",
176
+ # retry_policy_override: nil,
177
+ # provider: :ollama, assume_model_exists: true })
172
178
 
173
179
  # =============================================================================
174
180
  # Where to go next
@@ -50,7 +50,7 @@
50
50
  # tone: "negative"
51
51
  #
52
52
  # See also: examples/06_retry_variants.rb — same-model retry, reasoning_effort
53
- # escalation, and cross-provider fallback (Ollama → Anthropic → OpenAI).
53
+ # escalation, and cross-provider fallback (OpenAI → Anthropic).
54
54
  # =============================================================================
55
55
 
56
56
  require_relative "../lib/ruby_llm/contract"
@@ -8,7 +8,8 @@
8
8
  # not change — same prompt, schema, validates, retry_policy.
9
9
  #
10
10
  # Requires: gem install ruby_llm; export OPENAI_API_KEY=sk-...
11
- # (Or an Anthropic / Gemini / Mistral key, or a local Ollama server.)
11
+ # Another provider needs its own key in RubyLLM.configure below (e.g.
12
+ # c.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]) and its models in the step.
12
13
  #
13
14
  # Run: OPENAI_API_KEY=sk-... ruby examples/02_real_llm_minimal.rb
14
15
  # =============================================================================
@@ -40,6 +41,11 @@ puts "Tokens: #{result.trace[:usage]}" # real usage
40
41
  puts "Cost: $#{result.trace[:cost]}" # sum across retries
41
42
  puts "TL;DR: #{result.parsed_output[:tldr]}"
42
43
 
43
- # Switch provider per call — ruby_llm resolves the provider from the model name:
44
- # SummarizeArticle.run(article, context: { adapter: adapter, model: "claude-sonnet-4-6" })
45
- # SummarizeArticle.run(article, context: { adapter: adapter, model: "gemma3:4b" }) # local Ollama
44
+ # Switch provider per call — ruby_llm resolves the provider from the model name.
45
+ # The step's retry_policy chain picks the model of every attempt, so turn it off
46
+ # for the call (retry_policy_override: nil) to run a model of your choice:
47
+ # (configure c.anthropic_api_key, or c.ollama_api_base for Ollama, first)
48
+ # SummarizeArticle.run(article, context: { adapter: adapter, model: "claude-sonnet-4-6",
49
+ # retry_policy_override: nil })
50
+ # SummarizeArticle.run(article, context: { adapter: adapter, model: "gemma3:4b", retry_policy_override: nil,
51
+ # provider: :ollama, assume_model_exists: true }) # local Ollama
@@ -20,9 +20,9 @@
20
20
  # attempt 1 effort=low status=validation_failed
21
21
  # attempt 3 effort=high status=ok
22
22
  #
23
- # C — cross-provider Ollama → Anthropic → OpenAI
24
- # attempt 1 model=gemma3:4b status=validation_failed
25
- # attempt 3 model=gpt-5-nano status=ok
23
+ # C — cross-provider OpenAI → Anthropic
24
+ # attempt 1 model=gpt-5-nano status=validation_failed
25
+ # attempt 3 model=claude-haiku-4-5 status=ok
26
26
  # =============================================================================
27
27
 
28
28
  require_relative "../lib/ruby_llm/contract"
@@ -108,26 +108,30 @@ adapter = RubyLLM::Contract::Adapters::Test.new(responses: RESPONSES)
108
108
  print_trace("reasoning escalation", SummarizeArticleReasoningEscalation.run("article", context: { adapter: adapter }))
109
109
 
110
110
  # =============================================================================
111
- # VARIANT C — cross-provider fallback (Ollama → Anthropic → OpenAI)
111
+ # VARIANT C — cross-provider fallback (OpenAI → Anthropic)
112
112
  #
113
- # When to use: you want to start on a local model (cheap, private, no quota)
114
- # and fall back to hosted providers only when the local one cannot satisfy
115
- # the contract. Each tier is a different provider — ruby_llm detects the
116
- # provider from the model name.
113
+ # When to use: the cheap tiers live on one provider and the last resort on
114
+ # another. ruby_llm detects the provider from the model name, so a chain of
115
+ # models from its registry can cross providers.
117
116
  #
118
- # To run against real backends: configure ruby_llm for all three providers
119
- # (ollama_api_base + anthropic_api_key + openai_api_key) and swap the Test
120
- # adapter for Adapters::RubyLLM. The retry_policy itself is unchanged.
117
+ # To run against real backends: configure ruby_llm for both providers
118
+ # (openai_api_key + anthropic_api_key) and swap the Test adapter for
119
+ # Adapters::RubyLLM. The retry_policy itself is unchanged.
121
120
  #
122
- # Order matters: local first (costs nothing); hosted last (most accurate).
121
+ # A chain element carries only `model` and `reasoning_effort`. A local
122
+ # Ollama model needs `provider: :ollama` (and `assume_model_exists: true`)
123
+ # in the context, which applies to every attempt, so it cannot be one tier
124
+ # of a chain that also reaches hosted providers.
125
+ #
126
+ # Order matters: cheapest first; the last tier is the safety net.
123
127
  # =============================================================================
124
128
 
125
129
  class SummarizeArticleCrossProvider < SummarizeArticle
126
- retry_policy models: %w[gemma3:4b claude-haiku-4-5 gpt-5-nano]
130
+ retry_policy models: %w[gpt-5-nano gpt-5-mini claude-haiku-4-5]
127
131
  end
128
132
 
129
133
  puts "=" * 70
130
- puts "C — cross-provider fallback (Ollama → Anthropic → OpenAI)"
134
+ puts "C — cross-provider fallback (OpenAI → Anthropic)"
131
135
  puts "=" * 70
132
136
  adapter = RubyLLM::Contract::Adapters::Test.new(responses: RESPONSES)
133
137
  print_trace("cross-provider", SummarizeArticleCrossProvider.run("article", context: { adapter: adapter }))
@@ -140,8 +144,8 @@ print_trace("cross-provider", SummarizeArticleCrossProvider.run("article", conte
140
144
  # 2. `reasoning_effort` escalation is cheaper than model escalation when the
141
145
  # model is right but needs more thinking, not a stronger backbone.
142
146
  # 3. Cross-provider retry uses the same DSL — ruby_llm resolves the provider
143
- # from the model name. Start cheapest (often a local Ollama model), end
144
- # on the most accurate hosted provider.
147
+ # from the model name. Start cheapest, end on the most accurate model,
148
+ # whichever provider hosts it.
145
149
  # 4. The per-attempt trace (model, config, status, cost) is identical across
146
150
  # variants — your logging does not care which retry shape you picked.
147
151
  # =============================================================================
data/examples/README.md CHANGED
@@ -8,11 +8,11 @@ Pedagogical order: hook → activation → evolution → composition → quality
8
8
  |---|------|---------|
9
9
  | 00 | `00_basics.rb` | **"How do I start?"** — seven incremental layers: plain prompt → output_schema → validate → structured prompt → Hash input → cross-input validate → retry_policy → trace inspection, plus real-LLM swap pointer. |
10
10
  | 01 | `01_fallback_showcase.rb` | **"Show me the gem in 30 seconds."** — Part A: schema-only ships a flaky sample. Part B: full contract rejects it and retry_policy escalates to the next model. Per-attempt trace printed inline. |
11
- | 02 | `02_real_llm_minimal.rb` | **"How do I plug in a real LLM?"** — ~30 lines. `Adapters::RubyLLM.new` in context, same step. Also shows per-call provider switch (OpenAI → Anthropic → Ollama). |
11
+ | 02 | `02_real_llm_minimal.rb` | **"How do I plug in a real LLM?"** — ~30 lines. `Adapters::RubyLLM.new` in context, same step. Also shows a per-call model switch (Anthropic, or a local Ollama model) with the step's retry chain turned off for that call. |
12
12
  | 03 | `03_summarize_with_keywords.rb` | **"How does the contract evolve when the product grows?"** — marketing wants a "topic pills" row, so `SummarizeArticle` gains a keywords field with probability and cross-validation. Prompt, schema, and validates stay in lockstep. |
13
13
  | 04 | `04_summarize_and_translate.rb` | **"How do steps compose into a pipeline?"** — 3 steps threaded by `Pipeline::Base`: English summary → translate to French → quality review. Fail-fast: a rejected summary means translate and review never run. |
14
14
  | 05 | `05_eval_dataset.rb` | **"How do I stop silent prompt regressions?"** — define_eval with real cases, baseline vs regressed adapter, regression detection signal, inline eval_case. |
15
- | 06 | `06_retry_variants.rb` | **"What retry shapes exist beyond cross-model?"** — `attempts: 3` (variance absorption), `reasoning_effort` escalation (low→medium→high), cross-provider fallback (Ollama → Anthropic → OpenAI). |
15
+ | 06 | `06_retry_variants.rb` | **"What retry shapes exist beyond cross-model?"** — `attempts: 3` (variance absorption), `reasoning_effort` escalation (low→medium→high), cross-provider fallback (OpenAI → Anthropic). |
16
16
 
17
17
  Four of the seven carry an "Expected output" section in the file header (01, 03, 05, 06), so you can read what they print without running them. The other three differ: `00_basics.rb` prints nothing and annotates each result inline with `# =>`; `02_real_llm_minimal.rb` needs a live provider key, so its output is not deterministic; `04_summarize_and_translate.rb` prints but has no header block.
18
18
 
@@ -114,8 +114,12 @@ module RubyLLM
114
114
  "call compare_models with production_mode: on individual Step classes instead."
115
115
  end
116
116
 
117
+ # A Step's retry chain would pick every attempt's model, so a candidate
118
+ # runs without it (same-model retries stay), unless production_mode or
119
+ # the caller passes a retry policy for the comparison.
117
120
  def build_candidate_context(context, config, fallback_config)
118
- model_context = isolate_context(context).merge(model: config[:model])
121
+ model_context = { retry_policy_override: Step::RetryPolicy::WITHOUT_MODEL_CHAIN }
122
+ .merge(isolate_context(context), model: config[:model])
119
123
  model_context[:reasoning_effort] = config[:reasoning_effort] if config[:reasoning_effort]
120
124
  return model_context unless fallback_config
121
125
 
@@ -39,6 +39,8 @@ module RubyLLM
39
39
 
40
40
  def build_evaluation_result(result)
41
41
  case result
42
+ when EvaluationResult
43
+ result
42
44
  when true
43
45
  EvaluationResult.new(score: 1.0, passed: true, details: PASSED_DETAILS)
44
46
  when false
@@ -74,8 +74,8 @@ module RubyLLM
74
74
  def production_mode_table
75
75
  fallback_label = self.class.candidate_label(@fallback)
76
76
  rows = @reports.map do |label, report|
77
- chain = chain_label(label, fallback_label)
78
- { chain: chain, report: report, same: chain_same_as_fallback?(label, fallback_label) }
77
+ same = !report.production_mode?
78
+ { chain: same ? label : "#{label} → #{fallback_label}", report: report, same: same }
79
79
  end
80
80
 
81
81
  chain_width = [rows.map { |r| r[:chain].length }.max || 0, 20].max
@@ -92,14 +92,6 @@ module RubyLLM
92
92
 
93
93
  private
94
94
 
95
- def chain_label(label, fallback_label)
96
- label == fallback_label ? label : "#{label} → #{fallback_label}"
97
- end
98
-
99
- def chain_same_as_fallback?(label, fallback_label)
100
- label == fallback_label
101
- end
102
-
103
95
  def format_production_row(row, chain_width)
104
96
  report = row[:report]
105
97
  format(" %-#{chain_width}s %-13s %-10s %-14s %-9s %6.2f",
@@ -195,8 +195,17 @@ module RubyLLM
195
195
  end
196
196
  end
197
197
 
198
+ def runtime_policy(context)
199
+ return retry_policy unless context.key?(:retry_policy_override)
200
+
201
+ override = context[:retry_policy_override]
202
+ return override unless override == RetryPolicy::WITHOUT_MODEL_CHAIN
203
+
204
+ retry_policy if retry_policy&.model_list&.empty?
205
+ end
206
+
198
207
  def runtime_settings(context)
199
- policy = context.key?(:retry_policy_override) ? context[:retry_policy_override] : retry_policy
208
+ policy = runtime_policy(context)
200
209
  extra = context.slice(*ADAPTER_CONTEXT_KEYS).merge(merged_provider_options(context))
201
210
 
202
211
  # Always pass the class-level `thinking` config to the adapter when
@@ -15,6 +15,11 @@ module RubyLLM
15
15
  # transport retry could not).
16
16
  DEFAULT_RETRY_ON = %i[validation_failed parse_error].freeze
17
17
 
18
+ # Internal, not a documented override value: keeps a Step's own policy
19
+ # unless it picks models, so compare_models runs a candidate on its
20
+ # own model with the same-model retries the Step would give it.
21
+ WITHOUT_MODEL_CHAIN = :without_model_chain
22
+
18
23
  def initialize(models: nil, attempts: nil, retry_on: nil, &block)
19
24
  @configs = []
20
25
  @retryable_statuses = DEFAULT_RETRY_ON.dup
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "1.4.0"
5
+ VERSION = "1.4.1"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-contract
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.0
4
+ version: 1.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna