<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>Ruby - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/ruby/</link><description>Ruby - 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>Tue, 03 Nov 2026 00:00:00 +0000</lastBuildDate><image><url>https://tracyatteberry.com/images/feed-icon.jpg</url><title>Ruby - Tag - Tracy Atteberry</title><link>https://tracyatteberry.com/tags/ruby/</link></image><atom:link href="https://tracyatteberry.com/tags/ruby/" rel="self" type="application/rss+xml"/><item><title>Why I built Speechwave in Elixir (as a Rails developer)</title><link>https://tracyatteberry.com/posts/why-elixir/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/why-elixir/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/why-elixir/swhero.png" referrerpolicy="no-referrer">
            </div><h1 id="why-i-built-speechwave-in-elixir-as-a-rails-developer">Why I built Speechwave in Elixir (as a Rails developer)</h1>
<p>I&rsquo;ve been developing with Ruby on Rails for a long time. If you had asked me to bet on
what I&rsquo;d build my next web app with, I would have bet on Rails.</p>
<p>Then I started building <a href="https://speechwave.live" target="_blank" rel="noopener noreffer ">Speechwave</a>, a live emoji reaction app
for conference talks, and I wrote it in Elixir instead</p>
<p>This isn&rsquo;t a &ldquo;Rails is dead&rdquo; post. It&rsquo;s still a great choice for lots of
things. This is about what this particular app needed, why Elixir turned out to
a great choice, and why the hosting bill is about as small as a bill can get. If you&rsquo;re an Elixir
developer, some of this may be familiar.</p>
<h2 id="what-speechwave-does">What Speechwave does</h2>
<p>Attendees scan a QR code at a talk and are sent to a page of emoji buttons. They
tap one. A Chrome extension on the speaker&rsquo;s laptop receives that reaction and
floats the emoji over the speaker&rsquo;s Google Slides in real time. After the talk,
the speaker can see which slides got which reactions.</p>
<p>Look at that from a server&rsquo;s point of view. It&rsquo;s a lot of small, short-lived,
simultaneous things:</p>
<ul>
<li>Dozens (or hundreds) of phones holding open WebSocket connections</li>
<li>Each tap needs to be rate limited, optionally saved, and fanned out to
everyone who cares</li>
<li>The speaker&rsquo;s extension holds one more long-lived connection, and it isn&rsquo;t
even a web page</li>
<li>The whole thing is quiet for days, then very busy for 45 minutes</li>
</ul>
<p>That last point will matter quite a bit when we get to the hosting
section.</p>
<h2 id="why-it-works-the-three-things-i-would-have-had-to-do-in-rails">Why it works: the three things I would have had to do in Rails</h2>
<p>I don&rsquo;t want to oversell this. Rails can do real-time. Action Cable exists,
and Rails keeps improving. But in Rails I would have been assembling
pieces. In Phoenix, the pieces come in the box and they were designed together.</p>
<h3 id="1-a-message-bus-that-doesnt-care-whos-listening">1. A message bus that doesn&rsquo;t care who&rsquo;s listening</h3>
<p>Phoenix PubSub lets any process broadcast to a topic and any other process
subscribe to it. The attendee&rsquo;s LiveView doesn&rsquo;t know the Chrome extension
exists. After the rate limit check (and saving the reaction, if a session is
recording), it just broadcasts. Here&rsquo;s the handler from <code>talk_live.ex</code>, with the
persistence code trimmed out:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <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-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">handle_event</span><span class="p">(</span><span class="s2">&#34;react&#34;</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;emoji&#34;</span> <span class="o">=&gt;</span> <span class="n">emoji</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="nc">RateLimiter</span><span class="o">.</span><span class="n">allow?</span><span class="p">(</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">session_id</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...save the reaction if a session is recording...</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Endpoint</span><span class="o">.</span><span class="n">broadcast!</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">socket</span><span class="o">.</span><span class="n">assigns</span><span class="o">.</span><span class="n">talk</span><span class="o">.</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;new_reaction&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="p">%{</span><span class="ss">emoji</span><span class="p">:</span> <span class="n">emoji</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <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="p">{</span><span class="ss">:noreply</span><span class="p">,</span> <span class="n">socket</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 extension&rsquo;s channel is subscribed to the same topic, so it gets the
message. If it add a third kind of listener tomorrow, say a live dashboard,
nothing in that function changes.</p>
<h3 id="2-liveview-for-the-attendee-page">2. LiveView for the attendee page</h3>
<p>The attendee page is a LiveView, so we got WebSocket-backed interactivity without
building a single-page app and without maintaining a separate JavaScript
frontend. The UI is a function of server state, and Phoenix handles keeping the
browser in sync. For an app where nearly every interesting event is &ldquo;something
happened on the server, tell the clients,&rdquo; that model is a very good match.</p>
<h3 id="3-cheap-isolated-processes-for-everything-else">3. Cheap, isolated processes for everything else</h3>
<p>The rate limiter is a <code>GenServer</code> that owns an ETS table (an in-memory
key/value store built into the runtime). Reads don&rsquo;t queue behind the
GenServer, so a room full of people tapping at once doesn&rsquo;t turn into a
traffic jam. It&rsquo;s a few dozen lines and there is no Redis anywhere.</p>
<p>Presence, which tracks how many extensions are connected to a talk so we can
enforce plan limits, is also built in. So is the supervision tree that restarts
any piece that crashes without taking down the rest. Here&rsquo;s the top level of
the tree from <code>application.ex</code>, trimmed a bit for space:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <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-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">children</span> <span class="o">=</span>
</span></span><span class="line"><span class="cl">  <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.Repo</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span><span class="nc">Phoenix.PubSub</span><span class="p">,</span> <span class="ss">name</span><span class="p">:</span> <span class="nc">Speechwave.PubSub</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.RateLimiter</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nc">Speechwave.AuthThrottle</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ...a few more throttles and reapers...</span>
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Endpoint</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nc">SpeechwaveWeb.Presence</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span> <span class="o">++</span> <span class="n">backup_children</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">opts</span> <span class="o">=</span> <span class="p">[</span><span class="ss">strategy</span><span class="p">:</span> <span class="ss">:one_for_one</span><span class="p">,</span> <span class="ss">name</span><span class="p">:</span> <span class="nc">Speechwave.Supervisor</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="nc">Supervisor</span><span class="o">.</span><span class="n">start_link</span><span class="p">(</span><span class="n">children</span><span class="p">,</span> <span class="n">opts</span><span class="p">)</span></span></span></code></pre></div></div>
<p>I wrote down what happens when each child crashes (the rate limiter loses its
cooldown state, Presence resets its count, and so on) and for this app every
answer is &ldquo;acceptable, by design.&rdquo; Thinking about failure that explicitly is
something the platform nudges you toward. In a Rails apps, I mostly find out
what a crash does when it happens.</p>
<h2 id="the-hosting-bill">The hosting bill</h2>
<p>This is the part I was the most excited about, and why I&rsquo;m writing this
post in the first place.</p>
<p>Speechwave runs on <a href="https://fly.io" target="_blank" rel="noopener noreffer ">Fly.io</a>, and here are the essential bits
configuration:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-toml">
        <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-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;512mb&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">cpu_kind</span> <span class="p">=</span> <span class="s1">&#39;shared&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">cpus</span> <span class="p">=</span> <span class="mi">1</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span></span></span></code></pre></div></div>
<p>That&rsquo;s one shared CPU and 512 MB of memory. When nobody is using the app, the
machine suspends. When someone scans a QR code, it wakes up.</p>
<p>Three things make that work, and Elixir is part of each of them.</p>
<p><strong>The runtime is frugal.</strong> Because the BEAM runs lightweight processes instead
of OS threads, one small machine can hold a lot of open connections. I&rsquo;ve
configured the app to allow up to 1,000 concurrent connections on that single
512 MB box. For context, my realistic target is a few conference rooms, not a
stadium.</p>
<p><strong>One process serves everything.</strong> The reaction fan-out, the LiveView pages,
the Channel for the extension, the rate limiter, the presence tracking, and the
scheduled backups all run inside a single Elixir application. I don&rsquo;t need a
separate Redis for pub/sub, a separate worker process for background jobs, or a
separate WebSocket server. Fewer moving parts means fewer things to pay for and
fewer things to keep alive.</p>
<p><strong>SQLite is enough.</strong> The first version of this app used Fly&rsquo;s managed
Postgres. I&rsquo;ve since moved to SQLite on a Fly volume, with Ecto&rsquo;s SQLite
adapter and a pool size of 2. Reactions are small writes, the data volume is
tiny, and a few talk-shaped workloads doesn&rsquo;t need a database server.</p>
<p>That does mean I own backups. A GenServer takes a snapshot every hour using
SQLite&rsquo;s <code>VACUUM INTO</code> (which gives a consistent copy of a live database,
unlike copying the file) and uploads it to an S3-compatible object storage. It&rsquo;s
about a hundred lines of Elixir, it&rsquo;s supervised like everything else, and a
failed backup logs an error instead of crashing anything. If you want the
details, the <a href="https://docs.speechwave.live/dev/explainer/db-backup.html" target="_blank" rel="noopener noreffer ">codebase explainer</a>
walks through it.</p>
<p>Put those together and the cost is tiny. Fly waives invoices under $5, and only
last month did my bill creep above that to $5.49, just a little over the
line. Usage of the S3 object store at Tigris has stayed within its free
allowances.</p>
<p>I want to be careful about what I&rsquo;m claiming. That number reflects low traffic
on a young app. I haven&rsquo;t load tested a 5,000-person keynote, and I&rsquo;m not
claiming this configuration handles one. What I can say is that the floor is
very low, and the architecture doesn&rsquo;t force me to pay for a fleet of
services before I&rsquo;ve earned a paying user.</p>
<h2 id="could-i-have-done-this-in-rails">Could I have done this in Rails?</h2>
<p>Probably, yes. Rails 8 made SQLite a first-class citizen, and Solid Cable
removes the Redis requirement for Action Cable. I&rsquo;m not going to pretend that
Rails can&rsquo;t get to a similar place. A fair comparison, though, is about how
much I had to assemble and tune to get there, and about how comfortable the
result is at idle and at peak.</p>
<p>For this specific shape of app, many long-lived connections, constant
broadcast, tiny writes, spiky traffic, Phoenix gave me the right design
without much negotiation. In Rails I would have been making deliberate choices
to approximate it.</p>
<p>I&rsquo;ll also say what I did <strong>not</strong> get for free. Rails has an enormous ecosystem
of gems that solve the boring parts of an app. In Elixir I wrote more of the
glue myself. Authentication is a good example: magic links and OAuth took more
work (Phoenix&rsquo;s generator got me started, and a library called Assent handles
the OAuth side). If this had been a plain CRUD app with admin screens, Rails
may have been faster to build, and I may have shipped it sooner.</p>
<h2 id="what-learning-elixir-was-like-for-a-ruby-developer">What learning Elixir was like for a Ruby developer</h2>
<p>Easier than I expected, honestly. The syntax is friendly to Rubyists, the
tooling (<code>mix</code>, <code>iex</code>, ExUnit) feels familiar, and the community writes really
good documentation. The adjustments that took some effort were more conceptual:</p>
<ul>
<li>Data is immutable, so you transform values instead of mutating objects.</li>
<li>Pattern matching shows up everywhere, and it replaces a surprising amount of
<code>if</code> and <code>case</code> logic.</li>
<li>You think in processes, which is a new mental model, but a pleasant one once
it clicks.</li>
</ul>
<p>If you&rsquo;ve used Ruby 3&rsquo;s <code>case/in</code>, pattern matching won&rsquo;t be a brand new idea.
You can destructure hashes and arrays, bind variables, and pin values with <code>^</code>:</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">case</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl"><span class="k">in</span> <span class="p">{</span><span class="ss">status</span><span class="p">:</span> <span class="mi">200</span><span class="p">,</span> <span class="ss">body</span><span class="p">:}</span>
</span></span><span class="line"><span class="cl">  <span class="n">render</span><span class="p">(</span><span class="n">body</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">in</span> <span class="p">{</span><span class="ss">status</span><span class="p">:</span> <span class="mi">404</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="n">not_found</span>
</span></span><span class="line"><span class="cl"><span class="k">end</span></span></span></code></pre></div></div>
<p>The difference is how deep it goes. In Ruby, pattern matching is a feature you
opt into with <code>case/in</code>, and most Ruby code still uses <code>if</code>, <code>case/when</code>, and
guard clauses. In Elixir it&rsquo;s foundational. Even <code>=</code> is a match operator, not
just assignment. Most importantly, you can match right in a function&rsquo;s
arguments, so each clause of a function handles one shape of input. Speechwave&rsquo;s
Channel has two <code>join</code> functions, for example (from <code>reaction_channel.ex</code>, with
the first one&rsquo;s body trimmed):</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <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-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="kd">def</span> <span class="n">join</span><span class="p">(</span><span class="s2">&#34;reactions:&#34;</span> <span class="o">&lt;&gt;</span> <span class="n">slug</span><span class="p">,</span> <span class="p">%{</span><span class="s2">&#34;api_key&#34;</span> <span class="o">=&gt;</span> <span class="n">api_key</span><span class="p">},</span> <span class="n">socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...check the talk, the user, and the plan limits...</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="kd">def</span> <span class="n">join</span><span class="p">(</span><span class="s2">&#34;reactions:&#34;</span> <span class="o">&lt;&gt;</span> <span class="n">_slug</span><span class="p">,</span> <span class="n">_params</span><span class="p">,</span> <span class="n">_socket</span><span class="p">)</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#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 first clause only matches when the topic starts with <code>&quot;reactions:&quot;</code> and the
params include an <code>api_key</code>. Anything else falls through to the second clause,
which rejects it. No <code>if</code> required.</p>
<p>Ruby methods have one body, so you branch inside it. Elixir functions can have
many clauses, and the branching happens before your code even runs. That&rsquo;s the
part that changed how code was structured, and it&rsquo;s why pattern matching feels
more like a way of working than a convenient syntax.</p>
<p>Here&rsquo;s an example. The Channel join for the Chrome extension is a chain of
four checks, and each failure has its own reason. This is the body of that first
<code>join</code> clause, minus a couple of setup lines inside the <code>do</code> block:</p>
<div class="code-block code-line-numbers" style="counter-reset: code-block 0">
    <div class="code-header language-elixir">
        <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-elixir" data-lang="elixir"><span class="line"><span class="cl"><span class="n">with</span> <span class="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="p">%</span><span class="nc">Talks.Talk</span><span class="p">{}</span> <span class="o">=</span> <span class="n">talk</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="nc">Talks</span><span class="o">.</span><span class="n">get_talk_by_slug</span><span class="p">(</span><span class="n">slug</span><span class="p">)},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="p">%</span><span class="nc">Accounts.User</span><span class="p">{}</span> <span class="o">=</span> <span class="n">user</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="nc">Accounts</span><span class="o">.</span><span class="n">get_user_by_api_key</span><span class="p">(</span><span class="n">api_key</span><span class="p">)},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="no">true</span><span class="p">}</span> <span class="o">&lt;-</span> <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="n">talk</span><span class="o">.</span><span class="n">user_id</span> <span class="o">==</span> <span class="n">user</span><span class="o">.</span><span class="n">id</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">     <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span> <span class="ss">:ok</span><span class="p">}</span> <span class="o">&lt;-</span>
</span></span><span class="line"><span class="cl">       <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nc">Plans</span><span class="o">.</span><span class="n">check</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">          <span class="ss">:max_participants</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="n">user</span><span class="o">.</span><span class="n">plan</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">          <span class="nc">Presence</span><span class="o">.</span><span class="n">list</span><span class="p">(</span><span class="s2">&#34;reactions:</span><span class="si">#{</span><span class="n">slug</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span> <span class="o">|&gt;</span> <span class="n">map_size</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">        <span class="p">)}</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="c1"># ...subscribe to disconnect events, schedule presence tracking...</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:ok</span><span class="p">,</span> <span class="n">join_payload</span><span class="p">(</span><span class="n">user</span><span class="p">),</span> <span class="n">assign</span><span class="p">(</span><span class="n">socket</span><span class="p">,</span> <span class="ss">talk</span><span class="p">:</span> <span class="n">talk</span><span class="p">,</span> <span class="ss">user</span><span class="p">:</span> <span class="n">user</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="p">{</span><span class="ss">:talk</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;not_found&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:user</span><span class="p">,</span> <span class="no">nil</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:owner</span><span class="p">,</span> <span class="no">false</span><span class="p">}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;unauthorized&#34;</span><span class="p">}}</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span><span class="ss">:capacity</span><span class="p">,</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="ss">:limit_reached</span><span class="p">}}</span> <span class="o">-&gt;</span> <span class="p">{</span><span class="ss">:error</span><span class="p">,</span> <span class="p">%{</span><span class="ss">reason</span><span class="p">:</span> <span class="s2">&#34;capacity_reached&#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>I might have written that as a pile of guard clauses in Ruby. This version
reads top to bottom as a list of requirements, and the <code>else</code> block is a
table of what goes wrong.</p>
<p>As an aid to learning I had AI help me write a <a href="https://docs.speechwave.live/dev/course.html" target="_blank" rel="noopener noreffer ">short course for Ruby
developers</a>, drawing the examples
from the Speechwave codebase. If you&rsquo;re coming from the Rails side, that&rsquo;s an
interesting place to start.</p>
<h2 id="what-id-tell-another-rails-developer">What I&rsquo;d tell another Rails developer</h2>
<p>If your app is mostly forms, tables, and background jobs, stay with Rails. You&rsquo;ll
move faster and you&rsquo;ll have more gems to lean on.</p>
<p>If your app is mostly &ldquo;lots of people connected at once, and something
happening to all of them in real time,&rdquo; look hard at Phoenix. The runtime and
the framework were built for exactly that, and you may find, as I did, that the
infrastructure shrinks down to a single small machine.</p>
<p>And if you&rsquo;re already an Elixir developer: I&rsquo;d love your feedback on the
codebase. It&rsquo;s open, it&rsquo;s about 6,800 lines of application code, and I&rsquo;m sure
there are idioms I&rsquo;m still getting wrong.</p>
<ul>
<li>Try Speechwave: <a href="https://speechwave.live" target="_blank" rel="noopener noreffer ">speechwave.live</a></li>
<li>Read the code: <a href="https://github.com/speechwave-live/speechwave" target="_blank" rel="noopener noreffer ">github.com/speechwave-live/speechwave</a></li>
<li>Tour the internals: <a href="https://docs.speechwave.live/dev/explainer/index.html" target="_blank" rel="noopener noreffer ">codebase explainer</a></li>
<li>Peek at the AI-assisted <a href="https://docs.speechwave.live/dev/course.html" target="_blank" rel="noopener noreffer ">short course for Ruby developers</a></li>
</ul>
]]></description></item><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>MockOpenAI</title><link>https://tracyatteberry.com/portfolio/mockopenai/</link><pubDate>Fri, 20 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/mockopenai/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/mockopenai/mockopenai_hero.jpg" referrerpolicy="no-referrer">
            </div><p>Building a gem.</p>
]]></description></item><item><title>When 'just return JSON' isn't enough</title><link>https://tracyatteberry.com/posts/structured_output/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/structured_output/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/structured_output/structured_output.png" referrerpolicy="no-referrer">
            </div><h1 id="when-just-return-json-isnt-enough">When &ldquo;just return JSON&rdquo; isn&rsquo;t enough</h1>
<p>If you&rsquo;re building a tool that calls an AI API and needs structured data back, at some
point you&rsquo;ll write a prompt that ends with something like: &ldquo;Return a JSON array. No
prose, no explanation, just the JSON.&rdquo;</p>
<p>And most of the time, that works. But &ldquo;most of the time&rdquo; isn&rsquo;t good enough when a parse
error crashes your command mid-run.</p>
<p>I ran into this while building <a href="https://github.com/grymoire7/jojo" target="_blank" rel="noopener noreffer ">Jojo</a>, a CLI
tool that generates job application materials (tailored resumes, cover letters, and
a custom landing page) using AI. Several of its commands need structured data back
from the model: an array of annotations, an array of FAQ entries, an array of integer
indices. Plain text won&rsquo;t do; the output feeds directly into the next stage of the
pipeline.</p>
<h2 id="the-problem-with-prompt-instructions">The problem with prompt instructions</h2>
<p>Language models are trained to be helpful. Part of being helpful, from the model&rsquo;s
perspective, is adding context: explaining what it did, noting any assumptions,
formatting output to be readable. A prompt instruction to &ldquo;return only JSON&rdquo; fights
against that training.</p>
<p>The failure modes are predictable:</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>Here is the JSON array you requested:

```json
[{&#34;question&#34;: &#34;What is your experience with Ruby?&#34;, &#34;answer&#34;: &#34;...&#34;}]
```

I&#39;ve formatted this as valid JSON with the required fields.</code></pre></div>
<p>The model followed the spirit of the instruction (it returned valid JSON) but wrapped
it in markdown fences and a helpful introductory sentence. <code>JSON.parse</code> on that string
raises a <code>ParserError</code>.</p>
<p>The original code defended against this with a quick fence-strip:</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">cleaned_json</span> <span class="o">=</span> <span class="n">json_string</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\A```(?:json)?\n?/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\n?```\z/</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="no">JSON</span><span class="o">.</span><span class="n">parse</span><span class="p">(</span><span class="n">cleaned_json</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="kp">true</span><span class="p">)</span></span></span></code></pre></div></div>
<p>That handles the fence case. But what about the introductory sentence? What about a model
that adds a trailing note after the JSON? The regex grows, cases multiply, and you end
up with the same defensive code copy-pasted across three different generators.</p>
<h2 id="jsonextractor">JsonExtractor</h2>
<p>Instead, we pull this logic into a single place and make it more robust by
trying multiple strategies to just find the JSON in whatever the model returned.</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">JsonExtractor</span>
</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">call</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="kp">false</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">content</span> <span class="k">if</span> <span class="n">content</span><span class="o">.</span><span class="n">is_a?</span><span class="p">(</span><span class="no">Hash</span><span class="p">)</span> <span class="o">||</span> <span class="n">content</span><span class="o">.</span><span class="n">is_a?</span><span class="p">(</span><span class="nb">Array</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">stripped</span> <span class="o">=</span> <span class="n">content</span><span class="o">.</span><span class="n">gsub</span><span class="p">(</span><span class="sr">/\A```\w*\s*|\s*```\z/m</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">strip</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="n">try_parse</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="n">symbolize_names</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">      <span class="n">try_parse</span><span class="p">(</span><span class="n">stripped</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="n">symbolize_names</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">      <span class="n">extract_first_structure</span><span class="p">(</span><span class="n">content</span><span class="p">,</span> <span class="ss">symbolize_names</span><span class="p">:</span> <span class="n">symbolize_names</span><span class="p">)</span> <span class="o">||</span>
</span></span><span class="line"><span class="cl">      <span class="k">raise</span><span class="p">(</span><span class="no">JSON</span><span class="o">::</span><span class="no">ParserError</span><span class="p">,</span> <span class="s2">&#34;No JSON object found in response&#34;</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></code></pre></div></div>
<p>It tries three strategies in order:</p>
<ol>
<li>Parse the raw response directly. If the model actually followed instructions, done.</li>
<li>Strip markdown fences and try again. Handles the most common failure mode.</li>
<li>Find the first <code>{</code> or <code>[</code> in the response and parse the complete structure from there,
using a depth-tracking parser to handle nested objects correctly. This handles the
&ldquo;Here is the JSON: [&hellip;]&rdquo; case regardless of what prose surrounds it.</li>
</ol>
<p>Each generator now calls <code>JsonExtractor.call(response)</code> instead of rolling its own
fence-stripping. One test suite covers all the edge cases.</p>
<h2 id="the-cleaner-alternative">The cleaner alternative</h2>
<p>If you&rsquo;re using <a href="https://github.com/crmne/ruby_llm" target="_blank" rel="noopener noreffer ">RubyLLM</a>, a Ruby library that
wraps multiple AI providers behind a common interface, there&rsquo;s a more architecturally
elegant solution: structured output.</p>
<p>Instead of asking the model to &ldquo;return JSON&rdquo; and then parsing whatever it sends back,
you define a schema and ask the model to conform to it:</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">class</span> <span class="nc">FaqSchema</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Schema</span>
</span></span><span class="line"><span class="cl">  <span class="n">array</span> <span class="ss">:faqs</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">    <span class="n">string</span> <span class="ss">:question</span>
</span></span><span class="line"><span class="cl">    <span class="n">string</span> <span class="ss">:answer</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="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="o">.</span><span class="n">with_schema</span><span class="p">(</span><span class="no">FaqSchema</span><span class="p">)</span><span class="o">.</span><span class="n">ask</span><span class="p">(</span><span class="n">prompt</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># response.faqs is an array of objects with .question and .answer</span></span></span></code></pre></div></div>
<p>The model is constrained at the API level, not the prompt level. You get back a parsed
object that already matches your schema. No <code>JSON.parse</code>, no fence-stripping, no
<code>JsonExtractor</code>. The schema also serves as documentation for what the command expects.</p>
<p>This is genuinely cleaner. So why not use it?</p>
<h2 id="the-provider-support-problem">The provider support problem</h2>
<p>Structured output is a feature that providers implement, and not all models
support it. The major providers (Anthropic, OpenAI, Gemini) support it for
their recent models. But older models, like GPT-3.5, Gemini 1.0, Codex, etc.,
don&rsquo;t. Some providers, like Hugging Face, don&rsquo;t support it at all.</p>
<p>Jojo is designed to work with whatever model and provider the user configures. It
dynamically picks up provider credentials from environment variables and routes requests
through RubyLLM. A user might be running Claude Haiku, GPT-4o, or a locally-hosted
model through an OpenAI-compatible endpoint.</p>
<p>RubyLLM&rsquo;s structured output interface is uniform across providers: <code>chat.with_schema</code>
works the same way regardless of which model you&rsquo;re talking to. But when the underlying
model doesn&rsquo;t support structured output, you don&rsquo;t get graceful degradation. You get a
provider error.</p>
<p>That&rsquo;s the thing:</p>
<ul>
<li><code>JsonExtractor</code> works on any string. If the model returns prose with JSON buried in it,
that&rsquo;s handled. If the model returns clean JSON, that&rsquo;s handled. It never throws a
provider error because it never makes a structured output request.</li>
<li>Structured output fails loudly on unsupported models. There&rsquo;s no fallback. You have
to catch the error and decide what to do with it.</li>
</ul>
<p>For a tool where wide model support is a goal, <code>JsonExtractor</code> is more defensive. The
cost is that you&rsquo;re still at the mercy of whatever the model decides to return, but in
practice, every model returns something that contains valid JSON, even if it&rsquo;s surrounded
by other content. The extraction strategy covers the real-world failure modes.</p>
<p>Okay, you also give up strict JSON schema guarantees (if supported) but that&rsquo;s
a tradeoff I&rsquo;m willing to make for broader compatibility.</p>
<h2 id="when-structured-output-is-the-right-call">When structured output is the right call</h2>
<p>None of this is an argument against structured output in general. If you
have good control over exactly which model and provider your code
will run against, structured output is the better choice. The schema and be
explicit, the contract is enforced at the API level, and you&rsquo;re unlikely to
have a parse error at runtime.</p>
<p>You might wonder whether a hybrid approach could get the best of both worlds: check
whether the model supports structured output, use it if so, and fall back to
<code>JsonExtractor</code> if not. RubyLLM does expose this information. Model objects carry a
<code>capabilities</code> array that includes <code>'structured_output'</code> when the feature is supported.
So the check is technically feasible.</p>
<p>The problem is that it doesn&rsquo;t simplify anything. <code>JsonExtractor</code> still has to exist
for the fallback branch, so you haven&rsquo;t reduced maintenance burden. And now you have
two code paths returning different shapes: structured output returns a typed schema
object, while <code>JsonExtractor</code> returns a Hash. Every caller has to handle both, or you
need a normalization layer on top. You&rsquo;ve added complexity without removing any.</p>
<p>The tradeoff is essentially: how much do you want to trade portability for guarantees?
<code>JsonExtractor</code> is portable and handles the real-world messiness of model output.
Structured output gives you stronger guarantees but requires a supported model.</p>
<p>For a tool like Jojo, where users configure their own API credentials and model choices,
<code>JsonExtractor</code> keeps things working across the widest range of setups.
The &ldquo;clean&rdquo; architecture turned out to be less pragmatic once you considered who was
actually going to run it. That person is me, btw, and I want it to work with
whatever model I&rsquo;m testing with at the moment, without having to worry about
structured output support.</p>
]]></description></item><item><title>Building Jojo: turning job applications into marketing campaigns</title><link>https://tracyatteberry.com/posts/jojo/</link><pubDate>Fri, 20 Feb 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/jojo/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/jojo/landing_page.png" referrerpolicy="no-referrer">
            </div><h1 id="building-jojo-turning-job-applications-into-marketing-campaigns">Building Jojo: turning job applications into marketing campaigns</h1>
<p>When you apply for a job, you&rsquo;re competing against hundreds of other candidates.
Most of them submit a resume and a cover letter. The ambitious ones tailor those
documents to the role. And then everyone waits.</p>
<p>No matter how good your resume is, it&rsquo;s still a PDF in a pile of PDFs. You&rsquo;re
asking a hiring manager to do the work of figuring out why you&rsquo;re a fit. What
if you did that work for them?</p>
<p>That&rsquo;s the idea behind <a href="https://github.com/grymoire7/jojo" target="_blank" rel="noopener noreffer ">Jojo</a>, a Ruby CLI I
built to transform job applications into personalized marketing campaigns.
Instead of sending documents, you send a package: a tailored resume, a cover
letter informed by company research, and a dedicated landing page that shows
exactly why you&rsquo;re a match for the role.</p>
<p>The landing page is the centerpiece. It&rsquo;s a mini marketing site with an
annotated job description that maps your experience to their requirements,
portfolio projects selected for relevance to their tech stack, a branding
statement written for their company, LinkedIn recommendations, an FAQ section,
and a call-to-action to schedule a conversation. It turns a passive application
into an active pitch.</p>
<p>Think of it as treating each job application like a product launch. You&rsquo;re the
product. The company you&rsquo;re applying to is the only customer. Jojo builds the
marketing campaign.</p>
<div class="mermaid" id="id-2"></div>
<h2 id="how-it-works">How it works</h2>
<p>The workflow starts with two inputs: your resume data (a structured YAML file)
and a job description (a file or URL). From there, Jojo runs a pipeline of
AI-powered generation steps.</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"># Create a new application workspace</span>
</span></span><span class="line"><span class="cl">jojo new --slug acme-senior-dev --job posting.txt
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Generate everything</span>
</span></span><span class="line"><span class="cl">jojo generate --slug acme-senior-dev</span></span></code></pre></div></div>
<p>The <code>generate</code> command kicks off a sequence:</p>
<ol>
<li><strong>Research</strong> — AI analyzes the job description and (optionally) searches the
web to build a research document about the company, the role, and how to
position yourself.</li>
<li><strong>Resume</strong> — Your structured resume data is curated and rendered into a
tailored resume, emphasizing the most relevant experience.</li>
<li><strong>Branding</strong> — AI writes a personal branding statement specific to the
company and role.</li>
<li><strong>Cover letter</strong> — Generated from the research and tailored resume, so it
references specific things about the company rather than generic platitudes.</li>
<li><strong>Annotations</strong> — The job description is analyzed requirement by requirement,
with each one mapped to your matching experience.</li>
<li><strong>FAQ</strong> — AI generates role-specific questions and answers based on your
background and the job requirements.</li>
<li><strong>Website</strong> — Everything comes together in a self-contained landing page.</li>
<li><strong>PDF</strong> — Resume and cover letter are converted to PDF via Pandoc.</li>
</ol>
<p>Each step feeds into the next. The research informs the resume tailoring. The
resume informs the cover letter. The annotations and FAQ feed into the website.
It&rsquo;s a pipeline, not a collection of independent scripts.</p>
<p>Every application gets its own workspace directory organized by slug:</p>
<pre>
  applications/acme-senior-dev/
  ├── job_description.md
  ├── job_details.yml
  ├── research.md
  ├── resume.md
  ├── cover_letter.md
  ├── branding.md
  ├── faq.json
  ├── job_description_annotations.json
  ├── status.log
  └── website/
      └── index.html
</pre>
<p>For daily use, there&rsquo;s also an interactive TUI mode. Running <code>jojo</code> with
no arguments launches a dashboard that shows all your applications, tracks which
steps are complete, detects when artifacts are stale (because you regenerated a
dependency), and lets you generate or regenerate individual steps with a
keypress. The staleness detection uses file modification times. If you
regenerate your research, the dashboard knows your resume is now stale because
it was built from the old research.</p>
<pre>
  ┌─ Jojo ────────────────────────────────────────────┐
  │  Active: acme-senior-dev                          │
  │  Company: Acme Corp  •  Role: Senior Developer    │
  ├───────────────────────────────────────────────────┤
  │  Workflow                           Status        │
  │  1. Job Description            $   ✓ Generated    │
  │  2. Research                   $   ✓ Generated    │
  │  3. Resume                     $   * Stale        │
  │  4. Cover Letter               $   ○ Ready        │
  │  ...                                              │
  ├───────────────────────────────────────────────────┤
  │  [1-9] Generate item    [a] All ready    [q] Quit │
  └───────────────────────────────────────────────────┘
</pre>
<p>The <code>$</code> indicator shows which steps call paid APIs, so you know if an action
will cost something before you press the key. Steps that just combine existing
artifacts (like website generation) are free.</p>
<h2 id="architecture-the-command-pipeline">Architecture: the command pipeline</h2>
<p>Jojo is over 5K lines of Ruby source across ~50 source files. Most CLI commands follow
the same three-file pattern:</p>
<pre>
  lib/jojo/commands/{command_name}/
  ├── command.rb    — Orchestration: validates inputs, manages file I/O
  ├── generator.rb  — Content generation: builds context, calls AI
  └── prompt.rb     — AI prompts: system and user prompt templates
</pre>
<p>So when I need to add a new command, I can create these three files, follow the
pattern from the existing commands, and it (hopefully/usually) works. I don&rsquo;t
have to modify a central router or understand the internals of unrelated
commands. The pattern helps make the codebase predictable. If you&rsquo;ve read one
command, you understand the shape of all of them. That helps the human and the
AI assistant.</p>
<p>This wasn&rsquo;t the original architecture. The CLI started as a monolith in
<code>cli.rb</code>. Thor command definitions were mixed with validation logic, file
handling, and generation orchestration. It worked fine for the first few
commands, but soon things got messy. Adding a new feature meant navigating a
growing code heap and hoping your changes didn&rsquo;t break something unrelated.</p>
<p>The refactor extracted each command into its own module with a shared base
class that provides common behavior (slug resolution, config loading, AI client
setup). The CLI file shrank to a thin router with about 150 lines of small
methods that delegate to command classes. Interactive mode, which breifly
had a circular dependency calling back into the CLI class (eww) now calls
command classes directly through a simple adapter.</p>
<h3 id="dual-ai-models">Dual AI models</h3>
<p>Jojo configures two AI models. There&rsquo;s a reasoning model for complex tasks and
a text generation model for simpler ones.</p>
<p>Company research and resume tailoring need the strongest reasoning capabilities
as they&rsquo;re analyzing job requirements, cross-referencing your experience, and
making judgment calls about relevance. But extracting metadata from a job
description (company name, location, job title) is easier. Using a powerful
model for that is like hiring a senior architect to hang shelves.</p>
<p>The reasoning model handles research, resume curation, and cover letter writing.
The text generation model handles job description processing, annotations, FAQ
generation, and branding statements. Both models are configurable per provider,
so you can use a frontier model for reasoning and a faster model for text
generation, or whatever suits your budget and quality needs.</p>
<p>Even with the right model architecture, the AI still has a fundamental
trustworthiness problem when it comes to factual content (welcome to AI).</p>
<h2 id="solving-the-hallucination-problem">Solving the hallucination problem</h2>
<p>This was a technical decision that came from a hard fail.</p>
<p>The original resume generation would take the user&rsquo;s resume data (stored as
structured YAML), combine it with the job description and research, and ask the
AI to generate a tailored resume in markdown. The prompt included extensive
instructions about not fabricating information. It said things like &ldquo;only
include skills the candidate actually has&rdquo; and &ldquo;do not add technologies not
present in the source data.&rdquo;</p>
<p>The AI ignored these instructions way too often. I&rsquo;d review a generated resume
and find &ldquo;Kubernetes&rdquo; listed in my skills because the AI noticed I mentioned
Docker and helpfully inferred I must know Kubernetes too. Or it would embellish
a job description with responsibilities I never had. For a resume, this is not
good.</p>
<p>The first instinct was to add more guardrails to the prompt. More emphatic
instructions. More examples of what not to do. This helped a little, but it
didn&rsquo;t solve the problem. The AI still had the <em>ability</em> to modify anything,
and language-level instructions are suggestions, not constraints.</p>
<h3 id="the-insight-different-fields-have-different-risk-profiles">The insight: different fields have different risk profiles</h3>
<p>A professional summary should be rewritten for each role. That&rsquo;s the whole
point, but a list of programming languages must not be modified. The years you
worked at a company are facts. Your name is your name.</p>
<p>The problem was that &ldquo;AI shouldn&rsquo;t have the same permissions everywhere.&rdquo; Some
fields need smart tailoring. Others need strict preservation. And still others
might be removed or reordered. The idea was to define a permission system
that specifies what the AI can for different kinds of content.</p>
<h3 id="permission-based-curation">Permission-based curation</h3>
<p>The solution was a permission system embedded directly in the resume data:</p>
<div class="code-block code-line-numbers open" style="counter-reset: code-block 0">
    <div class="code-header language-yaml">
        <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-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Bob Denver&#34;</span><span class="w">               </span><span class="c"># default: read-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">email</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;bob@example.com&#34;</span><span class="w">         </span><span class="c"># default: read-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">summary: |                       # permission</span><span class="p">:</span><span class="w"> </span><span class="l">rewrite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">Polyglot developer who enjoys solving problems</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">with software...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">skills:                          # permission</span><span class="p">:</span><span class="w"> </span><span class="l">remove, reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">software engineering</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">full stack development</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">AI assisted development</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">languages:                       # permission</span><span class="p">:</span><span class="w"> </span><span class="l">reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Ruby</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Java</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Python</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="l">Go</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">experience:                      # permission</span><span class="p">:</span><span class="w"> </span><span class="l">reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">company</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Island Adventures Inc.&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">role</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Senior Software Engineer&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">start_date</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;2020-07&#34;</span><span class="w">        </span><span class="c"># read-only (nested)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">description: |               # permission</span><span class="p">:</span><span class="w"> </span><span class="l">rewrite</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="l">Full-stack developer delivering a SaaS platform...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">technologies:                # permission</span><span class="p">:</span><span class="w"> </span><span class="l">remove, reorder</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Ruby on Rails</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Vue</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Python</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">Docker</span></span></span></code></pre></div></div>
<p>Four permission levels:</p>
<ul>
<li><strong>read-only</strong> (default) — AI cannot modify, delete, add, or reorder. Contact
info, dates, company names.</li>
<li><strong>remove</strong> — AI can exclude irrelevant items but can&rsquo;t modify the ones it
keeps. A database skill list can drop SQLite if the role is all PostgreSQL.</li>
<li><strong>reorder</strong> — AI can prioritize by relevance but can&rsquo;t remove or modify. Your
programming languages list stays complete but puts the most relevant ones
first.</li>
<li><strong>rewrite</strong> — AI can generate new content using the original as a factual
baseline. Professional summary, job descriptions.</li>
</ul>
<p>In particular, the AI should never <em>add</em> items that aren&rsquo;t in the source data.
Though there is still a risk of hallucination in rewrite fields, the presence
of original content in smaller chunks provides a grounding that makes it less
likely.</p>
<h3 id="two-pass-pipeline">Two-pass pipeline</h3>
<p>The curation happens in two passes:</p>
<p><strong>Pass 1: Filter and reorder.</strong> The AI receives the full resume data and the
job description. It returns a filtered, reordered version that respects the
permissions on each field. Skills marked <code>remove, reorder</code> get filtered to ~70%
of the most relevant items and sorted by relevance. Lists marked <code>reorder</code> get
sorted but all items are preserved.</p>
<p><strong>Pass 2: Rewrite fields.</strong> The AI receives the filtered data and generates new
content for fields marked <code>rewrite</code>. For example, the professional summary and
experience descriptions. It uses the original content as a factual baseline.</p>
<p>Then an ERB template renders the final markdown. The template handles structure
and formatting. The AI never touches the output templating.</p>
<p>What makes this work as an engineering solution is that the Ruby code
<em>enforces</em> the permissions where possible. If the AI returns a reordered list
that&rsquo;s shorter than the original for a field that only has <code>reorder</code>
permission, the <code>Transformer</code> class raises a <code>PermissionViolation</code> error:</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">unless</span> <span class="n">can_remove</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="n">indices</span><span class="o">.</span><span class="n">length</span> <span class="o">!=</span> <span class="n">original_count</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="no">PermissionViolation</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="s2">&#34;LLM removed items from reorder-only field: </span><span class="si">#{</span><span class="n">field_path</span><span class="si">}</span><span class="s2">&#34;</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 permissions are no longer buried in  prompt instructions that the AI might
ignore. They&rsquo;re enforced in code. The AI provides <em>suggestions</em> for how to
curate the data, and the Ruby code validates those suggestions against the
permission rules before applying them. If the AI tries to exceed its
permissions, the operation fails rather than silently producing a resume with
fabricated content.</p>
<p>The result is a skills section always contains skills I actually have. My job
dates are always accurate. But my professional summary is tailored for each
role, emphasizing the experience most relevant to that specific position.</p>
<h3 id="what-structured-data-enables">What structured data enables</h3>
<p>In order to make the permission system work, we had to switch from an unstructured
markdown resume to a structured YAML format. This was a significant
architectural change and it required reworking the entire resume generation
pipeline. However, it was necessary to address the hallucination problem.</p>
<p>The permission system is the most visible benefit of using structured data, but
there are other advantages:</p>
<ul>
<li><strong>Narrower AI focus</strong> — With structured data, the AI can focus on curating
specific fields rather than trying to parse and understand a free-form markdown
document. This leads to better quality and more consistent results.</li>
<li><strong>Better output control</strong> — The ERB template handles formatting and
structure, so the AI only generates smaller pieces of content. This reduces
the chances of formatting errors or hallucinated sections and increases the
human control over the final output.</li>
<li><strong>Easier testing</strong> — Structured data is easier to work with in tests. You can
create synthetic resume data with specific permissions and verify that the
output respects those permissions. With unstructured markdown, it&rsquo;s harder to
assert that the AI didn&rsquo;t add or modify content it shouldn&rsquo;t have.</li>
</ul>
<h2 id="testing-as-a-development-discipline">Testing as a development discipline</h2>
<p>A permission system that enforces constraints in code is only trustworthy if
you actually test the enforcement. Jojo has 530 tests across two tiers, with
84% code coverage. Getting there was an intentional investment.</p>
<p>AI coding assistants are enthusiastic about writing features. They&rsquo;re less
enthusiastic about writing tests. This mirrors human tendencies. Tests aren&rsquo;t
as exciting as shipping the next feature, but with AI-assisted development the
gap is amplified.</p>
<p>When the first large refactor was needed I noticed that test coverage was
sitting at 31%. The code worked, but I had no safety net for refactoring. The
push to 84% was a conscious decision to invest in change enablement.</p>
<h3 id="three-kinds-of-tests">Three kinds of tests</h3>
<p>Jojo has three kinds of tests:</p>
<table>
	<thead>
			<tr>
					<th>Kind of test&hellip;</th>
					<th>It tests&hellip;</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Unit tests</td>
					<td>Do small units work?</td>
			</tr>
			<tr>
					<td>Integration tests</td>
					<td>Do small units work together?</td>
			</tr>
			<tr>
					<td>Linting</td>
					<td>Static code analysis</td>
			</tr>
	</tbody>
</table>
<p>All tests run on every <code>./bin/test</code> (or <code>rake test:all</code>) invocation and in CI.</p>
<h3 id="testing-api-dependent-code">Testing API-dependent code</h3>
<p>The trickiest part of testing Jojo is that a lot of interesting work
involves AI and Search API calls. You can&rsquo;t run those in CI without spending
money on every test run, but you also want tests that exercise real response
parsing.</p>
<p>The solution was the VCR gem. VCR records real HTTP interactions the first
time a test runs and saves them as &ldquo;cassettes.&rdquo; On subsequent runs, it replays
the recorded responses instead of making real API calls. You get fast,
deterministic tests that still exercise the full response-parsing pipeline.</p>
<h3 id="fixture-discipline">Fixture discipline</h3>
<p>One rule that has saved me more than once is that tests (and AI) never touch
the <code>inputs/</code> directory — no matter how much AI would like to. That directory
contains real resume data from the user. Tests use <code>test/fixtures/</code>
exclusively, with synthetic data designed for testability.</p>
<p>This is codified in the project&rsquo;s AI guidelines, which was previously prone to
such mistakes. The instructions are explicit, emphatic, and took a few
iterations to be effective. This testing discipline was part of the broader
experience of building with AI.</p>
<h2 id="building-with-ai">Building with AI</h2>
<p>There&rsquo;s a meta quality to this project: it&rsquo;s a tool that uses AI to generate
content, and it was built with AI assistance. Both
<a href="https://claude.ai/code" target="_blank" rel="noopener noreffer ">Claude</a> and <a href="https://z.ai" target="_blank" rel="noopener noreffer ">Z</a> helped with
development.</p>
<p>AI is pretty good at generating boilerplate, brainstorming design alternatives,
and automating the tedious parts of refactoring (like updating 50 files when
you rename a class).</p>
<p>But the decisions this post is about — the curation system, the architecture,
the decision to refactor and when, the test organization — those were human
decisions (as was the choice to use em-dashes just then). AI helped implement
them a bit faster, but it didn&rsquo;t tell me they were needed.</p>
<p>One nice thing about AI-assisted development was the ability to explore
approaches quickly. When I was designing the permission system, I could
describe different architectures, brainstorm, and get working prototypes, all
in fairly short order. That kind of rapid experimentation is really helpful.
The design thinking, however, still has to be yours.</p>
<p>One not-so-nice thing was needing to prod the AI to write tests for the
features it&rsquo;s helping to build. Also, let&rsquo;s be honest, there&rsquo;s a temptation to
let the AI go a little too long before reviewing its output. Left to its own
devices, an AI assistant will happily build feature after feature, with no test
coverage and growing technical debt. Just like a human developer on a deadline,
it needs someone to say &ldquo;we&rsquo;re not adding anything else until we address the
technical debt, and that includes tests.&rdquo;</p>
<h2 id="what-i-learned-and-whats-next">What I learned and what&rsquo;s next</h2>
<p>A few things I&rsquo;d do differently if I started over:</p>
<p><strong>Start with structured data sooner.</strong> The original design used a free-form
markdown resume as input. This was a frightful battle of prompt engineering
from the beginning. The switch to structured YAML data (<code>resume_data.yml</code>) was
the right call, but it required reworking the entire resume generation
pipeline. If I&rsquo;d started with structured data, the permission system would have
been a natural extension rather than a redesign.</p>
<p><strong>Build the interactive mode earlier.</strong> The TUI dashboard made the tool
dramatically more usable, but it came in Phase 6 out of 7. Earlier access to
the dependency graph and staleness detection would have improved my own
workflow during development.</p>
<p><strong>Force TDD from the start, or very near it.</strong> I had a test suite from the
beginning, of course, but it wasn&rsquo;t until I hit a major refactor that I made a
conscious decision to invest in better test coverage. If I had enforced TDD
from the start.</p>
<p>Basically, I would have spent a lot more time up front on planning the
architecture and testing strategy, which would have made the development
process smoother and more maintainable. AI assistance can be great, but
it&rsquo;s also really good at seducing you into bad habits.</p>
<h3 id="whats-next">What&rsquo;s next</h3>
<p>A few potential things for the roadmap:</p>
<ul>
<li><strong>Interview prep generation</strong> — STAR-method examples drawn from your resume
data, tailored to the specific role</li>
<li><strong>More and better theming options</strong> — The landing page is Jojo&rsquo;s UVP, but the
current design is pretty basic. More themes and customization options would let
users create a landing page that better reflects their personal brand.</li>
<li><strong>Application tracking</strong> — Status tracking across all applications with dates,
notes, and follow-up reminders</li>
<li><strong>Full SaaS product</strong> — A Rails app version of Jojo with a user-friendly interface
and full job search management features — this would be a much bigger project
but could help a wider audience.</li>
</ul>
<h3 id="try-it-out">Try it out</h3>
<p>Jojo is open source and available on
<a href="https://github.com/grymoire7/jojo" target="_blank" rel="noopener noreffer ">GitHub</a> with a <a href="https://grymoire7.github.io/jojo/" target="_blank" rel="noopener noreffer ">documentation
site</a>. It&rsquo;s a Ruby CLI that requires AI and
Search provider API keys. Setup takes just a few minutes.</p>
<p>If you&rsquo;re interested in the code, the architecture, or just want to talk about
AI-assisted development, I&rsquo;d enjoy hearing from you. You can find me on
<a href="https://linkedin.com/in/tracyatteberry" target="_blank" rel="noopener noreffer ">LinkedIn</a> or <a href="https://mastodon.social/@grymoire7" target="_blank" rel="noopener noreffer ">Mastodon</a>.</p>
]]></description></item><item><title>Jojo</title><link>https://tracyatteberry.com/portfolio/jojo/</link><pubDate>Fri, 20 Feb 2026 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/portfolio/jojo/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/jojo/landing_page.png" referrerpolicy="no-referrer">
            </div><p>Building a CLI for turning job applications into marketing campaigns. Jojo
helps you create personalized, engaging applications that stand out from the
crowd. It generates tailored cover letters, optimizes your resume for ATS, and
even creates a custom portfolio website for each application. With Jojo, you
can turn your job search into a marketing campaign that gets noticed.</p>
]]></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><item><title>Ruby Array Methods</title><link>https://tracyatteberry.com/posts/ruby_array_methods/</link><pubDate>Tue, 26 Nov 2024 00:00:00 +0000</pubDate><author>Tracy Atteberry</author><guid>https://tracyatteberry.com/posts/ruby_array_methods/</guid><description><![CDATA[<div class="featured-image">
                <img src="https://tracyatteberry.com/posts/ruby_array_methods/fruit_array.jpg" referrerpolicy="no-referrer">
            </div><h3 id="ruby-array-methods">Ruby Array Methods</h3>
<p>I wondered if this information would be more digestible using fruit. The
taste test is ongoing.</p>
<table>
	<thead>
			<tr>
					<th>Input</th>
					<th>Method</th>
					<th>Output</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>[🍋, 🍋, 🍋]</td>
					<td><code>all? { |🧺| 🧺 == 🍋 }</code></td>
					<td>true</td>
			</tr>
			<tr>
					<td>[🍋, 🍋, 🍊]</td>
					<td><code>any? { |🧺| 🧺 == 🍊 }</code></td>
					<td>true</td>
			</tr>
			<tr>
					<td>[🍋, 🍋, 🍋]</td>
					<td><code>count</code></td>
					<td>3</td>
			</tr>
			<tr>
					<td>[🍋, 🚫, 🍋]</td>
					<td><code>compact</code></td>
					<td>[🍋, 🍋]</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>first</code></td>
					<td>🍋</td>
			</tr>
			<tr>
					<td>[[🍋, 🍊, 🍉], [🍋, 🍊, 🍉]]</td>
					<td><code>flatten</code></td>
					<td>[🍋, 🍊, 🍉, 🍋, 🍊, 🍉]</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>include?(🍉)</code></td>
					<td>true</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>join</code></td>
					<td>🍋🍉🍋🍊</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>last</code></td>
					<td>🍊</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍌]</td>
					<td><code>map { |🧺| 🧺 * 2 }</code></td>
					<td>[🍋🍋, 🍉🍉, 🍌🍌]</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>none? { |🧺| 🧺 == 🍎 }</code></td>
					<td>true</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>one? { |🧺| 🧺 == 🍋 }</code></td>
					<td>false</td>
			</tr>
			<tr>
					<td>[🍋, 🍋, 🍊]</td>
					<td><code>reject { |🧺| 🧺 == 🍋 }</code></td>
					<td>[🍊]</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>reverse</code></td>
					<td>[🍊, 🍋, 🍉, 🍋]</td>
			</tr>
			<tr>
					<td>[🍋, 🍊, 🍉, 🍌, 🍎, 🍒]</td>
					<td><code>rotate(2)</code></td>
					<td>[🍉, 🍌, 🍎, 🍒, 🍋, 🍊]</td>
			</tr>
			<tr>
					<td>[🍋, 🍋, 🍊]</td>
					<td><code>select { |🧺| 🧺 == 🍋 }</code></td>
					<td>[🍋, 🍋]</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>slice(1..2)</code></td>
					<td>[🍉, 🍋]</td>
			</tr>
			<tr>
					<td>[[🍋, 🍊, 🍉], [🍋, 🍊, 🍉]]</td>
					<td><code>transpose</code></td>
					<td>[[🍋, 🍋], [🍊, 🍊], [🍉, 🍉]]</td>
			</tr>
			<tr>
					<td>[🍋, 🍉, 🍋, 🍊]</td>
					<td><code>uniq</code></td>
					<td>[🍋, 🍉, 🍊]</td>
			</tr>
			<tr>
					<td>[🍋, 🍋, 🍊]</td>
					<td><code>zip([🍉, 🍉, 🍉])</code></td>
					<td>[[🍋, 🍉], [🍋, 🍉], [🍊, 🍉]]</td>
			</tr>
	</tbody>
</table>
<p>🚫 = <code>nil</code></p>
]]></description></item></channel></rss>