ruby_llm-contract 1.2.0 → 1.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fead48c5516f8e0aa738d3ab1633ca2d07f7173aba6466a6d29e37634263b857
4
- data.tar.gz: 4ae4c314cd6365c5f5de455f9dc5ddc0741abd26fa751804b22c5ffded0e6cb1
3
+ metadata.gz: 2532bf3e16d6e810c3caf8ac7d3d94e9ed43f87cdb9945c26ca33977d619ba39
4
+ data.tar.gz: f281f6b10532ef145821e60f9de352cc7bb2f07946ff146497ec537b28d430c5
5
5
  SHA512:
6
- metadata.gz: 9b66df1e490e238fdacc84a728b874a008b80c8b44cdd882b444a84389bb77996ee6132efb6adaad79c91a04bee30eb7351241a4989ff1a4ed5df8d7a6cff619
7
- data.tar.gz: a055fd3d06ee4fa9591fa2a6cfed6872142febd950fe3a455daa6e588f04e157a585701456f4815483ecc3accb803cf3bbcffa9308c6dfa5011c00f6a7143274
6
+ metadata.gz: c53ac500520e51a63134c7060a214944abb7839753ac22007d2034d3ab338eb0f20d6b8fd06c86904a3ecd5e026f16f83e9d83b58cd051a6ff35ce2b208e7334
7
+ data.tar.gz: 9ae24e6b2719f40a2045deb8f419fc3d3adf92bd98c51d9360d08db440d189997f2f0866a8ca6ae5eecc611226d5869c8c57c1321e32390a188063c7aec05c12
data/CHANGELOG.md CHANGED
@@ -1,8 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 (2026-10-11)
4
+
5
+ ### Changed - may turn a green CI red
6
+
7
+ - **A non-null value under `anyOf` / `oneOf` is now validated against the branches.**
8
+ 0.10.3 taught the validator to accept `nil` through a null branch, but a non-null
9
+ value was never checked at all: the child node carried the composition wrapper,
10
+ which has no `type` or `properties`, so nothing ran. A nullable object block
11
+ (`any_of :block do object do ... end; null end`) missing a required field, with
12
+ a wrong-typed field or with an extra key came back `:ok`. Measured in an adopter:
13
+ a page-analysis block without `score` was `:ok` and became a 0/10 fail downstream.
14
+ `anyOf` now passes when at least one branch validates and `oneOf` when exactly one
15
+ does. When none does, the errors of the one branch whose type fits the value are
16
+ reported (`missing required field: block.score`); otherwise
17
+ `block: expected one of object, null, got String`. A `oneOf` value matching two
18
+ branches fails with `matches 2 oneOf branches, expected exactly 1`. Steps whose
19
+ model already returns schema-conforming output (OpenAI strict mode) are
20
+ unaffected; output that slipped through before now fails as `:validation_failed`.
21
+ - **`type` given as an array is checked.** `type: ["string", "null"]` accepted any
22
+ value; it now accepts a value of any listed type and reports
23
+ `x: expected string or null, got Integer` otherwise. `"null"` is a known type.
24
+
3
25
  ## 1.2.0 (2026-10-10)
4
26
 
5
- Everything new is opt-in, except the temperature fix below.
27
+ 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
28
 
7
29
  ### Added
8
30
 
@@ -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)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Contract
5
- VERSION = "1.2.0"
5
+ VERSION = "1.3.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.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justyna