squishling 0.2.0 → 0.4.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/CHANGELOG.md +140 -0
- data/README.md +118 -31
- data/docs/configuration.md +75 -21
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +73 -9
- data/docs/harnesses.md +326 -0
- data/docs/measuring-tokens.md +140 -0
- data/docs/naming.md +43 -0
- data/docs/observers.md +80 -0
- data/docs/routing.md +36 -27
- data/docs/schemas.md +24 -5
- data/lib/squishling/appendices.rb +3 -3
- data/lib/squishling/class_methods.rb +60 -29
- data/lib/squishling/collisions.rb +54 -0
- data/lib/squishling/configuration.rb +32 -0
- data/lib/squishling/definition.rb +56 -27
- data/lib/squishling/errors.rb +22 -1
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +93 -0
- data/lib/squishling/harness/registry.rb +69 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +79 -0
- data/lib/squishling/invoker.rb +26 -188
- data/lib/squishling/judge.rb +186 -0
- data/lib/squishling/llm_client.rb +180 -0
- data/lib/squishling/model_path.rb +82 -25
- data/lib/squishling/observer.rb +100 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/output_check.rb +109 -0
- data/lib/squishling/params.rb +47 -6
- data/lib/squishling/rotation.rb +26 -0
- data/lib/squishling/router.rb +8 -2
- data/lib/squishling/run/messages.rb +42 -0
- data/lib/squishling/run.rb +296 -0
- data/lib/squishling/source.rb +5 -5
- data/lib/squishling/squawk.rb +52 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +56 -7
- metadata +25 -3
|
@@ -7,45 +7,52 @@ module Squishling
|
|
|
7
7
|
# Matches the keys a squish! context: Hash may use.
|
|
8
8
|
NAME_KEY = ->(key) { key.is_a?(String) || key.is_a?(Symbol) }
|
|
9
9
|
|
|
10
|
-
attr_reader :klass, :name, :call_context
|
|
10
|
+
attr_reader :klass, :name, :call_context, :handed_off
|
|
11
11
|
|
|
12
|
-
def initialize(klass:, name:,
|
|
13
|
-
provider: nil, params: nil, predicate: nil, fallback: nil, validator: nil,
|
|
12
|
+
def initialize(klass:, name:, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil,
|
|
13
|
+
provider: nil, params: nil, harness: nil, predicate: nil, fallback: nil, validator: nil, squawk: nil,
|
|
14
|
+
call_context: {}, handed_off: false)
|
|
14
15
|
@klass = klass
|
|
15
16
|
@name = name
|
|
16
|
-
@
|
|
17
|
-
@
|
|
17
|
+
@purpose = purpose
|
|
18
|
+
@append_to_purpose = append_to_purpose
|
|
18
19
|
@output_schema = output_schema
|
|
19
20
|
@model = model
|
|
20
21
|
@provider = provider
|
|
21
22
|
@params = params
|
|
23
|
+
@harness = harness
|
|
22
24
|
@predicate = predicate
|
|
23
25
|
@fallback = fallback
|
|
24
26
|
@validator = validator
|
|
27
|
+
@squawk = squawk
|
|
25
28
|
@call_context = call_context
|
|
29
|
+
@handed_off = handed_off
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
# This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
|
|
29
33
|
# can't be overridden: the call must still return the method's result type.
|
|
30
|
-
def for_call(
|
|
31
|
-
provider: nil, params: nil)
|
|
34
|
+
def for_call(purpose: nil, append_to_purpose: nil, context: nil, model: nil, escalation: nil,
|
|
35
|
+
provider: nil, params: nil, harness: nil)
|
|
32
36
|
call_path = ModelPath.declare(model, escalation, "#{label} squish!")
|
|
33
37
|
raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
|
|
34
38
|
unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
|
|
35
39
|
raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
|
|
36
40
|
end
|
|
37
41
|
|
|
38
|
-
appended = Appendices.normalize(
|
|
42
|
+
appended = Appendices.normalize(append_to_purpose, "#{label} squish!") unless append_to_purpose.nil?
|
|
39
43
|
call_params = params && Params.normalize(params, "#{label} squish! params")
|
|
44
|
+
call_harness = Harness.normalize(harness, "#{label} squish!") unless harness.nil?
|
|
40
45
|
self.class.new(
|
|
41
46
|
klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
|
|
42
|
-
validator: @validator,
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
validator: @validator, squawk: @squawk,
|
|
48
|
+
purpose: purpose || @purpose,
|
|
49
|
+
append_to_purpose: [*@append_to_purpose, *appended],
|
|
45
50
|
model: call_path || @model, provider: call_path ? provider : @provider,
|
|
46
51
|
# merge, not Params.resolve: a nil at the method level must still unset the class's key.
|
|
47
52
|
params: call_params ? (@params || {}).merge(call_params) : @params,
|
|
48
|
-
|
|
53
|
+
harness: call_harness || @harness,
|
|
54
|
+
call_context: @call_context.merge((context || {}).transform_keys(&:to_sym)),
|
|
55
|
+
handed_off: true # only squish! builds a definition this way; it is not part of a declared one
|
|
49
56
|
)
|
|
50
57
|
end
|
|
51
58
|
|
|
@@ -53,17 +60,17 @@ module Squishling
|
|
|
53
60
|
"#{klass}##{name}"
|
|
54
61
|
end
|
|
55
62
|
|
|
56
|
-
# The system prompt: the
|
|
57
|
-
def
|
|
58
|
-
value = @
|
|
63
|
+
# The system prompt: the purpose, then each append_to_purpose section.
|
|
64
|
+
def purpose(receiver)
|
|
65
|
+
value = @purpose || klass.purpose
|
|
59
66
|
value = receiver.instance_exec(&value) if value.is_a?(Proc)
|
|
60
67
|
return value if value.nil? || value.empty?
|
|
61
68
|
|
|
62
|
-
[value, *Appendices.render(
|
|
69
|
+
[value, *Appendices.render(append_to_purpose, receiver, label)].join("\n\n")
|
|
63
70
|
end
|
|
64
71
|
|
|
65
|
-
def
|
|
66
|
-
Appendices.resolve(klass.
|
|
72
|
+
def append_to_purpose
|
|
73
|
+
Appendices.resolve(klass.squishling_append_to_purpose + (@append_to_purpose || []))
|
|
67
74
|
end
|
|
68
75
|
|
|
69
76
|
def schema
|
|
@@ -73,13 +80,20 @@ module Squishling
|
|
|
73
80
|
|
|
74
81
|
# One ModelPath::Step per attempt, from the first level (method, class, or config) that declares a model
|
|
75
82
|
# or escalation, or a single attempt on RubyLLM's default model. A provider travels with the model declared
|
|
76
|
-
# at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider.
|
|
83
|
+
# at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider. Models
|
|
84
|
+
# tied on priority are rotated by this method's call count (see Rotation).
|
|
77
85
|
def escalation_path
|
|
78
86
|
config = Squishling.config
|
|
79
87
|
entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
|
|
80
88
|
[config.default_model_path, config.default_provider]].find(&:first)
|
|
81
|
-
entries ||= [{ model: nil, attempts: 1 }]
|
|
82
|
-
ModelPath.
|
|
89
|
+
entries ||= [{ model: nil, attempts: 1, forward_rejected: true }]
|
|
90
|
+
turn = ModelPath.rotating?(entries) ? Rotation.advance(klass, name) : 0
|
|
91
|
+
ModelPath.steps(entries, provider:, params:, turn:)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# How the escalation is used (see Harness): the method's, the class's, the configured default, or :escalation.
|
|
95
|
+
def harness
|
|
96
|
+
@harness || klass.squishling_harness || Squishling.config.default_harness || Harness::DEFAULT
|
|
83
97
|
end
|
|
84
98
|
|
|
85
99
|
# Generation params: config defaults, overridden key by key by the class, then by the method.
|
|
@@ -92,6 +106,12 @@ module Squishling
|
|
|
92
106
|
@validator || klass.squishling_validator
|
|
93
107
|
end
|
|
94
108
|
|
|
109
|
+
# The observability hook: the method's, else the class's, else the configured one. `false` at a level
|
|
110
|
+
# silences the levels above it.
|
|
111
|
+
def squawk
|
|
112
|
+
[@squawk, klass.squishling_squawk, Squishling.config.squawk].find { |hook| !hook.nil? } || nil
|
|
113
|
+
end
|
|
114
|
+
|
|
95
115
|
def context_names
|
|
96
116
|
klass.squishling_context_names
|
|
97
117
|
end
|
|
@@ -115,18 +135,20 @@ module Squishling
|
|
|
115
135
|
coerce(receiver.instance_exec(e, **inputs, &handler))
|
|
116
136
|
end
|
|
117
137
|
|
|
118
|
-
# Deterministic return values:
|
|
119
|
-
#
|
|
138
|
+
# Deterministic and fallback return values: with an output_schema, every value is validated and
|
|
139
|
+
# typed like an LLM result. Without one, the value passes through untouched.
|
|
120
140
|
def coerce(value)
|
|
121
|
-
|
|
141
|
+
schema ? build_result(value, squished: false) : value
|
|
122
142
|
end
|
|
123
143
|
|
|
144
|
+
# A result of this schema's own class passes through; any other result is re-validated via to_h.
|
|
124
145
|
def build_result(attrs, squished:)
|
|
125
146
|
raise ConfigurationError, "#{label} has no output_schema" unless schema
|
|
126
|
-
return attrs if attrs.is_a?(
|
|
147
|
+
return attrs if schema.result_class && attrs.is_a?(schema.result_class)
|
|
127
148
|
|
|
128
|
-
|
|
129
|
-
errors =
|
|
149
|
+
attrs = attrs.to_h if attrs.is_a?(Result::Instance)
|
|
150
|
+
data, errors = jsonify_return(attrs)
|
|
151
|
+
errors = schema.validate(data) if errors.empty?
|
|
130
152
|
raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
|
|
131
153
|
|
|
132
154
|
schema.build(data, squished:)
|
|
@@ -162,6 +184,13 @@ module Squishling
|
|
|
162
184
|
|
|
163
185
|
private
|
|
164
186
|
|
|
187
|
+
# Values JSON can't represent (NaN, Infinity, cycles) are invalid output, not a raw JSON error.
|
|
188
|
+
def jsonify_return(attrs)
|
|
189
|
+
[Schema.jsonify(attrs), []]
|
|
190
|
+
rescue JSON::GeneratorError, JSON::NestingError => e
|
|
191
|
+
[nil, [e.message]]
|
|
192
|
+
end
|
|
193
|
+
|
|
165
194
|
# The implementation's parameters, beneath the prepended wrappers.
|
|
166
195
|
def parameters
|
|
167
196
|
Wrapper.implementation(klass.instance_method(name))&.parameters || []
|
data/lib/squishling/errors.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module Squishling
|
|
4
4
|
class Error < StandardError; end
|
|
5
5
|
|
|
6
|
-
# A programming or setup mistake (missing
|
|
6
|
+
# A programming or setup mistake (missing purpose/schema, non-strict schema, unknown model,
|
|
7
7
|
# missing API key). Never retried and never passed to squish_fallback.
|
|
8
8
|
class ConfigurationError < Error; end
|
|
9
9
|
|
|
@@ -23,6 +23,27 @@ module Squishling
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# A harness's samples (the squishsum harnesses', or a custom one's) were valid but didn't agree, and either no
|
|
27
|
+
# judge was declared (verdict nil) or the judge rejected both (verdict :neither, with its reason). candidates
|
|
28
|
+
# holds the typed results, so a squish_fallback can still return one of them, and raw all of them as Hashes.
|
|
29
|
+
# models names the model behind each role (sample a, sample b, then the judge if one ran), not one entry per
|
|
30
|
+
# attempt; attempts is nil.
|
|
31
|
+
class DisagreementError < InvalidOutputError
|
|
32
|
+
attr_reader :candidates, :verdict, :reason
|
|
33
|
+
|
|
34
|
+
# detail: is text Squishling wrote itself (a judgment model's choice and probability) and is added to the
|
|
35
|
+
# message. A chat judge's reason is model-written and can quote the input, so it stays out of the message
|
|
36
|
+
# (and the logs); read it from #reason.
|
|
37
|
+
def initialize(candidates:, verdict: nil, reason: nil, detail: nil, models: nil)
|
|
38
|
+
@candidates = candidates
|
|
39
|
+
@verdict = verdict
|
|
40
|
+
@reason = reason
|
|
41
|
+
error = verdict ? "the judge rejected both samples" : "the two samples disagreed"
|
|
42
|
+
error += " (#{detail})" if detail
|
|
43
|
+
super(errors: [error], raw: candidates.map(&:to_h), models:)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
26
47
|
# The LLM call itself failed (rate limit, server error, timeout, connection) after RubyLLM's own
|
|
27
48
|
# HTTP retries. The original exception is available as #cause.
|
|
28
49
|
class LLMError < Error; end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Harness
|
|
5
|
+
# The base of every harness: the built-ins, and your own. Subclass it, declare the options it takes, define
|
|
6
|
+
# call, and register it (Squishling.register_harness):
|
|
7
|
+
#
|
|
8
|
+
# class Tiebreaker < Squishling::Harness::Base
|
|
9
|
+
# option :tiebreaker, :step, required: true
|
|
10
|
+
#
|
|
11
|
+
# def call(run, tiebreaker:)
|
|
12
|
+
# first, second = run.samples(run.stages.first(2))
|
|
13
|
+
# run.agree?(first, second) ? first : run.escalate(tiebreaker)
|
|
14
|
+
# end
|
|
15
|
+
# end
|
|
16
|
+
# Squishling.register_harness(:tiebreaker, Tiebreaker)
|
|
17
|
+
#
|
|
18
|
+
# call gets a Run (the primitives that ask for samples, escalate, and judge) and the validated options as
|
|
19
|
+
# keywords, and returns the accepted typed result, or raises Squishling::InvalidOutputError (a
|
|
20
|
+
# DisagreementError when samples disagreed) or Squishling::LLMError, which squish_fallback handles.
|
|
21
|
+
class Base
|
|
22
|
+
Option = Data.define(:name, :type, :required)
|
|
23
|
+
|
|
24
|
+
# The kinds of parameter that can receive the run, call's first argument.
|
|
25
|
+
RUN_PARAMETERS = %i[req opt rest].freeze
|
|
26
|
+
KEYWORD_PARAMETERS = %i[key keyreq].freeze
|
|
27
|
+
REQUIRED_PARAMETERS = %i[req keyreq].freeze
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
# Declares an option: its name, one of OptionTypes::TYPES (or a callable that normalizes the value, see
|
|
31
|
+
# OptionTypes), and whether it must be given. A harness is declared with { type: :name, option_name: value },
|
|
32
|
+
# and the options are checked up front.
|
|
33
|
+
def option(name, type, required: false)
|
|
34
|
+
unless OptionTypes.type?(type)
|
|
35
|
+
raise ConfigurationError, "option #{name.inspect}: type must be one of #{OptionTypes::TYPES.join(', ')} " \
|
|
36
|
+
"or a callable taking (value, label, name), got #{type.inspect}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
raise ConfigurationError, "declare options on a subclass of Harness::Base, not on Base" if equal?(Base)
|
|
40
|
+
if name.to_sym == :type
|
|
41
|
+
raise ConfigurationError, "option :type is taken: it names the harness in a harness Hash"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
own_options[name.to_sym] = Option.new(name: name.to_sym, type:, required: required ? true : false)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The declared options by name, including those of the superclass.
|
|
48
|
+
def options
|
|
49
|
+
inherited = equal?(Base) ? {} : superclass.options
|
|
50
|
+
inherited.merge(own_options)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The options as given in a harness Hash (without type:): checked and normalized, or ConfigurationError.
|
|
54
|
+
# An option given as nil counts as not given.
|
|
55
|
+
def normalize_options(given, label)
|
|
56
|
+
given = given.transform_keys(&:to_sym)
|
|
57
|
+
unknown = given.keys - options.keys
|
|
58
|
+
raise ConfigurationError, "#{label}: unknown harness option(s) #{unknown.join(', ')}" if unknown.any?
|
|
59
|
+
|
|
60
|
+
given = given.compact
|
|
61
|
+
|
|
62
|
+
missing = options.values.select { |option| option.required && !given.key?(option.name) }
|
|
63
|
+
if missing.any?
|
|
64
|
+
raise ConfigurationError, "#{label}: harness option(s) #{missing.map(&:name).join(', ')} required"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
normalized = given.to_h do |name, value|
|
|
68
|
+
[name, OptionTypes.check(options.fetch(name).type, value, label, name)]
|
|
69
|
+
end
|
|
70
|
+
validate_options!(normalized, label)
|
|
71
|
+
normalized.freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Override to check options against each other; raise ConfigurationError (with the label) to reject.
|
|
75
|
+
def validate_options!(_options, _label); end
|
|
76
|
+
|
|
77
|
+
# Rejects a class that can't run as the harness registered under name: not a Base subclass, no call,
|
|
78
|
+
# or a call that doesn't take the run and each declared option.
|
|
79
|
+
def check_registrable!(klass, name)
|
|
80
|
+
unless klass.is_a?(Class) && klass <= Base && klass != Base
|
|
81
|
+
raise ConfigurationError, "harness #{name.inspect} must be a subclass of Squishling::Harness::Base, " \
|
|
82
|
+
"got #{klass.inspect}"
|
|
83
|
+
end
|
|
84
|
+
if klass.instance_method(:call).owner == Base
|
|
85
|
+
raise ConfigurationError, "harness #{name.inspect} must define call(run, **options)"
|
|
86
|
+
end
|
|
87
|
+
unless klass.public_method_defined?(:call)
|
|
88
|
+
raise ConfigurationError, "harness #{name.inspect}: call must be public"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
check_signature!(klass, name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def own_options
|
|
97
|
+
@own_options ||= {}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def check_signature!(klass, name)
|
|
101
|
+
parameters = klass.instance_method(:call).parameters
|
|
102
|
+
unless RUN_PARAMETERS.include?(parameters.first&.first)
|
|
103
|
+
raise ConfigurationError, "harness #{name.inspect}: call must take the run as its first argument"
|
|
104
|
+
end
|
|
105
|
+
if parameters.count { |type, _| type == :req } > 1
|
|
106
|
+
raise ConfigurationError, "harness #{name.inspect}: call takes the run, then options as keywords only"
|
|
107
|
+
end
|
|
108
|
+
if klass.instance_method(:initialize).parameters.any? { |type, _| REQUIRED_PARAMETERS.include?(type) }
|
|
109
|
+
raise ConfigurationError, "harness #{name.inspect}: a harness is created without arguments, " \
|
|
110
|
+
"so initialize can't require any"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
accepted = parameters.filter_map { |type, key| key if KEYWORD_PARAMETERS.include?(type) }
|
|
114
|
+
required = parameters.filter_map { |type, key| key if type == :keyreq }
|
|
115
|
+
# A ** takes every declared option, but not an option's absence.
|
|
116
|
+
missing = parameters.any? { |type, _| type == :keyrest } ? [] : klass.options.keys - accepted
|
|
117
|
+
undeclared = required - klass.options.keys
|
|
118
|
+
# An option that may be left out can't be a required keyword.
|
|
119
|
+
optional = required & klass.options.values.reject(&:required).map(&:name)
|
|
120
|
+
return if missing.empty? && undeclared.empty? && optional.empty?
|
|
121
|
+
|
|
122
|
+
problems = [("doesn't take #{missing.join(', ')}" if missing.any?),
|
|
123
|
+
("requires undeclared #{undeclared.join(', ')}" if undeclared.any?),
|
|
124
|
+
("requires optional #{optional.join(', ')} (give it a default)" if optional.any?)].compact
|
|
125
|
+
raise ConfigurationError, "harness #{name.inspect}: call #{problems.join(' and ')} " \
|
|
126
|
+
"(declare each option with option, and take it as a keyword)"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The harness itself; subclasses define it.
|
|
131
|
+
def call(_run, **)
|
|
132
|
+
raise ConfigurationError, "#{self.class} must define call(run, **options)"
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Harness
|
|
5
|
+
# The default: each attempt of the escalation, in order, until an output passes the schema and squish_validate.
|
|
6
|
+
class Escalation < Base
|
|
7
|
+
def self.normalize_options(given, label)
|
|
8
|
+
if given.transform_keys(&:to_sym).key?(:compare)
|
|
9
|
+
raise ConfigurationError, "#{label}: compare: only applies to the squishsum harnesses"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
super
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(run)
|
|
16
|
+
run.escalate
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Registry.register_built_in(:escalation, Escalation)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Harness
|
|
5
|
+
# Like Squishling::Harness::Squishsum, but samples that disagree go to a judge, which picks one or rejects both.
|
|
6
|
+
# A judge is one step: a model name or a Hash with the escalation step keys (except priority:), plus
|
|
7
|
+
# type: (:chat, the default, or :judgment for a System One decision model through RubyLLM.judge) and,
|
|
8
|
+
# for :judgment, min_confidence: (the probability the chosen candidate needs).
|
|
9
|
+
class JudgedSquishsum < Base
|
|
10
|
+
# Request keys RubyLLM's judgment protocols own (model and input are already reserved params).
|
|
11
|
+
JUDGMENT_RESERVED_PARAMS = %i[state questions].freeze
|
|
12
|
+
private_constant :JUDGMENT_RESERVED_PARAMS
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# A judge as a normalized escalation step (see ModelPath), plus its type and min_confidence. label is the
|
|
16
|
+
# declaration site and name the option it came from. Internal.
|
|
17
|
+
def normalize_judge(judge, label, name = :judge)
|
|
18
|
+
judge_label = "#{label} #{name}"
|
|
19
|
+
return ModelPath.normalize_step(judge, judge_label).merge(type: :chat) unless judge.is_a?(Hash)
|
|
20
|
+
|
|
21
|
+
judge = judge.transform_keys(&:to_sym)
|
|
22
|
+
type = judge.fetch(:type, :chat)
|
|
23
|
+
type = type.to_sym if type.is_a?(String)
|
|
24
|
+
unless Judge::TYPES.include?(type)
|
|
25
|
+
raise ConfigurationError,
|
|
26
|
+
"#{judge_label}: type: must be one of #{Judge::TYPES.join(', ')}, got #{type.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
if type == :chat && judge.key?(:min_confidence)
|
|
30
|
+
raise ConfigurationError, "#{judge_label}: min_confidence: only applies to type: :judgment judges"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
step = ModelPath.normalize_step(judge.except(*Judge::STEP_KEYS), judge_label).merge(type:)
|
|
34
|
+
ModelPath.single_step!(step, "#{judge_label}: a judge is one step, so it")
|
|
35
|
+
return step unless type == :judgment
|
|
36
|
+
|
|
37
|
+
Params.reject_reserved!(step[:params] || {}, JUDGMENT_RESERVED_PARAMS, judge_label)
|
|
38
|
+
|
|
39
|
+
step.merge(min_confidence: min_confidence(judge, judge_label))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The judge's prompt: a non-blank String, or a Proc evaluated against the instance. Internal.
|
|
43
|
+
def normalize_instructions(value, label, name = :judge_instructions)
|
|
44
|
+
return value if value.is_a?(Proc)
|
|
45
|
+
return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
|
|
46
|
+
|
|
47
|
+
raise ConfigurationError, "#{label}: #{name}: must be a non-blank String or a Proc"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def min_confidence(judge, label)
|
|
53
|
+
value = judge.fetch(:min_confidence, Judge::DEFAULT_MIN_CONFIDENCE)
|
|
54
|
+
return value.to_f if value.is_a?(Numeric) && value.between?(0, 1)
|
|
55
|
+
|
|
56
|
+
raise ConfigurationError, "#{label}: min_confidence: must be a number from 0 to 1, got #{value.inspect}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
option :compare, :proc
|
|
61
|
+
option :judge, ->(value, label, name) { normalize_judge(value, label, name) }
|
|
62
|
+
option :judge_instructions, ->(value, label, name) { normalize_instructions(value, label, name) }
|
|
63
|
+
|
|
64
|
+
def call(run, compare: nil, judge: nil, judge_instructions: nil)
|
|
65
|
+
stage = run.stages.first
|
|
66
|
+
# The judge is resolved before any sample is requested, so a missing one fails before costing anything.
|
|
67
|
+
judge = run.judge(judge:, instructions: judge_instructions)
|
|
68
|
+
first, second = run.samples([stage, stage])
|
|
69
|
+
return first if run.agree?(first, second, compare)
|
|
70
|
+
|
|
71
|
+
run.log("samples disagreed, asking the judge (#{judge.step.display_name})")
|
|
72
|
+
verdict = judge.verdict(first, second)
|
|
73
|
+
return { a: first, b: second }.fetch(verdict.choice) unless verdict.choice == :neither
|
|
74
|
+
|
|
75
|
+
models = [stage.first.model] * 2
|
|
76
|
+
raise DisagreementError.new(candidates: [first, second], verdict: :neither, reason: verdict.reason,
|
|
77
|
+
detail: verdict.detail, models: models + [judge.step.model])
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Registry.register_built_in(:judged_squishsum, JudgedSquishsum)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Harness
|
|
5
|
+
# The types a harness option can declare (see Base.option). Each checks a given value and returns it
|
|
6
|
+
# normalized, or raises ConfigurationError, so a bad option fails before any request is made.
|
|
7
|
+
# :step a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected:)
|
|
8
|
+
# :proc, :string, :symbol, :integer, :boolean
|
|
9
|
+
# A harness with a shape of its own declares the type as a callable instead, which gets the value, the
|
|
10
|
+
# declaration site, and the option's name, and returns the normalized value (or raises ConfigurationError).
|
|
11
|
+
module OptionTypes
|
|
12
|
+
TYPES = %i[step proc string symbol integer boolean].freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Whether type is one a harness can declare: a built-in one above, or a callable that takes the value, the
|
|
17
|
+
# label, and the name.
|
|
18
|
+
def type?(type)
|
|
19
|
+
TYPES.include?(type) || (type.respond_to?(:call) && takes_three?(type))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Whether the callable can be called with three positional arguments. One without a parameter list
|
|
23
|
+
# (an object with its own call) is trusted.
|
|
24
|
+
def takes_three?(callable)
|
|
25
|
+
return true if callable.is_a?(Proc) && !callable.lambda? # a proc drops the arguments it doesn't name
|
|
26
|
+
|
|
27
|
+
method = callable.is_a?(Proc) || callable.is_a?(Method) ? callable : callable.method(:call)
|
|
28
|
+
parameters = method.parameters.map(&:first)
|
|
29
|
+
return false if parameters.include?(:keyreq)
|
|
30
|
+
return true if parameters.include?(:rest)
|
|
31
|
+
|
|
32
|
+
3.between?(parameters.count(:req), parameters.count(:req) + parameters.count(:opt))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The value as the type's normalized form. label is the declaration site, name the option.
|
|
36
|
+
def check(type, value, label, name)
|
|
37
|
+
return deep_freeze(type.call(value, label, name)) if type.respond_to?(:call)
|
|
38
|
+
|
|
39
|
+
send(:"check_#{type}", value, "#{label} #{name}", "#{label}: #{name}:")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def check_step(value, label, _where)
|
|
43
|
+
step = ModelPath.normalize_step(value, label)
|
|
44
|
+
ModelPath.single_step!(step, "#{label}: a harness step")
|
|
45
|
+
deep_freeze(step)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def check_proc(value, _label, where)
|
|
49
|
+
check_kind(value, Proc, "a Proc", where)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def check_string(value, _label, where)
|
|
53
|
+
return value.dup.freeze if value.is_a?(String)
|
|
54
|
+
|
|
55
|
+
raise ConfigurationError, "#{where} must be a String, got #{value.class}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_symbol(value, _label, where)
|
|
59
|
+
return value.to_sym if value.is_a?(Symbol) || value.is_a?(String)
|
|
60
|
+
|
|
61
|
+
raise ConfigurationError, "#{where} must be a Symbol, got #{value.class}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def check_integer(value, _label, where)
|
|
65
|
+
check_kind(value, Integer, "an Integer", where)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def check_boolean(value, _label, where)
|
|
69
|
+
return value if [true, false].include?(value)
|
|
70
|
+
|
|
71
|
+
raise ConfigurationError, "#{where} must be true or false, got #{value.inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The value itself when it is a kind, else a ConfigurationError saying what was expected.
|
|
75
|
+
def check_kind(value, kind, description, where)
|
|
76
|
+
return value if value.is_a?(kind)
|
|
77
|
+
|
|
78
|
+
raise ConfigurationError, "#{where} must be #{description}, got #{value.class}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A frozen copy: normalized steps are shared by every call that uses the declaration, so none can change
|
|
82
|
+
# one, and the caller's own objects stay as they were.
|
|
83
|
+
def deep_freeze(value)
|
|
84
|
+
case value
|
|
85
|
+
when Hash then value.to_h { |key, item| [key, deep_freeze(item)] }.freeze
|
|
86
|
+
when Array then value.map { |item| deep_freeze(item) }.freeze
|
|
87
|
+
when String then value.dup.freeze
|
|
88
|
+
else value
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Harness
|
|
5
|
+
# The harness types by name: the built-ins, registered as they load, and your own (Squishling.register_harness).
|
|
6
|
+
# Shared across threads, so every read and write is under the lock.
|
|
7
|
+
module Registry
|
|
8
|
+
NAME = /\A[a-z][a-z0-9_]*\z/
|
|
9
|
+
|
|
10
|
+
LOCK = Mutex.new
|
|
11
|
+
private_constant :LOCK
|
|
12
|
+
@built_in = {}
|
|
13
|
+
@custom = {}
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def register_built_in(name, klass)
|
|
17
|
+
LOCK.synchronize { @built_in[name] = klass }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Registers a harness class (or a block, which becomes the body of a Base subclass) under a name.
|
|
21
|
+
def register(name, klass = nil, &block)
|
|
22
|
+
raise ConfigurationError, "register_harness takes a class or a block, not both" if klass && block
|
|
23
|
+
raise ConfigurationError, "register_harness needs a Harness::Base subclass or a block" unless klass || block
|
|
24
|
+
|
|
25
|
+
name = checked_name(name)
|
|
26
|
+
klass = Class.new(Base, &block) if block
|
|
27
|
+
Base.check_registrable!(klass, name)
|
|
28
|
+
LOCK.synchronize do
|
|
29
|
+
raise ConfigurationError, "harness #{name.inspect} is already registered" if taken?(name)
|
|
30
|
+
|
|
31
|
+
@custom[name] = klass
|
|
32
|
+
end
|
|
33
|
+
klass
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch(name)
|
|
37
|
+
LOCK.synchronize { @built_in[name] || @custom[name] }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def names
|
|
41
|
+
LOCK.synchronize { @built_in.keys + @custom.keys }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Forgets the registered harnesses; the built-ins stay.
|
|
45
|
+
def reset!
|
|
46
|
+
LOCK.synchronize { @custom.clear }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def checked_name(name)
|
|
52
|
+
unless name.is_a?(Symbol) || name.is_a?(String)
|
|
53
|
+
raise ConfigurationError, "a harness name must be a Symbol or String, got #{name.inspect}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
name = name.to_sym
|
|
57
|
+
return name if NAME.match?(name)
|
|
58
|
+
|
|
59
|
+
raise ConfigurationError,
|
|
60
|
+
"harness name #{name.inspect} must be lowercase letters, digits, and underscores, starting with a letter"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def taken?(name)
|
|
64
|
+
@built_in.key?(name) || @custom.key?(name)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
class Harness
|
|
5
|
+
# Two samples on the escalation's first step, asked concurrently. Identical outputs (or ones compare: calls
|
|
6
|
+
# equal) are accepted; different ones raise DisagreementError.
|
|
7
|
+
class Squishsum < Base
|
|
8
|
+
option :compare, :proc
|
|
9
|
+
|
|
10
|
+
def call(run, compare: nil)
|
|
11
|
+
stage = run.stages.first
|
|
12
|
+
first, second = run.samples([stage, stage])
|
|
13
|
+
return first if run.agree?(first, second, compare)
|
|
14
|
+
|
|
15
|
+
run.log("samples disagreed")
|
|
16
|
+
raise DisagreementError.new(candidates: [first, second], models: [stage.first.model] * 2)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
Registry.register_built_in(:squishsum, Squishsum)
|
|
21
|
+
end
|
|
22
|
+
end
|