<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>Gem - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/gem/</link><description>Gem - Tag - Tracy Atteberry</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><managingEditor>tracy@magicbydesign.com (Tracy Atteberry)</managingEditor><webMaster>tracy@magicbydesign.com (Tracy Atteberry)</webMaster><lastBuildDate>Fri, 08 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>Gem - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/gem/</link></image><atom:link href="https://tracyatteberry.com/tags/gem/" rel="self" type="application/rss+xml"/><item><title>TestGenAI</title><link>https://tracyatteberry.com/portfolio/testgenai/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/testgenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/testgenai/hero.jpg" referrerpolicy="no-referrer">
            </div><p>Building a presentation thingy.</p>
]]></description></item><item><title>TestGenAI: Building a Ruby CLI that writes your missing tests</title><link>https://tracyatteberry.com/posts/testgenai/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/testgenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/testgenai/hero.jpg" referrerpolicy="no-referrer">
            </div><h1 id="creating-a-gem-that-writes-your-missing-tests">Creating a gem that writes your missing tests</h1>
<p>No new project survives contact with the real world unscathed. We built
TestGenAI, ran it on itself, and it worked well. Then we ran it on another
codebase, and two things broke immediately. The fixes turned out to just as
interesting as the original build.</p>
<p>This is a walkthrough of how the tool works and what we learned when we took it
outside the greenhouse.</p>
<p>The code here is from <a href="https://github.com/grymoire7/testgenai" target="_blank" rel="noopener noreffer ">TestGenAI</a>, a
working Ruby CLI gem you can install and run against your own codebase.</p>
<h2 id="the-pipeline">The pipeline</h2>
<p>The pipeline has five stages:</p>
<ol>
<li>Scan your codebase to find classes and methods without test coverage</li>
<li>Build context for each untested method</li>
<li>Generate tests using an LLM with the mechanically curated context</li>
<li>Validate that the generated tests run and pass</li>
<li>Collect the results</li>
</ol>
<p>Each stage needs to be reliable enough that you can walk away and trust the
process to complete. That means handling errors gracefully, providing clear
output about what happened, and making it easy to pick up where things left off
if something breaks.</p>
<h2 id="finding-untested-code">Finding untested code</h2>
<p>Before you can generate tests, you need to know what needs testing. The right
approach depends on whether SimpleCov is available in the project.</p>
<p>If SimpleCov is set up, TestGenAI runs your test suite with <code>COVERAGE=true</code>,
reads the resulting <code>coverage/.resultset.json</code>, and uses AST parsing to find
methods where every executable line has zero hits. This scanner handles
partially-tested files correctly. It reports individual methods that were
never exercised, even if other methods in the same file have full coverage.</p>
<p>If SimpleCov isn&rsquo;t available, the scanner falls back to checking whether a spec
or test file exists for each source file. This approach is less accurate. A
file tested only through integration tests or through specs for its subclasses
will appear fully untested even if its methods are exercised constantly. The
SimpleCov scanner is worth setting up.</p>
<p>Both scanners share the same underlying logic for locating methods in source
files, which brings up something worth explaining.</p>
<h2 id="walking-the-ast">Walking the AST</h2>
<p>To locate methods, TestGenAI parses each Ruby source file into an abstract
syntax tree and walks it recursively. The walker looks for <code>:def</code> and <code>:defs</code>
nodes (instance and class methods), tracks the current class/module namespace,
and records each method&rsquo;s file, class, name, and line range.</p>
<p>That line range matters. The SimpleCov scanner uses it to check whether any
executable lines in the method had zero hits. A <code>nil</code> in SimpleCov&rsquo;s coverage
array means a line isn&rsquo;t executable, like a blank line, a comment, or an <code>end</code>.
The scanner filters those out before checking for zeros, so it only flags
methods where runnable code was never touched.</p>
<h3 id="the-parser-compatibility-problem">The parser compatibility problem</h3>
<p>To parse Ruby, the gem relies on the <code>parser</code> gem. In older versions, you&rsquo;d
call <code>Parser::CurrentRuby.parse(source)</code> and get back an AST. This worked fine
until Ruby 3.4, which switched its internal default parser to prism. Using
<code>Parser::CurrentRuby</code> with Ruby 3.4 produces warnings, and in some
configurations it fails entirely.</p>
<p>The prism project ships a compatibility shim,
<code>Prism::Translation::ParserCurrent</code>, that produces the same AST node types as
the old parser gem. The AST-walking code works unchanged. The only question is
which one to load.</p>
<p>The solution is a small file that runs at load time and sets a constant:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Testgenai</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="no">Gem</span><span class="o">::</span><span class="no">Version</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="no">RUBY_VERSION</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="no">Gem</span><span class="o">::</span><span class="no">Version</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="s2">&#34;3.4&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">require</span> <span class="s2">&#34;prism&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">CurrentParser</span> <span class="o">=</span> <span class="no">Prism</span><span class="o">::</span><span class="no">Translation</span><span class="o">::</span><span class="no">ParserCurrent</span>
</span></span><span class="line"><span class="cl">  <span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="nb">require</span> <span class="s2">&#34;parser/current&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="no">CurrentParser</span> <span class="o">=</span> <span class="no">Parser</span><span class="o">::</span><span class="no">CurrentRuby</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The rest of the codebase calls <code>CurrentParser.parse(source)</code> and never thinks
about which parser is underneath. The pattern of version check at load time,
constant as the abstraction is a clean way to handle the same kind of
compatibility gap you&rsquo;ll run into whenever Ruby ships a significant internal
change.</p>
<h2 id="context-generation-and-validation">Context, generation, and validation</h2>
<p>When you ask an LLM to write tests for a method, you can&rsquo;t just paste in the
method body. It needs the full class, the dependencies that file requires,
examples of how the method is called elsewhere in the codebase, and existing
test files it can match in style. Context quality is where quick-and-dirty AI
test generators fall apart, too little and the tests don&rsquo;t compile, too much
and you hit token limits.</p>
<p>The generator builds a prompt from all of that, sends it to the LLM via the
<code>ruby_llm</code> gem (which keeps the generator code provider-agnostic), and strips
any markdown fences from the response before passing it to the validator.</p>
<p>The validator writes the code to a temp file, runs <code>bundle exec rspec</code> or the
Minitest equivalent, and distinguishes between three outcomes: the file failed
to load (syntax errors, undefined constants), the tests ran but failed, or the
tests passed. Each outcome needs different handling. A file that doesn&rsquo;t load
gets deleted immediately because it&rsquo;s useless. A file that runs but fails gets
its error output fed back to the LLM for a retry.</p>
<p>The pipeline retries up to three times, passing failure details back each time.
LLMs are reasonably good at fixing specific errors when told what went wrong.
Undefined constants and wrong require paths almost always resolve in one retry.
More complex failures, like incorrect behavior assumptions, may not, and those
end up in a failed bucket for manual review.</p>
<h2 id="then-we-ran-it-on-a-real-project">Then we ran it on a real project</h2>
<p>The first external test run revealed two problems, both on the same day.</p>
<p>The first: generated tests were syntactically valid, ran, and passed — but they
looked nothing like the rest of the project&rsquo;s test suite. Wrong authentication
setup, wrong factory usage, helpers that weren&rsquo;t available. Tests that
technically pass but violate project conventions create a maintenance burden.</p>
<p>The second: the tool was silently destroying existing tests. When a spec file
already existed at the output path, the pipeline would overwrite it with the
newly generated content. Any tests already in that file were gone.</p>
<p>Both problems make complete sense in retrospect. The tool had only ever run on
its own codebase, where it was always generating new files and where the
conventions were deeply familiar to the model from the context it was seeing. A
different project broke both assumptions.</p>
<h2 id="fixing-the-conventions-gap">Fixing the conventions gap</h2>
<p>The core problem is that the LLM knows what your method does, but it doesn&rsquo;t
know how your team writes tests. It doesn&rsquo;t know that you authenticate in
<code>before</code> blocks a certain way, or that you have specific factory traits
available, or that you&rsquo;re not using <code>rails-controller-testing</code> so <code>assigns</code>
isn&rsquo;t an option.</p>
<p>The fix is a conventions system with two parts.</p>
<p><code>ConventionsExtractor</code> scans your existing test files and pulls out mechanical
facts: the most common authentication setup pattern, available factory traits
from your factories directory, frequently stubbed objects, whether
transactional fixtures are disabled and how cleanup is handled, and whether
specific helpers are unavailable based on what&rsquo;s in your Gemfile. These aren&rsquo;t
judgments, they&rsquo;re observations extracted directly from the code.</p>
<p><code>ConventionsSynthesizer</code> takes those raw facts and sends them to the LLM with a
prompt asking it to write a concise conventions guide explaining the rule
behind each pattern. The result is plain prose. Something like &ldquo;authentication
is handled in <code>before</code> blocks using <code>session[:user_id] =</code> rather than Devise
helpers; use this pattern consistently.&rdquo; That text gets prepended to every
generation prompt.</p>
<p>The synthesized guide is cached to <code>spec/conventions.md</code> and invalidated
automatically when spec files or Gemfiles change. Regenerating it costs one LLM
call.</p>
<p>Enable it with the <code>--conventions</code> flag:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">testgenai generate --provider anthropic --model claude-opus-4-7 --conventions</span></span></code></pre></div></div>
<p>Add <code>spec/conventions.md</code> to your <code>.gitignore</code>. It&rsquo;s a derived artifact and
probably not something to check in.</p>
<h2 id="fixing-the-overwrite-problem">Fixing the overwrite problem</h2>
<p>The overwrite bug was straightforward to diagnose and subtle to fix correctly.</p>
<p>The naive fix would be to skip generation if a spec file already exists. That&rsquo;s
less than ideal. The point is to add tests for untested methods, and
partially-tested files are the most common case.</p>
<p>Better behavior is to inject the generated tests into the existing file. The
pipeline now reads existing content before doing anything else. It combines
existing content with the newly generated code and validates the combined file.
If it passes, the combined content is written to the spec file.</p>
<p>If it fails, the pipeline restores the original file exactly as it was and
writes the generated-only code to a fallback path. A method in
<code>lib/payments/processor.rb</code> that already has a
<code>spec/payments/processor_spec.rb</code> would get its fallback at
<code>spec/payments/processor_context_spec.rb</code> (using the method name to scope the
filename). The failure output tells you where to find it:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><pre tabindex="0"><code>  ✗ Payments::Processor#context failed after 3 attempt(s)
    → Generated tests saved to spec/payments/processor_context_spec.rb for manual review</code></pre></div>
<p>You end up with your original tests intact and the generated attempt sitting
somewhere you can look at it and decide what to do.</p>
<h2 id="running-it">Running it</h2>
<p>Install the gem and point it at your project:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gem install testgenai
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> your_project
</span></span><span class="line"><span class="cl">testgenai generate --provider anthropic --model claude-opus-4-7</span></span></code></pre></div></div>
<p>Or add it to your Gemfile in the development group and use <code>bundle exec</code>.
Configuration can also come from environment variables:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">TESTGENAI_PROVIDER</span><span class="o">=</span>anthropic
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">TESTGENAI_MODEL</span><span class="o">=</span>claude-opus-4-7
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">ANTHROPIC_API_KEY</span><span class="o">=</span>your_api_key
</span></span><span class="line"><span class="cl">testgenai generate --conventions</span></span></code></pre></div></div>
<p>Three diagnostic commands are available before you commit to a full run:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">testgenai scan      <span class="c1"># find untested methods without making any API calls</span>
</span></span><span class="line"><span class="cl">testgenai context   <span class="c1"># show what context would be sent to the LLM for each method</span></span></span></code></pre></div></div>
<p><code>scan</code> gives you a picture of your coverage gaps. <code>context</code> is useful for
understanding what the LLM will see before spending API credits.</p>
<p>The goal isn&rsquo;t to replace the developer who understands the code and makes
decisions about testing. It&rsquo;s to handle the mechanical work: setting up
describe blocks, wiring test data, writing happy-path coverage. Then you can
spend your time on the parts that actually need your judgment. The second
project taught us that &ldquo;mechanical&rdquo; is more context-dependent than it looks.</p>
]]></description></item><item><title>Building MockOpenAI: a weekend MVP story</title><link>https://tracyatteberry.com/posts/mockopenai/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/mockopenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mockopenai/mockopenai_hero.jpg" referrerpolicy="no-referrer">
            </div><h1 id="building-mockopenai-a-weekend-mvp-story">Building MockOpenAI: a weekend MVP story</h1>
<p>Last weekend I built and published a Ruby gem. From idea to published thing
in about four days. Here&rsquo;s how it went, including the part where I had to
reconsider whether I&rsquo;d built something useful at all.</p>
<h2 id="friday-20-ideas-one-bet">Friday: 20 ideas, one bet</h2>
<p>I&rsquo;m between jobs right now. Good position to be in if you like building things,
terrible position to be in if you like eating. So I&rsquo;ve been running a little
experiment: each weekend, pick one small idea and see if I can ship it.</p>
<p>Friday&rsquo;s job was to generate and pick an idea. I sat down with an AI and
brainstormed 20 candidates. Developer tools. Content products. Micro-SaaS. I
narrowed it down to one: a local mock server for testing OpenAI-compatible
APIs.</p>
<p>The pitch to myself was simple. I write a lot of Ruby. I write a lot of tests.
Testing code that talks to LLMs is a bit annoying because, while the happy
path is easy to mock, some of the failure modes and edge cases can be more of a
pain. There had to be a better way.</p>
<p>By end of day Friday I had a repo, a gemspec, and a clear plan.</p>
<h2 id="saturday-build-day">Saturday: build day</h2>
<p>The core idea for MockOpenAI is that it&rsquo;s a real HTTP server running on
localhost, not a mock object or a stub. Your application code talks to it
exactly the way it would talk to the LLM provider in production. You just point
your client at <code>http://localhost:4000</code> instead of the usaul API endpoint.</p>
<p>That distinction makes a difference, I think. With a real HTTP server you can
test things that object-level mocking can&rsquo;t touch: actual TCP timeouts,
truncated streaming responses, retry headers. The kind of failure modes that
bite you in production but never show up in your test suite because you stubbed
them away.</p>
<p>The architecture is deliberately simple. A Rack server reads a shared JSON
state file on every request. Your tests write rules to that file. The server is
stateless. No client wrapping, no monkey-patching, no magic.</p>
<p>Here&rsquo;s what using it looks like:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles a rate limit&#34;</span><span class="p">,</span> <span class="ss">:mock_openai_rate_limit</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="no">MyService</span><span class="o">.</span><span class="n">call_llm</span><span class="p">(</span><span class="s2">&#34;Hello&#34;</span><span class="p">)</span> <span class="p">}</span><span class="o">.</span><span class="n">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">RubyLLM</span><span class="o">::</span><span class="no">RateLimitError</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles mixed outcomes&#34;</span><span class="p">,</span> <span class="ss">:mock_openai</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">MockOpenAI</span><span class="o">.</span><span class="n">set_responses</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 1&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 2&#34;</span><span class="p">,</span> <span class="ss">failure_mode</span><span class="p">:</span> <span class="ss">:timeout</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 3&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;Done&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="no">MyService</span><span class="o">.</span><span class="n">step1</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">eq</span><span class="p">(</span><span class="s2">&#34;OK&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="no">MyService</span><span class="o">.</span><span class="n">step2</span> <span class="p">}</span><span class="o">.</span><span class="n">to</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">Timeout</span><span class="o">::</span><span class="no">Error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="no">MyService</span><span class="o">.</span><span class="n">step3</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">eq</span><span class="p">(</span><span class="s2">&#34;Done&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The failure modes are:</p>
<table>
	<thead>
			<tr>
					<th>Mode</th>
					<th>What it does</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>:timeout</code></td>
					<td>Sleeps then closes the connection without responding</td>
			</tr>
			<tr>
					<td><code>:rate_limit</code></td>
					<td>Returns HTTP 429 with an OpenAI-format error body</td>
			</tr>
			<tr>
					<td><code>:malformed_json</code></td>
					<td>Returns truncated JSON that causes a parse error in your client</td>
			</tr>
			<tr>
					<td><code>:internal_error</code></td>
					<td>Returns HTTP 500</td>
			</tr>
			<tr>
					<td><code>:truncated_stream</code></td>
					<td>Sends partial SSE chunks then closes the connection</td>
			</tr>
	</tbody>
</table>
<p>You can also mix success and failure in a single test:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles mixed outcomes&#34;</span><span class="p">,</span> <span class="ss">:mock_openai</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="no">MockOpenAI</span><span class="o">.</span><span class="n">set_responses</span><span class="p">(</span><span class="o">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 1&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;OK&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 2&#34;</span><span class="p">,</span> <span class="ss">failure_mode</span><span class="p">:</span> <span class="ss">:timeout</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="ss">match</span><span class="p">:</span> <span class="s2">&#34;Step 3&#34;</span><span class="p">,</span> <span class="ss">response</span><span class="p">:</span> <span class="s2">&#34;Done&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="o">]</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>Saturday was productive. By end of day I had all the core classes written
TDD-style: <code>Config</code>, <code>State</code>, <code>Matcher</code>, <code>ResponseBuilder</code>,
<code>TemplateRenderer</code>, all five failure mode classes. The code was written, the
tests passed, and life was good.</p>
<h2 id="sunday-documentation-and-shipping">Sunday: documentation and shipping</h2>
<p>Sunday was docs day. I set up a Jekyll site and wrote the README. I added an
Anthropic endpoint too, because my personal projects use both.</p>
<p>I also migrated the first personal project to use MockOpenAI. That went
smoothly. The HTTP-level fidelity made a few tests a little more honest than
they&rsquo;d been with simple stubs at the client level.</p>
<h2 id="monday-the-uncomfortable-question">Monday: the uncomfortable question</h2>
<p>Monday I migrated a second personal project. This one used a helper module I&rsquo;d
written a while back to stub LLM calls. Just a few lines of code. It worked fine
for that project.</p>
<p>I stared at that code for a while. Here it is:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">RubyLLMMocks</span>
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nf">mock_ruby_llm_chat</span><span class="p">(</span><span class="ss">content</span><span class="p">:</span> <span class="kp">nil</span><span class="p">,</span> <span class="ss">error</span><span class="p">:</span> <span class="kp">nil</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">error</span>
</span></span><span class="line"><span class="cl">      <span class="n">allow</span><span class="p">(</span><span class="no">RubyLLM</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:chat</span><span class="p">)</span><span class="o">.</span><span class="n">and_raise</span><span class="p">(</span><span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">else</span>
</span></span><span class="line"><span class="cl">      <span class="n">mock_response</span> <span class="o">=</span> <span class="n">instance_double</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Message</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="ss">content</span><span class="p">:</span> <span class="n">content</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nb">inspect</span><span class="p">:</span> <span class="s2">&#34;RubyLLM::Message(content: </span><span class="si">#{</span><span class="n">content</span><span class="o">.</span><span class="n">inspect</span><span class="si">}</span><span class="s2">)&#34;</span>
</span></span><span class="line"><span class="cl">      <span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">mock_chat</span> <span class="o">=</span> <span class="n">instance_double</span><span class="p">(</span><span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="p">,</span> <span class="ss">ask</span><span class="p">:</span> <span class="n">mock_response</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">      <span class="n">allow</span><span class="p">(</span><span class="no">RubyLLM</span><span class="p">)</span><span class="o">.</span><span class="n">to</span> <span class="n">receive</span><span class="p">(</span><span class="ss">:chat</span><span class="p">)</span><span class="o">.</span><span class="n">and_return</span><span class="p">(</span><span class="n">mock_chat</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">end</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Example:</span>
</span></span><span class="line"><span class="cl"><span class="n">it</span> <span class="s2">&#34;handles general ruby_llm errors gracefully&#34;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="n">error</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Error</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="kp">nil</span><span class="p">,</span> <span class="s2">&#34;Unexpected error&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="n">mock_ruby_llm_chat</span><span class="p">(</span><span class="ss">error</span><span class="p">:</span> <span class="n">error</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">generator</span> <span class="o">=</span> <span class="n">described_class</span><span class="o">.</span><span class="n">new</span><span class="p">(</span><span class="n">options</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="n">expect</span> <span class="p">{</span> <span class="n">generator</span><span class="o">.</span><span class="n">generate</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">to</span> <span class="n">output</span><span class="p">(</span><span class="sr">/Error.*Unexpected error/m</span><span class="p">)</span><span class="o">.</span><span class="n">to_stdout</span>
</span></span><span class="line"><span class="cl">    <span class="o">.</span><span class="n">and</span> <span class="n">raise_error</span><span class="p">(</span><span class="no">SystemExit</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>That&rsquo;s it. Fifteen lines, no gem dependency, works perfectly for a project that
uses RubyLLM as a wrapper. The error case is handled with <code>and_raise</code>. Clean.</p>
<p>So the question had to be asked: did I just build a solution in search of a
problem?</p>
<p>After sitting with it, I don&rsquo;t think so. But I did have to sharpen my thinking
about <em>when</em> MockOpenAI actually earns its place versus when a helper method is
the right call.</p>
<p>The short version: if you&rsquo;re using a wrapper library like RubyLLM for all your
LLM calls, and you only need happy-path responses and exception simulation in
unit tests, the 15-line helper is probably the right answer. It&rsquo;s less to
maintain, has no extra dependencies, and does the job.</p>
<p>MockOpenAI is the right call when you need the actual HTTP layer in the
picture. When you&rsquo;re using the raw OpenAI or Anthropic client directly. When
you&rsquo;re running integration or system tests that make real HTTP connections.
When you need to test what happens when TCP actually times out, or when a
streaming response gets cut off halfway through, or when your retry logic
processes a <code>Retry-After</code> header.</p>
<p>Those are real problems. They&rsquo;re just not every project&rsquo;s problems. I added a
<a href="https://grymoire7.github.io/mockopenai/when-not-to-use.html" target="_blank" rel="noopener noreffer ">when not to use</a>
page to the docs to make the tradeoffs explicit.</p>
<h2 id="what-id-do-differently">What I&rsquo;d do differently</h2>
<p>One thing I&rsquo;d change: I&rsquo;d research the problem space a bit more to make sure I
had a better understanding of problem scope and existing solution. (Especially
ones I wrote myself!) The tool is solid, but I made some assumptions about the
breadth of problems it would solve for. That&rsquo;s a classic weekend MVP trap I
suppose. You&rsquo;re so focused on building that you skip the a bit of due diligence
you think you don&rsquo;t need.</p>
<p>The gem is published, the docs are live, and it works. The scope is narrower
than I originally thought, but the use case is real. That feels like an honest
result for a long weekend.</p>
<hr>
<p><em>MockOpenAI is a Ruby gem for testing OpenAI-compatible and Anthropic APIs
locally. References:</em></p>
<ul>
<li>Source: <a href="https://github.com/grymoire7/mockopenai" target="_blank" rel="noopener noreffer ">github.com/grymoire7/mockopenai</a>.</li>
<li>Docs: <a href="https://grymoire7.github.io/mockopenai" target="_blank" rel="noopener noreffer ">grymoire7.github.io/mockopenai</a>.</li>
<li>Landing page: <a href="https://tracyatteberry.com/mockopenai" target="_blank" rel="noopener noreffer ">tracyatteberry.com/mockopenai</a>.</li>
</ul>
]]></description></item><item><title>Building a Ruby CLI gem for Hyrum's Law</title><link>https://tracyatteberry.com/posts/hyrum/</link><pubDate>Thu, 20 Nov 2025 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/hyrum/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/hyrum/hyrum_top.png" referrerpolicy="no-referrer">
            </div><h1 id="building-a-ruby-cli-gem-for-hyrums-law">Building a Ruby CLI gem for Hyrum&rsquo;s Law</h1>
<p>When you build a public API, users will depend on behaviors you never intended
to guarantee. It&rsquo;s called Hyrum&rsquo;s Law, and it&rsquo;s particularly tricky when it
comes to error messages. Change &ldquo;User not found&rdquo; to &ldquo;No such user exists&rdquo; and
someone&rsquo;s regex breaks in production at 2am.</p>
<p>I built Hyrum to solve for  this. It&rsquo;s a Ruby CLI gem that uses AI to generate
variations of status messages, ensuring users never become dependent on exact
wording. It evolved from a single-provider tool into a multi-provider platform
that cut costs by 10x, reduced code complexity from 12 to 1, and added quality
validation for AI-generated content.</p>
<h2 id="the-problem-with-predictable-messages">The problem with predictable messages</h2>
<p><a href="https://www.laws-of-software.com/laws/hyrum/" target="_blank" rel="noopener noreffer ">Hyrum&rsquo;s Law</a> states that
all observable behaviors of your system will be depended on by somebody.
This creates a dilemma for API designers: you want clear, consistent
error messages, but you don&rsquo;t want users parsing them as if they were
structured data.</p>
<p>Error codes help with this. Return <code>404</code> or <code>E_NOT_FOUND</code> and the
message text can evolve independently. But this only works if you&rsquo;re
disciplined about using codes for everything that matters. In practice,
some context lives in the message text, and someone will parse it.</p>
<p>The traditional solution is thorough documentation warning against this.
The pragmatic solution is accepting that some percentage of users will
do it anyway. The solution presented here is to make the messages unpredictable
by design.</p>
<h2 id="building-the-initial-solution">Building the initial solution</h2>
<p>The first version was straightforward. The gem takes a message like &ldquo;The
server refuses the attempt to brew coffee with a teapot&rdquo; and generates
code in your language of choice (Ruby, JavaScript, Python, Java, or
JSON) that returns variations at random:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-ruby">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="k">module</span> <span class="nn">Messages</span>
</span></span><span class="line"><span class="cl">  <span class="no">MESSAGES</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="ss">e418</span><span class="p">:</span> <span class="o">[</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Invalid Brewing Method&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Teapot not designed for coffee brewing&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;Please use a suitable brewing device&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="o">]</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="o">.</span><span class="n">freeze</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">def</span> <span class="nc">self</span><span class="o">.</span><span class="nf">message</span><span class="p">(</span><span class="n">key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="no">MESSAGES</span><span class="o">[</span><span class="n">key</span><span class="o">].</span><span class="n">sample</span>
</span></span><span class="line"><span class="cl">  <span class="k">end</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>I started with OpenAI&rsquo;s API via the <code>ruby-openai</code> gem. It worked well
enough for the core use case. But eventually, two problems emerged.</p>
<p>First, cost. Running <code>gpt-4</code> for simple message generation was using a
chainsaw to cut butter. Second, vendor lock-in. Some projects had
Anthropic credits, others used local Ollama models. I needed to support
multiple providers without maintaining provider-specific code paths.</p>
<h2 id="the-migration-decision">The migration decision</h2>
<p>There were three options:</p>
<ol>
<li>Build custom adapters for each provider</li>
<li>Find an abstraction layer that handled the differences</li>
<li>Accept the limitation and move on</li>
</ol>
<p>Building custom adapters would provide complete control, but at the cost
of maintaining provider-specific logic as APIs evolved. Option three was
tempting but unsatisfying.</p>
<p>I chose option two, migrating to
<a href="https://github.com/crmne/ruby_llm" target="_blank" rel="noopener noreffer ">ruby_llm</a>. More than
swapping dependencies, this was a fundamental architecture change that
would affect testing strategy, error handling, configuration, and the
public API.</p>
<h2 id="key-technical-decisions">Key technical decisions</h2>
<h3 id="cost-optimization-through-model-selection">Cost optimization through model selection</h3>
<p>The most impactful decision was switching from premium to budget models.
For Anthropic, this meant <code>claude-sonnet-4</code> to <code>claude-haiku-20250514</code>,
a roughly <em><strong>10x cost reduction</strong></em>.</p>
<p>This wasn&rsquo;t about being cheap. (Okay, maybe it was a little bit about that.) It
was (mostly) about matching model capability to task complexity. Generating
three variations of &ldquo;Resource not found&rdquo; doesn&rsquo;t require deep reasoning. Budget
models handle it perfectly well. The quality remained identical while costs
dropped by an order of magnitude.</p>
<h3 id="testing-strategy-mock-at-the-right-level">Testing strategy: mock at the right level</h3>
<p>The original implementation used VCR to record HTTP interactions. This
is a common pattern, but it had problems:</p>
<ul>
<li>Maintaining cassettes for 10+ providers would be tedious</li>
<li>Tests would break when ruby_llm changed request formats</li>
<li>We would be testing ruby_llm&rsquo;s HTTP implementation, not our code</li>
</ul>
<p>The better approach: mock at the ruby_llm interface level. Instead of
recording HTTP traffic, we mock <code>RubyLLM.chat()</code> directly. One mock
setup works for all providers. Tests are faster, more maintainable, and
focused on our actual logic.</p>
<p>This eliminated the need for VCR and WebMock entirely, <em><strong>removing two
dependencies.</strong></em> 🎊</p>
<p>As an additional safeguard, I set up a GitHub Actions workflow that runs
the full test suite on every push. This catches regressions before they
reach main and provides confidence when accepting contributions. It&rsquo;s a
small addition that pays dividends in long-term maintainability.</p>
<h3 id="code-simplification-through-extraction">Code simplification through extraction</h3>
<p><code>FakeGenerator</code> started at 298 lines with embedded message data.
Extracting the messages to an external JSON file and refactoring the
logic brought it down to 36 lines. That&rsquo;s an 88% reduction.</p>
<p>Another big win was in <code>AiGenerator</code>. By letting ruby_llm handle provider
differences, the <em><strong>cyclomatic complexity dropped from 12 to 1</strong></em>. Twelve
decision points (checking provider types, handling edge cases) collapsed into a
single code path.</p>
<p>This is the value of a good abstraction layer. It reduces the lines of code,
sure, but it also reduces the number of things you have to think about.</p>
<p>To be honest, this is also currently a pain point. We swapped ruby-openai&rsquo;s
abastraction for a better one, but in an evolving ecosystem, we may need to
swap again. The key is that the architecture is now flexible enough to
accommodate future changes more easily.</p>
<h3 id="breaking-changes-as-a-design-tool">Breaking changes as a design tool</h3>
<p>The migration required environment variable changes:</p>
<ul>
<li><code>OPENAI_ACCESS_TOKEN</code> → <code>OPENAI_API_KEY</code></li>
<li><code>OLLAMA_URL</code> → <code>OLLAMA_API_BASE</code></li>
</ul>
<p>I considered adding migration helpers to detect old variables and warn
users. But the gem was pre-1.0 with minimal adoption. Adding complexity
for hypothetical users would hurt future maintainability more than it
helped current users.</p>
<p>The cleaner approach: document the breaking changes clearly, provide a
migration guide, and move forward with consistent naming. Sometimes the
right trade-off is accepting short-term pain for long-term simplicity.</p>
<h2 id="implementation-approach">Implementation approach</h2>
<p>I followed a disciplined TDD approach for the migration:</p>
<ol>
<li>Write failing tests for <code>AiGenerator</code></li>
<li>Implement minimal code to pass</li>
<li>Add error handling tests</li>
<li>Implement error handling</li>
<li>Verify across multiple providers</li>
</ol>
<p>Each commit represented a logical unit of work with a clear purpose. The
git history tells a story: dependency updates, test infrastructure, new
generator implementation, factory updates, cleanup, documentation.</p>
<p>This matters for maintainability. Six months from now, when I need to
add a new provider or debug an edge case, the git history explains not
only what changed but why.</p>
<h2 id="validating-non-deterministic-output">Validating non-deterministic output</h2>
<p>Getting AI to generate message variations is great, but
how do you know if the variations are any good?</p>
<p>Building a system that validates the quality of non-deterministic output
requires deeper thinking about what &ldquo;quality&rdquo; even means in this context.</p>
<h3 id="defining-useful-variation">Defining useful variation</h3>
<p>A good variation needs two properties:</p>
<ol>
<li><strong>Semantic similarity</strong> - It preserves the original message&rsquo;s meaning</li>
<li><strong>Lexical diversity</strong> - It uses different wording than other variations</li>
</ol>
<p>These goals exist in tension. Perfect similarity means identical text.
Perfect diversity means unrelated messages. The sweet spot is variations
that mean the same thing but say it differently.</p>
<p>I created a validation system that measures both metrics
and combines them into an overall quality score. This lets you validate
generated variations automatically.</p>
<h3 id="the-initial-design-mistake">The initial design mistake</h3>
<p>My first implementation compared variations to each other. Generate
five variations, measure how similar they are as a group, done. This
seemed logical until I tested it.</p>
<p>The problem: variations could be highly similar to each other but
completely different from the original message. A set of variations
about network timeouts would score well even if the original message
was about authentication failures. They were similar to each other,
but wrong.</p>
<p>The fix was obvious in hindsight: compare each variation to the
original message, not to other variations. Semantic similarity measures
how well each variation preserves the user&rsquo;s intent. Lexical diversity
measures how much the variations differ from each other. Two separate
concerns, two separate comparisons.</p>
<h3 id="semantic-similarity-with-embeddings">Semantic similarity with embeddings</h3>
<p>Measuring semantic similarity requires understanding meaning, not just
matching words. &ldquo;Server error&rdquo; and &ldquo;Internal server failure&rdquo; share
minimal text but convey the same concept. Simple string comparison
would fail.</p>
<p>The solution: embedding models. These convert text into high-dimensional
vectors where semantically similar content clusters together. Calculate
the cosine similarity between the original message&rsquo;s embedding and each
variation&rsquo;s embedding, and you have a numeric score for how well meaning
is preserved.</p>
<p>I designed this to be provider-agnostic from the start, learning from
the earlier migration experience. The validator uses <code>RubyLLM.embed()</code>
which works with any provider that supports embeddings (OpenAI, Google,
etc.). When embeddings aren&rsquo;t available, it falls back to a simpler
word overlap heuristic.</p>
<p>This graceful degradation was important. Users without embedding access
still get validation, just with reduced accuracy. The feature doesn&rsquo;t
silently fail or block users.</p>
<h3 id="api-design-for-optional-features">API design for optional features</h3>
<p>Quality validation needed to be opt-in. The core workflow is &ldquo;generate
variations and use them.&rdquo; Adding validation steps would slow things down
and require configuration. It needed to enhance the workflow without
disrupting it.</p>
<p>The CLI design reflects this:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-bash">
        <span class="code-title"><i class="arrow fas fa-chevron-right fa-fw" aria-hidden="true"></i></span>
        <span class="ellipses"><i class="fas fa-ellipsis-h fa-fw" aria-hidden="true"></i></span>
        <span class="copy" title="Copy to clipboard"><i class="far fa-copy fa-fw" aria-hidden="true"></i></span>
    </div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Basic usage - no validation</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Opt into validation</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby --validate
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Use in CI/CD with strict mode</span>
</span></span><span class="line"><span class="cl">hyrum -s openai -m <span class="s2">&#34;Server error&#34;</span> -f ruby --validate --strict --min-quality <span class="m">75</span></span></span></code></pre></div></div>
<p>Validation is off by default. Enable it when you want quality metrics.
Use <code>--strict</code> to fail builds when quality is too low. Use <code>--show-scores</code>
to include metrics in generated output.</p>
<p>Each flag serves a specific use case without cluttering the happy path.
This is backward compatible and makes the feature discoverable through
<code>--help</code> without overwhelming new users.</p>
<h2 id="what-i-learned">What I learned</h2>
<h3 id="shipping-a-ruby-gem-is-more-accessible-than-i-expected">Shipping a Ruby gem is more accessible than I expected</h3>
<p>I hadn&rsquo;t published a gem before this project. The Ruby ecosystem makes
it surprisingly straightforward: follow conventions for directory
structure, add a gemspec, and <code>gem build</code> handles the rest. RuboCop
enforces community standards, and RSpec provides solid testing patterns.</p>
<p>Most of the learning curve wasn&rsquo;t in the tooling. It was in the design
decisions around versioning, breaking changes, and API stability. Understanding
when to bump major vs minor versions, when breaking changes are acceptable, and
how much backward compatibility to maintain. These are judgment calls that come
with experience, not documentation per se.</p>
<h3 id="abstractions-have-a-cost-and-a-benefit">Abstractions have a cost and a benefit</h3>
<p>Ruby_llm&rsquo;s abstraction eliminated provider-specific code paths. But it
also added a dependency and gave up some provider-specific features
(like Anthropic&rsquo;s prompt caching). The trade-off made sense because the
gem&rsquo;s core use case doesn&rsquo;t need advanced features. Your mileage will
vary.</p>
<h3 id="model-selection-is-a-design-decision">Model selection is a design decision</h3>
<p>Defaulting to budget models wasn&rsquo;t about minimizing costs. It was about
right-sizing capability to task complexity. When your task genuinely
needs advanced reasoning, use advanced models. When it doesn&rsquo;t, you&rsquo;re
paying for capability you&rsquo;re not using.</p>
<h3 id="testing-at-the-right-abstraction-level-matters">Testing at the right abstraction level matters</h3>
<p>Mocking at the HTTP level tests the wrong thing. Mocking at the library
interface level tests your code. The latter is almost always better
unless you&rsquo;re specifically testing HTTP behavior.</p>
<h3 id="breaking-changes-are-acceptable-in-context">Breaking changes are acceptable in context</h3>
<p>Pre-1.0 software with limited adoption is the right time to make
breaking changes. Adding backward compatibility for a handful of users
creates technical debt that affects every future user. Sometimes the
generous thing is to break things cleanly.</p>
<h3 id="user-feedback-catches-design-flaws-early">User feedback catches design flaws early</h3>
<p>My initial quality validation design seemed sound in theory. It measured
variation quality as a group property. But the first test revealed the
flaw: variations could be similar to each other while being completely
unrelated to the original message.</p>
<p>This is why you test with real examples before building the whole system.
The fix (comparing to the original message) was trivial to implement
early. It would have been painful to retrofit later after building an
entire validation pipeline on the wrong assumption.</p>
<p>The lesson: design mistakes are inevitable. What matters is catching them
before they become weight-bearing walls in your architecture.</p>
<h3 id="graceful-degradation-beats-hard-dependencies">Graceful degradation beats hard dependencies</h3>
<p>Embedding models provide superior semantic similarity measurement. But
requiring them would block users whose AI providers don&rsquo;t support
embeddings. The word overlap fallback isn&rsquo;t as accurate, but it&rsquo;s
better than nothing.</p>
<p>This pattern appears throughout the gem. Can&rsquo;t access embeddings? Use
heuristics. Provider doesn&rsquo;t support structured output? Parse text.
Each graceful degradation expands the set of valid configurations.</p>
<p>The alternative is failing fast with clear errors. Both approaches are
valid, but for a tool that works across many providers, degradation
creates a better experience than strict requirements.</p>
<h3 id="validation-changes-what-done-means">Validation changes what &ldquo;done&rdquo; means</h3>
<p>Before quality validation, &ldquo;done&rdquo; meant &ldquo;generates variations.&rdquo; After,
it meant &ldquo;generates variations that preserve meaning while varying
wording.&rdquo; This shift changed the entire value proposition.</p>
<p>The interesting part is that validation makes the AI output more
trustworthy without requiring a better AI model. Same model, same cost,
but now you have quantitative confidence in the results. That&rsquo;s the
leverage of good metrics.</p>
<h2 id="the-result">The result</h2>
<p>Hyrum now supports 11 AI providers (OpenAI, Anthropic, Gemini, Ollama,
Mistral, DeepSeek, Perplexity, OpenRouter, Vertex AI, AWS Bedrock,
GPUStack) through a unified interface. The codebase is simpler,
tests are faster, and costs are 10x lower.</p>
<p>Quality validation adds confidence without complexity. Generate variations,
validate they preserve meaning while varying wording, and integrate the
results into your codebase with quantitative quality metrics. The
validation system works across all providers and degrades gracefully
when embeddings aren&rsquo;t available.</p>
<p>Best of all, the architecture can accommodate new providers and capabilities
without increasing complexity. When ruby_llm adds support for a new provider,
Hyrum gets it for free. When embedding models improve, quality validation
automatically benefits. That&rsquo;s the payoff of choosing the right abstractions.</p>
<p>The project is <a href="https://github.com/grymoire7/hyrum" target="_blank" rel="noopener noreffer ">open source on GitHub</a>.
If you&rsquo;re dealing with Hyrum&rsquo;s Law in your own APIs, or you&rsquo;re just
curious about the implementation details, check it out.</p>
]]></description></item></channel></rss>