squishling 0.2.0 → 0.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 +4 -4
- data/CHANGELOG.md +83 -0
- data/README.md +85 -30
- data/docs/configuration.md +40 -16
- data/docs/failures.md +68 -8
- data/docs/harnesses.md +198 -0
- data/docs/measuring-tokens.md +148 -0
- data/docs/naming.md +43 -0
- data/docs/routing.md +36 -27
- data/docs/schemas.md +4 -3
- 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 +19 -0
- data/lib/squishling/definition.rb +48 -23
- data/lib/squishling/errors.rb +22 -1
- data/lib/squishling/harness.rb +153 -0
- data/lib/squishling/invoker.rb +188 -133
- data/lib/squishling/judge.rb +142 -0
- data/lib/squishling/llm_client.rb +150 -0
- data/lib/squishling/model_path.rb +21 -7
- data/lib/squishling/output_check.rb +109 -0
- data/lib/squishling/params.rb +37 -1
- data/lib/squishling/router.rb +8 -2
- 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 +24 -7
- metadata +12 -3
|
@@ -6,27 +6,49 @@ module Squishling
|
|
|
6
6
|
DEFAULT_METHOD = :call
|
|
7
7
|
|
|
8
8
|
# Set class-wide options in one call:
|
|
9
|
-
# squishling model: "claude-sonnet-5-5",
|
|
9
|
+
# squishling model: "claude-sonnet-5-5", purpose: "...", output_schema: MySchema
|
|
10
10
|
# model: is a single attempt; escalation: is a list of models tried in order (see ModelPath):
|
|
11
11
|
# squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
|
|
12
12
|
# Pass provider: alongside model: for models missing from RubyLLM's registry, e.g.
|
|
13
|
-
# squishling model: "gpt-
|
|
13
|
+
# squishling model: "gpt-7-preview", provider: :openai
|
|
14
|
+
# provider: needs a model: or escalation: (declared on the class), and a subclass's model never inherits its
|
|
15
|
+
# parent's provider.
|
|
14
16
|
# params: are generation params merged over the configured defaults (see Configuration#default_params):
|
|
15
17
|
# squishling params: { temperature: 0.1, top_p: 0.9 }
|
|
16
|
-
#
|
|
17
|
-
# squishling
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
# append_to_purpose: adds sections after the purpose (see #append_to_purpose):
|
|
19
|
+
# squishling append_to_purpose: ["The Ruby that handles well-formed input:", self]
|
|
20
|
+
# harness: chooses how the escalation is used (see Harness):
|
|
21
|
+
# squishling harness: :judged_squishsum
|
|
22
|
+
# squawk: is called after every LLM attempt with the raw output (see Configuration#squawk); false silences
|
|
23
|
+
# an inherited one:
|
|
24
|
+
# squishling squawk: ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
|
|
25
|
+
def squishling(model: nil, escalation: nil, provider: nil, params: nil, harness: nil, purpose: nil,
|
|
26
|
+
append_to_purpose: nil, output_schema: nil, squawk: nil)
|
|
20
27
|
path = ModelPath.declare(model, escalation, to_s)
|
|
28
|
+
# A provider belongs to the model declared at the same level. Without a model here it would apply to
|
|
29
|
+
# nothing (the model comes from the config or a parent class), so it is an error unless this class already
|
|
30
|
+
# declared its own.
|
|
31
|
+
if provider && !path && !instance_variable_defined?(:@squishling_model_path)
|
|
32
|
+
raise ConfigurationError, "#{self}: provider: needs a model: or escalation: declared on this class"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Stored (even as nil) whenever a model is, so a subclass that declares its own model never inherits a
|
|
36
|
+
# provider meant for its parent's.
|
|
21
37
|
@squishling_model_path = path if path
|
|
22
|
-
@squishling_provider = provider if provider
|
|
38
|
+
@squishling_provider = provider if provider || path
|
|
23
39
|
@squishling_params = Params.normalize(params, "#{self} params") if params
|
|
24
|
-
|
|
25
|
-
self.
|
|
40
|
+
@squishling_harness = Harness.normalize(harness, to_s) unless harness.nil?
|
|
41
|
+
self.purpose(purpose) if purpose
|
|
42
|
+
self.append_to_purpose(append_to_purpose) unless append_to_purpose.nil?
|
|
26
43
|
self.output_schema(output_schema) if output_schema
|
|
44
|
+
@squishling_squawk = Squawk.validate(squawk, to_s) unless squawk.nil?
|
|
27
45
|
self
|
|
28
46
|
end
|
|
29
47
|
|
|
48
|
+
def squishling_squawk
|
|
49
|
+
squishling_lookup(:@squishling_squawk)
|
|
50
|
+
end
|
|
51
|
+
|
|
30
52
|
# The class's (or nearest ancestor's) model or escalation, as normalized steps.
|
|
31
53
|
def squishling_model_path
|
|
32
54
|
squishling_lookup(:@squishling_model_path)
|
|
@@ -36,33 +58,37 @@ module Squishling
|
|
|
36
58
|
squishling_lookup(:@squishling_provider)
|
|
37
59
|
end
|
|
38
60
|
|
|
61
|
+
def squishling_harness
|
|
62
|
+
squishling_lookup(:@squishling_harness)
|
|
63
|
+
end
|
|
64
|
+
|
|
39
65
|
# Generation params merged down the inheritance chain, so a subclass overrides individual keys.
|
|
40
66
|
def squishling_params
|
|
41
67
|
squishling_inherited(:squishling_params, {}).merge(@squishling_params || {})
|
|
42
68
|
end
|
|
43
69
|
|
|
44
70
|
# The system prompt. A String, or a Proc evaluated against the instance.
|
|
45
|
-
def
|
|
46
|
-
return squishling_lookup(:@
|
|
71
|
+
def purpose(text = nil, &block)
|
|
72
|
+
return squishling_lookup(:@squishling_purpose) if text.nil? && block.nil?
|
|
47
73
|
|
|
48
|
-
@
|
|
74
|
+
@squishling_purpose = block || text
|
|
49
75
|
end
|
|
50
76
|
|
|
51
|
-
# Sections appended to the system prompt after the
|
|
77
|
+
# Sections appended to the system prompt after the purpose, added to by subclasses, `squish`, and
|
|
52
78
|
# `squish!`. Items: Strings; a class or module (`self` for this class) or a method (`instance_method(:call)`),
|
|
53
79
|
# sent as its Ruby source; or a Proc evaluated against the instance. `false` drops inherited items.
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
def
|
|
80
|
+
# append_to_purpose "Here is the Ruby that parses well-formed invoices:", self
|
|
81
|
+
# append_to_purpose { "This client's invoices are in #{currency}." }
|
|
82
|
+
def append_to_purpose(*items, &block)
|
|
57
83
|
items = items.first if items.size == 1 && items.first.is_a?(Array)
|
|
58
84
|
items += [block] if block
|
|
59
|
-
(@
|
|
85
|
+
(@squishling_append_to_purpose ||= []).concat(Appendices.normalize(items, "#{self} append_to_purpose"))
|
|
60
86
|
self
|
|
61
87
|
end
|
|
62
88
|
|
|
63
89
|
# Every level's items in declaration order, `false` markers included (see Appendices.resolve).
|
|
64
|
-
def
|
|
65
|
-
squishling_inherited(:
|
|
90
|
+
def squishling_append_to_purpose
|
|
91
|
+
squishling_inherited(:squishling_append_to_purpose, []) + (@squishling_append_to_purpose || [])
|
|
66
92
|
end
|
|
67
93
|
|
|
68
94
|
# The output format: a Schematist::Schema subclass (RubyLLM::Schema with the ruby_llm-schema shim),
|
|
@@ -85,7 +111,7 @@ module Squishling
|
|
|
85
111
|
|
|
86
112
|
# Called with the error and the method's inputs (as keywords) when the LLM path fails with an
|
|
87
113
|
# InvalidOutputError or LLMError, evaluated against the instance. Its return value is used as
|
|
88
|
-
# the result (
|
|
114
|
+
# the result (validated against the schema and typed like a deterministic return); re-raise to propagate.
|
|
89
115
|
# squish_fallback { |error, **inputs| { priority: "medium", team: "support" } }
|
|
90
116
|
def squish_fallback(&block)
|
|
91
117
|
@squishling_fallback = block
|
|
@@ -118,21 +144,26 @@ module Squishling
|
|
|
118
144
|
end
|
|
119
145
|
|
|
120
146
|
# Make methods elastic. Each may override the class-level settings:
|
|
121
|
-
# squish :triage,
|
|
122
|
-
# fallback: ->(error, **) { { priority: "medium" } },
|
|
123
|
-
# validate: ->(result, **) { "team is required" if result.team.empty? }
|
|
147
|
+
# squish :triage, purpose: "...", escalation: %w[claude-haiku-4-5 claude-sonnet-5-5], when: ->(**) { true },
|
|
148
|
+
# fallback: ->(error, **) { { priority: "medium" } }, append_to_purpose: [...],
|
|
149
|
+
# validate: ->(result, **) { "team is required" if result.team.empty? },
|
|
150
|
+
# harness: :squishsum, squawk: ->(output:, error:, **) { Tracer.record(output, error) } do
|
|
124
151
|
# string :priority
|
|
125
152
|
# end
|
|
126
|
-
def squish(*names,
|
|
127
|
-
provider: nil, params: nil, when: nil, fallback: nil, validate: nil, &schema_block)
|
|
153
|
+
def squish(*names, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil, escalation: nil,
|
|
154
|
+
provider: nil, params: nil, harness: nil, when: nil, fallback: nil, validate: nil, squawk: nil, &schema_block)
|
|
128
155
|
schema = schema_block ? Schematist::Schema.create(&schema_block) : output_schema
|
|
129
156
|
model = ModelPath.declare(model, escalation, "#{self} squish")
|
|
157
|
+
raise ConfigurationError, "#{self} squish: provider: needs a model: or escalation:" if provider && !model
|
|
158
|
+
|
|
130
159
|
params &&= Params.normalize(params, "#{self} squish params")
|
|
131
|
-
unless
|
|
132
|
-
|
|
160
|
+
harness = Harness.normalize(harness, "#{self} squish") unless harness.nil?
|
|
161
|
+
unless append_to_purpose.nil?
|
|
162
|
+
append_to_purpose = Appendices.normalize(append_to_purpose, "#{self} squish append_to_purpose")
|
|
133
163
|
end
|
|
134
|
-
|
|
135
|
-
|
|
164
|
+
squawk = Squawk.validate(squawk, "#{self} squish")
|
|
165
|
+
options = { purpose:, append_to_purpose:, output_schema: schema, model:, provider:, params:, harness:,
|
|
166
|
+
predicate: binding.local_variable_get(:when), fallback:, validator: validate, squawk: }.compact
|
|
136
167
|
|
|
137
168
|
names.map(&:to_sym).each do |name|
|
|
138
169
|
(@squishling_methods ||= {})[name] = options
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# `include Squishling` puts its methods ahead of inherited ones, so a class that inherits a method of the same
|
|
5
|
+
# name (Sinatra::Base.call, a parent's own `purpose`, ...) would be silently shadowed. Methods the class
|
|
6
|
+
# defines itself always win, so they are never reported.
|
|
7
|
+
module Collisions
|
|
8
|
+
INSTANCE_METHODS = %i[squish! squishling_result].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Raises ConfigurationError naming every inherited method Squishling would override. Call it before
|
|
13
|
+
# extending the class with ClassMethods, and after the module is in the class's ancestors.
|
|
14
|
+
def check!(base)
|
|
15
|
+
found = class_collisions(base) + instance_collisions(base)
|
|
16
|
+
return if found.empty?
|
|
17
|
+
|
|
18
|
+
raise ConfigurationError,
|
|
19
|
+
"#{base} inherits methods that Squishling would override: #{found.join(', ')}. " \
|
|
20
|
+
"Include Squishling in a plain Ruby class instead (compose the framework object rather than inheriting it)"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def class_collisions(base)
|
|
24
|
+
ClassMethods.public_instance_methods(false).filter_map do |name|
|
|
25
|
+
next if name == :inherited || !base.respond_to?(name, true)
|
|
26
|
+
|
|
27
|
+
owner = base.method(name).owner
|
|
28
|
+
next if owner == base.singleton_class || owner == ClassMethods
|
|
29
|
+
|
|
30
|
+
"#{base}.#{name} (from #{describe(owner)})"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def instance_collisions(base)
|
|
35
|
+
ancestors = base.ancestors
|
|
36
|
+
inherited = ancestors.drop(ancestors.index(Squishling) + 1)
|
|
37
|
+
|
|
38
|
+
INSTANCE_METHODS.filter_map do |name|
|
|
39
|
+
next if defined_in?(base, name)
|
|
40
|
+
|
|
41
|
+
owner = inherited.find { |mod| defined_in?(mod, name) }
|
|
42
|
+
"#{base}##{name} (from #{describe(owner)})" if owner
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def defined_in?(mod, name)
|
|
47
|
+
mod.method_defined?(name, false) || mod.private_method_defined?(name, false)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def describe(owner)
|
|
51
|
+
owner.singleton_class? ? "#{owner.attached_object}'s class methods" : owner.to_s
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -23,16 +23,27 @@ module Squishling
|
|
|
23
23
|
# with_thinking; any other key (top_p, max_tokens, seed, ...) is passed to the provider as-is.
|
|
24
24
|
attr_reader :default_params
|
|
25
25
|
|
|
26
|
+
# How every squishling class that doesn't declare its own harness uses its escalation (see Harness):
|
|
27
|
+
# :escalation (the default), :squishsum, :judged_squishsum, :ensemble, :judged_ensemble, or a Hash with
|
|
28
|
+
# type: and its options.
|
|
29
|
+
attr_reader :default_harness
|
|
30
|
+
|
|
26
31
|
# Optional Logger for routing and escalation decisions.
|
|
27
32
|
attr_accessor :logger
|
|
28
33
|
|
|
34
|
+
# Optional callable run after every LLM attempt with the raw output (see Squawk), e.g.
|
|
35
|
+
# ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
|
|
36
|
+
attr_reader :squawk
|
|
37
|
+
|
|
29
38
|
def initialize
|
|
30
39
|
@default_model = nil
|
|
31
40
|
@default_escalation = nil
|
|
32
41
|
@default_model_path = nil
|
|
33
42
|
@default_provider = nil
|
|
34
43
|
@default_params = {}
|
|
44
|
+
@default_harness = nil
|
|
35
45
|
@logger = nil
|
|
46
|
+
@squawk = nil
|
|
36
47
|
end
|
|
37
48
|
|
|
38
49
|
def default_model=(model)
|
|
@@ -47,10 +58,18 @@ module Squishling
|
|
|
47
58
|
@default_escalation = escalation
|
|
48
59
|
end
|
|
49
60
|
|
|
61
|
+
def squawk=(hook)
|
|
62
|
+
@squawk = Squawk.validate(hook, "Squishling.configure")
|
|
63
|
+
end
|
|
64
|
+
|
|
50
65
|
def default_params=(params)
|
|
51
66
|
@default_params = Params.normalize(params, "default_params")
|
|
52
67
|
end
|
|
53
68
|
|
|
69
|
+
def default_harness=(harness)
|
|
70
|
+
@default_harness = harness.nil? ? nil : Harness.normalize(harness, "default_harness")
|
|
71
|
+
end
|
|
72
|
+
|
|
54
73
|
MAX_RETRIES_REMOVED = "max_retries was removed; use default_escalation (or a class/method escalation:) " \
|
|
55
74
|
"with attempts:, e.g. [{ model: \"claude-haiku-4-5\", attempts: 2 }]"
|
|
56
75
|
|
|
@@ -9,42 +9,47 @@ module Squishling
|
|
|
9
9
|
|
|
10
10
|
attr_reader :klass, :name, :call_context
|
|
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: {})
|
|
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
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
# This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
|
|
29
32
|
# can't be overridden: the call must still return the method's result type.
|
|
30
|
-
def for_call(
|
|
31
|
-
provider: nil, params: nil)
|
|
33
|
+
def for_call(purpose: nil, append_to_purpose: nil, context: nil, model: nil, escalation: nil,
|
|
34
|
+
provider: nil, params: nil, harness: nil)
|
|
32
35
|
call_path = ModelPath.declare(model, escalation, "#{label} squish!")
|
|
33
36
|
raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
|
|
34
37
|
unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
|
|
35
38
|
raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
|
|
36
39
|
end
|
|
37
40
|
|
|
38
|
-
appended = Appendices.normalize(
|
|
41
|
+
appended = Appendices.normalize(append_to_purpose, "#{label} squish!") unless append_to_purpose.nil?
|
|
39
42
|
call_params = params && Params.normalize(params, "#{label} squish! params")
|
|
43
|
+
call_harness = Harness.normalize(harness, "#{label} squish!") unless harness.nil?
|
|
40
44
|
self.class.new(
|
|
41
45
|
klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
|
|
42
|
-
validator: @validator,
|
|
43
|
-
|
|
44
|
-
|
|
46
|
+
validator: @validator, squawk: @squawk,
|
|
47
|
+
purpose: purpose || @purpose,
|
|
48
|
+
append_to_purpose: [*@append_to_purpose, *appended],
|
|
45
49
|
model: call_path || @model, provider: call_path ? provider : @provider,
|
|
46
50
|
# merge, not Params.resolve: a nil at the method level must still unset the class's key.
|
|
47
51
|
params: call_params ? (@params || {}).merge(call_params) : @params,
|
|
52
|
+
harness: call_harness || @harness,
|
|
48
53
|
call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
|
|
49
54
|
)
|
|
50
55
|
end
|
|
@@ -53,17 +58,17 @@ module Squishling
|
|
|
53
58
|
"#{klass}##{name}"
|
|
54
59
|
end
|
|
55
60
|
|
|
56
|
-
# The system prompt: the
|
|
57
|
-
def
|
|
58
|
-
value = @
|
|
61
|
+
# The system prompt: the purpose, then each append_to_purpose section.
|
|
62
|
+
def purpose(receiver)
|
|
63
|
+
value = @purpose || klass.purpose
|
|
59
64
|
value = receiver.instance_exec(&value) if value.is_a?(Proc)
|
|
60
65
|
return value if value.nil? || value.empty?
|
|
61
66
|
|
|
62
|
-
[value, *Appendices.render(
|
|
67
|
+
[value, *Appendices.render(append_to_purpose, receiver, label)].join("\n\n")
|
|
63
68
|
end
|
|
64
69
|
|
|
65
|
-
def
|
|
66
|
-
Appendices.resolve(klass.
|
|
70
|
+
def append_to_purpose
|
|
71
|
+
Appendices.resolve(klass.squishling_append_to_purpose + (@append_to_purpose || []))
|
|
67
72
|
end
|
|
68
73
|
|
|
69
74
|
def schema
|
|
@@ -78,10 +83,15 @@ module Squishling
|
|
|
78
83
|
config = Squishling.config
|
|
79
84
|
entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
|
|
80
85
|
[config.default_model_path, config.default_provider]].find(&:first)
|
|
81
|
-
entries ||= [{ model: nil, attempts: 1 }]
|
|
86
|
+
entries ||= [{ model: nil, attempts: 1, forward_rejected: true }]
|
|
82
87
|
ModelPath.steps(entries, provider:, params:)
|
|
83
88
|
end
|
|
84
89
|
|
|
90
|
+
# How the escalation is used (see Harness): the method's, the class's, the configured default, or :escalation.
|
|
91
|
+
def harness
|
|
92
|
+
@harness || klass.squishling_harness || Squishling.config.default_harness || Harness::DEFAULT
|
|
93
|
+
end
|
|
94
|
+
|
|
85
95
|
# Generation params: config defaults, overridden key by key by the class, then by the method.
|
|
86
96
|
def params
|
|
87
97
|
Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
|
|
@@ -92,6 +102,12 @@ module Squishling
|
|
|
92
102
|
@validator || klass.squishling_validator
|
|
93
103
|
end
|
|
94
104
|
|
|
105
|
+
# The observability hook: the method's, else the class's, else the configured one. `false` at a level
|
|
106
|
+
# silences the levels above it.
|
|
107
|
+
def squawk
|
|
108
|
+
[@squawk, klass.squishling_squawk, Squishling.config.squawk].find { |hook| !hook.nil? } || nil
|
|
109
|
+
end
|
|
110
|
+
|
|
95
111
|
def context_names
|
|
96
112
|
klass.squishling_context_names
|
|
97
113
|
end
|
|
@@ -115,18 +131,20 @@ module Squishling
|
|
|
115
131
|
coerce(receiver.instance_exec(e, **inputs, &handler))
|
|
116
132
|
end
|
|
117
133
|
|
|
118
|
-
# Deterministic return values:
|
|
119
|
-
#
|
|
134
|
+
# Deterministic and fallback return values: with an output_schema, every value is validated and
|
|
135
|
+
# typed like an LLM result. Without one, the value passes through untouched.
|
|
120
136
|
def coerce(value)
|
|
121
|
-
|
|
137
|
+
schema ? build_result(value, squished: false) : value
|
|
122
138
|
end
|
|
123
139
|
|
|
140
|
+
# A result of this schema's own class passes through; any other result is re-validated via to_h.
|
|
124
141
|
def build_result(attrs, squished:)
|
|
125
142
|
raise ConfigurationError, "#{label} has no output_schema" unless schema
|
|
126
|
-
return attrs if attrs.is_a?(
|
|
143
|
+
return attrs if schema.result_class && attrs.is_a?(schema.result_class)
|
|
127
144
|
|
|
128
|
-
|
|
129
|
-
errors =
|
|
145
|
+
attrs = attrs.to_h if attrs.is_a?(Result::Instance)
|
|
146
|
+
data, errors = jsonify_return(attrs)
|
|
147
|
+
errors = schema.validate(data) if errors.empty?
|
|
130
148
|
raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
|
|
131
149
|
|
|
132
150
|
schema.build(data, squished:)
|
|
@@ -162,6 +180,13 @@ module Squishling
|
|
|
162
180
|
|
|
163
181
|
private
|
|
164
182
|
|
|
183
|
+
# Values JSON can't represent (NaN, Infinity, cycles) are invalid output, not a raw JSON error.
|
|
184
|
+
def jsonify_return(attrs)
|
|
185
|
+
[Schema.jsonify(attrs), []]
|
|
186
|
+
rescue JSON::GeneratorError, JSON::NestingError => e
|
|
187
|
+
[nil, [e.message]]
|
|
188
|
+
end
|
|
189
|
+
|
|
165
190
|
# The implementation's parameters, beneath the prepended wrappers.
|
|
166
191
|
def parameters
|
|
167
192
|
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 sampling harness's samples (squishsum or ensemble) were valid but didn't agree, and either no judge was declared
|
|
27
|
+
# (verdict nil) or the judge rejected both (verdict :neither, with its reason). candidates holds the two
|
|
28
|
+
# typed results, so a squish_fallback can still return one of them, and raw both as Hashes. models names the
|
|
29
|
+
# model behind each role (sample a, sample b, then the judge if one ran), not one entry per attempt; attempts
|
|
30
|
+
# 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,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
# How a squished call uses its escalation. Declared as a type, or a Hash with the type and its options:
|
|
5
|
+
# harness: :squishsum
|
|
6
|
+
# harness: { type: :judged_squishsum, judge: "claude-opus-5-5", compare: ->(a, b, **) { a.team == b.team } }
|
|
7
|
+
# :escalation (the default) tries each attempt in order until an output passes. :squishsum asks the first
|
|
8
|
+
# escalation step twice, concurrently, and accepts the output only when both samples agree. :ensemble asks
|
|
9
|
+
# the first and second escalation steps once each instead (a cross-model check). :judged_squishsum and
|
|
10
|
+
# :judged_ensemble hand disagreeing samples to a judge that picks one or rejects both.
|
|
11
|
+
class Harness
|
|
12
|
+
TYPES = %i[escalation squishsum judged_squishsum ensemble judged_ensemble].freeze
|
|
13
|
+
ENSEMBLE_TYPES = %i[ensemble judged_ensemble].freeze
|
|
14
|
+
JUDGED_TYPES = %i[judged_squishsum judged_ensemble].freeze
|
|
15
|
+
KEYS = %i[type judge judge_instructions compare].freeze
|
|
16
|
+
|
|
17
|
+
# A judge is one step: a model name or a Hash with the escalation step keys (except order:), plus
|
|
18
|
+
# type: (:chat, the default, or :judgment for a System One decision model through RubyLLM.judge) and,
|
|
19
|
+
# for :judgment, min_confidence: (the probability the chosen candidate needs).
|
|
20
|
+
JUDGE_TYPES = %i[chat judgment].freeze
|
|
21
|
+
JUDGE_KEYS = %i[type min_confidence].freeze
|
|
22
|
+
# Request keys RubyLLM's judgment protocols own (model and input are already reserved params).
|
|
23
|
+
JUDGMENT_RESERVED_PARAMS = %i[state questions].freeze
|
|
24
|
+
DEFAULT_MIN_CONFIDENCE = 0.8
|
|
25
|
+
|
|
26
|
+
DEFAULT_JUDGE_INSTRUCTIONS = <<~TEXT.strip
|
|
27
|
+
You are an impartial judge. Two independent attempts at the same operation returned different outputs,
|
|
28
|
+
candidate "a" and candidate "b". Both already match the required output format, so judge only their
|
|
29
|
+
content. You are given the operation's purpose and its input. Decide which candidate correctly and
|
|
30
|
+
faithfully carries out the purpose for this input. If both do (for example, they differ only in
|
|
31
|
+
wording), choose either one, preferring "a". Choose "neither" only when both are wrong or you can't tell
|
|
32
|
+
whether either is correct. Never combine them or invent a third answer.
|
|
33
|
+
TEXT
|
|
34
|
+
|
|
35
|
+
attr_reader :type, :judge, :judge_instructions, :compare
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def normalize(value, label)
|
|
39
|
+
case value
|
|
40
|
+
when Symbol, String then new(type: type_for(value, label))
|
|
41
|
+
when Hash then from_hash(value, label)
|
|
42
|
+
else
|
|
43
|
+
raise ConfigurationError, "#{label}: harness must be one of #{TYPES.join(', ')} or a Hash with type:, " \
|
|
44
|
+
"got #{value.inspect}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def from_hash(hash, label)
|
|
51
|
+
hash = hash.transform_keys(&:to_sym)
|
|
52
|
+
unknown = hash.keys - KEYS
|
|
53
|
+
raise ConfigurationError, "#{label}: unknown harness option(s) #{unknown.join(', ')}" if unknown.any?
|
|
54
|
+
raise ConfigurationError, "#{label}: a harness Hash needs type:" unless hash.key?(:type)
|
|
55
|
+
|
|
56
|
+
type = type_for(hash[:type], label)
|
|
57
|
+
check_options!(type, hash, label)
|
|
58
|
+
new(type:, judge: hash[:judge].nil? ? nil : normalize_judge(hash[:judge], label),
|
|
59
|
+
judge_instructions: hash[:judge_instructions], compare: hash[:compare])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def type_for(value, label)
|
|
63
|
+
type = value.to_s.to_sym if value.is_a?(Symbol) || value.is_a?(String)
|
|
64
|
+
return type if TYPES.include?(type)
|
|
65
|
+
|
|
66
|
+
raise ConfigurationError, "#{label}: unknown harness #{value.inspect} (use #{TYPES.join(', ')})"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_options!(type, hash, label)
|
|
70
|
+
judge_options = %i[judge judge_instructions].select { |key| hash.key?(key) }
|
|
71
|
+
if judge_options.any? && !JUDGED_TYPES.include?(type)
|
|
72
|
+
options = judge_options.map { |key| "#{key}:" }.join(", ")
|
|
73
|
+
raise ConfigurationError,
|
|
74
|
+
"#{label}: #{options} can only be used with the #{JUDGED_TYPES.join(' and ')} harnesses"
|
|
75
|
+
end
|
|
76
|
+
if hash.key?(:compare) && type == :escalation
|
|
77
|
+
raise ConfigurationError, "#{label}: compare: only applies to the sampling harnesses (squishsum and ensemble)"
|
|
78
|
+
end
|
|
79
|
+
unless hash[:compare].nil? || hash[:compare].is_a?(Proc)
|
|
80
|
+
raise ConfigurationError, "#{label}: compare: must be a Proc, got #{hash[:compare].class}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
instructions = hash[:judge_instructions]
|
|
84
|
+
return if instructions.nil? || instructions.is_a?(Proc)
|
|
85
|
+
return if instructions.is_a?(String) && !instructions.strip.empty?
|
|
86
|
+
|
|
87
|
+
raise ConfigurationError, "#{label}: judge_instructions: must be a non-blank String or a Proc"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The judge as a normalized escalation step (see ModelPath), plus its type and min_confidence.
|
|
91
|
+
def normalize_judge(judge, label)
|
|
92
|
+
judge_label = "#{label} judge"
|
|
93
|
+
return ModelPath.normalize_step(judge, judge_label).merge(type: :chat) unless judge.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
judge = judge.transform_keys(&:to_sym)
|
|
96
|
+
raise ConfigurationError, "#{judge_label}: a judge is one step, so it takes no order:" if judge.key?(:order)
|
|
97
|
+
|
|
98
|
+
type = judge.fetch(:type, :chat)
|
|
99
|
+
type = type.to_sym if type.is_a?(String)
|
|
100
|
+
unless JUDGE_TYPES.include?(type)
|
|
101
|
+
raise ConfigurationError,
|
|
102
|
+
"#{judge_label}: type: must be one of #{JUDGE_TYPES.join(', ')}, got #{type.inspect}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if type == :chat && judge.key?(:min_confidence)
|
|
106
|
+
raise ConfigurationError, "#{judge_label}: min_confidence: only applies to type: :judgment judges"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
step = ModelPath.normalize_step(judge.except(*JUDGE_KEYS), judge_label).merge(type:)
|
|
110
|
+
return step unless type == :judgment
|
|
111
|
+
|
|
112
|
+
reserved = (step[:params] || {}).keys & JUDGMENT_RESERVED_PARAMS
|
|
113
|
+
if reserved.any?
|
|
114
|
+
raise ConfigurationError, "#{judge_label}: #{reserved.join(', ')} can't be set through params " \
|
|
115
|
+
"(controlled by Squishling/RubyLLM)"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
step.merge(min_confidence: min_confidence(judge, judge_label))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def min_confidence(judge, label)
|
|
122
|
+
value = judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
|
|
123
|
+
return value.to_f if value.is_a?(Numeric) && value.between?(0, 1)
|
|
124
|
+
|
|
125
|
+
raise ConfigurationError, "#{label}: min_confidence: must be a number from 0 to 1, got #{value.inspect}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def initialize(type:, judge: nil, judge_instructions: nil, compare: nil)
|
|
130
|
+
@type = type
|
|
131
|
+
@judge = judge
|
|
132
|
+
@judge_instructions = judge_instructions
|
|
133
|
+
@compare = compare
|
|
134
|
+
freeze
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
DEFAULT = new(type: :escalation)
|
|
138
|
+
|
|
139
|
+
# Whether the call asks two samples (squishsum and ensemble harnesses) rather than walking the escalation.
|
|
140
|
+
def squishsum?
|
|
141
|
+
type != :escalation
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Whether the two samples come from the first two escalation steps instead of both from the first.
|
|
145
|
+
def ensemble?
|
|
146
|
+
ENSEMBLE_TYPES.include?(type)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def judged?
|
|
150
|
+
JUDGED_TYPES.include?(type)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|