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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -0
  3. data/README.md +118 -31
  4. data/docs/configuration.md +75 -21
  5. data/docs/coolhand.md +66 -0
  6. data/docs/failures.md +73 -9
  7. data/docs/harnesses.md +326 -0
  8. data/docs/measuring-tokens.md +140 -0
  9. data/docs/naming.md +43 -0
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +36 -27
  12. data/docs/schemas.md +24 -5
  13. data/lib/squishling/appendices.rb +3 -3
  14. data/lib/squishling/class_methods.rb +60 -29
  15. data/lib/squishling/collisions.rb +54 -0
  16. data/lib/squishling/configuration.rb +32 -0
  17. data/lib/squishling/definition.rb +56 -27
  18. data/lib/squishling/errors.rb +22 -1
  19. data/lib/squishling/harness/base.rb +136 -0
  20. data/lib/squishling/harness/escalation.rb +22 -0
  21. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  22. data/lib/squishling/harness/option_types.rb +93 -0
  23. data/lib/squishling/harness/registry.rb +69 -0
  24. data/lib/squishling/harness/squishsum.rb +22 -0
  25. data/lib/squishling/harness.rb +79 -0
  26. data/lib/squishling/invoker.rb +26 -188
  27. data/lib/squishling/judge.rb +186 -0
  28. data/lib/squishling/llm_client.rb +180 -0
  29. data/lib/squishling/model_path.rb +82 -25
  30. data/lib/squishling/observer.rb +100 -0
  31. data/lib/squishling/observers/coolhand.rb +86 -0
  32. data/lib/squishling/output_check.rb +109 -0
  33. data/lib/squishling/params.rb +47 -6
  34. data/lib/squishling/rotation.rb +26 -0
  35. data/lib/squishling/router.rb +8 -2
  36. data/lib/squishling/run/messages.rb +42 -0
  37. data/lib/squishling/run.rb +296 -0
  38. data/lib/squishling/source.rb +5 -5
  39. data/lib/squishling/squawk.rb +52 -0
  40. data/lib/squishling/version.rb +1 -1
  41. data/lib/squishling.rb +56 -7
  42. metadata +25 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad18b43555b041d5c0ef6eabda578fc6a2111ecfbf962b2003a7b502584f0c91
4
- data.tar.gz: f4ce9bb54bd2c57abd73c5a2f0e50472c338e299f25fe7ed0dd2edb2f4854f06
3
+ metadata.gz: 0fb55da97a0a032802afcd90b8e56d497a42194206ad90f07d71bc46c70a9e49
4
+ data.tar.gz: 79430c869cda0726357c42c1ad1fdc7cbf9108a00ebcd56814df4e45acf99db6
5
5
  SHA512:
6
- metadata.gz: 726d27bcc6067039d3bba294a923c302f49fc756ce0882541461525d8421be48d5cbb9c02e1d201eb1c062c9519353f62b66f9cf43795fc46a15c9834d7e24fe
7
- data.tar.gz: 9e4093ad080e9742b38e6bfe3d59f2b5be21f0bf3ddfd5a8288b552bbe9d0abdffe3d47f6fea8d5d0c54afdb0172c69769451249e65f69cfc2665b96682b1b52
6
+ metadata.gz: 95f83f1f99d86b5b2fddcc0d5e7d845aff73dbb7cc85620eafb93f999edd69181e8a72c726409cdb6ec11fa1286264947c48ac5f3467c1fb368755ef8ad95b62
7
+ data.tar.gz: 4ad6fbec6185bb9e4369c972b9f561bce840db75a96ba02c619fe82d1726e15230330b8f457c740017b08e412b3da2c364fd40f657db3128b28cab9c01ba993b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,146 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-18
11
+
12
+ ### Added
13
+
14
+ - Custom harnesses: `Squishling.register_harness` (with `Squishling.harnesses` and `reset_harnesses!`) and
15
+ `Squishling::Harness::Base` let you write your own harness and use it anywhere a built-in works (`harness:`,
16
+ `squish!`, `default_harness`). A harness gets a `Squishling::Run` with `escalate`, `samples`, `judge`, `agree?`,
17
+ `stage`, and `log`, so it keeps model, provider, and params resolution, `squish_validate`, concurrent samples, and the
18
+ normal failure handling; it can only return typed results its `Run` produced, and its options are validated when it
19
+ is declared. The built-in `:escalation`, `:squishsum`, and `:judged_squishsum` now run on the same interface.
20
+ `squawk` metadata gains `role:` and `sample:`. See [Custom harnesses](docs/harnesses.md#custom-harnesses). (#31)
21
+ - Round-robin models: escalation steps that share a `priority:` form a tier. Each call rotates which model in the tier
22
+ goes first (counted per class and method, shared across threads), and the tier's other models are tried before the
23
+ next priority. `rotate: false` on a step pins its tier to the order written. (#38)
24
+ - `config.observer`: an around-style hook (`around(call) { ... }`) wrapped around every provider request, on the thread
25
+ that makes it, so a `:squishsum` sample's observer runs on its worker thread. It is told the request's `Observer::Call`
26
+ and gets back only an `Observer::Usage` (token counts and model), never the response. An observer that yields twice
27
+ or never raises `ConfigurationError`, and one that rescues the request's error has it raised again. See
28
+ [Observers](docs/observers.md). (#25, #40)
29
+ - A bundled Coolhand observer (`observer = :coolhand`) that declares each call's workload (`Class#method`) and template
30
+ (`squish:L<n>`, `sample:a`, or `judge`) through `Coolhand.with_context`. The workload's description is
31
+ `Class#method`, or `Class#method (rescue)` when a `squish!` call handed the call to the LLM (`Observer::Call#handed_off`),
32
+ never the purpose text. It needs coolhand 0.9.0 or newer. See
33
+ [Coolhand Labs](docs/coolhand.md). (#40)
34
+ - A docs page and landing-page section on the architecture, an explainer site in `site/` deployed to GitHub Pages, and
35
+ per-field `description:` documented as the best practice over `purpose` guidance. (#30, #33, #34, #36)
36
+
37
+ ### Changed
38
+
39
+ - **Behavior change:** when the `coolhand` gem is loaded and configured (an API key, not disabled), Squishling now
40
+ declares each request's workload and template to it automatically. Nothing changes for apps without Coolhand. To
41
+ opt out, set `config.observer = false`.
42
+ - **Behavior change:** two escalation steps may now share a `priority:` (they used to raise `duplicate order:`), and
43
+ they rotate per call as described above. If you relied on a fixed order between tied steps, give them distinct
44
+ priorities or set `rotate: false`.
45
+ - `order:` on an escalation step is deprecated in favor of `priority:`. It still works, with a deprecation warning, and
46
+ giving both on one step raises `ConfigurationError`. Judge and harness steps take no `priority:` or `rotate:`. (#38)
47
+ - **Behavior change:** `LLMError` and the `ConfigurationError` for a rejected request (400), bad credentials, or a
48
+ forbidden key no longer repeat the provider's own message, which can echo request content or part of an API key into
49
+ logs and error trackers. They carry the error class and HTTP status (`RubyLLM::RateLimitError, HTTP 429`); the
50
+ provider's message is still on `cause`. If you matched on provider text in `e.message`, read `e.cause.message`
51
+ instead.
52
+ - `docs/measuring-tokens.md` now points to observers for bringing your own metrics instead of walking through a
53
+ hand-rolled one. (#40)
54
+
55
+ ### Removed
56
+
57
+ - **Breaking change:** the `:ensemble` and `:judged_ensemble` harnesses added in 0.3.0 are gone. `harness: :ensemble`
58
+ or `:judged_ensemble` (and `default_harness`) now raise `ConfigurationError` (unknown harness). Use `:squishsum` or
59
+ `:judged_squishsum`, which sample the first escalation step twice with the judge defaulting to the next step, or
60
+ write a custom harness. (#22, #24)
61
+
62
+ ### Internal
63
+
64
+ - CI gains an exhale duplication and complexity gate (`bin/exhale`), and dependabot bumps `rubocop` (1.92), `ruby/setup-ruby`,
65
+ and `actions/upload-artifact`. These are development-only changes. (#27, #28, #29, #41)
66
+
67
+ ## [0.3.0] - 2026-10-10
68
+
69
+ ### Added
70
+
71
+ - Harnesses: a `harness:` option (on `squishling`, `squish`, `squish!`, and `default_harness` in
72
+ `Squishling.configure`) chooses how a call uses its escalation. `:escalation` is the existing behavior and stays
73
+ the default. `:squishsum` asks the first escalation step twice, concurrently, and accepts the result only when the
74
+ two samples agree (exact match, or a custom `compare:`). `:judged_squishsum` sends a disagreement to a judge: the
75
+ next escalation step or a dedicated `judge:` step, either a chat model or a Jev-style decision model through
76
+ `RubyLLM.judge`, with a default prompt you can override through `judge_instructions:`. Samples that still disagree
77
+ raise the new `Squishling::DisagreementError < InvalidOutputError`, which carries both typed results, and
78
+ `squish_fallback` remains the only fallback. See [Harnesses](docs/harnesses.md). (#16)
79
+ - `:ensemble` and `:judged_ensemble` harnesses: like the squishsum ones, but the two concurrent samples come from the
80
+ escalation's first and second steps (a cross-model check) instead of the first step twice, each retrying within its
81
+ own step's `attempts:`. A judged ensemble's judge defaults to the third step, or `judge:`, and
82
+ `DisagreementError#models` names the model behind each sample. An ensemble needs at least two distinct steps (adjacent
83
+ identical steps count as one step's attempts) or it raises `ConfigurationError` before any request. (#20)
84
+ - `squawk`, an opt-in hook for sending what the model returned to an error tracker or tracing tool. It is called after
85
+ every LLM attempt (every sample and judge attempt under a harness) with `output:`, `metadata:`, and `error:`, and
86
+ can be set on `Squishling.configure`, `squishling`, and `squish`. It does nothing by default, and exceptions it
87
+ raises propagate unchanged. (#15)
88
+ - `forward_rejected:` on escalation steps. When an attempt is rejected and the next step starts, the previous
89
+ output is forwarded to that step's provider by default (unchanged behavior); set `forward_rejected: false` to start
90
+ that step from the original input alone. (#14)
91
+ - `include Squishling` raises `ConfigurationError` when the class inherits a method Squishling would override (for
92
+ example `Sinatra::Base.call`) instead of silently shadowing it. See [Naming and collisions](docs/naming.md). (#17)
93
+
94
+ ### Documentation
95
+
96
+ - A new guide, [Measuring token spend](docs/measuring-tokens.md), covers working out what each squished path costs
97
+ (with Coolhand Labs, the `squawk` hook, OpenTelemetry, LangSmith, or RubyLLM's instrumenter), and the README is
98
+ reworked around using Squishling to harden code paths as the economics justify it. (#21)
99
+
100
+ ### Changed
101
+
102
+ - **Breaking:** the DSL method `instructions` is now `purpose`, and `append_instructions` is now `append_to_purpose`,
103
+ including the `instructions:`/`append_instructions:` keywords on `squishling`, `squish`, and `squish!`. There are no
104
+ aliases. Rename them in your classes: `instructions "..."` becomes `purpose "..."`, and
105
+ `append_instructions: [...]` becomes `append_to_purpose: [...]`. The error for a missing prompt now says
106
+ "has no purpose". (#17)
107
+ - **Breaking:** `ruby_llm` must now be `~> 2.1` (was `~> 2.0`); run `bundle update ruby_llm`. (#16)
108
+ - **Breaking:** every value a method with an `output_schema` returns is now validated and typed, not only Hashes. On
109
+ the Ruby path and in `squish_fallback`, a `nil`, String, Array, or other non-Hash return that used to pass through
110
+ now raises `InvalidOutputError`; return a Hash (or `result(...)`) that matches the schema. A result of the schema's
111
+ own class passes through, another schema's result is re-validated, and values JSON can't represent (`NaN`,
112
+ `Infinity`, cycles) raise `InvalidOutputError` instead of a raw JSON error. Inner calls (`super`, or the method
113
+ called from `squish_when` or a fallback) still only type Hashes, so overrides can reshape values. (#13)
114
+ - **Behavior change:** a provider now travels with the model declared at the same level on a class too. A subclass
115
+ that declares its own `model:` or `escalation:` no longer inherits its parent's `provider:`, which could send the
116
+ whole payload to the parent's provider under another provider's model name. If you relied on that, set `provider:`
117
+ next to the subclass's model.
118
+ - **Behavior change:** a `provider:` with no model beside it now raises `ConfigurationError` instead of being
119
+ silently ignored (the default provider was used). `squish ..., provider: ...` needs a `model:` or `escalation:` in
120
+ the same call, as `squish!` already did; `squishling provider: ...` needs one in the call or already declared on that
121
+ class. Move the `provider:` next to a model, or use `config.default_provider` with the default model.
122
+ - The `result` alias for `squishling_result` is no longer added when the class already has a `result` (its own or
123
+ inherited); use `squishling_result` there. (#17)
124
+ - `InvalidOutputError#message` and `config.logger` warnings no longer quote the model's response. For unparseable JSON
125
+ they report only the line and column; the raw output stays in `InvalidOutputError#raw`. (#15)
126
+
127
+ ### Fixed
128
+
129
+ - A number a model writes outside the range JSON can represent (such as `1e400`, which parses to `Infinity`), or a
130
+ string with invalid UTF-8, was accepted as valid, while the same value from a Ruby method was rejected. It is now
131
+ invalid output on the LLM path too, and no longer raises a bare `JSON::GeneratorError` when `:squishsum` compares samples.
132
+
133
+ ### Security
134
+
135
+ - Params can no longer replace the strict output format or tools through the containers that also hold ordinary
136
+ settings: `generationConfig`/`generation_config` (Google Gemini) and `completion_args` (Mistral Conversations) now
137
+ reject their format and tool keys, with symbol or string keys, and a non-Hash value for them is rejected.
138
+ `tool_config` is reserved at the top level. Settings such as `topK` still work. (#12)
139
+ - The previous model's rejected output forwarded to the next escalation step is capped at 4,000 characters
140
+ (`Invoker::MAX_FORWARDED_CHARS`), with a truncation marker. (#14)
141
+ - Model output is kept out of error messages and logs (see the `InvalidOutputError#message` change above); `squawk`
142
+ is the one sanctioned way for it to leave the process. (#15) A chat judge's `reason` is model-written, so
143
+ `DisagreementError#message` and the logs no longer include it; read it from `DisagreementError#reason`. A
144
+ `:judgment` judge's message still carries the choice and probability, and a choice other than `a`, `b`, or `neither`
145
+ is no longer repeated.
146
+ - Only the first 20 validation errors (plus a count of the rest) are fed back to the model, logged, and put in
147
+ `InvalidOutputError#message` for one invalid output, so a very large malformed response can no longer produce a
148
+ megabyte-sized retry message or log line.
149
+
10
150
  ## [0.2.0] - 2026-10-09
11
151
 
12
152
  ### Added
data/README.md CHANGED
@@ -1,46 +1,68 @@
1
- # Squishling
1
+ <h1 align="center">
2
+ <img src="assets/squishling-logo.png" alt="Squishling" width="420">
3
+ </h1>
4
+
5
+ <p align="center">
6
+ <strong><a href="https://coolhand-labs.github.io/squishling/">📖 Read the Squishling explainer →</a></strong>
7
+ </p>
2
8
 
3
9
  [![CI](https://github.com/Coolhand-Labs/squishling/actions/workflows/ci.yml/badge.svg)](https://github.com/Coolhand-Labs/squishling/actions/workflows/ci.yml)
10
+ [![Gem Version](https://badge.fury.io/rb/squishling.svg)](https://badge.fury.io/rb/squishling)
11
+
12
+ **Don't just use AI to write code. Use AI to not need code.** Write code only where it's worth maintaining.
13
+
14
+ A squished method either runs its Ruby implementation, with the LLM as a failover, or routes some callers
15
+ through an LLM as a way of eliminating (or discovering) the cost of maintaining them as deterministic code.
16
+ Either way it returns the same strict-schema-validated, typed result, so callers can't tell the difference.
4
17
 
5
- Elastic Ruby classes. A squished method either runs its Ruby implementation or sends its inputs through an LLM
6
- (via [RubyLLM](https://rubyllm.com)), and either way returns the same strict-schema-validated, typed result.
7
- Callers can't tell the difference.
18
+ Think of code as a cache for AI judgment. Every line you write is something to maintain, so the LLM is the
19
+ default and code has to earn its place: you build it for the inputs that carry the volume, and the LLM keeps
20
+ handling the rest. New to the idea? Read
21
+ [Elastic Software](https://everythingengineer.substack.com/p/beginners-write-software-with-ai) or browse the
22
+ [talk slides](https://michael.carroll.io/talks/2026/elastic-software/).
8
23
 
9
- Inspired by [Elastic Software](https://everythingengineer.substack.com/p/beginners-write-software-with-ai):
10
- start flexible with AI, then harden high-volume paths into code as the economics justify it.
24
+ Or try it on the [explainer site](https://coolhand-labs.github.io/squishling/):
25
+
26
+ - [Spectrum slider](https://coolhand-labs.github.io/squishling/#spectrum): drag from traditional software to fully
27
+ LLM and see the tradeoff at each point
28
+ - [Token-cost calculator](https://coolhand-labs.github.io/squishling/#number): price your tech debt in tokens
29
+ against code upkeep
30
+ - [Walkthrough](https://coolhand-labs.github.io/squishling/#squishling): describe it, tune it, pay for accuracy,
31
+ harden the common shapes
11
32
 
12
33
  ## Use cases
13
34
 
14
- ### Instant integration
35
+ ### Zero-code integrations
15
36
 
16
37
  Accept a new data source today, before anyone writes a parser. Declare what you want back and leave the
17
38
  method unimplemented: every call goes to the LLM, and its output is validated against your schema.
18
39
 
19
40
  ```ruby
20
- class PaymentWebhook
41
+ class ShipmentUpdate
21
42
  include Squishling
22
43
 
23
- instructions "Normalize this payment provider's webhook into our payment event."
44
+ purpose "Normalize this carrier's tracking webhook into our shipment update."
24
45
  output_schema do
25
- string :event, enum: %w[succeeded failed refunded disputed]
26
- integer :amount_cents
27
- string :currency
28
- string :external_id
46
+ string :status, enum: %w[label_created in_transit out_for_delivery delivered exception]
47
+ string :tracking_number
48
+ string :location
29
49
  end
30
50
  # No `def call` yet, so every webhook goes to the LLM.
31
51
  end
32
52
 
33
- event = PaymentWebhook.call(provider: "adyen", payload: request.raw_post)
34
- event.event # => "refunded"
35
- event.amount_cents # => 4200
36
- event.squished? # => true
53
+ update = ShipmentUpdate.call(carrier: "acme-freight", payload: request.raw_post)
54
+ update.status # => "out_for_delivery"
55
+ update.squished? # => true
37
56
  ```
38
57
 
39
- When one provider carries the volume, write `def call` for it and add
40
- `squish_when { |provider:, **| provider != "stripe" }`. Stripe then runs in Ruby, everything else stays on the
58
+ When one carrier carries the volume, write `def call` for it and add
59
+ `squish_when { |carrier:, **| carrier != "ups" }`. UPS then runs in Ruby, everything else stays on the
41
60
  LLM, and callers don't change. See [Hardening a path](docs/routing.md#hardening-a-path).
42
61
 
43
- ### Error recovery
62
+ Validation guarantees the *shape* of the result, not that it's true. Where a wrong value is costly (money,
63
+ identity), cross-check it against another source or keep that path in Ruby.
64
+
65
+ ### Rescue errors your code can't handle yet
44
66
 
45
67
  Keep the Ruby you have for the inputs it understands, and hand the rest to the LLM instead of failing. When the
46
68
  parser raises, `squish!` sends this call to the LLM with the error and the parser's own source as context.
@@ -49,8 +71,8 @@ parser raises, `squish!` sends this call to the LLM with the error and the parse
49
71
  class InvoiceParser
50
72
  include Squishling
51
73
 
52
- instructions "Extract the invoice fields from the vendor's document."
53
- append_instructions "The Ruby parser that handles well-formed invoices:", self # this class's source
74
+ purpose "Extract the invoice fields from the vendor's document."
75
+ append_to_purpose "The Ruby parser that handles well-formed invoices:", self # this class's source
54
76
  output_schema do
55
77
  string :invoice_number
56
78
  number :total
@@ -60,7 +82,7 @@ class InvoiceParser
60
82
  invoice = VendorFormats.fetch(vendor).parse(document)
61
83
  result(invoice_number: invoice.number, total: invoice.total)
62
84
  rescue VendorFormats::ParseError => e
63
- squish!(append_instructions: "The parser failed on this document; the error is in the context.",
85
+ squish!(append_to_purpose: "The parser failed on this document; the error is in the context.",
64
86
  context: { parse_error: e })
65
87
  end
66
88
  end
@@ -73,13 +95,42 @@ Both calls return the same result class. If the LLM can't deliver either, `squis
73
95
  return, or the error is raised with the original `ParseError` as its cause. See
74
96
  [Handing off to the LLM](docs/routing.md#handing-off-to-the-llm-with-squish).
75
97
 
98
+ ### Retire code you no longer need
99
+
100
+ Go the other way too. Find the parsers and handlers that serve a thin tail of your traffic and squish them. When a
101
+ path's monthly token cost is lower than what its code costs to maintain, delete the code and relish the feeling of a
102
+ slimmer code base (or let your AI coding agent relish it).
103
+
104
+ ### Measure your tech debt in tokens
105
+
106
+ Code you haven't written is debt you haven't taken on. A squished path has a running cost you can read off a
107
+ meter, so "should we write a parser for this?" becomes arithmetic: what the path costs in tokens each month,
108
+ against what it costs to write and maintain the code. Write it when the first number is bigger.
109
+
110
+ Add and initialize the [`coolhand`](https://github.com/Coolhand-Labs/coolhand-ruby) gem, and it picks up your
111
+ squishling calls automatically and measures their accuracy and cost:
112
+
113
+ ```ruby
114
+ # Gemfile
115
+ gem "coolhand"
116
+
117
+ # config/initializers/coolhand.rb
118
+ Coolhand.configure do |config|
119
+ config.api_key = ENV.fetch("COOLHAND_API_KEY")
120
+ end
121
+ ```
122
+
123
+ `squished?` tells you which path served a call. Coolhand records your LLM requests and responses; see
124
+ [what each tool sees](docs/measuring-tokens.md#privacy). Want to roll your own metrics? Check out
125
+ [our guide](docs/measuring-tokens.md) for doing it with other tools.
126
+
76
127
  ## Installation
77
128
 
78
129
  ```ruby
79
130
  gem "squishling"
80
131
  ```
81
132
 
82
- Requires Ruby 3.3+ and RubyLLM 2.x. Configure your provider API keys in RubyLLM as usual, then optionally set a
133
+ Requires Ruby 3.3+ and RubyLLM 2.1+. Configure your provider API keys in RubyLLM as usual, then optionally set a
83
134
  universal model, or an escalation of models to try in order:
84
135
 
85
136
  ```ruby
@@ -91,7 +142,7 @@ end
91
142
 
92
143
  ## How it works
93
144
 
94
- A squishling class needs **instructions** (the system prompt) and an **output schema** (the shape of the result,
145
+ A squishling class needs a **purpose** (the system prompt) and an **output schema** (the shape of the result,
95
146
  validated on both paths). A squished call goes to the LLM when:
96
147
 
97
148
  - its `squish_when` predicate is truthy for these inputs,
@@ -100,17 +151,22 @@ validated on both paths). A squished call goes to the LLM when:
100
151
 
101
152
  Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM output.
102
153
 
154
+ **Best practice: describe fields in the schema, not the purpose.** Put per-field guidance in a `description:` on
155
+ the field (`string :status, description: "..."`, or `optional(:eta, description: "...") { string }`). The model
156
+ sees it right where the value goes, and the purpose stays short and about the goal. See
157
+ [Describing fields](docs/schemas.md#describing-fields).
158
+
103
159
  ## Features
104
160
 
105
161
  - **One contract, two paths**: Ruby returns and LLM output are validated against the same strict schema and
106
162
  returned as the same typed `Data` objects. `squished?` tells you which path served a call.
107
- - **Your code as context**: `append_instructions` adds sections to the prompt, including a class's or method's
163
+ - **Your code as context**: `append_to_purpose` adds sections to the prompt, including a class's or method's
108
164
  own Ruby source.
109
165
  - **Any RubyLLM provider and model**: OpenAI, Anthropic Claude, Google Gemini, AWS Bedrock, OpenRouter, and more.
110
166
  Set a universal, per-class, per-method, or per-call model, plus layered generation params (temperature,
111
167
  reasoning effort, top_p, …).
112
- - **Model escalation**: declare an `escalation:` instead of a `model:`, with per-step `attempts:`, `order:`, provider,
113
- and params. Invalid output (with its errors fed back) or a provider failure moves on to the next attempt. Steps can
168
+ - **Model escalation**: declare an `escalation:` instead of a `model:`, with per-step `attempts:`, `priority:` (models tied on priority take turns going first), provider,
169
+ params, and `forward_rejected:`. Invalid output (with its errors fed back) or a provider failure moves on to the next attempt. Steps can
114
170
  cross providers, e.g. a local Qwen model on Ollama, then Anthropic Claude Haiku on AWS Bedrock, then Claude Opus:
115
171
 
116
172
  ```ruby
@@ -130,23 +186,41 @@ Otherwise the Ruby runs, and whatever it returns is validated and typed like LLM
130
186
  { model: "claude-haiku-4-5", provider: :bedrock }, # small hosted model
131
187
  { model: "claude-opus-5-5", provider: :anthropic } # last resort
132
188
  ]
133
- # instructions, output_schema, ...
189
+ # purpose, output_schema, ...
134
190
  end
135
191
  ```
192
+ - **[Harnesses](docs/harnesses.md)**: choose how a call uses its escalation with `harness:`
193
+ - [`:escalation`](docs/configuration.md#models-and-escalation) (default): try each attempt until one passes
194
+ - [`:squishsum`](docs/harnesses.md#samples): two concurrent samples, accepted only if they agree
195
+ - [`:judged_squishsum`](docs/harnesses.md#the-judge): a chat or Jev judge picks between disagreeing samples
196
+ - [Your own](docs/harnesses.md#custom-harnesses): `Squishling.register_harness` plugs a custom harness into the same
197
+ machinery (samples, judges, `squawk`, `squish_fallback`)
136
198
  - **Output contracts**: beyond the strict schema, conditional rules (`given`) and Ruby checks (`squish_validate`)
137
199
  reject bad output and trigger the next attempt.
138
200
  - **Defined failure behavior**: provider errors become `Squishling::LLMError`, and `squish_fallback` lets you decide
139
201
  what to return when every attempt fails.
202
+ - **Observability**: the opt-in [`squawk`](docs/failures.md#observing-every-attempt-squawk) hook sends every attempt's
203
+ raw output to your error tracker. Model output stays out of error messages and logs otherwise.
140
204
  - **Opt-in context**: only method arguments, the instance state you name with `squish_context`, the `context:` you
141
- pass to `squish!`, and the source you choose to append are sent to the provider.
205
+ pass to `squish!`, and the source you choose to append are sent to the provider. The one addition: when
206
+ [escalation](docs/configuration.md#models-and-escalation) moves to the next step, that step also sees the previous
207
+ model's rejected output, unless the step sets `forward_rejected: false`.
142
208
 
143
209
  ## Documentation
144
210
 
211
+ - [Explainer site](https://coolhand-labs.github.io/squishling/): interactive walkthrough, spectrum slider, and
212
+ token-cost calculator
145
213
  - [Configuration](docs/configuration.md): options, models and escalation, providers, generation params, inheritance
146
- - [Routing](docs/routing.md): when a call goes to the LLM, `squish!`, `append_instructions`, hardening a path,
214
+ - [Routing](docs/routing.md): when a call goes to the LLM, `squish!`, `append_to_purpose`, hardening a path,
147
215
  what the LLM sees
148
216
  - [Output schemas](docs/schemas.md): schema forms, strict mode, typed results, optional vs. empty, contracts
149
217
  - [Failure handling](docs/failures.md): escalation, `squish_validate`, error classes, fallbacks
218
+ - [Harnesses](docs/harnesses.md): escalation, squishsum, judges (chat or Jev), and custom harnesses
219
+ - [Naming and collisions](docs/naming.md): the methods `include Squishling` adds and what happens when a name is taken
220
+ - [Measuring token spend](docs/measuring-tokens.md): see what each squished path costs, with Coolhand Labs,
221
+ the `squawk` hook, your own observer, or OpenTelemetry
222
+ - [Observers](docs/observers.md): wrap every provider request, and the built-in [Coolhand Labs](docs/coolhand.md) observer
223
+ that files each call under its workload and template
150
224
  - [Live examples](examples/README.md): end-to-end tests against Anthropic Claude Haiku and OpenAI GPT-6 Luna
151
225
 
152
226
  ## Development
@@ -157,7 +231,20 @@ bundle exec rake # RSpec (offline; RubyLLM is stubbed) + RuboCop
157
231
  ```
158
232
 
159
233
  See [AGENTS.md](AGENTS.md) for repo conventions, and [SECURITY.md](SECURITY.md) for reporting vulnerabilities.
234
+ Have a suggestion to improve Squishling? [Open an issue](https://github.com/Coolhand-Labs/squishling/issues).
235
+
236
+ ## Credits
237
+
238
+ Inspired by [Elastic Software](https://everythingengineer.substack.com/p/beginners-write-software-with-ai); see also
239
+ the [talk slides](https://michael.carroll.io/talks/2026/elastic-software/) from XO Ruby NYC 2026.
160
240
 
161
241
  ## License
162
242
 
163
243
  Apache-2.0
244
+
245
+ ---
246
+
247
+ <p align="center">
248
+ This open source project is supported by <a href="https://coolhandlabs.com/">Coolhand Labs</a>.<br><br>
249
+ <a href="https://coolhandlabs.com/"><img src="assets/coolhand-labs.png" alt="Coolhand Labs" width="220"></a>
250
+ </p>
@@ -17,6 +17,7 @@ Squishling.configure do |config|
17
17
  config.default_model = "claude-sonnet-5-5" # or default_escalation, below
18
18
  config.default_provider = nil
19
19
  config.default_params = { temperature: 0 }
20
+ config.default_harness = :escalation
20
21
  config.logger = Rails.logger
21
22
  end
22
23
  ```
@@ -27,7 +28,10 @@ end
27
28
  | `default_escalation` | `nil` | The universal [escalation](#models-and-escalation): models tried in order. Assigning it clears `default_model`, and vice versa. |
28
29
  | `default_provider` | `nil` | The provider for `default_model`/`default_escalation` steps that don't name one. Only needed for models missing from RubyLLM's registry (see below). |
29
30
  | `default_params` | `{}` | Generation params for every call (temperature, reasoning effort, top_p, …), overridable per class, per method, and per escalation step. See [Generation params](#generation-params). |
30
- | `logger` | `nil` | Any `Logger`. Debug lines when a call routes to the LLM, warnings on each escalation and when a fallback is used. |
31
+ | `squawk` | `nil` | A callable run after every LLM attempt with the raw output, for sending it to an error tracker or tracing tool. Also settable per class and per method. See [Observing every attempt](failures.md#observing-every-attempt-squawk). |
32
+ | `observer` | `nil` | An around-style hook for every provider request: `:coolhand`, any object responding to `around(call)`, or `false` for none. `nil` uses the [Coolhand observer](coolhand.md) when the `coolhand` gem is configured. See [Observers](observers.md). |
33
+ | `default_harness` | `nil` (`:escalation`) | How every squishling class that doesn't declare its own uses its escalation: `:escalation`, `:squishsum`, `:judged_squishsum`, a [registered custom harness](harnesses.md#custom-harnesses), or a Hash with `type:` and options. See [Harnesses](harnesses.md). |
34
+ | `logger` | `nil` | Any `Logger`. Debug lines when a call routes to the LLM, warnings on each escalation and when a fallback is used. Never includes the raw model response; see [what ends up in errors and logs](failures.md#what-ends-up-in-errors-and-logs). |
31
35
 
32
36
  Transport-level retries (rate limits, 5xx, timeouts) are configured on RubyLLM itself
33
37
  (`RubyLLM.config.max_retries`, `request_timeout`).
@@ -59,20 +63,50 @@ Each step is a model name, or a Hash with:
59
63
  |---|---|---|
60
64
  | `model:` | required | The model id. |
61
65
  | `attempts:` | `1` | How many attempts this step gets before escalating to the next one. |
62
- | `order:` | position | An Integer; steps run lowest first. Give every step an `order:` or none (duplicates are rejected). Gaps are fine. |
66
+ | `priority:` | position | An Integer; steps run lowest first. Give every step a `priority:` or none. Steps with the same priority are one tier (see [Rotating models](#rotating-models-within-a-priority)). Gaps are fine. `order:` is the deprecated name for it. |
67
+ | `rotate:` | `true` | Set `false` on a step to keep its tier in the order written instead of rotating it. |
63
68
  | `provider:` | the level's `provider:` | See [Providers](#providers-and-newly-released-models). |
69
+ | `forward_rejected:` | `true` | Set `false` to start this step from the original input alone, without the previous step's rejected output and its errors (see below). |
64
70
  | `params:` | `{}` | [Generation params](#generation-params) for this step, merged over the class and method params key by key. |
65
71
 
66
- Without `order:`, steps run in the order written. With it, the order is explicit and doesn't depend on position:
72
+ Without `priority:`, steps run in the order written. With it, the order is explicit and doesn't depend on position:
67
73
 
68
74
  ```ruby
69
75
  squishling escalation: [
70
- { model: "claude-opus-5-5", order: 20, params: { thinking: { effort: :high } } },
71
- { model: "claude-haiku-4-5", order: 0, attempts: 2 },
72
- { model: "claude-sonnet-5-5", order: 10 }
76
+ { model: "claude-opus-5-5", priority: 30, params: { thinking: { effort: :high } } },
77
+ { model: "claude-haiku-4-5", priority: 10, attempts: 2 },
78
+ { model: "gemma-4b", priority: 10 },
79
+ { model: "claude-sonnet-5-5", priority: 20 }
73
80
  ]
74
81
  ```
75
82
 
83
+ **Best practice:** number priorities 10, 20, 30, and so on, so a model can be slotted in between later (a `15`)
84
+ without renumbering the rest. Haiku and Gemma share the lowest priority here, so they take turns going first (see below).
85
+
86
+ ### Rotating models within a priority
87
+
88
+ Give several models the same `priority:` and they share a tier. Each call rotates which of them goes first, so
89
+ every model gets an even share of the real work over many calls, a fair test of how well each handles it:
90
+
91
+ ```ruby
92
+ squishling escalation: [
93
+ { model: "claude-haiku-4-5", priority: 10 },
94
+ { model: "gemma-4b", priority: 10 },
95
+ { model: "claude-sonnet-5-5", priority: 20 }
96
+ ]
97
+ # call 1: haiku, gemma-4b, sonnet call 2: gemma-4b, haiku, sonnet call 3: haiku, gemma-4b, sonnet
98
+ ```
99
+
100
+ - If the model that goes first fails (invalid output or a provider failure, after its own `attempts:`), the other
101
+ models in the tier are tried, in rotated order, before the call escalates to the next priority.
102
+ - Each class and method counts its own calls (`AClass#a`, `AClass#b` and `BClass#a` rotate independently), shared across
103
+ threads, but not across processes: each process (a Puma worker, say) starts its own count at the first model, and it
104
+ restarts with the process. A `squish!` whose own escalation has a rotating tier counts against its method too.
105
+ - A tier rotates by its declared order, so ties keep the order they were written in. A tier with a single model
106
+ never rotates, and neither does an escalation without `priority:`. `rotate: false` on any step of a tier keeps the
107
+ whole tier in the order written, whatever its other steps say.
108
+ - Rotation also moves which stages the [`:squishsum` and `:judged_squishsum`](harnesses.md) harnesses sample first.
109
+
76
110
  `params:` on a step let one step switch to a reasoning model that rejects sampling params:
77
111
 
78
112
  ```ruby
@@ -105,10 +139,13 @@ end
105
139
  Passing both `model:` and `escalation:` in one declaration raises `ConfigurationError`, and so does a list passed as
106
140
  `model:`. Across separate declarations (a reopened class, or two config assignments), the latest one wins.
107
141
 
108
- Consecutive attempts of the same step (same model, provider, and params, e.g. `attempts: 2`) continue one
109
- conversation. Moving to a different step starts a fresh chat with the original input plus the previous output and why
110
- it was rejected, so no provider-specific history crosses providers. Configuration errors (bad credentials, an unknown model, a request the provider rejects) never move to the
111
- next attempt. See [Failure handling](failures.md).
142
+ Consecutive attempts of the same step (same model, provider, params, and `forward_rejected:`, e.g. `attempts: 2`)
143
+ continue one conversation. Moving to a different step starts a fresh chat with the original input plus the previous
144
+ output and why it was rejected, so no provider-specific history crosses providers. That rejected output is
145
+ model-generated and is sent to the next step's provider, even when it is a different one, truncated to 4,000
146
+ characters. If you don't want a step to see it (say, because the next step is a different provider), set
147
+ `forward_rejected: false` on that step and it starts from the original input only. Configuration errors (bad
148
+ credentials, an unknown model, a request the provider rejects) never move to the next attempt. See [Failure handling](failures.md).
112
149
 
113
150
  ## Providers and newly released models
114
151
 
@@ -116,14 +153,21 @@ RubyLLM looks models up in its bundled registry. To use a model that isn't there
116
153
  OpenAI or Anthropic model, name its provider next to it. Squishling then tells RubyLLM to assume the model exists:
117
154
 
118
155
  ```ruby
119
- squishling model: "gpt-6-luna", provider: :openai
156
+ squishling model: "gpt-7-preview", provider: :openai
120
157
  squish :triage, model: "claude-haiku-4-5", provider: :anthropic
121
- Squishling.configure { |c| c.default_model = "gpt-6-luna"; c.default_provider = :openai }
158
+ Squishling.configure { |c| c.default_model = "gpt-7-preview"; c.default_provider = :openai }
122
159
  ```
123
160
 
124
161
  A provider is paired with the model or escalation declared at the same level, and applies to that escalation's steps
125
- that don't name their own. A per-method model never inherits a class-level provider meant for a different model. For models that are in the registry, a provider is optional
126
- and the normal registry lookup is kept.
162
+ that don't name their own. A model never inherits a provider meant for a different model: a per-method model doesn't
163
+ take the class's provider, and a subclass that declares its own `model:` or `escalation:` doesn't take its parent's
164
+ (a subclass that only changes params or the purpose keeps the parent's model and provider together). For models that
165
+ are in the registry, a provider is optional and the normal registry lookup is kept.
166
+
167
+ A `provider:` with no model beside it would apply to nothing, so it raises `ConfigurationError`:
168
+ `squish ..., provider: ...` needs a `model:` or `escalation:` in the same call (as `squish!` always has), and
169
+ `squishling provider: ...` needs one in the call or already declared on that same class (not inherited). Set the
170
+ provider next to the model, or, for the configured default model, use `config.default_provider`.
127
171
 
128
172
  ## Generation params
129
173
 
@@ -164,22 +208,32 @@ end
164
208
 
165
209
  - Keys that Squishling or RubyLLM control (`model`, `messages`, `input`, `instructions`, `system`, `stream`,
166
210
  `response_format`, `text`, `output_config`, `tools`, `tool_choice`, `schema`, and the camelCase and plural
167
- spellings other providers use, such as `systemInstruction`, `toolConfig`, `outputConfig`, `inputs`, …) raise
168
- `ConfigurationError`, because they would override the model, the conversation, or the strict output format.
211
+ spellings other providers use, such as `systemInstruction`, `toolConfig`/`tool_config`, `outputConfig`, `inputs`, …)
212
+ raise `ConfigurationError`, because they would override the model, the conversation, or the strict output format.
213
+ - **Nested containers** such as Gemini's `generationConfig` (and `generation_config` for Gemini Interactions) and
214
+ Mistral Conversations' `completion_args` accept ordinary settings, but reject the keys inside them that carry the
215
+ output format or tools (`responseMimeType`, `responseSchema`, `responseJsonSchema`, `response_format`, `tools`,
216
+ `tool_choice`, `toolConfig`, and their snake_case spellings). Use `output_schema` instead:
217
+
218
+ ```ruby
219
+ squishling params: { generationConfig: { topK: 5 } } # ok
220
+ squishling params: { generationConfig: { responseMimeType: "text/plain" } } # ConfigurationError
221
+ ```
222
+
169
223
  - If a provider rejects a param, the call raises `Squishling::ConfigurationError` naming the params. It isn't
170
224
  retried or sent to `squish_fallback`. See [Failure handling](failures.md).
171
225
 
172
226
  ## Inheritance
173
227
 
174
- Subclasses inherit the model or escalation, provider, generation params (merged key by key), instructions, output schema,
228
+ Subclasses inherit the model or escalation and its provider (as a pair), harness, generation params (merged key by key), purpose, output schema,
175
229
  `squish_when` predicate, `squish_context` names, `squish_validate`, `squish_fallback`, and every `squish` declaration. Overrides in a subclass, including
176
230
  overridden methods, are routed the same way.
177
231
 
178
- `append_instructions` sections are added to, not replaced: a subclass's sections follow its parent's, and
179
- `append_instructions false` drops the inherited ones. See [Appending to the instructions](routing.md#appending-to-the-instructions).
232
+ `append_to_purpose` sections are added to, not replaced: a subclass's sections follow its parent's, and
233
+ `append_to_purpose false` drops the inherited ones. See [Appending to the purpose](routing.md#appending-to-the-purpose).
180
234
 
181
235
  ## Per-call overrides
182
236
 
183
- Inside a squished method, `squish!` sends the call to the LLM with its own `instructions:`,
184
- `append_instructions:`, `context:`, `model:` or `escalation:` (with `provider:`), and `params:`. Each layers over the method and class
237
+ Inside a squished method, `squish!` sends the call to the LLM with its own `purpose:`,
238
+ `append_to_purpose:`, `context:`, `model:` or `escalation:` (with `provider:`), `params:`, and `harness:`. Each layers over the method and class
185
239
  settings the same way they layer over each other. See [Handing off to the LLM](routing.md#handing-off-to-the-llm-with-squish).