squishling 0.4.0 → 0.5.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 +75 -0
- data/README.md +25 -9
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +53 -18
- data/docs/failures.md +123 -18
- data/docs/harnesses.md +24 -14
- data/docs/measuring-tokens.md +8 -6
- data/docs/routing.md +5 -0
- data/docs/schemas.md +11 -2
- data/docs/testing.md +183 -0
- data/docs/verifying.md +84 -0
- data/lib/squishling/adapter/error_body.rb +47 -0
- data/lib/squishling/adapter.rb +275 -0
- data/lib/squishling/adapters/openai_compatible.rb +304 -0
- data/lib/squishling/adapters/openai_options.rb +72 -0
- data/lib/squishling/adapters/ruby_llm.rb +193 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +57 -6
- data/lib/squishling/definition.rb +90 -16
- data/lib/squishling/errors.rb +92 -7
- data/lib/squishling/harness/judged_squishsum.rb +2 -2
- data/lib/squishling/harness/option_types.rb +2 -1
- data/lib/squishling/harness/registry.rb +8 -36
- data/lib/squishling/invoker.rb +21 -9
- data/lib/squishling/judge.rb +16 -3
- data/lib/squishling/llm_client.rb +66 -132
- data/lib/squishling/model_path.rb +60 -13
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +11 -2
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +6 -23
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +5 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +40 -3
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +66 -42
- data/lib/squishling/schema.rb +10 -9
- data/lib/squishling/squawk.rb +33 -3
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +73 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +57 -4
- metadata +40 -21
data/lib/squishling/squawk.rb
CHANGED
|
@@ -1,17 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Squishling
|
|
4
|
-
# The opt-in observability hook: a callable run after every LLM attempt
|
|
5
|
-
# it can be sent somewhere (an error tracker, a
|
|
6
|
-
# to leave the process; nothing runs unless one
|
|
4
|
+
# The opt-in observability hook: a callable run after every LLM attempt (or only the rejected and retried
|
|
5
|
+
# ones, see squawk_on:) with the attempt's raw output, so it can be sent somewhere (an error tracker, a
|
|
6
|
+
# tracing tool). It's the one sanctioned way for model output to leave the process; nothing runs unless one
|
|
7
|
+
# is configured.
|
|
7
8
|
module Squawk
|
|
8
9
|
KEYWORD_TYPES = %i[key keyreq].freeze
|
|
9
10
|
POSITIONAL_TYPES = %i[req opt].freeze
|
|
10
11
|
KEYWORD_CAPTURE_TYPES = %i[key keyreq keyrest].freeze
|
|
11
12
|
KEYWORDS = %i[output metadata error].freeze
|
|
13
|
+
# The squawk_on: conditions, each answering "does the hook run for this attempt?" from the attempt's 1-based
|
|
14
|
+
# number (the one its metadata carries), its error (nil when accepted), and whether it is the last one.
|
|
15
|
+
CONDITIONS = {
|
|
16
|
+
all: ->(_attempt, _error, _final) { true },
|
|
17
|
+
rejections: ->(_attempt, error, _final) { !error.nil? },
|
|
18
|
+
retries: ->(attempt, error, _final) { !error.nil? || attempt > 1 },
|
|
19
|
+
final: ->(_attempt, _error, final) { final }
|
|
20
|
+
}.freeze
|
|
21
|
+
ON = CONDITIONS.keys.freeze
|
|
12
22
|
|
|
13
23
|
module_function
|
|
14
24
|
|
|
25
|
+
# Which attempts reach the hook (squawk_on:): one condition or a list of them, or nil to inherit. :all is
|
|
26
|
+
# every attempt, :rejections only the ones that errored, :retries those plus any accepted attempt after the
|
|
27
|
+
# first, and :final the last attempt the escalation allows (metadata's final:), however it ended; an earlier
|
|
28
|
+
# accepted attempt is not final. A list runs the hook when any of them holds.
|
|
29
|
+
def validate_on(value, where)
|
|
30
|
+
return value if value.nil?
|
|
31
|
+
|
|
32
|
+
conditions = value.is_a?(Array) ? value : [value]
|
|
33
|
+
unless conditions.any? && conditions.all? { |condition| CONDITIONS.key?(condition) }
|
|
34
|
+
raise ConfigurationError,
|
|
35
|
+
"#{where} squawk_on must be one or a list of #{ON.map(&:inspect).join(', ')}, got #{value.inspect}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
value.is_a?(Array) ? value.dup.freeze : value
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def report?(on, attempt, error, final)
|
|
42
|
+
Array(on).any? { |condition| CONDITIONS.fetch(condition).call(attempt, error, final) }
|
|
43
|
+
end
|
|
44
|
+
|
|
15
45
|
# The hook itself, or nil/false to inherit/silence. Anything else must respond to call and take keywords
|
|
16
46
|
# only, which is checked here so a mismatch fails at setup, not inside the first attempt.
|
|
17
47
|
def validate(hook, where)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Outside Rails, load it from your Rakefile: load "squishling/tasks/squishling.rake"
|
|
4
|
+
unless Rake::Task.task_defined?("squishling:verify")
|
|
5
|
+
namespace :squishling do
|
|
6
|
+
desc "Verify every squished class without making a request (see Squishling.verify)"
|
|
7
|
+
task :verify do
|
|
8
|
+
# Only loaded classes are known, so load them all first. Rails defines :environment after loading its
|
|
9
|
+
# railties' tasks, so it can't be a prerequisite declared here.
|
|
10
|
+
Rake::Task[:environment].invoke if Rake::Task.task_defined?(:environment)
|
|
11
|
+
Rails.application.eager_load! if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
12
|
+
|
|
13
|
+
report = Squishling.verify
|
|
14
|
+
puts report
|
|
15
|
+
abort unless report.ok?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# A Squishling adapter that answers from a script instead of a provider (see Script). Install it with
|
|
6
|
+
# Testing.install. It records every request (#requests) and groups them into the chats they belong to
|
|
7
|
+
# (#conversations), and it is safe to ask from the worker threads a harness's samples run on: everything shared
|
|
8
|
+
# is taken under one lock, and replies are chosen by (step, sample, attempt), never by arrival order.
|
|
9
|
+
class Adapter < Squishling::Adapter::Base
|
|
10
|
+
# One chat: its sample (nil unless a harness sample's) and the turns exchanged so far.
|
|
11
|
+
Conversation = Struct.new(:index, :sample, :transcript)
|
|
12
|
+
|
|
13
|
+
attr_reader :script
|
|
14
|
+
|
|
15
|
+
def initialize(script = Script.new)
|
|
16
|
+
super()
|
|
17
|
+
@script = script
|
|
18
|
+
@lock = Mutex.new
|
|
19
|
+
@requests = []
|
|
20
|
+
@conversations = []
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def judgments?
|
|
24
|
+
true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Every request so far, in the order they arrived (Testing::Request).
|
|
28
|
+
def requests
|
|
29
|
+
@lock.synchronize { @requests.dup }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The requests of each chat, in order: a retry on the same step shares a chat, a new step starts one.
|
|
33
|
+
def conversations
|
|
34
|
+
requests.select(&:conversation).group_by(&:conversation).sort.map(&:last)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def requests_for(role: nil, sample: nil)
|
|
38
|
+
requests.select do |request|
|
|
39
|
+
(role.nil? || request.role == role) && (sample.nil? || request.sample == sample.to_s)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The models the requests went to, in order (nil for a step that names none).
|
|
44
|
+
def models
|
|
45
|
+
requests.map(&:model)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# The first request's arguments: what left the process.
|
|
49
|
+
def arguments
|
|
50
|
+
requests.first&.arguments
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def complete(request)
|
|
54
|
+
outcome = @lock.synchronize { answer(request) }
|
|
55
|
+
raise outcome if outcome.is_a?(Exception)
|
|
56
|
+
|
|
57
|
+
outcome
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def judge(input, questions:, step:, label:)
|
|
61
|
+
outcome = @lock.synchronize { judged(input, questions, step, label) }
|
|
62
|
+
raise outcome if outcome.is_a?(Exception)
|
|
63
|
+
|
|
64
|
+
outcome
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def answer(request)
|
|
70
|
+
call = call_of(request)
|
|
71
|
+
outcome = outcome_for(:chat, call, request.label, request.model)
|
|
72
|
+
conversation = conversation_for(request, call[:sample])
|
|
73
|
+
conversation.transcript.push(*turns(request, outcome)) unless outcome.is_a?(Exception)
|
|
74
|
+
record(request, call, outcome, conversation.index)
|
|
75
|
+
outcome
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def judged(input, _questions, step, label)
|
|
79
|
+
call = { role: :judge, sample: nil, attempt: Observer.current&.attempt, attempts: Observer.current&.attempts,
|
|
80
|
+
model: step.model, provider: step.provider }
|
|
81
|
+
outcome = outcome_for(:judgment, call, label, step.model)
|
|
82
|
+
payload = Schema.jsonify(input)
|
|
83
|
+
@requests << Request.new(label:, type: :judgment, role: :judge, sample: nil, attempt: call[:attempt],
|
|
84
|
+
attempts: call[:attempts], model: step.model, provider: step.provider, params: step.params,
|
|
85
|
+
system_prompt: nil, output_schema: nil, messages: [], conversation: nil, payload:, reply: outcome)
|
|
86
|
+
outcome
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def outcome_for(kind, call, label, model)
|
|
90
|
+
rule = @script.rule_for(kind, call)
|
|
91
|
+
item = rule ? rule.take : Rule::EXHAUSTED
|
|
92
|
+
return unscripted(kind, call, label, model, rule) if item.equal?(Rule::EXHAUSTED)
|
|
93
|
+
|
|
94
|
+
resolve(item, label)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def resolve(item, label)
|
|
98
|
+
case item
|
|
99
|
+
when Failure then Failures.build(item.intent, label)
|
|
100
|
+
when Exception, Judgment then item
|
|
101
|
+
when Class then item <= Exception ? item.new : response(item)
|
|
102
|
+
when Reply then Squishling::Adapter::Response.new(content: item.content, model: item.model, tokens: item.usage)
|
|
103
|
+
else response(item)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def response(content)
|
|
108
|
+
Squishling::Adapter::Response.new(content:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def unscripted(kind, call, label, model, rule)
|
|
112
|
+
aimed = call.compact.except(:attempts).map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
|
|
113
|
+
why = if rule
|
|
114
|
+
"its script #{rule.empty? ? 'has no replies' : 'has no answers left (once)'}"
|
|
115
|
+
else
|
|
116
|
+
"no script covers it"
|
|
117
|
+
end
|
|
118
|
+
UnscriptedRequestError.new("#{label}: unscripted #{kind} request to #{model.inspect} (#{aimed}): #{why}. " \
|
|
119
|
+
"Scripted: #{@script.describe}")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The role, sample, and attempt of the request: from the observer that wraps it, or, when the adapter was
|
|
123
|
+
# installed without one, what the request itself shows.
|
|
124
|
+
def call_of(request)
|
|
125
|
+
observed = Observer.current
|
|
126
|
+
base = { model: request.model, provider: request.provider }
|
|
127
|
+
if observed
|
|
128
|
+
return base.merge(role: observed.role, sample: observed.sample, attempt: observed.attempt,
|
|
129
|
+
attempts: observed.attempts)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
base.merge(role: judge_schema?(request.schema) ? :judge : nil, sample: nil, attempt: nil, attempts: nil)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def judge_schema?(schema)
|
|
136
|
+
schema["schema"] == Schema.for(Judge::VERDICT_SCHEMA).llm_schema["schema"]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# A request that continues a chat sends the chat's transcript back, so it finds its conversation by it.
|
|
140
|
+
def conversation_for(request, sample)
|
|
141
|
+
history = request.messages[0...-1]
|
|
142
|
+
found = history.empty? ? nil : @conversations.reverse.find { |c| c.sample == sample && c.transcript == history }
|
|
143
|
+
found || Conversation.new(@conversations.size, sample, []).tap { |chat| @conversations << chat }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def turns(request, response)
|
|
147
|
+
[{ role: :user, content: request.messages.last.fetch(:content) }, { role: :assistant, content: response.text }]
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def record(request, call, outcome, conversation)
|
|
151
|
+
@requests << Request.new(label: request.label, type: :chat, role: call[:role], sample: call[:sample]&.to_s,
|
|
152
|
+
attempt: call[:attempt], attempts: call[:attempts], model: request.model, provider: request.provider,
|
|
153
|
+
params: request.params, system_prompt: request.instructions, output_schema: request.schema["schema"],
|
|
154
|
+
messages: request.messages, conversation:, payload: Request.payload_of(request.messages), reply: outcome)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# The adapter behind Testing.forbid_provider_calls!: any request that reaches it raises ProviderCallError, which
|
|
159
|
+
# is not a Squishling::Error, so a squish_fallback can't absorb it. It remembers the calls that were attempted,
|
|
160
|
+
# for a test that rescued the error.
|
|
161
|
+
class ForbiddenAdapter < Squishling::Adapter::Base
|
|
162
|
+
def initialize
|
|
163
|
+
super
|
|
164
|
+
@lock = Mutex.new
|
|
165
|
+
@attempts = []
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# The labels ("Klass#method") of the calls that reached it.
|
|
169
|
+
def attempts
|
|
170
|
+
@lock.synchronize { @attempts.dup }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def judgments?
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def complete(request)
|
|
178
|
+
refuse(request.label)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def judge(_input, label:, **)
|
|
182
|
+
refuse(label)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
private
|
|
186
|
+
|
|
187
|
+
def refuse(label)
|
|
188
|
+
@lock.synchronize { @attempts << label }
|
|
189
|
+
raise ProviderCallError, "#{label} reached the provider, but no LLM call was expected. Script it with " \
|
|
190
|
+
"Squishling::Testing.install, or fix the code under test"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# The assertions, independent of any test framework: each returns a failure message, or nil when it holds. The
|
|
6
|
+
# Minitest and RSpec mixins turn a message into a failure in their own way.
|
|
7
|
+
module Checks
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# The requests of a fake adapter (or an Array of them) sent exactly these arguments, as JSON would carry them.
|
|
11
|
+
def sent_arguments(source, expected, request: 0)
|
|
12
|
+
sent = requests_of(source)[request]
|
|
13
|
+
return "expected request ##{request} to have been sent, but only #{requests_of(source).size} were" unless sent
|
|
14
|
+
|
|
15
|
+
wanted = Schema.jsonify(expected)
|
|
16
|
+
return nil if sent.arguments == wanted
|
|
17
|
+
|
|
18
|
+
"expected the model to be sent arguments #{wanted.inspect}, but it was sent #{sent.arguments.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call_count(source, expected)
|
|
22
|
+
actual = requests_of(source).size
|
|
23
|
+
actual == expected ? nil : "expected #{expected} LLM call(s), but there were #{actual}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Runs the block with provider calls forbidden: a failure message when it tried to make one.
|
|
27
|
+
def no_llm_call(&)
|
|
28
|
+
attempts = Testing.no_llm_call(&)
|
|
29
|
+
return nil if attempts.empty?
|
|
30
|
+
|
|
31
|
+
"expected no LLM call, but #{attempts.uniq.join(', ')} reached the provider (#{attempts.size} request(s))"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def requests_of(source)
|
|
35
|
+
source.respond_to?(:requests) ? source.requests : Array(source)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# Runs the parts of a squished method that don't need a model, for unit tests: the output checks, the fallback,
|
|
6
|
+
# and the prompt. Each takes the method's arguments as args: and keywords (as the method is called), and a
|
|
7
|
+
# receiver: for a class whose constructor takes arguments.
|
|
8
|
+
module Direct
|
|
9
|
+
class << self
|
|
10
|
+
# The messages squish_validate and the output schema reject this reply with ([] when it would be accepted).
|
|
11
|
+
def validate(klass, reply, method: :call, receiver: nil, args: [], **kwargs)
|
|
12
|
+
definition = klass.squishling_definition(method)
|
|
13
|
+
schema = definition.schema or raise SetupError, "#{definition.label} has no output_schema"
|
|
14
|
+
inputs = definition.bind_arguments(args, kwargs)
|
|
15
|
+
prompt = Invoker::Prompt.new(instructions: nil, schema:, input: nil, role: nil)
|
|
16
|
+
OutputCheck.new(definition, receiver || klass.new, inputs).call(reply, prompt).last.map(&:message)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# What squish_fallback returns for this error, typed like any result of the method.
|
|
20
|
+
def fallback_for(klass, error, method: :call, receiver: nil, args: [], **kwargs)
|
|
21
|
+
definition = klass.squishling_definition(method)
|
|
22
|
+
handler = definition.fallback or raise SetupError, "#{definition.label} has no squish_fallback"
|
|
23
|
+
inputs = definition.bind_arguments(args, kwargs)
|
|
24
|
+
definition.coerce((receiver || klass.new).instance_exec(error, **inputs, &handler))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The first request a call would make, without making it: { system_prompt:, user_message:, output_schema:,
|
|
28
|
+
# step: } (the step is the ModelPath::Step with the model, provider and params; rotation isn't advanced).
|
|
29
|
+
def preview(klass, method: :call, receiver: nil, args: [], **kwargs)
|
|
30
|
+
definition = klass.squishling_definition(method)
|
|
31
|
+
receiver ||= klass.new
|
|
32
|
+
inputs = definition.bind_arguments(args, kwargs)
|
|
33
|
+
prompt = Invoker.new(definition, receiver, inputs).prompt
|
|
34
|
+
{ system_prompt: prompt.instructions, user_message: prompt.input,
|
|
35
|
+
output_schema: prompt.schema.llm_schema["schema"],
|
|
36
|
+
step: definition.escalation_path(receiver, inputs, advance: false).first }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# What the test helpers raise. None of these is a Squishling::Error, so squish_fallback, which absorbs
|
|
6
|
+
# InvalidOutputError and LLMError, can never hide a test setup mistake.
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
|
|
9
|
+
# A request reached the test adapter and no script covers it.
|
|
10
|
+
class UnscriptedRequestError < Error; end
|
|
11
|
+
|
|
12
|
+
# A request reached the provider seam while provider calls were forbidden (see Testing.forbid_provider_calls!).
|
|
13
|
+
class ProviderCallError < Error; end
|
|
14
|
+
|
|
15
|
+
# A stub, a fixture, or a direct check was asked for something the method's schema doesn't allow.
|
|
16
|
+
class SetupError < Error; end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# Replies derived from an output schema, so a test that doesn't care about the content stays valid when the schema
|
|
6
|
+
# changes, and a test of "out of range gets re-asked" doesn't hand-write a bad payload. Replies are Hashes with
|
|
7
|
+
# string keys, as JSON would carry them, and are always checked against the schema before they are returned.
|
|
8
|
+
module Fixtures
|
|
9
|
+
FORMATS = {
|
|
10
|
+
"date-time" => "2024-01-01T00:00:00Z", "date" => "2024-01-01", "time" => "00:00:00Z",
|
|
11
|
+
"email" => "user@example.com", "uri" => "https://example.com", "uuid" => "00000000-0000-4000-8000-000000000000"
|
|
12
|
+
}.freeze
|
|
13
|
+
MAX_DEPTH = 8
|
|
14
|
+
WRONG_VALUES = ["not valid", -1_000_000_007, [], {}, true, nil].freeze
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# A reply that satisfies the schema of a squished class's method, a Squishling::Schema, or a schema Hash.
|
|
18
|
+
# overrides replace top-level attributes.
|
|
19
|
+
def valid_reply_for(source, method: :call, **overrides)
|
|
20
|
+
schema = schema_of(source, method)
|
|
21
|
+
reply = Generator.new(schema.json_schema).value
|
|
22
|
+
reply = reply.merge(overrides.transform_keys(&:to_s)) if reply.is_a?(Hash)
|
|
23
|
+
verify!(schema, reply, "valid")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# A valid reply with one top-level attribute (violating:) broken: out of its enum or range, the wrong type,
|
|
27
|
+
# null, or missing. how: (:enum, :range, :type, :null, :missing) picks the break; by default the first that
|
|
28
|
+
# the schema rejects. Overrides are applied to the valid reply first.
|
|
29
|
+
def invalid_reply_for(source, violating:, how: nil, method: :call, **overrides)
|
|
30
|
+
schema = schema_of(source, method)
|
|
31
|
+
base = valid_reply_for(schema, **overrides)
|
|
32
|
+
key = violating.to_s
|
|
33
|
+
raise SetupError, "the schema has no property #{key.inspect} to violate" unless schema.json_schema.fetch(
|
|
34
|
+
"properties", {}
|
|
35
|
+
).key?(key)
|
|
36
|
+
|
|
37
|
+
candidates = breaks(schema.json_schema, key, how)
|
|
38
|
+
broken = candidates.lazy.map { |value| mutate(base, key, value) }.find { |reply| schema.validate(reply).any? }
|
|
39
|
+
broken or raise SetupError,
|
|
40
|
+
"couldn't find a value of #{key.inspect} that the schema rejects#{" (how: #{how.inspect})" if how}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def schema_of(source, method)
|
|
46
|
+
case source
|
|
47
|
+
when Schema then source
|
|
48
|
+
when Class then source.squishling_definition(method).schema or raise SetupError,
|
|
49
|
+
"#{source} has no output_schema"
|
|
50
|
+
else Schema.for(source)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def verify!(schema, reply, kind)
|
|
55
|
+
errors = schema.validate(reply)
|
|
56
|
+
return reply if errors.empty?
|
|
57
|
+
|
|
58
|
+
raise SetupError,
|
|
59
|
+
"couldn't build a #{kind} reply (#{errors.join('; ')}); pass the attributes it needs as overrides"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def mutate(base, key, value)
|
|
63
|
+
value.equal?(:missing) ? base.except(key) : base.merge(key => value)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def breaks(json_schema, key, how)
|
|
67
|
+
node = Generator.new(json_schema).node_for(json_schema.fetch("properties").fetch(key))
|
|
68
|
+
all = { enum: enum_breaks(node), range: range_breaks(node), type: WRONG_VALUES.compact, null: [nil],
|
|
69
|
+
missing: [:missing] }
|
|
70
|
+
return all.fetch(how) { raise ArgumentError, "how: is one of #{all.keys.map(&:inspect).join(', ')}" } if how
|
|
71
|
+
|
|
72
|
+
all.values.flatten(1)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def enum_breaks(node)
|
|
76
|
+
values = node["enum"] ? ["not in the enum"] : []
|
|
77
|
+
node.key?("const") ? values + ["not the const"] : values
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def range_breaks(node)
|
|
81
|
+
bounds = node.slice("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "minLength", "maxLength",
|
|
82
|
+
"minItems", "maxItems")
|
|
83
|
+
bounds.flat_map { |name, limit| beyond(name, limit) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def beyond(name, limit)
|
|
87
|
+
case name
|
|
88
|
+
when "minimum", "exclusiveMinimum" then [limit - 1]
|
|
89
|
+
when "maximum", "exclusiveMaximum" then [limit + 1]
|
|
90
|
+
when "minLength" then ["x" * [limit - 1, 0].max]
|
|
91
|
+
when "maxLength" then ["x" * (limit + 1)]
|
|
92
|
+
when "minItems" then [[nil] * [limit - 1, 0].max]
|
|
93
|
+
when "maxItems" then [[nil] * (limit + 1)]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Builds one value that satisfies a JSON schema (see Fixtures).
|
|
99
|
+
class Generator
|
|
100
|
+
def initialize(root)
|
|
101
|
+
@root = root
|
|
102
|
+
@expanding = []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The value for a node. A $ref being expanded is remembered, so a recursive schema (a node whose child is
|
|
106
|
+
# the same node or null) ends at the null instead of nesting forever.
|
|
107
|
+
def value(node = @root, depth = 0)
|
|
108
|
+
ref = node["$ref"]
|
|
109
|
+
@expanding.push(ref) if ref
|
|
110
|
+
generate(node_for(node), depth)
|
|
111
|
+
ensure
|
|
112
|
+
@expanding.pop if ref
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The schema node with a root-relative $ref followed.
|
|
116
|
+
def node_for(node)
|
|
117
|
+
ref = node["$ref"]
|
|
118
|
+
return node unless ref
|
|
119
|
+
|
|
120
|
+
path = ref.delete_prefix("#").split("/").reject(&:empty?)
|
|
121
|
+
node.except("$ref").merge(path.reduce(@root) do |memo, part|
|
|
122
|
+
memo.fetch(part.gsub("~1", "/").gsub("~0", "~"))
|
|
123
|
+
end)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def generate(node, depth)
|
|
129
|
+
return node["const"] if node.key?("const")
|
|
130
|
+
return node["enum"].first if node["enum"]
|
|
131
|
+
return from_branch(node, depth) if node["anyOf"] || node["oneOf"]
|
|
132
|
+
return from_all_of(node, depth) if node["allOf"]
|
|
133
|
+
|
|
134
|
+
typed(node, depth)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def from_branch(node, depth)
|
|
138
|
+
branches = node["anyOf"] || node["oneOf"]
|
|
139
|
+
open = branches.reject { |branch| branch["$ref"] && @expanding.include?(branch["$ref"]) }
|
|
140
|
+
chosen = open.find { |branch| branch["type"] != "null" } || open.first || branches.first
|
|
141
|
+
value(chosen, depth + 1)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def from_all_of(node, depth)
|
|
145
|
+
parts = node["allOf"].map { |part| value(part, depth + 1) }.grep(Hash)
|
|
146
|
+
parts.reduce({}) { |memo, part| memo.merge(part) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def typed(node, depth)
|
|
150
|
+
case primary_type(node)
|
|
151
|
+
when "object" then object(node, depth)
|
|
152
|
+
when "array" then array(node, depth)
|
|
153
|
+
when "string" then string(node)
|
|
154
|
+
when "integer", "number" then number(node)
|
|
155
|
+
when "boolean" then true
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def primary_type(node)
|
|
160
|
+
types = Array(node["type"])
|
|
161
|
+
types.find { |type| type != "null" } || (types.first if types.any?) || inferred_type(node)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def inferred_type(node)
|
|
165
|
+
return "object" if node["properties"]
|
|
166
|
+
|
|
167
|
+
"array" if node["items"]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def object(node, depth)
|
|
171
|
+
return {} if depth > MAX_DEPTH
|
|
172
|
+
|
|
173
|
+
(node["properties"] || {}).transform_values { |property| value(property, depth + 1) }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def array(node, depth)
|
|
177
|
+
return [] if depth > MAX_DEPTH || !node["items"]
|
|
178
|
+
|
|
179
|
+
Array.new(node["minItems"].to_i) { value(node["items"], depth + 1) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def string(node)
|
|
183
|
+
text = FORMATS.fetch(node["format"], "example")
|
|
184
|
+
text = text.ljust(node["minLength"], "x") if node["minLength"]
|
|
185
|
+
node["maxLength"] ? text[0, node["maxLength"]] : text
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def number(node)
|
|
189
|
+
number = clamp_up(0, node["minimum"], node["exclusiveMinimum"])
|
|
190
|
+
number = clamp_down(number, node["maximum"], node["exclusiveMaximum"])
|
|
191
|
+
primary_type(node) == "integer" ? number.ceil : number
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def clamp_up(number, minimum, exclusive)
|
|
195
|
+
number = minimum if minimum && number < minimum
|
|
196
|
+
exclusive && number <= exclusive ? exclusive + 1 : number
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def clamp_down(number, maximum, exclusive)
|
|
200
|
+
number = maximum if maximum && number > maximum
|
|
201
|
+
exclusive && number >= exclusive ? exclusive - 1 : number
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# The test-facing helpers, shared by the Minitest and RSpec mixins. A mixin supplies #squishling_failure(message),
|
|
6
|
+
# which fails the current test.
|
|
7
|
+
module Helpers
|
|
8
|
+
# Installs a scripted adapter and returns it. The block scripts it (see Script); the adapter's #requests,
|
|
9
|
+
# #conversations and #arguments show what was sent.
|
|
10
|
+
def fake_squishling(&)
|
|
11
|
+
Testing.install(&)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Any LLM request raises until a test installs a fake, naming the squished method that made it.
|
|
15
|
+
def forbid_provider_calls!
|
|
16
|
+
Testing.forbid_provider_calls!
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def assert_sent_arguments(source, expected, request: 0)
|
|
20
|
+
failure = Checks.sent_arguments(source, expected, request:)
|
|
21
|
+
squishling_failure(failure) if failure
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def assert_llm_calls(source, expected)
|
|
25
|
+
failure = Checks.call_count(source, expected)
|
|
26
|
+
squishling_failure(failure) if failure
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def assert_no_llm_call(&)
|
|
30
|
+
failure = Checks.no_llm_call(&)
|
|
31
|
+
squishling_failure(failure) if failure
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stub_squishling_result(klass, attrs = nil, **, &)
|
|
35
|
+
Testing.stub_result(klass, attrs, **, &)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def stub_squishling_error(klass, kind, **, &)
|
|
39
|
+
Testing.stub_error(klass, kind, **, &)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def squishling_validate(klass, reply, **inputs)
|
|
43
|
+
Testing.validate(klass, reply, **inputs)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def squishling_fallback_for(klass, error, **inputs)
|
|
47
|
+
Testing.fallback_for(klass, error, **inputs)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def squishling_preview(klass, **inputs)
|
|
51
|
+
Testing.preview(klass, **inputs)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def valid_reply_for(source, **overrides)
|
|
55
|
+
Testing.valid_reply_for(source, **overrides)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def invalid_reply_for(source, **)
|
|
59
|
+
Testing.invalid_reply_for(source, **)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def capture_squawk
|
|
63
|
+
Testing.capture_squawk
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def with_squawk(capture = capture_squawk, &)
|
|
67
|
+
Testing.with_squawk(capture, &)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Squishling
|
|
4
|
+
module Testing
|
|
5
|
+
# The helpers for Minitest: include it in a test case (or ActiveSupport::TestCase). What it installs (the
|
|
6
|
+
# adapter, observer, and stubs) is put back after each test.
|
|
7
|
+
#
|
|
8
|
+
# class RegexInferrerTest < Minitest::Test
|
|
9
|
+
# include Squishling::Testing::Minitest
|
|
10
|
+
#
|
|
11
|
+
# def test_retries_a_bad_regex
|
|
12
|
+
# fake = fake_squishling { |llm| llm.step(:any).reply({ regex: "[bad(" }, { regex: "Run.*" }) }
|
|
13
|
+
# assert_equal "Run.*", RegexInferrer.call(sample_turns: turns).regex
|
|
14
|
+
# assert_sent_arguments fake, { sample_turns: turns }
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
module Minitest
|
|
18
|
+
include Helpers
|
|
19
|
+
|
|
20
|
+
def after_teardown
|
|
21
|
+
Testing.reset!
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def squishling_failure(message)
|
|
28
|
+
flunk message
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|