ruby_llm-contract 1.0.0 → 1.1.0

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.
data/README.md CHANGED
@@ -168,7 +168,7 @@ Different layers, complementary. [`ruby_llm-tribunal`](https://github.com/Alqemi
168
168
 
169
169
  ## Status & versioning
170
170
 
171
- Stable at **1.0.0**. Semver tracked; breaking changes flagged in [CHANGELOG](CHANGELOG.md). Pin `~> 1.0`.
171
+ Stable since **1.0.0**. Semver tracked; breaking changes flagged in [CHANGELOG](CHANGELOG.md). Pin `~> 1.0`.
172
172
 
173
173
  What 1.0 promises not to break inside the 1.x line: the documented DSL
174
174
  (`prompt`, `output_schema`, `validate`, `retry_policy`, `max_cost`, `define_eval`,
@@ -145,7 +145,7 @@ report.save_baseline!
145
145
  expect(SummarizeArticle).to pass_eval("regression").without_regressions
146
146
  ```
147
147
 
148
- `without_regressions` fails the build only if a previously-passing case now fails — a new model version, a prompt tweak, or an upstream change that silently lowered quality.
148
+ `without_regressions` fails the build only if a previously-passing case now fails — a new model version, a prompt tweak, or an upstream change that silently lowered quality. A previously-passing case that was skipped this run (no adapter configured) also fails the gate, listed as `SKIPPED` rather than as a regression.
149
149
 
150
150
  ## Budget caps
151
151
 
@@ -173,6 +173,8 @@ max_cost 0.01, on_unknown_pricing: :warn
173
173
 
174
174
  Default is `:refuse`. Use `:warn` only when you accept running without a cost ceiling (fine-tuned models you trust, private endpoints).
175
175
 
176
+ The eval-level gates - `with_maximum_cost` on `pass_eval`, `maximum_cost` on the rake task, and `maximum_cost:` on `assert_eval_passes` - follow the same rule since 1.1.0. A report's total counts a case it cannot price as $0, so when any case ran on a model without pricing data the gate fails and names the cases instead of comparing an undercounted total. Opt out with `.on_unknown_pricing(:warn)`, `t.on_unknown_pricing = :warn` or `on_unknown_pricing: :warn`, which checks the budget against the priced cases and prints a warning. Offline runs (`sample_response`, a `Test` adapter without `usage:`) report zero tokens, cost $0 with or without pricing, and are never flagged. The check sees only reported tokens: an adapter that returns no token counts looks like a zero-token run.
177
+
176
178
  ### Preflight cost estimates
177
179
 
178
180
  Check what a call is likely to cost before invoking it:
@@ -160,7 +160,7 @@ end
160
160
 
161
161
  - `.with_context(model: "gpt-4.1-mini")` — pick model / pass adapter
162
162
  - `.with_minimum_score(0.8)` — gate on average score
163
- - `.with_maximum_cost(0.01)` — gate on total cost
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)
165
165
  - `.compared_with(SummarizeArticleV1)` — A/B against another step; implies regression check
166
166
 
@@ -97,10 +97,11 @@ module RubyLLM
97
97
  # moved provider pricing into a nested value object and dropped those flat
98
98
  # readers, so it has to be walked: pricing -> text_tokens -> standard.
99
99
  #
100
- # nil means "unknown pricing", which `max_cost` fails closed on. The guards
101
- # keep the two known shapes explicit, but `calculate` rescues StandardError,
102
- # so a future shape this walk cannot read still degrades to nil rather than
103
- # surfacing - every budgeted call would be refused with no error.
100
+ # nil means "unknown pricing". The guards keep the two known shapes
101
+ # explicit, but `calculate` rescues StandardError, so a future shape this
102
+ # walk cannot read also degrades to nil: `max_cost` then refuses the call
103
+ # citing "no pricing data" rather than the real cause, and paths that floor
104
+ # unknown cost to 0.0 (eval cost estimates, report totals) undercount.
104
105
  def self.prices_for(model_info)
105
106
  if model_info.respond_to?(:input_price_per_million)
106
107
  return { input: model_info.input_price_per_million,
@@ -44,6 +44,10 @@ module RubyLLM
44
44
  @runs.sum(&:total_cost) / @runs.length.to_f
45
45
  end
46
46
 
47
+ def unknown_cost_results
48
+ @runs.flat_map(&:unknown_cost_results)
49
+ end
50
+
47
51
  def avg_latency_ms
48
52
  latencies = @runs.filter_map(&:avg_latency_ms)
49
53
  return nil if latencies.empty?
@@ -19,6 +19,7 @@ module RubyLLM
19
19
  current = @current[name]
20
20
  next unless current
21
21
  next unless baseline[:passed] && !current[:passed]
22
+ next if skipped?(current)
22
23
 
23
24
  {
24
25
  case: name,
@@ -47,8 +48,19 @@ module RubyLLM
47
48
  (current_score - baseline_score).round(4)
48
49
  end
49
50
 
51
+ # Passed in the baseline, not run now (no adapter): not evidence of a
52
+ # regression, but not evidence it still passes either, so the gate fails.
53
+ def skipped_passing_cases
54
+ @baseline.filter_map do |name, baseline|
55
+ current = @current[name]
56
+ next unless current && baseline[:passed] && skipped?(current)
57
+
58
+ { case: name, detail: current[:details] }
59
+ end
60
+ end
61
+
50
62
  def regressed?
51
- regressions.any? || removed_passing_cases.any?
63
+ regressions.any? || removed_passing_cases.any? || skipped_passing_cases.any?
52
64
  end
53
65
 
54
66
  def removed_passing_cases
@@ -70,6 +82,7 @@ module RubyLLM
70
82
  def to_s
71
83
  lines = ["Score: #{baseline_score.round(2)} → #{current_score.round(2)} (#{format_delta})"]
72
84
  regressions.each { |r| lines << " REGRESSED #{r[:case]}: #{r[:detail]}" }
85
+ skipped_passing_cases.each { |s| lines << " SKIPPED #{s[:case]}: #{s[:detail]}" }
73
86
  improvements.each { |r| lines << " IMPROVED #{r[:case]}" }
74
87
  new_cases.each { |c| lines << " NEW #{c}" }
75
88
  removed_cases.each { |c| lines << " REMOVED #{c}" }
@@ -82,12 +95,16 @@ module RubyLLM
82
95
  # Asymmetric on purpose: the baseline side arrives already filtered by
83
96
  # ReportStats#evaluated_results, the current side does not, so this
84
97
  # string check is the only skip filter on one of the two sides.
85
- evaluated = cases.reject { |c| c[:details]&.start_with?(CaseResult::SKIPPED_DETAILS_PREFIX) }
98
+ evaluated = cases.reject { |c| skipped?(c) }
86
99
  return 0.0 if evaluated.empty?
87
100
 
88
101
  evaluated.sum { |c| c[:score] } / evaluated.length
89
102
  end
90
103
 
104
+ def skipped?(serialized_case)
105
+ serialized_case[:details]&.start_with?(CaseResult::SKIPPED_DETAILS_PREFIX)
106
+ end
107
+
91
108
  def index_by_name(cases)
92
109
  cases.each_with_object({}) { |c, h| h[c[:name]] = c }
93
110
  end
@@ -15,8 +15,9 @@ module RubyLLM
15
15
  attr_reader :name, :input, :output, :expected, :step_status,
16
16
  :score, :details, :duration_ms, :cost, :attempts
17
17
 
18
- def initialize(name:, input:, output:, expected:, step_status:,
19
- score:, passed:, label: nil, details: nil, duration_ms: nil, cost: nil, attempts: nil)
18
+ def initialize(name:, input:, output:, expected:, step_status:, # rubocop:disable Metrics/ParameterLists
19
+ score:, passed:, label: nil, details: nil, duration_ms: nil, cost: nil, attempts: nil,
20
+ cost_unknown: false)
20
21
  @name = name
21
22
  @input = input
22
23
  @output = output
@@ -29,6 +30,7 @@ module RubyLLM
29
30
  @duration_ms = duration_ms
30
31
  @cost = cost
31
32
  @attempts = attempts
33
+ @cost_unknown = cost_unknown
32
34
  freeze
33
35
  end
34
36
 
@@ -40,6 +42,10 @@ module RubyLLM
40
42
  !@passed
41
43
  end
42
44
 
45
+ def cost_unknown?
46
+ @cost_unknown
47
+ end
48
+
43
49
  def label
44
50
  @label || (@passed ? "PASS" : "FAIL")
45
51
  end
@@ -69,7 +75,7 @@ module RubyLLM
69
75
  duration_ms: @duration_ms,
70
76
  cost: @cost,
71
77
  attempts: @attempts
72
- }
78
+ }.tap { |hash| hash[:cost_unknown] = true if @cost_unknown }
73
79
  end
74
80
 
75
81
  private
@@ -19,7 +19,8 @@ module RubyLLM
19
19
  details: evaluation.details,
20
20
  duration_ms: trace_metric(trace, :total_latency_ms, :latency_ms),
21
21
  cost: trace_metric(trace, :total_cost, :cost),
22
- attempts: trace_attempts(trace)
22
+ attempts: trace_attempts(trace),
23
+ cost_unknown: trace.respond_to?(:cost_unknown?) && trace.cost_unknown?
23
24
  )
24
25
  end
25
26
 
@@ -15,7 +15,7 @@ module RubyLLM
15
15
  BASELINE_DIR = ".eval_baselines"
16
16
 
17
17
  def_delegators :@stats, :score, :passed, :failed, :skipped, :failures, :pass_rate, :pass_rate_ratio,
18
- :total_cost, :avg_latency_ms, :passed?,
18
+ :total_cost, :unknown_cost_results, :avg_latency_ms, :passed?,
19
19
  :production_mode?, :escalation_rate, :single_shot_cost, :single_shot_latency_ms,
20
20
  :effective_cost, :effective_latency_ms, :latency_percentiles
21
21
  def_delegators :@presenter, :summary, :to_s, :print_summary
@@ -44,6 +44,11 @@ module RubyLLM
44
44
  @results.sum { |result| result.cost || 0.0 }
45
45
  end
46
46
 
47
+ # Cases total_cost could not price; it counts them as 0.0.
48
+ def unknown_cost_results
49
+ evaluated_results.select(&:cost_unknown?)
50
+ end
51
+
47
52
  def avg_latency_ms
48
53
  latencies = @results.filter_map(&:duration_ms)
49
54
  return nil if latencies.empty?
@@ -58,7 +58,8 @@ module RubyLLM
58
58
  label: "FAIL",
59
59
  details: "step expectations failed: #{failure_details}",
60
60
  duration_ms: result.duration_ms,
61
- cost: result.cost
61
+ cost: result.cost,
62
+ cost_unknown: result.cost_unknown?
62
63
  )
63
64
  end
64
65
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Contract
5
+ module Eval
6
+ # Suite-level counterpart of the step-level `max_cost ... on_unknown_pricing:`.
7
+ # A report's total_cost counts an unpriced case as 0.0, so a maximum_cost
8
+ # gate that only compares totals passes whatever those cases really cost.
9
+ # Shared by the rake task, the pass_eval matcher and assert_eval_passes.
10
+ module UnknownCostGate
11
+ MODES = %i[refuse warn].freeze
12
+
13
+ def self.validate!(mode)
14
+ return mode if MODES.include?(mode)
15
+
16
+ raise ArgumentError, "on_unknown_pricing must be :refuse or :warn, got #{mode.inspect}"
17
+ end
18
+
19
+ # Returns the failure message under :refuse, nil when the gate holds.
20
+ def self.check(reports, mode:)
21
+ names = reports.flat_map(&:unknown_cost_results).map(&:name).uniq
22
+ return nil if names.empty?
23
+
24
+ if mode == :warn
25
+ warn "[ruby_llm-contract] #{describe(names)} - maximum_cost checked against priced cases only"
26
+ return nil
27
+ end
28
+
29
+ "#{describe(names)}. Register pricing via CostCalculator.register_model " \
30
+ "or set on_unknown_pricing to :warn to check maximum_cost against priced cases only."
31
+ end
32
+
33
+ def self.describe(names)
34
+ "#{names.length} case(s) have unknown cost (model has no pricing data): #{names.join(", ")}"
35
+ end
36
+ private_class_method :describe
37
+ end
38
+ end
39
+ end
40
+ end
@@ -22,6 +22,7 @@ require_relative "eval/report_presenter"
22
22
  require_relative "eval/report_storage"
23
23
  require_relative "eval/report"
24
24
  require_relative "eval/aggregated_report"
25
+ require_relative "eval/unknown_cost_gate"
25
26
  require_relative "eval/eval_definition"
26
27
  require_relative "eval/candidate_label"
27
28
  require_relative "eval/model_comparison"
@@ -30,7 +30,9 @@ module RubyLLM
30
30
  refute result.ok?, msg || "Expected step result NOT to satisfy contract, but it passed"
31
31
  end
32
32
 
33
- def assert_eval_passes(step, eval_name, minimum_score: nil, maximum_cost: nil, context: {}, msg: nil)
33
+ def assert_eval_passes(step, eval_name, minimum_score: nil, maximum_cost: nil, context: {}, msg: nil,
34
+ on_unknown_pricing: :refuse)
35
+ Eval::UnknownCostGate.validate!(on_unknown_pricing)
34
36
  report = step.run_eval(eval_name, context: context)
35
37
 
36
38
  if minimum_score
@@ -42,6 +44,9 @@ module RubyLLM
42
44
  end
43
45
 
44
46
  if maximum_cost
47
+ unknown_cost_failure = Eval::UnknownCostGate.check([report], mode: on_unknown_pricing)
48
+ assert unknown_cost_failure.nil?,
49
+ msg || "Expected #{eval_name} eval to stay within maximum cost, but #{unknown_cost_failure}"
45
50
  assert report.total_cost <= maximum_cost,
46
51
  msg || "Expected #{eval_name} eval cost <= $#{format("%.4f", maximum_cost)}, got $#{format("%.4f", report.total_cost)}"
47
52
  end
@@ -33,6 +33,13 @@ module RubyLLM
33
33
  value.dig(*rest)
34
34
  end
35
35
 
36
+ # total_cost sums only the steps that could be priced.
37
+ def cost_unknown?
38
+ return false unless @step_traces.is_a?(Array)
39
+
40
+ @step_traces.any? { |step_trace| step_trace.respond_to?(:cost_unknown?) && step_trace.cost_unknown? }
41
+ end
42
+
36
43
  def to_h
37
44
  { trace_id: @trace_id, total_latency_ms: @total_latency_ms,
38
45
  total_usage: @total_usage, step_traces: @step_traces,
@@ -4,8 +4,9 @@ module RubyLLM
4
4
  module Contract
5
5
  class RakeTask < ::Rake::TaskLib
6
6
  # Gate ordering (preserved from pre-refactor behaviour):
7
- # 1. cost gate runs FIRST — if `maximum_cost` set and exceeded, the
8
- # suite aborts before any score check; passed_reports is empty.
7
+ # 1. cost gate runs FIRST — if `maximum_cost` set and exceeded, or any
8
+ # case has unknown cost under on_unknown_pricing :refuse, the suite
9
+ # aborts before any score check; passed_reports is empty.
9
10
  # 2. score gate runs per-report; a report passes if
10
11
  # `report_meets_score?` AND `!check_regression`.
11
12
  # 3. overall passed = ALL reports passed AND cost gate not tripped.
@@ -16,20 +17,24 @@ module RubyLLM
16
17
  end
17
18
  end
18
19
 
19
- def self.evaluate(host_reports:, minimum_score:, maximum_cost:, fail_on_regression:)
20
+ def self.evaluate(host_reports:, minimum_score:, maximum_cost:, fail_on_regression:,
21
+ on_unknown_pricing: :refuse)
20
22
  new(host_reports: host_reports,
21
23
  minimum_score: minimum_score,
22
24
  maximum_cost: maximum_cost,
23
- fail_on_regression: fail_on_regression).verdict
25
+ fail_on_regression: fail_on_regression,
26
+ on_unknown_pricing: on_unknown_pricing).verdict
24
27
  end
25
28
 
26
29
  attr_reader :verdict
27
30
 
28
- def initialize(host_reports:, minimum_score:, maximum_cost:, fail_on_regression:)
31
+ def initialize(host_reports:, minimum_score:, maximum_cost:, fail_on_regression:,
32
+ on_unknown_pricing: :refuse)
29
33
  @host_reports = host_reports
30
34
  @minimum_score = minimum_score
31
35
  @maximum_cost = maximum_cost
32
36
  @fail_on_regression = fail_on_regression
37
+ @on_unknown_pricing = Eval::UnknownCostGate.validate!(on_unknown_pricing)
33
38
  @verdict = build_verdict
34
39
  end
35
40
 
@@ -37,11 +42,12 @@ module RubyLLM
37
42
 
38
43
  def build_verdict
39
44
  suite_cost = compute_suite_cost
45
+ cost_failure = cost_failure_message(suite_cost)
40
46
 
41
- if cost_exceeded?(suite_cost)
47
+ if cost_failure
42
48
  return Verdict.new(
43
49
  passed: false,
44
- abort_reason: cost_abort_message(suite_cost),
50
+ abort_reason: cost_failure,
45
51
  passed_reports: [],
46
52
  suite_cost: suite_cost
47
53
  )
@@ -60,8 +66,11 @@ module RubyLLM
60
66
  @host_reports.sum { |_host, report| report.total_cost }
61
67
  end
62
68
 
63
- def cost_exceeded?(suite_cost)
64
- @maximum_cost && suite_cost > @maximum_cost
69
+ def cost_failure_message(suite_cost)
70
+ return nil unless @maximum_cost
71
+ return cost_abort_message(suite_cost) if suite_cost > @maximum_cost
72
+
73
+ Eval::UnknownCostGate.check(@host_reports.map(&:last), mode: @on_unknown_pricing)
65
74
  end
66
75
 
67
76
  def cost_abort_message(suite_cost)
@@ -2,13 +2,15 @@
2
2
 
3
3
  require "rake"
4
4
  require "rake/tasklib"
5
+ require_relative "eval/unknown_cost_gate"
5
6
  require_relative "rake_task/suite_gate"
6
7
 
7
8
  module RubyLLM
8
9
  module Contract
9
10
  class RakeTask < ::Rake::TaskLib
10
11
  attr_accessor :name, :context, :fail_on_empty, :minimum_score, :maximum_cost,
11
- :eval_dirs, :save_baseline, :fail_on_regression, :track_history
12
+ :eval_dirs, :save_baseline, :fail_on_regression, :track_history,
13
+ :on_unknown_pricing
12
14
 
13
15
  def initialize(name = :"ruby_llm_contract:eval", &block)
14
16
  super()
@@ -18,10 +20,13 @@ module RubyLLM
18
20
  @minimum_score = nil # nil = require 100%; float = threshold
19
21
  @maximum_cost = nil # nil = no cost limit; float = budget cap (suite-level)
20
22
  @eval_dirs = [] # directories to load eval files from (non-Rails)
23
+ # With maximum_cost: :refuse fails on cases the budget cannot price; :warn gates priced cases only
24
+ @on_unknown_pricing = :refuse
21
25
  @save_baseline = false
22
26
  @fail_on_regression = false
23
27
  @track_history = false
24
28
  block&.call(self)
29
+ Eval::UnknownCostGate.validate!(@on_unknown_pricing)
25
30
  define_task
26
31
  end
27
32
 
@@ -44,7 +49,8 @@ module RubyLLM
44
49
  host_reports: host_reports,
45
50
  minimum_score: @minimum_score,
46
51
  maximum_cost: @maximum_cost,
47
- fail_on_regression: @fail_on_regression
52
+ fail_on_regression: @fail_on_regression,
53
+ on_unknown_pricing: @on_unknown_pricing
48
54
  )
49
55
 
50
56
  abort "\nEval suite FAILED: #{verdict.abort_reason}" unless verdict.passed?
@@ -64,6 +64,10 @@ RSpec::Matchers.define :pass_eval do |eval_name|
64
64
  @maximum_cost = cost
65
65
  end
66
66
 
67
+ chain :on_unknown_pricing do |mode|
68
+ @on_unknown_pricing = RubyLLM::Contract::Eval::UnknownCostGate.validate!(mode)
69
+ end
70
+
67
71
  chain :without_regressions do
68
72
  @check_regressions = true
69
73
  end
@@ -78,6 +82,9 @@ RSpec::Matchers.define :pass_eval do |eval_name|
78
82
  @context ||= {}
79
83
  @minimum_score ||= nil
80
84
  @maximum_cost ||= nil
85
+ @on_unknown_pricing ||= :refuse
86
+ @unknown_cost_failure = nil
87
+ @unknown_cost_only = false
81
88
  @check_regressions ||= false
82
89
  @comparison_step ||= nil
83
90
  @error = nil
@@ -97,7 +104,11 @@ RSpec::Matchers.define :pass_eval do |eval_name|
97
104
  @report.passed?
98
105
  end
99
106
 
100
- cost_ok = @maximum_cost ? @report.total_cost <= @maximum_cost : true
107
+ cost_ok = true
108
+ if @maximum_cost
109
+ @unknown_cost_failure = RubyLLM::Contract::Eval::UnknownCostGate.check([@report], mode: @on_unknown_pricing)
110
+ cost_ok = @report.total_cost <= @maximum_cost && @unknown_cost_failure.nil?
111
+ end
101
112
 
102
113
  regression_ok = if @prompt_diff
103
114
  @prompt_diff.safe_to_switch?
@@ -108,6 +119,7 @@ RSpec::Matchers.define :pass_eval do |eval_name|
108
119
  true
109
120
  end
110
121
 
122
+ @unknown_cost_only = score_ok && regression_ok && @report.total_cost <= @maximum_cost if @unknown_cost_failure
111
123
  score_ok && cost_ok && regression_ok
112
124
  rescue StandardError => e
113
125
  @error = e
@@ -115,71 +127,87 @@ RSpec::Matchers.define :pass_eval do |eval_name|
115
127
  end
116
128
 
117
129
  failure_message do
118
- if @prompt_diff && !@prompt_diff.safe_to_switch?
119
- msg = "expected #{@eval_name} eval to be safe to switch from baseline prompt\n"
120
-
121
- # Check empty sides first — most fundamental problem
122
- bl_empty = @prompt_diff.baseline_empty?
123
- cd_empty = @prompt_diff.candidate_empty?
124
- if bl_empty || cd_empty
125
- msg += " One side has no evaluated cases (all skipped or no adapter?)\n"
126
- if sample_response_only_compare?
127
- msg += " compare_with ignores sample_response; pass model: or with_context(adapter: ...)\n"
130
+ other_failure_message = lambda do
131
+ if @prompt_diff && !@prompt_diff.safe_to_switch?
132
+ msg = "expected #{@eval_name} eval to be safe to switch from baseline prompt\n"
133
+
134
+ # Check empty sides first — most fundamental problem
135
+ bl_empty = @prompt_diff.baseline_empty?
136
+ cd_empty = @prompt_diff.candidate_empty?
137
+ if bl_empty || cd_empty
138
+ msg += " One side has no evaluated cases (all skipped or no adapter?)\n"
139
+ if sample_response_only_compare?
140
+ msg += " compare_with ignores sample_response; pass model: or with_context(adapter: ...)\n"
141
+ end
142
+ msg += " Candidate score: #{@prompt_diff.candidate_score}, Baseline score: #{@prompt_diff.baseline_score}"
143
+ next msg
128
144
  end
129
- msg += " Candidate score: #{@prompt_diff.candidate_score}, Baseline score: #{@prompt_diff.baseline_score}"
130
- next msg
131
- end
132
145
 
133
- # Check dataset comparability — names, inputs, AND expected must match
134
- unless @prompt_diff.cases_comparable?
135
- unless @prompt_diff.case_names_match?
136
- mm = @prompt_diff.mismatched_cases
137
- msg += " Case set mismatch — candidate and baseline must have identical cases:\n"
138
- mm[:only_in_baseline].each { |n| msg += " only in baseline: #{n}\n" }
139
- mm[:only_in_candidate].each { |n| msg += " only in candidate: #{n}\n" }
140
- end
141
- @prompt_diff.input_mismatches.each do |m|
142
- msg += " Input mismatch for '#{m[:case]}' — same name but different inputs\n"
146
+ # Check dataset comparability — names, inputs, AND expected must match
147
+ unless @prompt_diff.cases_comparable?
148
+ unless @prompt_diff.case_names_match?
149
+ mm = @prompt_diff.mismatched_cases
150
+ msg += " Case set mismatch — candidate and baseline must have identical cases:\n"
151
+ mm[:only_in_baseline].each { |n| msg += " only in baseline: #{n}\n" }
152
+ mm[:only_in_candidate].each { |n| msg += " only in candidate: #{n}\n" }
153
+ end
154
+ @prompt_diff.input_mismatches.each do |m|
155
+ msg += " Input mismatch for '#{m[:case]}' — same name but different inputs\n"
156
+ end
157
+ @prompt_diff.expected_mismatches.each do |m|
158
+ msg += " Expected mismatch for '#{m[:case]}' — same name/input but different expected values\n"
159
+ end
160
+ next msg
143
161
  end
144
- @prompt_diff.expected_mismatches.each do |m|
145
- msg += " Expected mismatch for '#{m[:case]}' — same name/input but different expected values\n"
162
+
163
+ # Check per-case score regressions (even if global average is flat)
164
+ if @prompt_diff.score_regressions.any?
165
+ msg += " Per-case score regressions (#{@prompt_diff.score_regressions.length}):\n"
166
+ @prompt_diff.score_regressions.each do |r|
167
+ msg += " #{r[:case]}: #{r[:baseline_score]} -> #{r[:candidate_score]} (#{r[:delta]})\n"
168
+ end
169
+ msg += " Score delta: #{@prompt_diff.score_delta}"
170
+ next msg
146
171
  end
147
- next msg
148
- end
149
172
 
150
- # Check per-case score regressions (even if global average is flat)
151
- if @prompt_diff.score_regressions.any?
152
- msg += " Per-case score regressions (#{@prompt_diff.score_regressions.length}):\n"
153
- @prompt_diff.score_regressions.each do |r|
154
- msg += " #{r[:case]}: #{r[:baseline_score]} -> #{r[:candidate_score]} (#{r[:delta]})\n"
173
+ # Check pass/fail regressions and removed cases
174
+ removed = @prompt_diff.removed_passing_cases
175
+ reg_count = @prompt_diff.regressions.length + removed.length
176
+ msg += " Found #{reg_count} regression(s):\n"
177
+ @prompt_diff.regressions.each do |r|
178
+ msg += " #{r[:case]}: was PASS, now FAIL -- #{r[:detail]}\n"
179
+ end
180
+ removed.each do |name|
181
+ msg += " #{name}: REMOVED (was passing in baseline)\n"
155
182
  end
156
183
  msg += " Score delta: #{@prompt_diff.score_delta}"
157
184
  next msg
158
185
  end
159
186
 
160
- # Check pass/fail regressions and removed cases
161
- removed = @prompt_diff.removed_passing_cases
162
- reg_count = @prompt_diff.regressions.length + removed.length
163
- msg += " Found #{reg_count} regression(s):\n"
164
- @prompt_diff.regressions.each do |r|
165
- msg += " #{r[:case]}: was PASS, now FAIL -- #{r[:detail]}\n"
166
- end
167
- removed.each do |name|
168
- msg += " #{name}: REMOVED (was passing in baseline)\n"
187
+ msg = format_failure_message(@eval_name, @error, @report, @minimum_score, @maximum_cost)
188
+ if @diff&.regressed?
189
+ msg += "\n\nRegressions from baseline:\n"
190
+ @diff.regressions.each do |r|
191
+ msg += " #{r[:case]}: was PASS, now FAIL -- #{r[:detail]}\n"
192
+ end
193
+ @diff.skipped_passing_cases.each do |s|
194
+ msg += " #{s[:case]}: was PASS, now SKIPPED -- #{s[:detail]}\n"
195
+ end
196
+ msg += " Score delta: #{@diff.score_delta}"
169
197
  end
170
- msg += " Score delta: #{@prompt_diff.score_delta}"
171
- next msg
198
+ msg
172
199
  end
173
200
 
174
- msg = format_failure_message(@eval_name, @error, @report, @minimum_score, @maximum_cost)
175
- if @diff&.regressed?
176
- msg += "\n\nRegressions from baseline:\n"
177
- @diff.regressions.each do |r|
178
- msg += " #{r[:case]}: was PASS, now FAIL -- #{r[:detail]}\n"
179
- end
180
- msg += " Score delta: #{@diff.score_delta}"
201
+ # Cost first, as in the rake suite gate: an unpriced total cannot be trusted.
202
+ # An exception outranks it, and any other failure is still listed.
203
+ if @unknown_cost_failure && @error.nil?
204
+ unknown_cost = "expected #{@eval_name} eval to stay within maximum cost, but #{@unknown_cost_failure}"
205
+ next unknown_cost if @unknown_cost_only
206
+
207
+ next "#{unknown_cost}\n\n#{other_failure_message.call}"
181
208
  end
182
- msg
209
+
210
+ other_failure_message.call
183
211
  end
184
212
 
185
213
  failure_message_when_negated do
@@ -50,6 +50,25 @@ module RubyLLM
50
50
  )
51
51
  end
52
52
 
53
+ # True when a call ran on a named model, used tokens, and still got no
54
+ # cost, so any total built from this trace undercounts. A zero-token call
55
+ # (Test adapter, sample_response) costs 0 whatever the pricing. A retried
56
+ # step is judged per attempt: a priced subtotal must not hide an unpriced
57
+ # attempt, and the merged trace may have re-priced on the last model.
58
+ def cost_unknown?
59
+ if @attempts.is_a?(Array) && !@attempts.empty?
60
+ @attempts.any? { |attempt| self.class.unpriced?(attempt[:model], attempt[:usage], attempt[:cost]) }
61
+ else
62
+ self.class.unpriced?(@model, @usage, @cost)
63
+ end
64
+ end
65
+
66
+ def self.unpriced?(model, usage, cost)
67
+ return false unless model && cost.nil? && usage.is_a?(Hash)
68
+
69
+ ((usage[:input_tokens] || 0) + (usage[:output_tokens] || 0)).positive?
70
+ end
71
+
53
72
  def to_h
54
73
  { messages: @messages, model: @model, latency_ms: @latency_ms,
55
74
  usage: @usage, attempts: @attempts, cost: @cost }.compact
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
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.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna
@@ -59,6 +59,7 @@ executables: []
59
59
  extensions: []
60
60
  extra_rdoc_files: []
61
61
  files:
62
+ - ".ruby-version"
62
63
  - CHANGELOG.md
63
64
  - LICENSE
64
65
  - README.md
@@ -152,6 +153,7 @@ files:
152
153
  - lib/ruby_llm/contract/eval/step_expectation_applier.rb
153
154
  - lib/ruby_llm/contract/eval/step_result_normalizer.rb
154
155
  - lib/ruby_llm/contract/eval/trait_evaluator.rb
156
+ - lib/ruby_llm/contract/eval/unknown_cost_gate.rb
155
157
  - lib/ruby_llm/contract/minitest.rb
156
158
  - lib/ruby_llm/contract/pipeline.rb
157
159
  - lib/ruby_llm/contract/pipeline/base.rb