ruby_llm-contract 0.10.5 → 1.0.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -5
  3. data/README.md +12 -2
  4. data/docs/guide/llm_judge.md +3 -3
  5. data/docs/guide/multimodal_input.md +4 -3
  6. data/docs/guide/output_schema.md +2 -2
  7. data/docs/guide/relation_to_tribunal.md +28 -0
  8. data/examples/README.md +1 -1
  9. data/lib/ruby_llm/contract/adapters/ruby_llm.rb +13 -9
  10. data/lib/ruby_llm/contract/concerns/context_helpers.rb +0 -2
  11. data/lib/ruby_llm/contract/concerns/deep_symbolize.rb +0 -1
  12. data/lib/ruby_llm/contract/contract/parser.rb +0 -3
  13. data/lib/ruby_llm/contract/contract/schema_validator/bound_rule.rb +0 -1
  14. data/lib/ruby_llm/contract/contract/schema_validator/enum_rule.rb +0 -1
  15. data/lib/ruby_llm/contract/contract/schema_validator/node.rb +0 -4
  16. data/lib/ruby_llm/contract/contract/schema_validator/scalar_rules.rb +0 -1
  17. data/lib/ruby_llm/contract/contract/schema_validator/type_rule.rb +0 -1
  18. data/lib/ruby_llm/contract/cost_calculator.rb +27 -2
  19. data/lib/ruby_llm/contract/eval/baseline_diff.rb +4 -2
  20. data/lib/ruby_llm/contract/eval/candidate_label.rb +31 -0
  21. data/lib/ruby_llm/contract/eval/case_executor.rb +2 -2
  22. data/lib/ruby_llm/contract/eval/case_result.rb +8 -0
  23. data/lib/ruby_llm/contract/eval/contract_detail_builder.rb +0 -2
  24. data/lib/ruby_llm/contract/eval/model_comparison.rb +3 -2
  25. data/lib/ruby_llm/contract/eval/pipeline_result_adapter.rb +1 -2
  26. data/lib/ruby_llm/contract/eval/prompt_diff_comparator.rb +0 -1
  27. data/lib/ruby_llm/contract/eval/prompt_diff_presenter.rb +0 -1
  28. data/lib/ruby_llm/contract/eval/prompt_diff_serializer.rb +0 -1
  29. data/lib/ruby_llm/contract/eval/report_presenter.rb +0 -1
  30. data/lib/ruby_llm/contract/eval/report_stats.rb +0 -1
  31. data/lib/ruby_llm/contract/eval/report_storage.rb +0 -1
  32. data/lib/ruby_llm/contract/eval/retry_optimizer.rb +10 -20
  33. data/lib/ruby_llm/contract/eval/trait_evaluator.rb +0 -2
  34. data/lib/ruby_llm/contract/eval.rb +1 -0
  35. data/lib/ruby_llm/contract/pipeline/runner.rb +0 -1
  36. data/lib/ruby_llm/contract/rake_task/suite_gate.rb +0 -12
  37. data/lib/ruby_llm/contract/rake_task.rb +1 -2
  38. data/lib/ruby_llm/contract/step/base.rb +12 -3
  39. data/lib/ruby_llm/contract/step/dsl.rb +2 -4
  40. data/lib/ruby_llm/contract/step/limit_checker.rb +0 -2
  41. data/lib/ruby_llm/contract/step/retry_executor.rb +0 -2
  42. data/lib/ruby_llm/contract/token_estimator.rb +0 -2
  43. data/lib/ruby_llm/contract/version.rb +1 -1
  44. data/lib/ruby_llm/contract.rb +0 -4
  45. data/ruby_llm-contract.gemspec +6 -5
  46. metadata +7 -11
  47. data/.rubocop.yml +0 -58
  48. data/Gemfile +0 -13
  49. data/Gemfile.lock +0 -278
  50. data/Rakefile +0 -8
@@ -3,8 +3,7 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Eval
6
- # Lightweight adapter that wraps a Pipeline::Result to look like a Step::Result.
7
- # Replaces OpenStruct usage in Runner#normalize_pipeline_result.
6
+ # Replaces OpenStruct usage in StepResultNormalizer#normalize_pipeline_result.
8
7
  PipelineResultAdapter = Struct.new(:status, :ok_flag, :parsed_output, :validation_errors, :trace) do
9
8
  def ok?
10
9
  ok_flag
@@ -3,7 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Eval
6
- # Encapsulates the safety and mismatch rules for prompt A/B comparison.
7
6
  class PromptDiffComparator
8
7
  def initialize(candidate_cases:, baseline_cases:, diff:)
9
8
  @candidate_cases = candidate_cases
@@ -3,7 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Eval
6
- # Renders a prompt diff as a readable console summary.
7
6
  class PromptDiffPresenter
8
7
  VARIANT_LABEL_WIDTH = 12
9
8
  TABLE_WIDTH = 26
@@ -3,7 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Eval
6
- # Normalizes report results into comparable prompt-diff case hashes.
7
6
  class PromptDiffSerializer
8
7
  def call(report)
9
8
  report.results.reject { |result| result.step_status == :skipped }.map do |result|
@@ -3,7 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Eval
6
- # Formats eval reports for console and string output.
7
6
  class ReportPresenter
8
7
  def initialize(report:, stats:)
9
8
  @report = report
@@ -3,7 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Eval
6
- # Computes aggregate metrics for an eval report.
7
6
  class ReportStats
8
7
  def initialize(results:)
9
8
  @results = results
@@ -6,7 +6,6 @@ require "fileutils"
6
6
  module RubyLLM
7
7
  module Contract
8
8
  module Eval
9
- # Persists eval reports as history entries and regression baselines.
10
9
  class ReportStorage
11
10
  def initialize(report:, stats:)
12
11
  @report = report
@@ -13,6 +13,10 @@ module RubyLLM
13
13
  # result.print_summary
14
14
  # result.to_dsl # => copy-paste retry_policy
15
15
  class RetryOptimizer
16
+ # Documented CANDIDATES= shorthand, parsed by
17
+ # OptimizeRakeTask#parse_candidates and rendered in this table's headers.
18
+ EFFORT_SEPARATOR = "@"
19
+
16
20
  Result = Struct.new(:step_name, :eval_names, :candidate_labels, :score_matrix,
17
21
  :constraining_eval, :chain, :chain_details, keyword_init: true) do
18
22
  # Terminology alias — `hardest_eval` is the narrative name used in docs;
@@ -80,11 +84,10 @@ module RubyLLM
80
84
  end
81
85
 
82
86
  def short_candidate_label(label)
83
- label
84
- .sub("gpt-5-", "")
85
- .sub("gpt-4.1", "4.1")
86
- .sub(" (effort: ", "@")
87
- .sub(")", "")
87
+ config = CandidateLabel.parse(label)
88
+ model = config[:model].sub("gpt-5-", "").sub("gpt-4.1", "4.1")
89
+ effort = config[:reasoning_effort]
90
+ effort ? "#{model}#{EFFORT_SEPARATOR}#{effort}" : model
88
91
  end
89
92
 
90
93
  def print_dsl(io)
@@ -176,11 +179,9 @@ module RubyLLM
176
179
  def build_chain(matrix, labels, evals)
177
180
  total = evals.size
178
181
 
179
- # Find cheapest model that passes every eval — the safe fallback.
180
182
  safe_fallback = labels.find { |l| evals.all? { |e| (matrix.dig(e, l) || 0) >= @min_score } }
181
183
  return [[], []] unless safe_fallback
182
184
 
183
- # Prepend cheaper models that pass a strict subset.
184
185
  chain = []
185
186
  details = []
186
187
  covered_evals = Set.new
@@ -193,27 +194,16 @@ module RubyLLM
193
194
  next if new_additions.empty?
194
195
 
195
196
  covered_evals.merge(new_additions)
196
- chain << parse_label_to_config(label)
197
+ chain << CandidateLabel.parse(label)
197
198
  details << { label: label, passes: new_additions.size, cost: label }
198
199
  end
199
200
 
200
- # Always end with the safe fallback.
201
- chain << parse_label_to_config(safe_fallback)
201
+ chain << CandidateLabel.parse(safe_fallback)
202
202
  details << { label: safe_fallback, passes: total, cost: safe_fallback }
203
203
 
204
204
  [chain, details]
205
205
  end
206
206
 
207
- def parse_label_to_config(label)
208
- if label.match?(/\(effort: (\w+)\)/)
209
- model = label.sub(/\s*\(effort:.*/, "").strip
210
- effort = label.match(/\(effort: (\w+)\)/)[1]
211
- { model: model, reasoning_effort: effort }
212
- else
213
- { model: label }
214
- end
215
- end
216
-
217
207
  def empty_result(evals)
218
208
  Result.new(
219
209
  step_name: @step.name || @step.to_s,
@@ -3,8 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Eval
6
- # Extracted from Runner to reduce class length.
7
- # Evaluates expected_traits against parsed output.
8
6
  module TraitEvaluator
9
7
  private
10
8
 
@@ -23,6 +23,7 @@ require_relative "eval/report_storage"
23
23
  require_relative "eval/report"
24
24
  require_relative "eval/aggregated_report"
25
25
  require_relative "eval/eval_definition"
26
+ require_relative "eval/candidate_label"
26
27
  require_relative "eval/model_comparison"
27
28
  require_relative "eval/baseline_diff"
28
29
  require_relative "eval/prompt_diff_serializer"
@@ -95,7 +95,6 @@ module RubyLLM
95
95
  ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
96
96
  end
97
97
 
98
- # Encapsulates mutable state during pipeline execution
99
98
  class ExecutionState
100
99
  attr_reader :trace_id, :step_results, :step_traces, :outputs_by_step,
101
100
  :current_input, :status, :failed_step
@@ -3,18 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  class RakeTask < ::Rake::TaskLib
6
- # Encapsulates the pass/fail gate that runs after `RakeTask#define_task`
7
- # has collected eval reports. Extracted from the prior `define_task`
8
- # god-method so each gating dimension (cost, score, regression) is
9
- # testable in isolation.
10
- #
11
- # Returns a `Verdict` value object with:
12
- # - `passed?` — overall gate verdict
13
- # - `abort_reason` — String for `abort` when `passed? == false`, nil otherwise
14
- # - `passed_reports` — [[host, report], ...] of reports that individually passed
15
- # (used to decide which baselines to save)
16
- # - `suite_cost` — total cost across all reports
17
- #
18
6
  # Gate ordering (preserved from pre-refactor behaviour):
19
7
  # 1. cost gate runs FIRST — if `maximum_cost` set and exceeded, the
20
8
  # suite aborts before any score check; passed_reports is empty.
@@ -164,7 +164,7 @@ module RubyLLM
164
164
  Array(JSON.parse(raw))
165
165
  else
166
166
  raw.split(",").map(&:strip).reject(&:empty?).map do |entry|
167
- model, effort = entry.split("@", 2)
167
+ model, effort = entry.split(Eval::RetryOptimizer::EFFORT_SEPARATOR, 2)
168
168
  config = { model: model.strip }
169
169
  config[:reasoning_effort] = effort.strip if effort && !effort.empty?
170
170
  config
@@ -191,7 +191,6 @@ module RubyLLM
191
191
  end
192
192
  end
193
193
 
194
- # Auto-register the optimize task when this file is loaded
195
194
  OptimizeRakeTask.new
196
195
  end
197
196
  end
@@ -6,6 +6,11 @@ module RubyLLM
6
6
  class Base
7
7
  DEFAULT_OUTPUT_TOKENS = 256
8
8
 
9
+ # Eval::CaseExecutor substring-matches this to choose skip over raise.
10
+ # Reworded without the consumer, every skipped eval case becomes a hard
11
+ # failure of the whole run, so both ends read it from here.
12
+ NO_ADAPTER_MESSAGE = "No adapter configured"
13
+
9
14
  def self.inherited(subclass)
10
15
  super
11
16
  Contract.register_eval_host(subclass) if respond_to?(:eval_defined?) && eval_defined?
@@ -141,7 +146,10 @@ module RubyLLM
141
146
  def estimate_eval_cost_for_model(cases, model_name)
142
147
  cases.sum do |test_case|
143
148
  estimate = estimate_cost(input: test_case.input, model: model_name)
144
- estimate ? estimate[:estimated_cost] : 0.0
149
+ # Two misses floor to 0.0, not one: model absent from the registry
150
+ # (nil estimate), or present with unreadable pricing (hash whose
151
+ # estimated_cost is nil). Documented as a floor, not a fail-closed.
152
+ (estimate && estimate[:estimated_cost]) || 0.0
145
153
  end.round(6)
146
154
  end
147
155
 
@@ -225,8 +233,9 @@ module RubyLLM
225
233
  adapter = context[:adapter] || RubyLLM::Contract.configuration.default_adapter
226
234
  return adapter if adapter
227
235
 
228
- raise RubyLLM::Contract::Error, "No adapter configured. Set one with RubyLLM::Contract.configure " \
229
- "{ |c| c.default_adapter = ... } or pass context: { adapter: ... }"
236
+ raise RubyLLM::Contract::Error,
237
+ "#{NO_ADAPTER_MESSAGE}. Set one with RubyLLM::Contract.configure " \
238
+ "{ |c| c.default_adapter = ... } or pass context: { adapter: ... }"
230
239
  end
231
240
 
232
241
  # ADR-0021 deliverable 2: narrow ArgumentError rescue to DSL-setup phase only.
@@ -3,8 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Step
6
- # Extracted from Base to reduce class length.
7
- # DSL accessor methods for step definition (input_type, output_type, prompt, etc.).
8
6
  module Dsl # rubocop:disable Metrics/ModuleLength
9
7
  # Sentinel signalling "explicitly reset" (`some_attr(:default)`).
10
8
  # Distinguishes reset (lookup stops at this class, returns nil) from
@@ -65,8 +63,8 @@ module RubyLLM
65
63
 
66
64
  def output_schema(&block)
67
65
  if block
68
- require "ruby_llm/schema"
69
- @output_schema = ::RubyLLM::Schema.create(&block)
66
+ require "schematist"
67
+ @output_schema = ::Schematist::Schema.create(&block)
70
68
  elsif defined?(@output_schema)
71
69
  @output_schema
72
70
  elsif superclass.respond_to?(:output_schema)
@@ -3,8 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Step
6
- # Extracted from Runner to reduce class length.
7
- # Handles input token limit and cost limit checks.
8
6
  module LimitChecker
9
7
  private
10
8
 
@@ -3,8 +3,6 @@
3
3
  module RubyLLM
4
4
  module Contract
5
5
  module Step
6
- # Extracted from Base to reduce class length.
7
- # Handles retry logic: run_with_retry, build_retry_result, aggregate usage, build attempt entries.
8
6
  module RetryExecutor
9
7
  include Concerns::UsageAggregator
10
8
 
@@ -23,8 +23,6 @@ module RubyLLM
23
23
  module TokenEstimator
24
24
  CHARS_PER_TOKEN = 4
25
25
 
26
- # Heuristic estimate. Returns an integer token count.
27
- # See module docstring for accuracy caveats.
28
26
  def self.estimate(messages)
29
27
  return 0 unless messages.is_a?(Array)
30
28
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "0.10.5"
5
+ VERSION = "1.0.0"
6
6
  end
7
7
  end
@@ -21,8 +21,6 @@ module RubyLLM
21
21
  step_adapter_overrides.clear
22
22
  end
23
23
 
24
- # --- Eval host registry ---
25
-
26
24
  def register_eval_host(klass)
27
25
  eval_hosts << klass unless eval_hosts.include?(klass)
28
26
  end
@@ -101,9 +99,7 @@ module RubyLLM
101
99
 
102
100
  private
103
101
 
104
- # Filter stale hosts, deduplicate by name (last wins), prune registry in-place
105
102
  def live_eval_hosts
106
- # Remove hosts without evals
107
103
  @eval_hosts&.reject! { |h| !h.respond_to?(:eval_defined?) || !h.eval_defined? }
108
104
 
109
105
  # Deduplicate: if two classes share a name (reload), keep the latest
@@ -16,7 +16,6 @@ Gem::Specification.new do |spec|
16
16
  spec.license = "MIT"
17
17
  spec.required_ruby_version = ">= 3.2.0"
18
18
 
19
- spec.metadata["homepage_uri"] = spec.homepage
20
19
  spec.metadata["source_code_uri"] = spec.homepage
21
20
  spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
22
21
  spec.metadata["documentation_uri"] = "#{spec.homepage}#readme"
@@ -25,16 +24,18 @@ Gem::Specification.new do |spec|
25
24
  spec.files = Dir.chdir(__dir__) do
26
25
  # Internal trackers + dev configs excluded so the published gem
27
26
  # contains only what adopters actually need at runtime.
28
- excluded_files = %w[TODO.md .rspec .rubycritic.yml .simplecov]
27
+ excluded_files = %w[.rspec .simplecov .rubocop.yml .rubocop_todo.yml Gemfile Rakefile]
29
28
  `git ls-files -z`.split("\x0").reject do |f|
30
29
  (File.expand_path(f) == __FILE__) ||
31
- f.start_with?("spec/", "docs/ideas/", "doc/", ".ai/", ".claude/", ".git", ".revive/") ||
30
+ f.start_with?("spec/", "docs/ideas/", "doc/", ".ai/", ".claude/", ".git",
31
+ ".revive/", "gemfiles/") ||
32
32
  excluded_files.include?(f)
33
33
  end
34
34
  end
35
35
  spec.require_paths = ["lib"]
36
36
 
37
37
  spec.add_dependency "dry-types", "~> 1.7"
38
- spec.add_dependency "ruby_llm", "~> 1.12"
39
- spec.add_dependency "ruby_llm-schema", "~> 0.3"
38
+ spec.add_dependency "ruby_llm", "~> 2.0"
39
+ # schematist is what ruby_llm-schema became; ruby_llm 2.x depends on it too.
40
+ spec.add_dependency "schematist", "~> 1.1"
40
41
  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: 0.10.5
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna
@@ -29,28 +29,28 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '1.12'
32
+ version: '2.0'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '1.12'
39
+ version: '2.0'
40
40
  - !ruby/object:Gem::Dependency
41
- name: ruby_llm-schema
41
+ name: schematist
42
42
  requirement: !ruby/object:Gem::Requirement
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '0.3'
46
+ version: '1.1'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '0.3'
53
+ version: '1.1'
54
54
  description: Wraps RubyLLM::Chat with input/output contracts, business-rule validation,
55
55
  retry with model escalation on validation failure, pre-flight cost ceilings, and
56
56
  an evaluation framework. Sibling abstraction to RubyLLM::Agent — same niche (reusable
@@ -59,13 +59,9 @@ executables: []
59
59
  extensions: []
60
60
  extra_rdoc_files: []
61
61
  files:
62
- - ".rubocop.yml"
63
62
  - CHANGELOG.md
64
- - Gemfile
65
- - Gemfile.lock
66
63
  - LICENSE
67
64
  - README.md
68
- - Rakefile
69
65
  - docs/architecture.md
70
66
  - docs/guide/best_practices.md
71
67
  - docs/guide/eval_first.md
@@ -124,6 +120,7 @@ files:
124
120
  - lib/ruby_llm/contract/eval.rb
125
121
  - lib/ruby_llm/contract/eval/aggregated_report.rb
126
122
  - lib/ruby_llm/contract/eval/baseline_diff.rb
123
+ - lib/ruby_llm/contract/eval/candidate_label.rb
127
124
  - lib/ruby_llm/contract/eval/case_executor.rb
128
125
  - lib/ruby_llm/contract/eval/case_result.rb
129
126
  - lib/ruby_llm/contract/eval/case_result_builder.rb
@@ -200,7 +197,6 @@ homepage: https://github.com/justi/ruby_llm-contract
200
197
  licenses:
201
198
  - MIT
202
199
  metadata:
203
- homepage_uri: https://github.com/justi/ruby_llm-contract
204
200
  source_code_uri: https://github.com/justi/ruby_llm-contract
205
201
  changelog_uri: https://github.com/justi/ruby_llm-contract/blob/main/CHANGELOG.md
206
202
  documentation_uri: https://github.com/justi/ruby_llm-contract#readme
data/.rubocop.yml DELETED
@@ -1,58 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.2
3
- NewCops: enable
4
- SuggestExtensions: false
5
-
6
- Style/Documentation:
7
- Enabled: false
8
-
9
- Style/StringLiterals:
10
- EnforcedStyle: double_quotes
11
-
12
- Style/StringLiteralsInInterpolation:
13
- EnforcedStyle: double_quotes
14
-
15
- Metrics/BlockLength:
16
- Exclude:
17
- - 'spec/**/*'
18
- - '*.gemspec'
19
-
20
- Metrics/MethodLength:
21
- Max: 25
22
-
23
- Layout/LineLength:
24
- Max: 120
25
-
26
- Style/OneClassPerFile:
27
- Exclude:
28
- - 'spec/**/*'
29
- - 'examples/**/*'
30
-
31
- Lint/UnusedBlockArgument:
32
- Exclude:
33
- - 'spec/**/*'
34
-
35
- Naming/VariableNumber:
36
- Exclude:
37
- - 'spec/**/*'
38
- - 'examples/**/*'
39
-
40
- AllCops:
41
- Exclude:
42
- - 'internal/**/*'
43
-
44
- Metrics/ClassLength:
45
- Max: 140
46
-
47
- Metrics/ModuleLength:
48
- Max: 150
49
-
50
- Metrics/AbcSize:
51
- Max: 30
52
-
53
- Metrics/ParameterLists:
54
- Max: 12
55
- MaxOptionalParameters: 10
56
-
57
- Style/FormatStringToken:
58
- Enabled: false
data/Gemfile DELETED
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source "https://rubygems.org"
4
-
5
- gemspec
6
-
7
- group :development, :test do
8
- gem "rake", "~> 13.0"
9
- gem "rspec", "~> 3.13"
10
- gem "rubocop", "~> 1.75"
11
- gem "rubycritic", "~> 4.9"
12
- gem "simplecov", "~> 0.22"
13
- end