ruby_llm-contract 1.2.0 → 1.3.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: fead48c5516f8e0aa738d3ab1633ca2d07f7173aba6466a6d29e37634263b857
4
- data.tar.gz: 4ae4c314cd6365c5f5de455f9dc5ddc0741abd26fa751804b22c5ffded0e6cb1
3
+ metadata.gz: b3d6985e56b8e410f796207f23b52e910b925f1bf4263353da65ca3f540c9535
4
+ data.tar.gz: 4cb5f7471dc5d97f3d3bd78fe41110cb6a9f28139e90029872fa612ed4ef3e9e
5
5
  SHA512:
6
- metadata.gz: 9b66df1e490e238fdacc84a728b874a008b80c8b44cdd882b444a84389bb77996ee6132efb6adaad79c91a04bee30eb7351241a4989ff1a4ed5df8d7a6cff619
7
- data.tar.gz: a055fd3d06ee4fa9591fa2a6cfed6872142febd950fe3a455daa6e588f04e157a585701456f4815483ecc3accb803cf3bbcffa9308c6dfa5011c00f6a7143274
6
+ metadata.gz: 62e15223ae05cfa64fd64959eb62858ace305a6f24e23e2c3f466219681eae4a1cd4c08addaf9d7d309f554c4ead57e1051d9b4b1eb55bcea4542ce8df23d789
7
+ data.tar.gz: e4c07a0a8b4706cbc062ca713ab9c8a2e87906b2fa4126a3b6d10f72ef30625ce6c8c5ea0a29ac30fef0b5251a06fd451bd1462c45289a0ab1cc939cdc25b989
data/CHANGELOG.md CHANGED
@@ -1,8 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.1 (2026-10-11)
4
+
5
+ ### Fixed
6
+
7
+ - **`Eval::Recommender` priced a call N times too low on a `compare_models(runs: N)`
8
+ comparison.** With `runs > 1` each report is an `AggregatedReport`: its results
9
+ hold the cases of all N runs, while `total_cost` is the mean cost of one run. The
10
+ cost per call divided one run's cost by the cases of every run, so with `runs: 3`
11
+ a $0.002 call showed as $0.0007. That skewed `best`, the retry chain, the rationale
12
+ and `savings`, and a candidate measured over more runs looked cheaper than one
13
+ measured once. Cases are now counted per run. `Step.recommend` always compares
14
+ with one run, so only a `Recommender` built by hand on a multi-run comparison
15
+ was affected.
16
+
17
+ ## 1.3.0 (2026-10-11)
18
+
19
+ ### Changed - may turn a green CI red
20
+
21
+ - **A non-null value under `anyOf` / `oneOf` is now validated against the branches.**
22
+ 0.10.3 taught the validator to accept `nil` through a null branch, but a non-null
23
+ value was never checked at all: the child node carried the composition wrapper,
24
+ which has no `type` or `properties`, so nothing ran. A nullable object block
25
+ (`any_of :block do object do ... end; null end`) missing a required field, with
26
+ a wrong-typed field or with an extra key came back `:ok`. Measured in an adopter:
27
+ a page-analysis block without `score` was `:ok` and became a 0/10 fail downstream.
28
+ `anyOf` now passes when at least one branch validates and `oneOf` when exactly one
29
+ does. When none does, the errors of the one branch whose type fits the value are
30
+ reported (`missing required field: block.score`); otherwise
31
+ `block: expected one of object, null, got String`. A `oneOf` value matching two
32
+ branches fails with `matches 2 oneOf branches, expected exactly 1`. Steps whose
33
+ model already returns schema-conforming output (OpenAI strict mode) are
34
+ unaffected; output that slipped through before now fails as `:validation_failed`.
35
+ - **`type` given as an array is checked.** `type: ["string", "null"]` accepted any
36
+ value; it now accepts a value of any listed type and reports
37
+ `x: expected string or null, got Integer` otherwise. `"null"` is a known type.
38
+
3
39
  ## 1.2.0 (2026-10-10)
4
40
 
5
- Everything new is opt-in, except the temperature fix below.
41
+ Everything new is opt-in. Two changes under "Changed" can affect existing steps: the temperature fix and `retry_on` rejecting conditions it cannot match.
6
42
 
7
43
  ### Added
8
44
 
@@ -29,6 +29,22 @@ module RubyLLM
29
29
  Array(schema[:required]).map(&:to_s)
30
30
  end
31
31
 
32
+ def composition
33
+ SchemaValidator::COMPOSITION_KEYWORDS.each do |keyword|
34
+ branches = schema[keyword]
35
+ return [keyword, branches] if branches.is_a?(Array) && !branches.empty?
36
+ end
37
+ nil
38
+ end
39
+
40
+ def with_schema(branch_schema)
41
+ with(schema: branch_schema)
42
+ end
43
+
44
+ def label
45
+ path || "root"
46
+ end
47
+
32
48
  def items_schema
33
49
  schema[:items]
34
50
  end
@@ -9,12 +9,12 @@ module RubyLLM
9
9
  end
10
10
 
11
11
  def validate(node)
12
- expected_type = node.expected_type
12
+ expected_types = Array(node.schema[:type]).map(&:to_s)
13
13
  value = node.value
14
- return unless expected_type
15
- return if type_valid?(expected_type, value)
14
+ return if expected_types.empty?
15
+ return if expected_types.any? { |type| type_valid?(type, value) }
16
16
 
17
- @errors << "#{node.path}: expected #{expected_type}, got #{value.class}"
17
+ @errors << "#{node.path}: expected #{expected_types.join(" or ")}, got #{value.class}"
18
18
  end
19
19
 
20
20
  private
@@ -25,8 +25,10 @@ module RubyLLM
25
25
  "number" => ->(value) { value.is_a?(Numeric) },
26
26
  "boolean" => ->(value) { value.is_a?(TrueClass) || value.is_a?(FalseClass) },
27
27
  "array" => ->(value) { value.is_a?(Array) },
28
- "object" => ->(value) { value.is_a?(Hash) }
28
+ "object" => ->(value) { value.is_a?(Hash) },
29
+ "null" => lambda(&:nil?)
29
30
  }.freeze
31
+ COMPOSITION_KEYWORDS = %i[anyOf oneOf].freeze
30
32
 
31
33
  def self.validate(parsed_output, schema)
32
34
  new(parsed_output, schema).validate
@@ -59,11 +61,52 @@ module RubyLLM
59
61
  end
60
62
 
61
63
  def validate_node(node)
64
+ return validate_composition(node) if node.composition
65
+
62
66
  @scalar_rules.validate(node)
63
67
  @object_rules.validate(node) { |child| validate_node(child) } if node.hash? && node.object_schema?
64
68
  validate_array(node) if node.array?
65
69
  end
66
70
 
71
+ # anyOf passes when at least one branch validates, oneOf when exactly one
72
+ # does. When none does, the errors of the one branch whose type fits the
73
+ # value are the useful ones; otherwise the value matches no branch type.
74
+ def validate_composition(node)
75
+ keyword, branches = node.composition
76
+ branch_errors = branches.map { |branch| errors_for(node.with_schema(branch)) }
77
+ passing = branch_errors.count(&:empty?)
78
+ return if keyword == :anyOf ? passing.positive? : passing == 1
79
+
80
+ if passing > 1
81
+ @errors << "#{node.label}: matches #{passing} oneOf branches, expected exactly 1"
82
+ else
83
+ report_no_matching_branch(node, branches, branch_errors)
84
+ end
85
+ end
86
+
87
+ def report_no_matching_branch(node, branches, branch_errors)
88
+ fitting = branches.each_index.select { |i| branch_type_fits?(branches[i], node.value) }
89
+ return @errors.concat(branch_errors[fitting.first]) if fitting.size == 1
90
+
91
+ types = branches.flat_map { |branch| branch_types(branch) }.uniq
92
+ @errors << "#{node.label}: expected one of #{types.join(", ")}, got #{node.value.class}"
93
+ end
94
+
95
+ def errors_for(node)
96
+ before = @errors.size
97
+ validate_node(node)
98
+ @errors.slice!(before..)
99
+ end
100
+
101
+ def branch_type_fits?(branch, value)
102
+ branch_types(branch).any? { |type| TYPE_CHECKS.fetch(type, ->(_) { true }).call(value) }
103
+ end
104
+
105
+ def branch_types(branch)
106
+ types = Array(branch[:type]).map(&:to_s)
107
+ types.empty? && branch.key?(:properties) ? ["object"] : types
108
+ end
109
+
67
110
  def validate_array(node)
68
111
  items_schema = node.items_schema
69
112
  return unless items_schema.is_a?(Hash)
@@ -40,15 +40,11 @@ module RubyLLM
40
40
  report = @comparison.reports[label]
41
41
  next nil unless report
42
42
 
43
- evaluated_count = report.results.count { |r| r.step_status != CaseResult::SKIPPED_STATUS }
44
- cases_count = [evaluated_count, 1].max
45
- cost_per_call = report.total_cost.to_f / cases_count
46
-
47
43
  {
48
44
  label: label,
49
45
  config: config,
50
46
  score: report.score,
51
- cost_per_call: cost_per_call,
47
+ cost_per_call: cost_per_call(report),
52
48
  latency: report.avg_latency_ms || Float::INFINITY,
53
49
  pass_rate_ratio: report.pass_rate_ratio,
54
50
  total_cost: report.total_cost,
@@ -117,15 +113,20 @@ module RubyLLM
117
113
  current_report = @comparison.reports[current_label]
118
114
  return {} if current_report.nil? || unknown_cost?(current_report)
119
115
 
120
- current_evaluated = current_report.results.count { |r| r.step_status != CaseResult::SKIPPED_STATUS }
121
- current_cases = [current_evaluated, 1].max
122
- current_cost = current_report.total_cost.to_f / current_cases
123
- diff = current_cost - best[:cost_per_call]
116
+ diff = cost_per_call(current_report) - best[:cost_per_call]
124
117
  return {} unless diff.positive?
125
118
 
126
119
  { per_call: diff.round(6), monthly_at: { 10_000 => (diff * 10_000).round(2) } }
127
120
  end
128
121
 
122
+ # An AggregatedReport's results hold the cases of every run while its
123
+ # total_cost is the mean of one run, so cases are counted per run.
124
+ def cost_per_call(report)
125
+ runs = report.respond_to?(:runs) ? report.runs.length : 1
126
+ evaluated = report.results.count { |r| r.step_status != CaseResult::SKIPPED_STATUS }
127
+ report.total_cost.to_f * runs / [evaluated, 1].max
128
+ end
129
+
129
130
  # A subtotal over unpriced cases is not a cost to rank by. Zero stays
130
131
  # "unknown pricing" too: offline runs report $0 without any call.
131
132
  def cost_known?(scored_candidate)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "1.2.0"
5
+ VERSION = "1.3.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.2.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna