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 +4 -4
- data/CHANGELOG.md +37 -1
- data/lib/ruby_llm/contract/contract/schema_validator/node.rb +16 -0
- data/lib/ruby_llm/contract/contract/schema_validator/type_rule.rb +4 -4
- data/lib/ruby_llm/contract/contract/schema_validator.rb +44 -1
- data/lib/ruby_llm/contract/eval/recommender.rb +10 -9
- data/lib/ruby_llm/contract/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b3d6985e56b8e410f796207f23b52e910b925f1bf4263353da65ca3f540c9535
|
|
4
|
+
data.tar.gz: 4cb5f7471dc5d97f3d3bd78fe41110cb6a9f28139e90029872fa612ed4ef3e9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
12
|
+
expected_types = Array(node.schema[:type]).map(&:to_s)
|
|
13
13
|
value = node.value
|
|
14
|
-
return
|
|
15
|
-
return if type_valid?(
|
|
14
|
+
return if expected_types.empty?
|
|
15
|
+
return if expected_types.any? { |type| type_valid?(type, value) }
|
|
16
16
|
|
|
17
|
-
@errors << "#{node.path}: expected #{
|
|
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
|
-
|
|
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)
|