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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +257 -224
- data/README.md +1 -1
- data/docs/guide/getting_started.md +3 -1
- data/docs/guide/testing.md +1 -1
- data/lib/ruby_llm/contract/cost_calculator.rb +5 -4
- data/lib/ruby_llm/contract/eval/aggregated_report.rb +4 -0
- data/lib/ruby_llm/contract/eval/baseline_diff.rb +19 -2
- data/lib/ruby_llm/contract/eval/case_result.rb +9 -3
- data/lib/ruby_llm/contract/eval/case_result_builder.rb +2 -1
- data/lib/ruby_llm/contract/eval/report.rb +1 -1
- data/lib/ruby_llm/contract/eval/report_stats.rb +5 -0
- data/lib/ruby_llm/contract/eval/step_expectation_applier.rb +2 -1
- data/lib/ruby_llm/contract/eval/unknown_cost_gate.rb +40 -0
- data/lib/ruby_llm/contract/eval.rb +1 -0
- data/lib/ruby_llm/contract/minitest.rb +6 -1
- data/lib/ruby_llm/contract/pipeline/trace.rb +7 -0
- data/lib/ruby_llm/contract/rake_task/suite_gate.rb +18 -9
- data/lib/ruby_llm/contract/rake_task.rb +8 -2
- data/lib/ruby_llm/contract/rspec/pass_eval.rb +80 -52
- data/lib/ruby_llm/contract/step/trace.rb +19 -0
- data/lib/ruby_llm/contract/version.rb +1 -1
- metadata +3 -1
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
|
|
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:
|
data/docs/guide/testing.md
CHANGED
|
@@ -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"
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
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|
|
|
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?
|
|
@@ -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,
|
|
8
|
-
#
|
|
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
|
|
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
|
|
47
|
+
if cost_failure
|
|
42
48
|
return Verdict.new(
|
|
43
49
|
passed: false,
|
|
44
|
-
abort_reason:
|
|
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
|
|
64
|
-
|
|
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 =
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
171
|
-
next msg
|
|
198
|
+
msg
|
|
172
199
|
end
|
|
173
200
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
@
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
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
|
+
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
|